www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

http.js (35246B)


      1 /**
      2  * Functions for performing HTTP requests, both via XMLHTTPRequest and using a hidden browser
      3  * @namespace
      4  */
      5 Zotero.HTTP = new function() {
      6 	this.lastGoogleScholarQueryTime = 0;
      7 
      8 	/**
      9 	 * Exception returned for unexpected status when promise* is used
     10 	 * @constructor
     11 	 */
     12 	this.UnexpectedStatusException = function(xmlhttp, msg) {
     13 		this.xmlhttp = xmlhttp;
     14 		this.status = xmlhttp.status;
     15 		this.channelStatus = null;
     16 		this.responseStatus = null;
     17 		this.channel = xmlhttp.channel;
     18 		this.message = msg;
     19 		this.stack = new Error().stack;
     20 		
     21 		// Hide password from debug output
     22 		//
     23 		// Password also shows up in channel.name (nsIRequest.name), but that's
     24 		// read-only and has to be handled in Zotero.varDump()
     25 		try {
     26 			if (xmlhttp.channel) {
     27 				if (xmlhttp.channel.URI.password) {
     28 					xmlhttp.channel.URI.password = "********";
     29 				}
     30 				if (xmlhttp.channel.URI.spec) {
     31 					xmlhttp.channel.URI.spec = xmlhttp.channel.URI.spec.replace(/key=[^&]+&?/, "key=********");
     32 				}
     33 			}
     34 		}
     35 		catch (e) {
     36 			Zotero.debug(e, 1);
     37 		}
     38 		
     39 		// If the connection failed, try to find out what really happened
     40 		if (!this.status) {
     41 			try {
     42 				if (xmlhttp.channel.status) {
     43 					this.channelStatus = xmlhttp.channel.status;
     44 					Zotero.debug("Channel status was " + this.channelStatus, 2);
     45 				}
     46 			}
     47 			catch (e) {}
     48 			try {
     49 				if (xmlhttp.channel.responseStatus) {
     50 					this.responseStatus = xmlhttp.channel.responseStatus;
     51 					Zotero.debug("Response status was " + this.responseStatus, 2);
     52 				}
     53 			}
     54 			catch (e) {}
     55 		}
     56 	};
     57 	this.UnexpectedStatusException.prototype = Object.create(Error.prototype);
     58 	this.UnexpectedStatusException.prototype.is4xx = function () {
     59 		return this.status >= 400 && this.status < 500;
     60 	}
     61 	this.UnexpectedStatusException.prototype.is5xx = function () {
     62 		return this.status >= 500 && this.status < 600;
     63 	}
     64 	
     65 	/**
     66 	 * Exception returned if the browser is offline when promise* is used
     67 	 * @constructor
     68 	 */
     69 	this.BrowserOfflineException = function() {
     70 		this.message = "XMLHttpRequest could not complete because the browser is offline";
     71 		this.stack = new Error().stack;
     72 	};
     73 	this.BrowserOfflineException.prototype = Object.create(Error.prototype);
     74 
     75 	this.TimeoutException = function(ms) {
     76 		this.message = "XMLHttpRequest has timed out after " + ms + "ms";
     77 		this.stack = new Error().stack;
     78 	};
     79 	this.TimeoutException.prototype = Object.create(Error.prototype);
     80 
     81 	this.SecurityException = function (msg, options = {}) {
     82 		this.message = msg;
     83 		this.stack = new Error().stack;
     84 		for (let i in options) {
     85 			this[i] = options[i];
     86 		}
     87 	};
     88 	this.SecurityException.prototype = Object.create(
     89 		// Zotero.Error not available in the connector
     90 		Zotero.Error ? Zotero.Error.prototype : Error.prototype
     91 	);
     92 	
     93 	
     94 	this.promise = function () {
     95 		Zotero.debug("Zotero.HTTP.promise() is deprecated -- use Zotero.HTTP.request()", 2);
     96 		return this.request.apply(this, arguments);
     97 	}
     98 	
     99 	/**
    100 	 * Get a promise for a HTTP request
    101 	 *
    102 	 * @param {String} method The method of the request ("GET", "POST", "HEAD", or "OPTIONS")
    103 	 * @param {nsIURI|String}	url				URL to request
    104 	 * @param {Object} [options] Options for HTTP request:<ul>
    105 	 *         <li>body - The body of a POST request</li>
    106 	 *         <li>headers - Object of HTTP headers to send with the request</li>
    107 	 *         <li>cookieSandbox - The sandbox from which cookies should be taken</li>
    108 	 *         <li>debug - Log response text and status code</li>
    109 	 *         <li>dontCache - If set, specifies that the request should not be fulfilled from the cache</li>
    110 	 *         <li>foreground - Make a foreground request, showing certificate/authentication dialogs if necessary</li>
    111 	 *         <li>headers - HTTP headers to include in the request</li>
    112 	 *         <li>logBodyLength - Length of request body to log (defaults to 1024)</li>
    113 	 *         <li>timeout - Request timeout specified in milliseconds
    114 	 *         <li>requestObserver - Callback to receive XMLHttpRequest after open()</li>
    115 	 *         <li>responseType - The type of the response. See XHR 2 documentation for legal values</li>
    116 	 *         <li>responseCharset - The charset the response should be interpreted as</li>
    117 	 *         <li>successCodes - HTTP status codes that are considered successful, or FALSE to allow all</li>
    118 	 *     </ul>
    119 	 * @param {Zotero.CookieSandbox} [cookieSandbox] Cookie sandbox object
    120 	 * @return {Promise<XMLHttpRequest>} A promise resolved with the XMLHttpRequest object if the
    121 	 *     request succeeds, or rejected if the browser is offline or a non-2XX status response
    122 	 *     code is received (or a code not in options.successCodes if provided).
    123 	 */
    124 	this.request = Zotero.Promise.coroutine(function* (method, url, options = {}) {
    125 		if (url instanceof Components.interfaces.nsIURI) {
    126 			// Don't display password in console
    127 			var dispURL = this.getDisplayURI(url).spec;
    128 			url = url.spec;
    129 		}
    130 		else {
    131 			var dispURL = url;
    132 		}
    133 		
    134 		// Don't display API key in console
    135 		dispURL = dispURL.replace(/key=[^&]+&?/, "").replace(/\?$/, "");
    136 		
    137 		if (options.body && typeof options.body == 'string') {
    138 			let len = options.logBodyLength !== undefined ? options.logBodyLength : 1024;
    139 			var bodyStart = options.body.substr(0, len);
    140 			// Don't display sync password or session id in console
    141 			bodyStart = bodyStart.replace(/password":"[^"]+/, 'password":"********');
    142 			bodyStart = bodyStart.replace(/password=[^&]+/, 'password=********');
    143 			bodyStart = bodyStart.replace(/sessionid=[^&]+/, 'sessionid=********');
    144 			
    145 			Zotero.debug("HTTP " + method + ' "'
    146 				+ (options.body.length > len
    147 					? bodyStart + '\u2026" (' + options.body.length + ' chars)' : bodyStart + '"')
    148 				+ " to " + dispURL);
    149 		} else {
    150 			Zotero.debug("HTTP " + method + " " + dispURL);
    151 		}
    152 		
    153 		if (url.startsWith('http') && this.browserIsOffline()) {
    154 			Zotero.debug("HTTP " + method + " " + dispURL + " failed: Browser is offline");
    155 			throw new this.BrowserOfflineException();
    156 		}
    157 		
    158 		var deferred = Zotero.Promise.defer();
    159 		
    160 		if (!this.mock) {
    161 			var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"]
    162 				.createInstance();
    163 		}
    164 		else {
    165 			var xmlhttp = new this.mock;
    166 			// Add a dummy overrideMimeType() if it's not mocked
    167 			// https://github.com/cjohansen/Sinon.JS/issues/559
    168 			if (!xmlhttp.overrideMimeType) {
    169 				xmlhttp.overrideMimeType = function () {};
    170 			}
    171 		}
    172 		// Prevent certificate/authentication dialogs from popping up
    173 		if (!options.foreground) {
    174 			xmlhttp.mozBackgroundRequest = true;
    175 		}
    176 		xmlhttp.open(method, url, true);
    177 		
    178 		// Pass the request to a callback
    179 		if (options.requestObserver) {
    180 			options.requestObserver(xmlhttp);
    181 		}
    182 		
    183 		if (method == 'PUT') {
    184 			// Some servers (e.g., Jungle Disk DAV) return a 200 response code
    185 			// with Content-Length: 0, which triggers a "no element found" error
    186 			// in Firefox, so we override to text
    187 			xmlhttp.overrideMimeType("text/plain");
    188 		}
    189 		
    190 		// Send cookie even if "Allow third-party cookies" is disabled (>=Fx3.6 only)
    191 		var channel = xmlhttp.channel,
    192 			isFile = channel instanceof Components.interfaces.nsIFileChannel;
    193 		if(channel instanceof Components.interfaces.nsIHttpChannelInternal) {
    194 			channel.forceAllowThirdPartyCookie = true;
    195 			
    196 			// Set charset
    197 			//
    198 			// This is the method used in the connector, but as noted there, this parameter is a
    199 			// legacy of XPCOM functionality (where it could be set on the nsIChannel, which
    200 			// doesn't seem to work anymore), and we should probably allow responseContentType to
    201 			// be set instead
    202 			if (options.responseCharset) {
    203 				xmlhttp.overrideMimeType(`text/plain; charset=${responseCharset}`);
    204 			}
    205 			
    206 			// Disable caching if requested
    207 			if (options.dontCache) {
    208 				channel.loadFlags |= Components.interfaces.nsIRequest.LOAD_BYPASS_CACHE;
    209 			}
    210 		}
    211 
    212 		// Set responseType
    213 		if (options.responseType) {
    214 			xmlhttp.responseType = options.responseType;
    215 		}
    216 		
    217 		// Send headers
    218 		var headers = {};
    219 		if (options && options.headers) {
    220 			Object.assign(headers, options.headers);
    221 		}
    222 		var compressedBody = false;
    223 		if (options.body) {
    224 			if (!headers["Content-Type"]) {
    225 				headers["Content-Type"] = "application/x-www-form-urlencoded";
    226 			}
    227 			else if (headers["Content-Type"] == 'multipart/form-data') {
    228 				// Allow XHR to set Content-Type with boundary for multipart/form-data
    229 				delete headers["Content-Type"];
    230 			}
    231 			
    232 			if (options.compressBody && this.isWriteMethod(method)) {
    233 				headers['Content-Encoding'] = 'gzip';
    234 				compressedBody = yield Zotero.Utilities.Internal.gzip(options.body);
    235 				
    236 				let oldLen = options.body.length;
    237 				let newLen = compressedBody.length;
    238 				Zotero.debug(`${method} body gzipped from ${oldLen} to ${newLen}; `
    239 					+ Math.round(((oldLen - newLen) / oldLen) * 100) + "% savings");
    240 			}
    241 		}
    242 		if (options.debug) {
    243 			if (headers["Zotero-API-Key"]) {
    244 				let dispHeaders = {};
    245 				Object.assign(dispHeaders, headers);
    246 				if (dispHeaders["Zotero-API-Key"]) {
    247 					dispHeaders["Zotero-API-Key"] = "[Not shown]";
    248 				}
    249 				Zotero.debug(dispHeaders);
    250 			}
    251 			else {
    252 				Zotero.debug(headers);
    253 			}
    254 		}
    255 		for (var header in headers) {
    256 			xmlhttp.setRequestHeader(header, headers[header]);
    257 		}
    258 
    259 		// Set timeout
    260 		if (options.timeout) {
    261 			xmlhttp.timeout = options.timeout;
    262 		}
    263 
    264 		xmlhttp.ontimeout = function() {
    265 			deferred.reject(new Zotero.HTTP.TimeoutException(options.timeout));
    266 		};
    267 
    268 		xmlhttp.onloadend = function() {
    269 			var status = xmlhttp.status;
    270 			
    271 			// If an invalid HTTP response (e.g., NS_ERROR_INVALID_CONTENT_ENCODING) includes a
    272 			// 4xx or 5xx HTTP response code, swap it in, since it might be enough info to do
    273 			// what we need (e.g., verify a 404 from a WebDAV server)
    274 			try {
    275 				if (!status && xmlhttp.channel.responseStatus >= 400) {
    276 					Zotero.warn(`Overriding status for invalid response for ${dispURL} `
    277 						+ `(${xmlhttp.channel.status})`);
    278 					status = xmlhttp.channel.responseStatus;
    279 				}
    280 			}
    281 			catch (e) {}
    282 			
    283 			if (options.successCodes) {
    284 				var success = options.successCodes.indexOf(status) != -1;
    285 			}
    286 			// Explicit FALSE means allow any status code
    287 			else if (options.successCodes === false) {
    288 				var success = true;
    289 			}
    290 			else if(isFile) {
    291 				var success = status == 200 || status == 0;
    292 			}
    293 			else {
    294 				var success = status >= 200 && status < 300;
    295 			}
    296 			
    297 			if(success) {
    298 				Zotero.debug("HTTP " + method + " " + dispURL
    299 					+ " succeeded with " + status);
    300 				if (options.debug) {
    301 					Zotero.debug(xmlhttp.responseText);
    302 				}
    303 				deferred.resolve(xmlhttp);
    304 			} else {
    305 				let msg = "HTTP " + method + " " + dispURL + " failed with status code " + status;
    306 				if (!xmlhttp.responseType && xmlhttp.responseText) {
    307 					msg += ":\n\n" + xmlhttp.responseText;
    308 				}
    309 				Zotero.debug(msg, 1);
    310 				
    311 				try {
    312 					_checkSecurity(xmlhttp, channel);
    313 				}
    314 				catch (e) {
    315 					deferred.reject(e);
    316 					return;
    317 				}
    318 				
    319 				deferred.reject(new Zotero.HTTP.UnexpectedStatusException(xmlhttp, msg));
    320 			}
    321 		};
    322 		
    323 		if (options.cookieSandbox) {
    324 			options.cookieSandbox.attachToInterfaceRequestor(xmlhttp);
    325 		}
    326 		
    327 		// Send binary data
    328 		if (compressedBody) {
    329 			let numBytes = compressedBody.length;
    330 			let ui8Data = new Uint8Array(numBytes);
    331 			for (let i = 0; i < numBytes; i++) {
    332 				ui8Data[i] = compressedBody.charCodeAt(i) & 0xff;
    333 			}
    334 			xmlhttp.send(ui8Data);
    335 		}
    336 		// Send regular request
    337 		else {
    338 			xmlhttp.send(options.body || null);
    339 		}
    340 		
    341 		return deferred.promise;
    342 	});
    343 	
    344 	/**
    345 	 * Send an HTTP GET request via XMLHTTPRequest
    346 	 * 
    347 	 * @param {nsIURI|String}	url				URL to request
    348 	 * @param {Function} 		onDone			Callback to be executed upon request completion
    349 	 * @param {String} 		responseCharset	Character set to force on the response
    350 	 * @param {Zotero.CookieSandbox} [cookieSandbox] Cookie sandbox object
    351 	 * @param {Object} requestHeaders HTTP headers to include with request
    352 	 * @return {XMLHttpRequest} The XMLHttpRequest object if the request was sent, or
    353 	 *     false if the browser is offline
    354 	 * @deprecated Use {@link Zotero.HTTP.request}
    355 	 */
    356 	this.doGet = function(url, onDone, responseCharset, cookieSandbox, requestHeaders) {
    357 		if (url instanceof Components.interfaces.nsIURI) {
    358 			// Don't display password in console
    359 			var disp = this.getDisplayURI(url);
    360 			Zotero.debug("HTTP GET " + disp.spec);
    361 			url = url.spec;
    362 		}
    363 		else {
    364 			Zotero.debug("HTTP GET " + url);
    365 		}
    366 		if (this.browserIsOffline()){
    367 			return false;
    368 		}
    369 		
    370 		var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"]
    371 						.createInstance();
    372 		
    373 		// Prevent certificate/authentication dialogs from popping up
    374 		xmlhttp.mozBackgroundRequest = true;
    375 		xmlhttp.open('GET', url, true);
    376 		
    377 		// Send cookie even if "Allow third-party cookies" is disabled (>=Fx3.6 only)
    378 		var channel = xmlhttp.channel;
    379 		channel.QueryInterface(Components.interfaces.nsIHttpChannelInternal);
    380 		channel.forceAllowThirdPartyCookie = true;
    381 		
    382 		// Set charset -- see note in request() above
    383 		if (responseCharset) {
    384 			xmlhttp.overrideMimeType(`text/plain; charset=${responseCharset}`);
    385 		}
    386 		
    387 		// Set request headers
    388 		if (requestHeaders) {
    389 			for (var header in requestHeaders) {
    390 				xmlhttp.setRequestHeader(header, requestHeaders[header]);
    391 			}
    392 		}
    393 	
    394 		// Don't cache GET requests
    395 		xmlhttp.channel.loadFlags |= Components.interfaces.nsIRequest.LOAD_BYPASS_CACHE;
    396 		
    397 		var useMethodjit = Components.utils.methodjit;
    398 		/** @ignore */
    399 		xmlhttp.onreadystatechange = function() {
    400 			_stateChange(xmlhttp, onDone);
    401 		};
    402 		
    403 		if(cookieSandbox) cookieSandbox.attachToInterfaceRequestor(xmlhttp.getInterface(Components.interfaces.nsIInterfaceRequestor));
    404 		xmlhttp.send(null);
    405 		
    406 		return xmlhttp;
    407 	}
    408 	
    409 	/**
    410 	 * Send an HTTP POST request via XMLHTTPRequest
    411 	 *
    412 	 * @param {String} url URL to request
    413 	 * @param {String} body Request body
    414 	 * @param {Function} onDone Callback to be executed upon request completion
    415 	 * @param {String} headers Request HTTP headers
    416 	 * @param {String} responseCharset Character set to force on the response
    417 	 * @param {Zotero.CookieSandbox} [cookieSandbox] Cookie sandbox object
    418 	 * @return {XMLHttpRequest} The XMLHttpRequest object if the request was sent, or
    419 	 *     false if the browser is offline
    420 	 * @deprecated Use {@link Zotero.HTTP.request}
    421 	 */
    422 	this.doPost = function(url, body, onDone, headers, responseCharset, cookieSandbox) {
    423 		if (url instanceof Components.interfaces.nsIURI) {
    424 			// Don't display password in console
    425 			var disp = this.getDisplayURI(url);
    426 			url = url.spec;
    427 		}
    428 		
    429 		var bodyStart = body.substr(0, 1024);
    430 		// Don't display sync password or session id in console
    431 		bodyStart = bodyStart.replace(/password=[^&]+/, 'password=********');
    432 		bodyStart = bodyStart.replace(/sessionid=[^&]+/, 'sessionid=********');
    433 		
    434 		Zotero.debug("HTTP POST "
    435 			+ (body.length > 1024 ?
    436 				bodyStart + '... (' + body.length + ' chars)' : bodyStart)
    437 			+ " to " + (disp ? disp.spec : url));
    438 		
    439 		
    440 		if (this.browserIsOffline()){
    441 			return false;
    442 		}
    443 		
    444 		var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"]
    445 					.createInstance();
    446 		// Prevent certificate/authentication dialogs from popping up
    447 		xmlhttp.mozBackgroundRequest = true;
    448 		xmlhttp.open('POST', url, true);
    449 		// Send cookie even if "Allow third-party cookies" is disabled (>=Fx3.6 only)
    450 		var channel = xmlhttp.channel;
    451 		channel.QueryInterface(Components.interfaces.nsIHttpChannelInternal);
    452 		channel.forceAllowThirdPartyCookie = true;
    453 		
    454 		// Set charset -- see note in request() above
    455 		if (responseCharset) {
    456 			xmlhttp.overrideMimeType(`text/plain; charset=${responseCharset}`);
    457 		}
    458 		
    459 		if (headers) {
    460 			if (typeof headers == 'string') {
    461 				var msg = "doPost() now takes a headers object rather than a requestContentType -- update your code";
    462 				Zotero.debug(msg, 2);
    463 				Components.utils.reportError(msg);
    464 				headers = {
    465 					"Content-Type": headers
    466 				};
    467 			}
    468 		}
    469 		else {
    470 			headers = {};
    471 		}
    472 		
    473 		if (!headers["Content-Type"]) {
    474 			headers["Content-Type"] = "application/x-www-form-urlencoded";
    475 		}
    476 		
    477 		for (var header in headers) {
    478 			xmlhttp.setRequestHeader(header, headers[header]);
    479 		}
    480 		
    481 		var useMethodjit = Components.utils.methodjit;
    482 		/** @ignore */
    483 		xmlhttp.onreadystatechange = function() {
    484 			_stateChange(xmlhttp, onDone);
    485 		};
    486 		
    487 		if(cookieSandbox) cookieSandbox.attachToInterfaceRequestor(xmlhttp.getInterface(Components.interfaces.nsIInterfaceRequestor));
    488 		xmlhttp.send(body);
    489 		
    490 		return xmlhttp;
    491 	}
    492 	
    493 	/**
    494 	 * Send an HTTP HEAD request via XMLHTTPRequest
    495 	 *
    496 	 * @param {String} url URL to request
    497 	 * @param {Function} onDone Callback to be executed upon request completion
    498 	 * @param {Object} requestHeaders HTTP headers to include with request
    499 	 * @param {Zotero.CookieSandbox} [cookieSandbox] Cookie sandbox object
    500 	 * @return {XMLHttpRequest} The XMLHttpRequest object if the request was sent, or
    501 	 *     false if the browser is offline
    502 	 * @deprecated Use {@link Zotero.HTTP.request}
    503 	 */
    504 	this.doHead = function(url, onDone, requestHeaders, cookieSandbox) {
    505 		if (url instanceof Components.interfaces.nsIURI) {
    506 			// Don't display password in console
    507 			var disp = this.getDisplayURI(url);
    508 			Zotero.debug("HTTP HEAD " + disp.spec);
    509 			url = url.spec;
    510 		}
    511 		else {
    512 			Zotero.debug("HTTP HEAD " + url);
    513 		
    514 		}
    515 		
    516 		if (this.browserIsOffline()){
    517 			return false;
    518 		}
    519 		
    520 		// Workaround for "Accept third-party cookies" being off in Firefox 3.0.1
    521 		// https://www.zotero.org/trac/ticket/1070
    522 		var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"]
    523 						.createInstance();
    524 		// Prevent certificate/authentication dialogs from popping up
    525 		xmlhttp.mozBackgroundRequest = true;
    526 		xmlhttp.open('HEAD', url, true);
    527 		// Send cookie even if "Allow third-party cookies" is disabled (>=Fx3.6 only)
    528 		var channel = xmlhttp.channel;
    529 		channel.QueryInterface(Components.interfaces.nsIHttpChannelInternal);
    530 		channel.forceAllowThirdPartyCookie = true;
    531 		
    532 		if (requestHeaders) {
    533 			for (var header in requestHeaders) {
    534 				xmlhttp.setRequestHeader(header, requestHeaders[header]);
    535 			}
    536 		}
    537 		
    538 		// Don't cache HEAD requests
    539 		xmlhttp.channel.loadFlags |= Components.interfaces.nsIRequest.LOAD_BYPASS_CACHE;
    540 		
    541 		var useMethodjit = Components.utils.methodjit;
    542 		/** @ignore */
    543 		xmlhttp.onreadystatechange = function() {
    544 			_stateChange(xmlhttp, onDone);
    545 		};
    546 		
    547 		if(cookieSandbox) cookieSandbox.attachToInterfaceRequestor(xmlhttp.getInterface(Components.interfaces.nsIInterfaceRequestor));
    548 		xmlhttp.send(null);
    549 		
    550 		return xmlhttp;
    551 	}
    552 	
    553 	/**
    554 	 * Send an HTTP OPTIONS request via XMLHTTPRequest
    555 	 *
    556 	 * @param	{nsIURI}		url
    557 	 * @param	{Function}	onDone
    558 	 * @return	{XMLHTTPRequest}
    559 	 * @deprecated Use {@link Zotero.HTTP.request}
    560 	 */
    561 	this.doOptions = function (uri, callback) {
    562 		// Don't display password in console
    563 		var disp = this.getDisplayURI(uri);
    564 		Zotero.debug("HTTP OPTIONS for " + disp.spec);
    565 		
    566 		if (Zotero.HTTP.browserIsOffline()){
    567 			return false;
    568 		}
    569 		
    570 		var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"]
    571 					.createInstance();
    572 		// Prevent certificate/authentication dialogs from popping up
    573 		xmlhttp.mozBackgroundRequest = true;
    574 		xmlhttp.open('OPTIONS', uri.spec, true);
    575 		
    576 		var useMethodjit = Components.utils.methodjit;
    577 		/** @ignore */
    578 		xmlhttp.onreadystatechange = function() {
    579 			_stateChange(xmlhttp, callback);
    580 		};
    581 		xmlhttp.send(null);
    582 		return xmlhttp;
    583 	}
    584 	
    585 	
    586 	/**
    587 	 * Make a foreground HTTP request in order to trigger a proxy authentication dialog
    588 	 *
    589 	 * Other Zotero.HTTP requests are background requests by default, and
    590 	 * background requests don't trigger a proxy auth prompt, so we make a
    591 	 * foreground request on startup and resolve the promise
    592 	 * Zotero.proxyAuthComplete when we're done. Any network requests that want
    593 	 * to wait for proxy authentication can wait for that promise.
    594 	 */
    595 	this.triggerProxyAuth = function () {
    596 		if (!Zotero.isStandalone
    597 				|| !Zotero.Prefs.get("triggerProxyAuthentication")
    598 				|| Zotero.HTTP.browserIsOffline()) {
    599 			Zotero.proxyAuthComplete = Zotero.Promise.resolve();
    600 			return false;
    601 		}
    602 		
    603 		var deferred = Zotero.Promise.defer();
    604 		Zotero.proxyAuthComplete = deferred.promise;
    605 		
    606 		Zotero.Promise.try(function () {
    607 			var uris = Zotero.Prefs.get('proxyAuthenticationURLs').split(',');
    608 			uris = Zotero.Utilities.arrayShuffle(uris);
    609 			uris.unshift(ZOTERO_CONFIG.PROXY_AUTH_URL);
    610 			
    611 			return Zotero.spawn(function* () {
    612 				for (let i = 0; i <= uris.length; i++) {
    613 					let uri = uris.shift();
    614 					if (!uri) {
    615 						break;
    616 					}
    617 					
    618 					// For non-Zotero URLs, wait for PAC initialization,
    619 					// in a rather ugly and inefficient manner
    620 					if (i == 1) {
    621 						let installed = yield Zotero.Promise.try(_pacInstalled)
    622 						.then(function (installed) {
    623 							if (installed) throw true;
    624 						})
    625 						.delay(500)
    626 						.then(_pacInstalled)
    627 						.then(function (installed) {
    628 							if (installed) throw true;
    629 						})
    630 						.delay(1000)
    631 						.then(_pacInstalled)
    632 						.then(function (installed) {
    633 							if (installed) throw true;
    634 						})
    635 						.delay(2000)
    636 						.then(_pacInstalled)
    637 						.catch(function () {
    638 							return true;
    639 						});
    640 						if (!installed) {
    641 							Zotero.debug("No general proxy or PAC file found -- assuming direct connection");
    642 							break;
    643 						}
    644 					}
    645 					
    646 					let proxyInfo = yield _proxyAsyncResolve(uri);
    647 					if (proxyInfo) {
    648 						Zotero.debug("Proxy required for " + uri + " -- making HEAD request to trigger auth prompt");
    649 						yield Zotero.HTTP.request("HEAD", uri, {
    650 							foreground: true,
    651 							dontCache: true
    652 						})
    653 						.catch(function (e) {
    654 							Components.utils.reportError(e);
    655 							var msg = "Error connecting to proxy -- proxied requests may not work";
    656 							Zotero.log(msg, 'error');
    657 							Zotero.debug(msg, 1);
    658 						});
    659 						break;
    660 					}
    661 					else {
    662 						Zotero.debug("Proxy not required for " + uri);
    663 					}
    664 				}
    665 				deferred.resolve();
    666 			});
    667 		})
    668 		.catch(function (e) {
    669 			Components.utils.reportError(e);
    670 			Zotero.debug(e, 1);
    671 			deferred.resolve();
    672 		});
    673 	}
    674 	
    675 	
    676 	/**
    677 	 * Test if a PAC file is installed
    678 	 *
    679 	 * There might be a better way to do this that doesn't require stepping
    680 	 * through the error log and doing a fragile string comparison.
    681 	 */
    682 	_pacInstalled = function () {
    683 		return Zotero.getErrors(true).some(val => val.indexOf("PAC file installed") == 0)
    684 	}
    685 	
    686 	
    687 	_proxyAsyncResolve = function (uri) {
    688 		Components.utils.import("resource://gre/modules/NetUtil.jsm");
    689 		var pps = Components.classes["@mozilla.org/network/protocol-proxy-service;1"]
    690 			.getService(Components.interfaces.nsIProtocolProxyService);
    691 		var deferred = Zotero.Promise.defer();
    692 		pps.asyncResolve(
    693 			NetUtil.newURI(uri),
    694 			0,
    695 			{
    696 				onProxyAvailable: function (req, uri, proxyInfo, status) {
    697 					//Zotero.debug("onProxyAvailable");
    698 					//Zotero.debug(status);
    699 					deferred.resolve(proxyInfo);
    700 				},
    701 				
    702 				QueryInterface: function (iid) {
    703 					const interfaces = [
    704 						Components.interfaces.nsIProtocolProxyCallback,
    705 						Components.interfaces.nsISupports
    706 					];
    707 					if (!interfaces.some(function(v) { return iid.equals(v) })) {
    708 						throw Components.results.NS_ERROR_NO_INTERFACE;
    709 					}
    710 					return this;
    711 				},
    712 			}
    713 		);
    714 		return deferred.promise;
    715 	}
    716 	
    717 	
    718 	//
    719 	// WebDAV methods
    720 	//
    721 	
    722 	this.WebDAV = {};
    723 	
    724 	/**
    725 	 * Send a WebDAV MKCOL request via XMLHTTPRequest
    726 	 *
    727 	 * @param	{nsIURI}		url
    728 	 * @param	{Function}	onDone
    729 	 * @return	{XMLHTTPRequest}
    730 	 */
    731 	this.WebDAV.doMkCol = function (uri, callback) {
    732 		// Don't display password in console
    733 		var disp = Zotero.HTTP.getDisplayURI(uri);
    734 		Zotero.debug("HTTP MKCOL " + disp.spec);
    735 		
    736 		if (Zotero.HTTP.browserIsOffline()) {
    737 			return false;
    738 		}
    739 		
    740 		var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"]
    741 					.createInstance();
    742 		// Prevent certificate/authentication dialogs from popping up
    743 		xmlhttp.mozBackgroundRequest = true;
    744 		xmlhttp.open('MKCOL', uri.spec, true);
    745 		var useMethodjit = Components.utils.methodjit;
    746 		/** @ignore */
    747 		xmlhttp.onreadystatechange = function() {
    748 			_stateChange(xmlhttp, callback);
    749 		};
    750 		xmlhttp.send(null);
    751 		return xmlhttp;
    752 	}
    753 	
    754 	
    755 	/**
    756 	 * Send a WebDAV PUT request via XMLHTTPRequest
    757 	 *
    758 	 * @param	{nsIURI}		url
    759 	 * @param	{Function}	onDone
    760 	 * @return	{XMLHTTPRequest}
    761 	 */
    762 	this.WebDAV.doDelete = function (uri, callback) {
    763 		// Don't display password in console
    764 		var disp = Zotero.HTTP.getDisplayURI(uri);
    765 		
    766 		Zotero.debug("WebDAV DELETE to " + disp.spec);
    767 		
    768 		if (Zotero.HTTP.browserIsOffline()) {
    769 			return false;
    770 		}
    771 		
    772 		var xmlhttp = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"]
    773 					.createInstance();
    774 		// Prevent certificate/authentication dialogs from popping up
    775 		xmlhttp.mozBackgroundRequest = true;
    776 		xmlhttp.open("DELETE", uri.spec, true);
    777 		// Firefox 3 throws a "no element found" error even with a
    778 		// 204 ("No Content") response, so we override to text
    779 		xmlhttp.overrideMimeType("text/plain");
    780 		var useMethodjit = Components.utils.methodjit;
    781 		/** @ignore */
    782 		xmlhttp.onreadystatechange = function() {
    783 			_stateChange(xmlhttp, callback);
    784 		};
    785 		xmlhttp.send(null);
    786 		return xmlhttp;
    787 	}
    788 	
    789 	
    790 	this.isWriteMethod = function (method) {
    791 		return method == 'POST' || method == 'PUT' || method == 'PATCH' || method == 'DELETE';
    792 	};
    793 	
    794 	
    795 	this.getDisplayURI = function (uri) {
    796 		var disp = uri.clone();
    797 		if (disp.password) {
    798 			disp.password = "********";
    799 		}
    800 		return disp;
    801 	}
    802 	
    803 	
    804 	/**
    805 	 * Get the Authorization header used by a channel
    806 	 *
    807 	 * As of Firefox 3.0.1 subsequent requests to higher-level directories
    808 	 * seem not to authenticate properly and just return 401s, so this
    809 	 * can be used to manually include the Authorization header in a request
    810 	 *
    811 	 * It can also be used to check whether a request was forced to
    812 	 * use authentication
    813 	 *
    814 	 * @param	{nsIChannel}		channel
    815 	 * @return	{String|FALSE}				Authorization header, or FALSE if none
    816 	 */
    817 	this.getChannelAuthorization = function (channel) {
    818 		try {
    819 			channel.QueryInterface(Components.interfaces.nsIHttpChannel);
    820 			var authHeader = channel.getRequestHeader("Authorization");
    821 			return authHeader;
    822 		}
    823 		catch (e) {
    824 			Zotero.debug(e);
    825 			return false;
    826 		}
    827 	}
    828 	
    829 	
    830 	/**
    831 	 * Checks if the browser is currently in "Offline" mode
    832 	 *
    833 	 * @type Boolean
    834 	 */
    835 	this.browserIsOffline = function() { 
    836 		return Components.classes["@mozilla.org/network/io-service;1"]
    837 			.getService(Components.interfaces.nsIIOService).offline;
    838 	}
    839 	
    840 	
    841 	/**
    842 	 * Load one or more documents using XMLHttpRequest
    843 	 *
    844 	 * This should stay in sync with the equivalent function in the connector
    845 	 *
    846 	 * @param {String|String[]} urls URL(s) of documents to load
    847 	 * @param {Function} processor - Callback to be executed for each document loaded; if function returns
    848 	 *     a promise, it's waited for before continuing
    849 	 * @param {Zotero.CookieSandbox} [cookieSandbox] Cookie sandbox object
    850 	 * @return {Promise<Array>} - A promise for an array of results from the processor runs
    851 	 */
    852 	this.processDocuments = async function (urls, processor, cookieSandbox) {
    853 		// Handle old signature: urls, processor, onDone, onError, dontDelete, cookieSandbox
    854 		if (arguments.length > 3) {
    855 			Zotero.debug("Zotero.HTTP.processDocuments() now takes only 3 arguments -- update your code");
    856 			var onDone = arguments[2];
    857 			var onError = arguments[3];
    858 			var cookieSandbox = arguments[5];
    859 		}
    860 		
    861 		if (typeof urls == "string") urls = [urls];
    862 		var funcs = urls.map(url => () => {
    863 			return Zotero.HTTP.request(
    864 				"GET",
    865 				url,
    866 				{
    867 					responseType: 'document'
    868 				}
    869 			)
    870 			.then((xhr) => {
    871 				var doc = this.wrapDocument(xhr.response, url);
    872 				return processor(doc, url);
    873 			});
    874 		});
    875 		
    876 		// Run processes serially
    877 		// TODO: Add some concurrency?
    878 		var f;
    879 		var results = [];
    880 		while (f = funcs.shift()) {
    881 			try {
    882 				results.push(await f());
    883 			}
    884 			catch (e) {
    885 				if (onError) {
    886 					onError(e);
    887 				}
    888 				throw e;
    889 			}
    890 		}
    891 		
    892 		// Deprecated
    893 		if (onDone) {
    894 			onDone();
    895 		}
    896 		
    897 		return results;
    898 	};
    899 	
    900 	
    901 	/**
    902 	 * Load one or more documents in a hidden browser
    903 	 *
    904 	 * @param {String|String[]} urls URL(s) of documents to load
    905 	 * @param {Function} processor - Callback to be executed for each document loaded; if function returns
    906 	 *     a promise, it's waited for before continuing
    907 	 * @param {Function} onDone - Callback to be executed after all documents have been loaded
    908 	 * @param {Function} onError - Callback to be executed if an error occurs
    909 	 * @param {Boolean} dontDelete Don't delete the hidden browser upon completion; calling function
    910 	 *                             must call deleteHiddenBrowser itself.
    911 	 * @param {Zotero.CookieSandbox} [cookieSandbox] Cookie sandbox object
    912 	 * @return {browser} Hidden browser used for loading
    913 	 */
    914 	this.loadDocuments = function (urls, processor, onDone, onError, dontDelete, cookieSandbox) {
    915 		// (Approximately) how many seconds to wait if the document is left in the loading state and
    916 		// pageshow is called before we call pageshow with an incomplete document
    917 		const LOADING_STATE_TIMEOUT = 120;
    918 		var firedLoadEvent = 0;
    919 		
    920 		/**
    921 		 * Loads the next page
    922 		 * @inner
    923 		 */
    924 		var doLoad = function() {
    925 			if(currentURL < urls.length) {
    926 				var url = urls[currentURL],
    927 					hiddenBrowser = hiddenBrowsers[currentURL];
    928 				firedLoadEvent = 0;
    929 				currentURL++;
    930 				try {
    931 					Zotero.debug("Zotero.HTTP.loadDocuments: Loading " + url);
    932 					hiddenBrowser.loadURI(url);
    933 				} catch(e) {
    934 					if (onError) {
    935 						onError(e);
    936 						return;
    937 					} else {
    938 						if(!dontDelete) Zotero.Browser.deleteHiddenBrowser(hiddenBrowsers);
    939 						throw(e);
    940 					}
    941 				}
    942 			} else {
    943 				if(!dontDelete) Zotero.Browser.deleteHiddenBrowser(hiddenBrowsers);
    944 				if (onDone) onDone();
    945 			}
    946 		};
    947 		
    948 		/**
    949 		 * Callback to be executed when a page load completes
    950 		 * @inner
    951 		 */
    952 		var onLoad = function(e) {
    953 			var hiddenBrowser = e.currentTarget,
    954 				doc = hiddenBrowser.contentDocument;
    955 			if(hiddenBrowser.zotero_loaded) return;
    956 			if(!doc) return;
    957 			var url = doc.documentURI;
    958 			if(url === "about:blank") return;
    959 			if(doc.readyState === "loading" && (firedLoadEvent++) < 120) {
    960 				// Try again in a second
    961 				Zotero.setTimeout(onLoad.bind(this, {"currentTarget":hiddenBrowser}), 1000);
    962 				return;
    963 			}
    964 			
    965 			Zotero.debug("Zotero.HTTP.loadDocuments: " + url + " loaded");
    966 			hiddenBrowser.removeEventListener("pageshow", onLoad, true);
    967 			hiddenBrowser.zotero_loaded = true;
    968 			
    969 			var maybePromise;
    970 			var error;
    971 			try {
    972 				maybePromise = processor(doc);
    973 			}
    974 			catch (e) {
    975 				error = e;
    976 			}
    977 			
    978 			// If processor returns a promise, wait for it
    979 			if (maybePromise && maybePromise.then) {
    980 				maybePromise.then(() => doLoad())
    981 				.catch(e => {
    982 					if (onError) {
    983 						onError(e);
    984 					}
    985 					else {
    986 						throw e;
    987 					}
    988 				});
    989 				return;
    990 			}
    991 			
    992 			try {
    993 				if (error) {
    994 					if (onError) {
    995 						onError(error);
    996 					}
    997 					else {
    998 						throw error;
    999 					}
   1000 				}
   1001 			}
   1002 			finally {
   1003 				doLoad();
   1004 			}
   1005 		};
   1006 		
   1007 		if(typeof(urls) == "string") urls = [urls];
   1008 		
   1009 		var hiddenBrowsers = [],
   1010 			currentURL = 0;
   1011 		for(var i=0; i<urls.length; i++) {
   1012 			var hiddenBrowser = Zotero.Browser.createHiddenBrowser();
   1013 			if(cookieSandbox) cookieSandbox.attachToBrowser(hiddenBrowser);
   1014 			hiddenBrowser.addEventListener("pageshow", onLoad, true);
   1015 			hiddenBrowsers[i] = hiddenBrowser;
   1016 		}
   1017 		
   1018 		doLoad();
   1019 		
   1020 		return hiddenBrowsers.length === 1 ? hiddenBrowsers[0] : hiddenBrowsers.slice();
   1021 	}
   1022 	
   1023 	/**
   1024 	 * Handler for XMLHttpRequest state change
   1025 	 *
   1026 	 * @param {nsIXMLHttpRequest} xmlhttp XMLHttpRequest whose state just changed
   1027 	 * @param {Function} [callback] Callback for request completion
   1028 	 * @param {*} [data] Data to be passed back to callback as the second argument
   1029 	 * @private
   1030 	 */
   1031 	function _stateChange(xmlhttp, callback, data) {
   1032 		switch (xmlhttp.readyState){
   1033 			// Request not yet made
   1034 			case 1:
   1035 				break;
   1036 			
   1037 			case 2:
   1038 				break;
   1039 			
   1040 			// Called multiple times while downloading in progress
   1041 			case 3:
   1042 				break;
   1043 			
   1044 			// Download complete
   1045 			case 4:
   1046 				if (callback) {
   1047 					callback(xmlhttp, data);
   1048 				}
   1049 			break;
   1050 		}
   1051 	}
   1052 	
   1053 	function _checkSecurity(xmlhttp, channel) {
   1054 		if (xmlhttp.status != 0 || !channel) {
   1055 			return;
   1056 		}
   1057 		
   1058 		let secInfo = channel.securityInfo;
   1059 		let msg;
   1060 		let dialogButtonText;
   1061 		let dialogButtonCallback;
   1062 		if (secInfo instanceof Ci.nsITransportSecurityInfo) {
   1063 			secInfo.QueryInterface(Ci.nsITransportSecurityInfo);
   1064 			if ((secInfo.securityState & Ci.nsIWebProgressListener.STATE_IS_INSECURE)
   1065 					== Ci.nsIWebProgressListener.STATE_IS_INSECURE) {
   1066 				let url = channel.name;
   1067 				let ios = Components.classes["@mozilla.org/network/io-service;1"]
   1068 					.getService(Components.interfaces.nsIIOService);
   1069 				try {
   1070 					var uri = ios.newURI(url, null, null);
   1071 					var host = uri.host;
   1072 				}
   1073 				catch (e) {
   1074 					Zotero.debug(e);
   1075 				}
   1076 				let kbURL = 'https://www.zotero.org/support/kb/ssl_certificate_error';
   1077 				msg = Zotero.getString('sync.storage.error.webdav.sslCertificateError', host);
   1078 				dialogButtonText = Zotero.getString('general.moreInformation');
   1079 				dialogButtonCallback = function () {
   1080 					let wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
   1081 						.getService(Components.interfaces.nsIWindowMediator);
   1082 					let win = wm.getMostRecentWindow("navigator:browser");
   1083 					win.ZoteroPane.loadURI(kbURL, { metaKey: true, shiftKey: true });
   1084 				};
   1085 			}
   1086 			else if ((secInfo.securityState & Ci.nsIWebProgressListener.STATE_IS_BROKEN)
   1087 					== Ci.nsIWebProgressListener.STATE_IS_BROKEN) {
   1088 				msg = Zotero.getString('sync.error.sslConnectionError');
   1089 			}
   1090 			if (msg) {
   1091 				throw new Zotero.HTTP.SecurityException(
   1092 					msg,
   1093 					{
   1094 						dialogButtonText,
   1095 						dialogButtonCallback
   1096 					}
   1097 				);
   1098 			}
   1099 		}
   1100 	}
   1101 
   1102 	/**
   1103 	 * Mimics the window.location/document.location interface, given an nsIURL
   1104 	 * @param {nsIURL} url
   1105 	 */
   1106 	this.Location = function(url) {
   1107 		this._url = url;
   1108 		this.hash = url.ref ? "#"+url.ref : "";
   1109 		this.host = url.hostPort;
   1110 		this.hostname = url.host;
   1111 		this.href = url.spec;
   1112 		this.pathname = url.filePath;
   1113 		this.port = (url.schemeIs("https") ? 443 : 80);
   1114 		this.protocol = url.scheme+":";
   1115 		this.search = url.query ? "?"+url.query : "";
   1116 	};
   1117 	this.Location.prototype = {
   1118 		"toString":function() {
   1119 			return this.href;
   1120 		},
   1121 		"__exposedProps__":{
   1122 			"hash":"r",
   1123 			"host":"r",
   1124 			"hostname":"r",
   1125 			"href":"r",
   1126 			"pathname":"r",
   1127 			"port":"r",
   1128 			"protocol":"r",
   1129 			"search":"r",
   1130 			"toString":"r"
   1131 		}
   1132 	};
   1133 
   1134 	/**
   1135 	 * Mimics an HTMLWindow given an nsIURL
   1136 	 * @param {nsIURL} url
   1137 	 */
   1138 	this.Window = function(url) {
   1139 		this._url = url;
   1140 		this.top = this;
   1141 		this.location = Zotero.HTTP.Location(url);
   1142 	};
   1143 	this.Window.prototype.__exposedProps__ = {
   1144 		"top":"r",
   1145 		"location":"r"
   1146 	};
   1147 
   1148 	/**
   1149 	 * Wraps an HTMLDocument object returned by XMLHttpRequest DOMParser to make it look more like it belongs
   1150 	 * to a browser. This is necessary if the document is to be passed to Zotero.Translate.
   1151 	 * @param {HTMLDocument} doc Document returned by 
   1152 	 * @param {nsIURL|String} url
   1153 	 */
   1154 	 this.wrapDocument = function(doc, url) {
   1155 	 	if(typeof url !== "object") {
   1156 	 		url = Services.io.newURI(url, null, null).QueryInterface(Components.interfaces.nsIURL);
   1157 		}
   1158 		return Zotero.Translate.DOMWrapper.wrap(doc, {
   1159 			"documentURI":url.spec,
   1160 			"URL":url.spec,
   1161 			"location":new Zotero.HTTP.Location(url),
   1162 			"defaultView":new Zotero.HTTP.Window(url)
   1163 		});
   1164 	 }
   1165 }