From af8b0164b59b5d6f8d31533e7d37d18bb7e29314 Mon Sep 17 00:00:00 2001 From: Lilia Date: Fri, 20 Oct 2017 15:52:02 -0700 Subject: [PATCH] Move to node fetch API for web requests instead of XHR (#1552) * Use node-fetch instead of xhr * Remove XMLHttpRequest.js // FREEBIE * Avoid calling json() on non json responses Previously we would catch and swallow JSON parsing errors resulting from an empty response, though empty responses are normal from a few endpoints, like requesting sms or voice registration codes. Since the JSON parsing call is now handled internally by node-fetch, we have to keep closer track of our expected response type to avoid throwing an exception. // FREEBIE --- Gruntfile.js | 2 - js/XMLHttpRequest.js | 637 ------------------------------------------- js/libtextsecure.js | 145 +++++----- libtextsecure/api.js | 145 +++++----- package.json | 1 + preload.js | 4 +- yarn.lock | 13 + 7 files changed, 177 insertions(+), 770 deletions(-) delete mode 100644 js/XMLHttpRequest.js diff --git a/Gruntfile.js b/Gruntfile.js index 67ff643c3..5ec898f3e 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -101,7 +101,6 @@ module.exports = function(grunt) { '!js/Mp3LameEncoder.min.js', '!js/libsignal-protocol-worker.js', '!js/components.js', - '!js/XMLHttpRequest.js', '!js/signal_protocol_store.js', '_locales/**/*' ], @@ -170,7 +169,6 @@ module.exports = function(grunt) { '!js/Mp3LameEncoder.min.js', '!js/libsignal-protocol-worker.js', '!js/components.js', - '!js/XMLHttpRequest.js', 'test/**/*.js', '!test/blanket_mocha.js', '!test/test.js', diff --git a/js/XMLHttpRequest.js b/js/XMLHttpRequest.js deleted file mode 100644 index 2afd5d2be..000000000 --- a/js/XMLHttpRequest.js +++ /dev/null @@ -1,637 +0,0 @@ -/** - * Wrapper for built-in http.js to emulate the browser XMLHttpRequest object. - * - * This can be used with JS designed for browsers to improve reuse of code and - * allow the use of existing libraries. - * - * Usage: include("XMLHttpRequest.js") and use XMLHttpRequest per W3C specs. - * - * @author Dan DeFelippi - * @contributor David Ellis - * @license MIT - */ - -var Url = require("url"); -var spawn = require("child_process").spawn; -var fs = require("fs"); - -exports.XMLHttpRequest = function() { - "use strict"; - - /** - * Private variables - */ - var self = this; - var http = require("http"); - var https = require("https"); - - // Holds http.js objects - var request; - var response; - - // Request settings - var settings = {}; - - // Disable header blacklist. - // Not part of XHR specs. - var disableHeaderCheck = false; - - // Set some default headers - var defaultHeaders = { - "User-Agent": "node-XMLHttpRequest", - "Accept": "*/*", - }; - - var headers = {}; - var headersCase = {}; - var certificateAuthorities; - var responseOffset; - - // These headers are not user setable. - // The following are allowed but banned in the spec: - // * user-agent - var forbiddenRequestHeaders = [ - "accept-charset", - "accept-encoding", - "access-control-request-headers", - "access-control-request-method", - "connection", - "content-length", - "content-transfer-encoding", - "cookie", - "cookie2", - "date", - "expect", - "host", - "keep-alive", - "origin", - "referer", - "te", - "trailer", - "transfer-encoding", - "upgrade", - "via" - ]; - - // These request methods are not allowed - var forbiddenRequestMethods = [ - "TRACE", - "TRACK", - "CONNECT" - ]; - - // Send flag - var sendFlag = false; - // Error flag, used when errors occur or abort is called - var errorFlag = false; - - // Event listeners - var listeners = {}; - - /** - * Constants - */ - - this.UNSENT = 0; - this.OPENED = 1; - this.HEADERS_RECEIVED = 2; - this.LOADING = 3; - this.DONE = 4; - - /** - * Public vars - */ - - // Current state - this.readyState = this.UNSENT; - - // default ready state change handler in case one is not set or is set late - this.onreadystatechange = null; - - // Result & response - this.responseText = ""; - this.responseXML = ""; - this.status = null; - this.statusText = null; - - // Whether cross-site Access-Control requests should be made using - // credentials such as cookies or authorization headers - this.withCredentials = false; - - /** - * Private methods - */ - - /** - * Check if the specified header is allowed. - * - * @param string header Header to validate - * @return boolean False if not allowed, otherwise true - */ - var isAllowedHttpHeader = function(header) { - return disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1); - }; - - /** - * Check if the specified method is allowed. - * - * @param string method Request method to validate - * @return boolean False if not allowed, otherwise true - */ - var isAllowedHttpMethod = function(method) { - return (method && forbiddenRequestMethods.indexOf(method) === -1); - }; - - /** - * Public methods - */ - - /** - * Open the connection. Currently supports local server requests. - * - * @param string method Connection method (eg GET, POST) - * @param string url URL for the connection. - * @param boolean async Asynchronous connection. Default is true. - * @param string user Username for basic authentication (optional) - * @param string password Password for basic authentication (optional) - */ - this.open = function(method, url, async, user, password) { - this.abort(); - errorFlag = false; - - // Check for valid request method - if (!isAllowedHttpMethod(method)) { - throw new Error("SecurityError: Request method not allowed"); - } - - settings = { - "method": method, - "url": url.toString(), - "async": (typeof async !== "boolean" ? true : async), - "user": user || null, - "password": password || null - }; - - setState(this.OPENED); - }; - - /** - * Disables or enables isAllowedHttpHeader() check the request. Enabled by default. - * This does not conform to the W3C spec. - * - * @param boolean state Enable or disable header checking. - */ - this.setDisableHeaderCheck = function(state) { - disableHeaderCheck = state; - }; - - /** - * Sets a header for the request or appends the value if one is already set. - * - * @param string header Header name - * @param string value Header value - */ - this.setRequestHeader = function(header, value) { - if (this.readyState !== this.OPENED) { - throw new Error("INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN"); - } - if (!isAllowedHttpHeader(header)) { - console.warn("Refused to set unsafe header \"" + header + "\""); - return; - } - if (sendFlag) { - throw new Error("INVALID_STATE_ERR: send flag is true"); - } - header = headersCase[header.toLowerCase()] || header; - headersCase[header.toLowerCase()] = header; - headers[header] = headers[header] ? headers[header] + ', ' + value : value; - }; - - this.setCertificateAuthorities = function(list) { - certificateAuthorities = list; - }; - - /** - * Gets a header from the server response. - * - * @param string header Name of header to get. - * @return string Text of the header or null if it doesn't exist. - */ - this.getResponseHeader = function(header) { - if (typeof header === "string" - && this.readyState > this.OPENED - && response - && response.headers - && response.headers[header.toLowerCase()] - && !errorFlag - ) { - return response.headers[header.toLowerCase()]; - } - - return null; - }; - - /** - * Gets all the response headers. - * - * @return string A string with all response headers separated by CR+LF - */ - this.getAllResponseHeaders = function() { - if (this.readyState < this.HEADERS_RECEIVED || errorFlag) { - return ""; - } - var result = ""; - - for (var i in response.headers) { - // Cookie headers are excluded - if (i !== "set-cookie" && i !== "set-cookie2") { - result += i + ": " + response.headers[i] + "\r\n"; - } - } - return result.substr(0, result.length - 2); - }; - - /** - * Gets a request header - * - * @param string name Name of header to get - * @return string Returns the request header or empty string if not set - */ - this.getRequestHeader = function(name) { - if (typeof name === "string" && headersCase[name.toLowerCase()]) { - return headers[headersCase[name.toLowerCase()]]; - } - - return ""; - }; - - /** - * Sends the request to the server. - * - * @param string data Optional data to send as request body. - */ - this.send = function(data) { - if (this.readyState !== this.OPENED) { - throw new Error("INVALID_STATE_ERR: connection must be opened before send() is called"); - } - - if (sendFlag) { - throw new Error("INVALID_STATE_ERR: send has already been called"); - } - - var ssl = false, local = false; - var url = Url.parse(settings.url); - var host; - // Determine the server - switch (url.protocol) { - case "https:": - ssl = true; - // SSL & non-SSL both need host, no break here. - case "http:": - host = url.hostname; - break; - - case "file:": - local = true; - break; - - case undefined: - case null: - case "": - host = "localhost"; - break; - - default: - throw new Error("Protocol not supported."); - } - - // Load files off the local filesystem (file://) - if (local) { - if (settings.method !== "GET") { - throw new Error("XMLHttpRequest: Only GET method is supported"); - } - - if (settings.async) { - fs.readFile(url.pathname, "utf8", function(error, data) { - if (error) { - self.handleError(error); - } else { - self.status = 200; - self.responseText = data; - setState(self.DONE); - } - }); - } else { - try { - this.responseText = fs.readFileSync(url.pathname, "utf8"); - this.status = 200; - setState(self.DONE); - } catch(e) { - this.handleError(e); - } - } - - return; - } - - // Default to port 80. If accessing localhost on another port be sure - // to use http://localhost:port/path - var port = url.port || (ssl ? 443 : 80); - // Add query string if one is used - var uri = url.pathname + (url.search ? url.search : ""); - - // Set the defaults if they haven't been set - for (var name in defaultHeaders) { - if (!headersCase[name.toLowerCase()]) { - headers[name] = defaultHeaders[name]; - } - } - - // Set the Host header or the server may reject the request - headers.Host = host; - if (!((ssl && port === 443) || port === 80)) { - headers.Host += ":" + url.port; - } - - // Set Basic Auth if necessary - if (settings.user) { - if (typeof settings.password === "undefined") { - settings.password = ""; - } - var authBuf = new Buffer(settings.user + ":" + settings.password); - headers.Authorization = "Basic " + authBuf.toString("base64"); - } - - // Set content length header - if (settings.method === "GET" || settings.method === "HEAD") { - data = null; - } else if (data) { - headers["Content-Length"] = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data); - - if (!headers["Content-Type"]) { - headers["Content-Type"] = "text/plain;charset=UTF-8"; - } - } else if (settings.method === "POST") { - // For a post with no data set Content-Length: 0. - // This is required by buggy servers that don't meet the specs. - headers["Content-Length"] = 0; - } - - var options = { - host: host, - port: port, - path: uri, - method: settings.method, - headers: headers, - agent: new https.Agent({ ca: certificateAuthorities }), - withCredentials: self.withCredentials - }; - - // Reset error flag - errorFlag = false; - - // Handle async requests - if (settings.async) { - // Use the proper protocol - var doRequest = ssl ? https.request : http.request; - - // Request is being sent, set send flag - sendFlag = true; - - // As per spec, this is called here for historical reasons. - self.dispatchEvent("readystatechange"); - - // Handler for the response - var responseHandler = function responseHandler(resp) { - // Set response var to the response we got back - // This is so it remains accessable outside this scope - response = resp; - // Check for redirect - // @TODO Prevent looped redirects - if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307) { - // Change URL to the redirect location - settings.url = response.headers.location; - var url = Url.parse(settings.url); - // Set host var in case it's used later - host = url.hostname; - // Options for the new request - var newOptions = { - hostname: url.hostname, - port: url.port, - path: url.path, - method: response.statusCode === 303 ? "GET" : settings.method, - headers: headers, - withCredentials: self.withCredentials - }; - - // Issue the new request - request = doRequest(newOptions, responseHandler).on("error", errorHandler); - request.end(); - // @TODO Check if an XHR event needs to be fired here - return; - } - - if (self.responseType === "arraybuffer") { - self.response = new ArrayBuffer(response.headers['content-length']); - responseOffset = 0; - } else { - response.setEncoding("utf8"); - } - - setState(self.HEADERS_RECEIVED); - self.status = response.statusCode; - - response.on("data", function(chunk) { - // Make sure there's some data - if (chunk) { - if (self.responseType === "arraybuffer") { - chunk.copy(new Uint8Array(self.response), responseOffset); - responseOffset += chunk.length; - } else { - self.responseText += chunk; // chunk is a string - } - } - // Don't emit state changes if the connection has been aborted. - if (sendFlag) { - setState(self.LOADING); - } - }); - - response.on("end", function() { - if (sendFlag) { - // Discard the end event if the connection has been aborted - - setState(self.DONE); - sendFlag = false; - } - }); - - response.on("error", function(error) { - self.handleError(error); - }); - }; - - // Error handler for the request - var errorHandler = function errorHandler(error) { - self.handleError(error); - }; - - // Create the request - request = doRequest(options, responseHandler).on("error", errorHandler); - - // Node 0.4 and later won't accept empty data. Make sure it's needed. - if (data) { - request.write(Buffer.from(data)); - } - - request.end(); - - self.dispatchEvent("loadstart"); - } else { // Synchronous - // Create a temporary file for communication with the other Node process - var contentFile = ".node-xmlhttprequest-content-" + process.pid; - var syncFile = ".node-xmlhttprequest-sync-" + process.pid; - fs.writeFileSync(syncFile, "", "utf8"); - // The async request the other Node process executes - var execString = "var http = require('http'), https = require('https'), fs = require('fs');" - + "var doRequest = http" + (ssl ? "s" : "") + ".request;" - + "var options = " + JSON.stringify(options) + ";" - + "var responseText = '';" - + "var req = doRequest(options, function(response) {" - + "response.setEncoding('utf8');" - + "response.on('data', function(chunk) {" - + " responseText += chunk;" - + "});" - + "response.on('end', function() {" - + "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, text: responseText}}), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" - + "response.on('error', function(error) {" - + "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: error}), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" - + "}).on('error', function(error) {" - + "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: error}), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" - + (data ? "req.write('" + JSON.stringify(data).slice(1,-1).replace(/'/g, "\\'") + "');":"") - + "req.end();"; - // Start the other Node Process, executing this string - var syncProc = spawn(process.argv[0], ["-e", execString]); - while(fs.existsSync(syncFile)) { - // Wait while the sync file is empty - } - var resp = JSON.parse(fs.readFileSync(contentFile, 'utf8')); - // Kill the child process once the file has data - syncProc.stdin.end(); - // Remove the temporary file - fs.unlinkSync(contentFile); - - if (resp.err) { - self.handleError(resp.err); - } else { - response = resp.data; - self.status = resp.data.statusCode; - self.responseText = resp.data.text; - setState(self.DONE); - } - } - }; - - /** - * Called when an error is encountered to deal with it. - */ - this.handleError = function(error) { - this.status = 0; - this.statusText = error; - this.responseText = error.stack; - errorFlag = true; - setState(this.DONE); - this.dispatchEvent('error'); - }; - - /** - * Aborts a request. - */ - this.abort = function() { - if (request) { - request.abort(); - request = null; - } - - headers = defaultHeaders; - this.status = 0; - this.responseText = ""; - this.responseXML = ""; - - errorFlag = true; - - if (this.readyState !== this.UNSENT - && (this.readyState !== this.OPENED || sendFlag) - && this.readyState !== this.DONE) { - sendFlag = false; - setState(this.DONE); - } - this.readyState = this.UNSENT; - this.dispatchEvent('abort'); - }; - - /** - * Adds an event listener. Preferred method of binding to events. - */ - this.addEventListener = function(event, callback) { - if (!(event in listeners)) { - listeners[event] = []; - } - // Currently allows duplicate callbacks. Should it? - listeners[event].push(callback); - }; - - /** - * Remove an event callback that has already been bound. - * Only works on the matching funciton, cannot be a copy. - */ - this.removeEventListener = function(event, callback) { - if (event in listeners) { - // Filter will return a new array with the callback removed - listeners[event] = listeners[event].filter(function(ev) { - return ev !== callback; - }); - } - }; - - /** - * Dispatch any events, including both "on" methods and events attached using addEventListener. - */ - this.dispatchEvent = function(event) { - if (typeof self["on" + event] === "function") { - self["on" + event](); - } - if (event in listeners) { - for (var i = 0, len = listeners[event].length; i < len; i++) { - listeners[event][i].call(self); - } - } - }; - - /** - * Changes readyState and calls onreadystatechange. - * - * @param int state New state - */ - var setState = function(state) { - if (state == self.LOADING || self.readyState !== state) { - self.readyState = state; - - if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) { - self.dispatchEvent("readystatechange"); - } - - if (self.readyState === self.DONE && !errorFlag) { - self.dispatchEvent("load"); - // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) - self.dispatchEvent("loadend"); - } - } - }; -}; diff --git a/js/libtextsecure.js b/js/libtextsecure.js index 7f310f66e..cd261ba3c 100644 --- a/js/libtextsecure.js +++ b/js/libtextsecure.js @@ -37480,66 +37480,72 @@ var TextSecureServer = (function() { return new nodeWebSocket(url, null, null, null, requestOptions); } - var XMLHttpRequest = nodeXMLHttpRequest; window.setImmediate = nodeSetImmediate; - // Promise-based async xhr routine function promise_ajax(url, options) { - return new Promise(function (resolve, reject) { - if (!url) { - url = options.host + '/' + options.path; - } - console.log(options.type, url); - var xhr = new XMLHttpRequest(); - xhr.open(options.type, url, true /*async*/); + return new Promise(function (resolve, reject) { + if (!url) { + url = options.host + '/' + options.path; + } + console.log(options.type, url); + var fetchOptions = { + method: options.type, + body: options.data || null, + headers: { 'X-Signal-Agent': 'OWD' }, + agent: new httpsAgent({ca: options.certificateAuthorities}) + }; - if ( options.responseType ) { - xhr[ 'responseType' ] = options.responseType; - } - if (options.user && options.password) { - xhr.setRequestHeader("Authorization", "Basic " + btoa(getString(options.user) + ":" + getString(options.password))); - } - if (options.contentType) { - xhr.setRequestHeader( "Content-Type", options.contentType ); - } - xhr.setRequestHeader( 'X-Signal-Agent', 'OWD' ); + if (fetchOptions.body instanceof ArrayBuffer) { + // node-fetch doesn't support ArrayBuffer, only node Buffer + var contentLength = fetchOptions.body.byteLength; + fetchOptions.body = nodeBuffer.from(fetchOptions.body); - if (options.certificateAuthorities) { - xhr.setCertificateAuthorities(options.certificateAuthorities); - } + // node-fetch doesn't set content-length like S3 requires + fetchOptions.headers["Content-Length"] = contentLength; + } - xhr.onload = function() { - var result = xhr.response; - if ( (!xhr.responseType || xhr.responseType === "text") && - typeof xhr.responseText === "string" ) { - result = xhr.responseText; - } - if (options.dataType === 'json') { - try { result = JSON.parse(xhr.responseText + ''); } catch(e) {} - if (options.validateResponse) { - if (!validateResponse(result, options.validateResponse)) { - console.log(options.type, url, xhr.status, 'Error'); - reject(HTTPError(xhr.status, result, options.stack)); - } - } - } - if ( 0 <= xhr.status && xhr.status < 400) { - console.log(options.type, url, xhr.status, 'Success'); - resolve(result, xhr.status); - } else { - console.log(options.type, url, xhr.status, 'Error'); - reject(HTTPError(xhr.status, result, options.stack)); + if (options.user && options.password) { + fetchOptions.headers["Authorization"] = "Basic " + btoa(getString(options.user) + ":" + getString(options.password)); + } + if (options.contentType) { + fetchOptions.headers["Content-Type"] = options.contentType; + } + window.nodeFetch(url, fetchOptions).then(function(response) { + var resultPromise; + if (options.responseType === 'json') { + resultPromise = response.json(); + } else if (!options.responseType || options.responseType === 'text') { + resultPromise = response.text(); + } else if (options.responseType === 'arraybuffer') { + resultPromise = response.buffer(); + } + return resultPromise.then(function(result) { + if (options.responseType === 'arraybuffer') { + result = result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength); + } + if (options.responseType === 'json') { + if (options.validateResponse) { + if (!validateResponse(result, options.validateResponse)) { + console.log(options.type, url, response.status, 'Error'); + reject(HTTPError(response.status, result, options.stack)); } - }; - xhr.onerror = function() { - console.log(options.type, url, xhr.status, 'Error'); - console.log(xhr.statusText); - reject(HTTPError(xhr.status, xhr.statusText, options.stack)); - }; - xhr.send( options.data || null ); - - scheduleHangWorkaround(); + } + } + if ( 0 <= response.status && response.status < 400) { + console.log(options.type, url, response.status, 'Success'); + resolve(result, response.status); + } else { + console.log(options.type, url, response.status, 'Error'); + reject(HTTPError(response.status, result, options.stack)); + } + }); + }).catch(function(e) { + console.log(options.type, url, 0, 'Error'); + console.log(e); + reject(HTTPError(0, e.toString(), options.stack)); }); + scheduleHangWorkaround(); + }); } function retry_ajax(url, options, limit, count) { @@ -37605,14 +37611,14 @@ var TextSecureServer = (function() { param.urlParameters = ''; } return ajax(null, { - host : this.url, - path : URL_CALLS[param.call] + param.urlParameters, - type : param.httpType, - data : param.jsonData && textsecure.utils.jsonThing(param.jsonData), - contentType : 'application/json; charset=utf-8', - dataType : 'json', - user : this.username, - password : this.password, + host : this.url, + path : URL_CALLS[param.call] + param.urlParameters, + type : param.httpType, + data : param.jsonData && textsecure.utils.jsonThing(param.jsonData), + contentType : 'application/json; charset=utf-8', + responseType : param.responseType, + user : this.username, + password : this.password, validateResponse: param.validateResponse, certificateAuthorities: window.config.certificateAuthorities }).catch(function(e) { @@ -37655,6 +37661,7 @@ var TextSecureServer = (function() { call : 'profile', httpType : 'GET', urlParameters : '/' + number, + responseType : 'json', }); }, getAvatar: function(path) { @@ -37687,12 +37694,13 @@ var TextSecureServer = (function() { registrationId : registrationId, }; - var call, urlPrefix, schema; + var call, urlPrefix, schema, responseType; if (deviceName) { jsonData.name = deviceName; call = 'devices'; urlPrefix = '/'; schema = { deviceId: 'number' }; + responseType = 'json' } else { call = 'accounts'; urlPrefix = '/code/'; @@ -37705,13 +37713,14 @@ var TextSecureServer = (function() { httpType : 'PUT', urlParameters : urlPrefix + code, jsonData : jsonData, + responseType : responseType, validateResponse : schema }); }, getDevices: function(number) { return this.ajax({ - call : 'devices', - httpType : 'GET', + call : 'devices', + httpType : 'GET', }); }, registerKeys: function(genKeys) { @@ -37746,6 +37755,7 @@ var TextSecureServer = (function() { return this.ajax({ call : 'signed', httpType : 'PUT', + responseType : 'json', jsonData : { keyId: signedPreKey.keyId, publicKey: btoa(getString(signedPreKey.publicKey)), @@ -37757,6 +37767,7 @@ var TextSecureServer = (function() { return this.ajax({ call : 'keys', httpType : 'GET', + responseType : 'json', validateResponse : {count: 'number'} }).then(function(res) { return res.count; @@ -37770,6 +37781,7 @@ var TextSecureServer = (function() { call : 'keys', httpType : 'GET', urlParameters : "/" + number + "/" + deviceId, + responseType : 'json', validateResponse : {identityKey: 'string', devices: 'object'} }).then(function(res) { if (res.devices.constructor !== Array) { @@ -37806,6 +37818,7 @@ var TextSecureServer = (function() { httpType : 'PUT', urlParameters : '/' + destination, jsonData : jsonData, + responseType : 'json', }); }, getAttachment: function(id) { @@ -37813,6 +37826,7 @@ var TextSecureServer = (function() { call : 'attachment', httpType : 'GET', urlParameters : '/' + id, + responseType : 'json', validateResponse : {location: 'string'} }).then(function(response) { return ajax(response.location, { @@ -37824,8 +37838,9 @@ var TextSecureServer = (function() { }, putAttachment: function(encryptedBin) { return this.ajax({ - call : 'attachment', - httpType : 'GET', + call : 'attachment', + httpType : 'GET', + responseType : 'json', }).then(function(response) { return ajax(response.location, { type : "PUT", diff --git a/libtextsecure/api.js b/libtextsecure/api.js index 9910ef786..fc2e12f98 100644 --- a/libtextsecure/api.js +++ b/libtextsecure/api.js @@ -43,66 +43,72 @@ var TextSecureServer = (function() { return new nodeWebSocket(url, null, null, null, requestOptions); } - var XMLHttpRequest = nodeXMLHttpRequest; window.setImmediate = nodeSetImmediate; - // Promise-based async xhr routine function promise_ajax(url, options) { - return new Promise(function (resolve, reject) { - if (!url) { - url = options.host + '/' + options.path; - } - console.log(options.type, url); - var xhr = new XMLHttpRequest(); - xhr.open(options.type, url, true /*async*/); + return new Promise(function (resolve, reject) { + if (!url) { + url = options.host + '/' + options.path; + } + console.log(options.type, url); + var fetchOptions = { + method: options.type, + body: options.data || null, + headers: { 'X-Signal-Agent': 'OWD' }, + agent: new httpsAgent({ca: options.certificateAuthorities}) + }; - if ( options.responseType ) { - xhr[ 'responseType' ] = options.responseType; - } - if (options.user && options.password) { - xhr.setRequestHeader("Authorization", "Basic " + btoa(getString(options.user) + ":" + getString(options.password))); - } - if (options.contentType) { - xhr.setRequestHeader( "Content-Type", options.contentType ); - } - xhr.setRequestHeader( 'X-Signal-Agent', 'OWD' ); + if (fetchOptions.body instanceof ArrayBuffer) { + // node-fetch doesn't support ArrayBuffer, only node Buffer + var contentLength = fetchOptions.body.byteLength; + fetchOptions.body = nodeBuffer.from(fetchOptions.body); - if (options.certificateAuthorities) { - xhr.setCertificateAuthorities(options.certificateAuthorities); - } + // node-fetch doesn't set content-length like S3 requires + fetchOptions.headers["Content-Length"] = contentLength; + } - xhr.onload = function() { - var result = xhr.response; - if ( (!xhr.responseType || xhr.responseType === "text") && - typeof xhr.responseText === "string" ) { - result = xhr.responseText; - } - if (options.dataType === 'json') { - try { result = JSON.parse(xhr.responseText + ''); } catch(e) {} - if (options.validateResponse) { - if (!validateResponse(result, options.validateResponse)) { - console.log(options.type, url, xhr.status, 'Error'); - reject(HTTPError(xhr.status, result, options.stack)); - } - } - } - if ( 0 <= xhr.status && xhr.status < 400) { - console.log(options.type, url, xhr.status, 'Success'); - resolve(result, xhr.status); - } else { - console.log(options.type, url, xhr.status, 'Error'); - reject(HTTPError(xhr.status, result, options.stack)); + if (options.user && options.password) { + fetchOptions.headers["Authorization"] = "Basic " + btoa(getString(options.user) + ":" + getString(options.password)); + } + if (options.contentType) { + fetchOptions.headers["Content-Type"] = options.contentType; + } + window.nodeFetch(url, fetchOptions).then(function(response) { + var resultPromise; + if (options.responseType === 'json') { + resultPromise = response.json(); + } else if (!options.responseType || options.responseType === 'text') { + resultPromise = response.text(); + } else if (options.responseType === 'arraybuffer') { + resultPromise = response.buffer(); + } + return resultPromise.then(function(result) { + if (options.responseType === 'arraybuffer') { + result = result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength); + } + if (options.responseType === 'json') { + if (options.validateResponse) { + if (!validateResponse(result, options.validateResponse)) { + console.log(options.type, url, response.status, 'Error'); + reject(HTTPError(response.status, result, options.stack)); } - }; - xhr.onerror = function() { - console.log(options.type, url, xhr.status, 'Error'); - console.log(xhr.statusText); - reject(HTTPError(xhr.status, xhr.statusText, options.stack)); - }; - xhr.send( options.data || null ); - - scheduleHangWorkaround(); + } + } + if ( 0 <= response.status && response.status < 400) { + console.log(options.type, url, response.status, 'Success'); + resolve(result, response.status); + } else { + console.log(options.type, url, response.status, 'Error'); + reject(HTTPError(response.status, result, options.stack)); + } + }); + }).catch(function(e) { + console.log(options.type, url, 0, 'Error'); + console.log(e); + reject(HTTPError(0, e.toString(), options.stack)); }); + scheduleHangWorkaround(); + }); } function retry_ajax(url, options, limit, count) { @@ -168,14 +174,14 @@ var TextSecureServer = (function() { param.urlParameters = ''; } return ajax(null, { - host : this.url, - path : URL_CALLS[param.call] + param.urlParameters, - type : param.httpType, - data : param.jsonData && textsecure.utils.jsonThing(param.jsonData), - contentType : 'application/json; charset=utf-8', - dataType : 'json', - user : this.username, - password : this.password, + host : this.url, + path : URL_CALLS[param.call] + param.urlParameters, + type : param.httpType, + data : param.jsonData && textsecure.utils.jsonThing(param.jsonData), + contentType : 'application/json; charset=utf-8', + responseType : param.responseType, + user : this.username, + password : this.password, validateResponse: param.validateResponse, certificateAuthorities: window.config.certificateAuthorities }).catch(function(e) { @@ -218,6 +224,7 @@ var TextSecureServer = (function() { call : 'profile', httpType : 'GET', urlParameters : '/' + number, + responseType : 'json', }); }, getAvatar: function(path) { @@ -250,12 +257,13 @@ var TextSecureServer = (function() { registrationId : registrationId, }; - var call, urlPrefix, schema; + var call, urlPrefix, schema, responseType; if (deviceName) { jsonData.name = deviceName; call = 'devices'; urlPrefix = '/'; schema = { deviceId: 'number' }; + responseType = 'json' } else { call = 'accounts'; urlPrefix = '/code/'; @@ -268,13 +276,14 @@ var TextSecureServer = (function() { httpType : 'PUT', urlParameters : urlPrefix + code, jsonData : jsonData, + responseType : responseType, validateResponse : schema }); }, getDevices: function(number) { return this.ajax({ - call : 'devices', - httpType : 'GET', + call : 'devices', + httpType : 'GET', }); }, registerKeys: function(genKeys) { @@ -309,6 +318,7 @@ var TextSecureServer = (function() { return this.ajax({ call : 'signed', httpType : 'PUT', + responseType : 'json', jsonData : { keyId: signedPreKey.keyId, publicKey: btoa(getString(signedPreKey.publicKey)), @@ -320,6 +330,7 @@ var TextSecureServer = (function() { return this.ajax({ call : 'keys', httpType : 'GET', + responseType : 'json', validateResponse : {count: 'number'} }).then(function(res) { return res.count; @@ -333,6 +344,7 @@ var TextSecureServer = (function() { call : 'keys', httpType : 'GET', urlParameters : "/" + number + "/" + deviceId, + responseType : 'json', validateResponse : {identityKey: 'string', devices: 'object'} }).then(function(res) { if (res.devices.constructor !== Array) { @@ -369,6 +381,7 @@ var TextSecureServer = (function() { httpType : 'PUT', urlParameters : '/' + destination, jsonData : jsonData, + responseType : 'json', }); }, getAttachment: function(id) { @@ -376,6 +389,7 @@ var TextSecureServer = (function() { call : 'attachment', httpType : 'GET', urlParameters : '/' + id, + responseType : 'json', validateResponse : {location: 'string'} }).then(function(response) { return ajax(response.location, { @@ -387,8 +401,9 @@ var TextSecureServer = (function() { }, putAttachment: function(encryptedBin) { return this.ajax({ - call : 'attachment', - httpType : 'GET', + call : 'attachment', + httpType : 'GET', + responseType : 'json', }).then(function(response) { return ajax(response.location, { type : "PUT", diff --git a/package.json b/package.json index 58d8d028e..1d095ec7b 100644 --- a/package.json +++ b/package.json @@ -155,6 +155,7 @@ "emoji-js": "^3.2.2", "lodash": "^4.17.4", "mkdirp": "^0.5.1", + "node-fetch": "^1.7.3", "os-locale": "^2.1.0", "rimraf": "^2.6.2", "semver": "^5.4.1", diff --git a/preload.js b/preload.js index 19f928190..7b2c2683c 100644 --- a/preload.js +++ b/preload.js @@ -42,7 +42,6 @@ require('./js/backup'); window.nodeSetImmediate = setImmediate; - window.nodeXMLHttpRequest = require("./js/XMLHttpRequest").XMLHttpRequest; window.nodeWebSocket = require("websocket").w3cwebsocket; // Linux seems to periodically let the event loop stop, so this is a global workaround @@ -51,4 +50,7 @@ }, 1000); window.EmojiConvertor = require('emoji-js'); + window.nodeFetch = require('node-fetch'); + window.httpsAgent = require('https').Agent; + window.nodeBuffer = Buffer; })(); diff --git a/yarn.lock b/yarn.lock index 9b806d080..99b7f537b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1177,6 +1177,12 @@ emoji-js@^3.2.2: dependencies: emoji-datasource "3.0.0" +encoding@^0.1.11: + version "0.1.12" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" + dependencies: + iconv-lite "~0.4.13" + end-of-stream@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.0.tgz#7a90d833efda6cfa6eac0f4949dbb0fad3a63206" @@ -2530,6 +2536,13 @@ node-emoji@^1.5.1, node-emoji@^1.8.1: dependencies: lodash.toarray "^4.4.0" +node-fetch@^1.7.3: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + node-gyp@^3.3.1: version "3.6.1" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.6.1.tgz#19561067ff185464aded478212681f47fd578cbc"