UNPKG

35.9 kBJavaScriptView Raw
1/*
2Copyright 2015, 2016 OpenMarket Ltd
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8 http://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15*/
16"use strict";
17/**
18 * This is an internal module. See {@link MatrixHttpApi} for the public class.
19 * @module http-api
20 */
21import Promise from 'bluebird';
22const parseContentType = require('content-type').parse;
23
24const utils = require("./utils");
25
26// we use our own implementation of setTimeout, so that if we get suspended in
27// the middle of a /sync, we cancel the sync as soon as we awake, rather than
28// waiting for the delay to elapse.
29const callbacks = require("./realtime-callbacks");
30
31/*
32TODO:
33- CS: complete register function (doing stages)
34- Identity server: linkEmail, authEmail, bindEmail, lookup3pid
35*/
36
37/**
38 * A constant representing the URI path for release 0 of the Client-Server HTTP API.
39 */
40module.exports.PREFIX_R0 = "/_matrix/client/r0";
41
42/**
43 * A constant representing the URI path for as-yet unspecified Client-Server HTTP APIs.
44 */
45module.exports.PREFIX_UNSTABLE = "/_matrix/client/unstable";
46
47/**
48 * URI path for the identity API
49 */
50module.exports.PREFIX_IDENTITY_V1 = "/_matrix/identity/api/v1";
51
52/**
53 * URI path for the media repo API
54 */
55module.exports.PREFIX_MEDIA_R0 = "/_matrix/media/r0";
56
57/**
58 * Construct a MatrixHttpApi.
59 * @constructor
60 * @param {EventEmitter} event_emitter The event emitter to use for emitting events
61 * @param {Object} opts The options to use for this HTTP API.
62 * @param {string} opts.baseUrl Required. The base client-server URL e.g.
63 * 'http://localhost:8008'.
64 * @param {Function} opts.request Required. The function to call for HTTP
65 * requests. This function must look like function(opts, callback){ ... }.
66 * @param {string} opts.prefix Required. The matrix client prefix to use, e.g.
67 * '/_matrix/client/r0'. See PREFIX_R0 and PREFIX_UNSTABLE for constants.
68 *
69 * @param {boolean} opts.onlyData True to return only the 'data' component of the
70 * response (e.g. the parsed HTTP body). If false, requests will return an
71 * object with the properties <tt>code</tt>, <tt>headers</tt> and <tt>data</tt>.
72 *
73 * @param {string} opts.accessToken The access_token to send with requests. Can be
74 * null to not send an access token.
75 * @param {Object=} opts.extraParams Optional. Extra query parameters to send on
76 * requests.
77 * @param {Number=} opts.localTimeoutMs The default maximum amount of time to wait
78 * before timing out the request. If not specified, there is no timeout.
79 * @param {boolean} [opts.useAuthorizationHeader = false] Set to true to use
80 * Authorization header instead of query param to send the access token to the server.
81 */
82module.exports.MatrixHttpApi = function MatrixHttpApi(event_emitter, opts) {
83 utils.checkObjectHasKeys(opts, ["baseUrl", "request", "prefix"]);
84 opts.onlyData = opts.onlyData || false;
85 this.event_emitter = event_emitter;
86 this.opts = opts;
87 this.useAuthorizationHeader = Boolean(opts.useAuthorizationHeader);
88 this.uploads = [];
89};
90
91module.exports.MatrixHttpApi.prototype = {
92
93 /**
94 * Get the content repository url with query parameters.
95 * @return {Object} An object with a 'base', 'path' and 'params' for base URL,
96 * path and query parameters respectively.
97 */
98 getContentUri: function() {
99 const params = {
100 access_token: this.opts.accessToken,
101 };
102 return {
103 base: this.opts.baseUrl,
104 path: "/_matrix/media/v1/upload",
105 params: params,
106 };
107 },
108
109 /**
110 * Upload content to the Home Server
111 *
112 * @param {object} file The object to upload. On a browser, something that
113 * can be sent to XMLHttpRequest.send (typically a File). Under node.js,
114 * a Buffer, String or ReadStream.
115 *
116 * @param {object} opts options object
117 *
118 * @param {string=} opts.name Name to give the file on the server. Defaults
119 * to <tt>file.name</tt>.
120 *
121 * @param {boolean=} opts.includeFilename if false will not send the filename,
122 * e.g for encrypted file uploads where filename leaks are undesirable.
123 * Defaults to true.
124 *
125 * @param {string=} opts.type Content-type for the upload. Defaults to
126 * <tt>file.type</tt>, or <tt>applicaton/octet-stream</tt>.
127 *
128 * @param {boolean=} opts.rawResponse Return the raw body, rather than
129 * parsing the JSON. Defaults to false (except on node.js, where it
130 * defaults to true for backwards compatibility).
131 *
132 * @param {boolean=} opts.onlyContentUri Just return the content URI,
133 * rather than the whole body. Defaults to false (except on browsers,
134 * where it defaults to true for backwards compatibility). Ignored if
135 * opts.rawResponse is true.
136 *
137 * @param {Function=} opts.callback Deprecated. Optional. The callback to
138 * invoke on success/failure. See the promise return values for more
139 * information.
140 *
141 * @param {Function=} opts.progressHandler Optional. Called when a chunk of
142 * data has been uploaded, with an object containing the fields `loaded`
143 * (number of bytes transferred) and `total` (total size, if known).
144 *
145 * @return {module:client.Promise} Resolves to response object, as
146 * determined by this.opts.onlyData, opts.rawResponse, and
147 * opts.onlyContentUri. Rejects with an error (usually a MatrixError).
148 */
149 uploadContent: function(file, opts) {
150 if (utils.isFunction(opts)) {
151 // opts used to be callback
152 opts = {
153 callback: opts,
154 };
155 } else if (opts === undefined) {
156 opts = {};
157 }
158
159 // default opts.includeFilename to true (ignoring falsey values)
160 const includeFilename = opts.includeFilename !== false;
161
162 // if the file doesn't have a mime type, use a default since
163 // the HS errors if we don't supply one.
164 const contentType = opts.type || file.type || 'application/octet-stream';
165 const fileName = opts.name || file.name;
166
167 // we used to recommend setting file.stream to the thing to upload on
168 // nodejs.
169 const body = file.stream ? file.stream : file;
170
171 // backwards-compatibility hacks where we used to do different things
172 // between browser and node.
173 let rawResponse = opts.rawResponse;
174 if (rawResponse === undefined) {
175 if (global.XMLHttpRequest) {
176 rawResponse = false;
177 } else {
178 console.warn(
179 "Returning the raw JSON from uploadContent(). Future " +
180 "versions of the js-sdk will change this default, to " +
181 "return the parsed object. Set opts.rawResponse=false " +
182 "to change this behaviour now.",
183 );
184 rawResponse = true;
185 }
186 }
187
188 let onlyContentUri = opts.onlyContentUri;
189 if (!rawResponse && onlyContentUri === undefined) {
190 if (global.XMLHttpRequest) {
191 console.warn(
192 "Returning only the content-uri from uploadContent(). " +
193 "Future versions of the js-sdk will change this " +
194 "default, to return the whole response object. Set " +
195 "opts.onlyContentUri=false to change this behaviour now.",
196 );
197 onlyContentUri = true;
198 } else {
199 onlyContentUri = false;
200 }
201 }
202
203 // browser-request doesn't support File objects because it deep-copies
204 // the options using JSON.parse(JSON.stringify(options)). Instead of
205 // loading the whole file into memory as a string and letting
206 // browser-request base64 encode and then decode it again, we just
207 // use XMLHttpRequest directly.
208 // (browser-request doesn't support progress either, which is also kind
209 // of important here)
210
211 const upload = { loaded: 0, total: 0 };
212 let promise;
213
214 // XMLHttpRequest doesn't parse JSON for us. request normally does, but
215 // we're setting opts.json=false so that it doesn't JSON-encode the
216 // request, which also means it doesn't JSON-decode the response. Either
217 // way, we have to JSON-parse the response ourselves.
218 let bodyParser = null;
219 if (!rawResponse) {
220 bodyParser = function(rawBody) {
221 let body = JSON.parse(rawBody);
222 if (onlyContentUri) {
223 body = body.content_uri;
224 if (body === undefined) {
225 throw Error('Bad response');
226 }
227 }
228 return body;
229 };
230 }
231
232 if (global.XMLHttpRequest) {
233 const defer = Promise.defer();
234 const xhr = new global.XMLHttpRequest();
235 upload.xhr = xhr;
236 const cb = requestCallback(defer, opts.callback, this.opts.onlyData);
237
238 const timeout_fn = function() {
239 xhr.abort();
240 cb(new Error('Timeout'));
241 };
242
243 // set an initial timeout of 30s; we'll advance it each time we get
244 // a progress notification
245 xhr.timeout_timer = callbacks.setTimeout(timeout_fn, 30000);
246
247 xhr.onreadystatechange = function() {
248 switch (xhr.readyState) {
249 case global.XMLHttpRequest.DONE:
250 callbacks.clearTimeout(xhr.timeout_timer);
251 var resp;
252 try {
253 if (!xhr.responseText) {
254 throw new Error('No response body.');
255 }
256 resp = xhr.responseText;
257 if (bodyParser) {
258 resp = bodyParser(resp);
259 }
260 } catch (err) {
261 err.http_status = xhr.status;
262 cb(err);
263 return;
264 }
265 cb(undefined, xhr, resp);
266 break;
267 }
268 };
269 xhr.upload.addEventListener("progress", function(ev) {
270 callbacks.clearTimeout(xhr.timeout_timer);
271 upload.loaded = ev.loaded;
272 upload.total = ev.total;
273 xhr.timeout_timer = callbacks.setTimeout(timeout_fn, 30000);
274 if (opts.progressHandler) {
275 opts.progressHandler({
276 loaded: ev.loaded,
277 total: ev.total,
278 });
279 }
280 });
281 let url = this.opts.baseUrl + "/_matrix/media/v1/upload";
282
283 const queryArgs = [];
284
285 if (includeFilename && fileName) {
286 queryArgs.push("filename=" + encodeURIComponent(fileName));
287 }
288
289 if (!this.useAuthorizationHeader) {
290 queryArgs.push("access_token="
291 + encodeURIComponent(this.opts.accessToken));
292 }
293
294 if (queryArgs.length > 0) {
295 url += "?" + queryArgs.join("&");
296 }
297
298 xhr.open("POST", url);
299 if (this.useAuthorizationHeader) {
300 xhr.setRequestHeader("Authorization", "Bearer " + this.opts.accessToken);
301 }
302 xhr.setRequestHeader("Content-Type", contentType);
303 xhr.send(body);
304 promise = defer.promise;
305
306 // dirty hack (as per _request) to allow the upload to be cancelled.
307 promise.abort = xhr.abort.bind(xhr);
308 } else {
309 const queryParams = {};
310
311 if (includeFilename && fileName) {
312 queryParams.filename = fileName;
313 }
314
315 promise = this.authedRequest(
316 opts.callback, "POST", "/upload", queryParams, body, {
317 prefix: "/_matrix/media/v1",
318 headers: {"Content-Type": contentType},
319 json: false,
320 bodyParser: bodyParser,
321 },
322 );
323 }
324
325 const self = this;
326
327 // remove the upload from the list on completion
328 const promise0 = promise.finally(function() {
329 for (let i = 0; i < self.uploads.length; ++i) {
330 if (self.uploads[i] === upload) {
331 self.uploads.splice(i, 1);
332 return;
333 }
334 }
335 });
336
337 // copy our dirty abort() method to the new promise
338 promise0.abort = promise.abort;
339
340 upload.promise = promise0;
341 this.uploads.push(upload);
342
343 return promise0;
344 },
345
346 cancelUpload: function(promise) {
347 if (promise.abort) {
348 promise.abort();
349 return true;
350 }
351 return false;
352 },
353
354 getCurrentUploads: function() {
355 return this.uploads;
356 },
357
358 idServerRequest: function(callback, method, path, params, prefix) {
359 const fullUri = this.opts.idBaseUrl + prefix + path;
360
361 if (callback !== undefined && !utils.isFunction(callback)) {
362 throw Error(
363 "Expected callback to be a function but got " + typeof callback,
364 );
365 }
366
367 const opts = {
368 uri: fullUri,
369 method: method,
370 withCredentials: false,
371 json: false,
372 _matrix_opts: this.opts,
373 };
374 if (method == 'GET') {
375 opts.qs = params;
376 } else {
377 opts.form = params;
378 }
379
380 const defer = Promise.defer();
381 this.opts.request(
382 opts,
383 requestCallback(defer, callback, this.opts.onlyData),
384 );
385 // ID server does not always take JSON, so we can't use requests' 'json'
386 // option as we do with the home server, but it does return JSON, so
387 // parse it manually
388 return defer.promise.then(function(response) {
389 return JSON.parse(response);
390 });
391 },
392
393 /**
394 * Perform an authorised request to the homeserver.
395 * @param {Function} callback Optional. The callback to invoke on
396 * success/failure. See the promise return values for more information.
397 * @param {string} method The HTTP method e.g. "GET".
398 * @param {string} path The HTTP path <b>after</b> the supplied prefix e.g.
399 * "/createRoom".
400 *
401 * @param {Object=} queryParams A dict of query params (these will NOT be
402 * urlencoded). If unspecified, there will be no query params.
403 *
404 * @param {Object} data The HTTP JSON body.
405 *
406 * @param {Object|Number=} opts additional options. If a number is specified,
407 * this is treated as `opts.localTimeoutMs`.
408 *
409 * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before
410 * timing out the request. If not specified, there is no timeout.
411 *
412 * @param {sting=} opts.prefix The full prefix to use e.g.
413 * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix.
414 *
415 * @param {Object=} opts.headers map of additional request headers
416 *
417 * @return {module:client.Promise} Resolves to <code>{data: {Object},
418 * headers: {Object}, code: {Number}}</code>.
419 * If <code>onlyData</code> is set, this will resolve to the <code>data</code>
420 * object only.
421 * @return {module:http-api.MatrixError} Rejects with an error if a problem
422 * occurred. This includes network problems and Matrix-specific error JSON.
423 */
424 authedRequest: function(callback, method, path, queryParams, data, opts) {
425 if (!queryParams) {
426 queryParams = {};
427 }
428 if (this.useAuthorizationHeader) {
429 if (isFinite(opts)) {
430 // opts used to be localTimeoutMs
431 opts = {
432 localTimeoutMs: opts,
433 };
434 }
435 if (!opts) {
436 opts = {};
437 }
438 if (!opts.headers) {
439 opts.headers = {};
440 }
441 if (!opts.headers.Authorization) {
442 opts.headers.Authorization = "Bearer " + this.opts.accessToken;
443 }
444 if (queryParams.access_token) {
445 delete queryParams.access_token;
446 }
447 } else {
448 if (!queryParams.access_token) {
449 queryParams.access_token = this.opts.accessToken;
450 }
451 }
452
453 const requestPromise = this.request(
454 callback, method, path, queryParams, data, opts,
455 );
456
457 const self = this;
458 requestPromise.catch(function(err) {
459 if (err.errcode == 'M_UNKNOWN_TOKEN') {
460 self.event_emitter.emit("Session.logged_out");
461 } else if (err.errcode == 'M_CONSENT_NOT_GIVEN') {
462 self.event_emitter.emit(
463 "no_consent",
464 err.message,
465 err.data.consent_uri,
466 );
467 }
468 });
469
470 // return the original promise, otherwise tests break due to it having to
471 // go around the event loop one more time to process the result of the request
472 return requestPromise;
473 },
474
475 /**
476 * Perform a request to the homeserver without any credentials.
477 * @param {Function} callback Optional. The callback to invoke on
478 * success/failure. See the promise return values for more information.
479 * @param {string} method The HTTP method e.g. "GET".
480 * @param {string} path The HTTP path <b>after</b> the supplied prefix e.g.
481 * "/createRoom".
482 *
483 * @param {Object=} queryParams A dict of query params (these will NOT be
484 * urlencoded). If unspecified, there will be no query params.
485 *
486 * @param {Object} data The HTTP JSON body.
487 *
488 * @param {Object=} opts additional options
489 *
490 * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before
491 * timing out the request. If not specified, there is no timeout.
492 *
493 * @param {sting=} opts.prefix The full prefix to use e.g.
494 * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix.
495 *
496 * @param {Object=} opts.headers map of additional request headers
497 *
498 * @return {module:client.Promise} Resolves to <code>{data: {Object},
499 * headers: {Object}, code: {Number}}</code>.
500 * If <code>onlyData</code> is set, this will resolve to the <code>data</code>
501 * object only.
502 * @return {module:http-api.MatrixError} Rejects with an error if a problem
503 * occurred. This includes network problems and Matrix-specific error JSON.
504 */
505 request: function(callback, method, path, queryParams, data, opts) {
506 opts = opts || {};
507 const prefix = opts.prefix !== undefined ? opts.prefix : this.opts.prefix;
508 const fullUri = this.opts.baseUrl + prefix + path;
509
510 return this.requestOtherUrl(
511 callback, method, fullUri, queryParams, data, opts,
512 );
513 },
514
515 /**
516 * Perform an authorised request to the homeserver with a specific path
517 * prefix which overrides the default for this call only. Useful for hitting
518 * different Matrix Client-Server versions.
519 * @param {Function} callback Optional. The callback to invoke on
520 * success/failure. See the promise return values for more information.
521 * @param {string} method The HTTP method e.g. "GET".
522 * @param {string} path The HTTP path <b>after</b> the supplied prefix e.g.
523 * "/createRoom".
524 * @param {Object} queryParams A dict of query params (these will NOT be
525 * urlencoded).
526 * @param {Object} data The HTTP JSON body.
527 * @param {string} prefix The full prefix to use e.g.
528 * "/_matrix/client/v2_alpha".
529 * @param {Number=} localTimeoutMs The maximum amount of time to wait before
530 * timing out the request. If not specified, there is no timeout.
531 * @return {module:client.Promise} Resolves to <code>{data: {Object},
532 * headers: {Object}, code: {Number}}</code>.
533 * If <code>onlyData</code> is set, this will resolve to the <code>data</code>
534 * object only.
535 * @return {module:http-api.MatrixError} Rejects with an error if a problem
536 * occurred. This includes network problems and Matrix-specific error JSON.
537 *
538 * @deprecated prefer authedRequest with opts.prefix
539 */
540 authedRequestWithPrefix: function(callback, method, path, queryParams, data,
541 prefix, localTimeoutMs) {
542 return this.authedRequest(
543 callback, method, path, queryParams, data, {
544 localTimeoutMs: localTimeoutMs,
545 prefix: prefix,
546 },
547 );
548 },
549
550 /**
551 * Perform a request to the homeserver without any credentials but with a
552 * specific path prefix which overrides the default for this call only.
553 * Useful for hitting different Matrix Client-Server versions.
554 * @param {Function} callback Optional. The callback to invoke on
555 * success/failure. See the promise return values for more information.
556 * @param {string} method The HTTP method e.g. "GET".
557 * @param {string} path The HTTP path <b>after</b> the supplied prefix e.g.
558 * "/createRoom".
559 * @param {Object} queryParams A dict of query params (these will NOT be
560 * urlencoded).
561 * @param {Object} data The HTTP JSON body.
562 * @param {string} prefix The full prefix to use e.g.
563 * "/_matrix/client/v2_alpha".
564 * @param {Number=} localTimeoutMs The maximum amount of time to wait before
565 * timing out the request. If not specified, there is no timeout.
566 * @return {module:client.Promise} Resolves to <code>{data: {Object},
567 * headers: {Object}, code: {Number}}</code>.
568 * If <code>onlyData</code> is set, this will resolve to the <code>data</code>
569 * object only.
570 * @return {module:http-api.MatrixError} Rejects with an error if a problem
571 * occurred. This includes network problems and Matrix-specific error JSON.
572 *
573 * @deprecated prefer request with opts.prefix
574 */
575 requestWithPrefix: function(callback, method, path, queryParams, data, prefix,
576 localTimeoutMs) {
577 return this.request(
578 callback, method, path, queryParams, data, {
579 localTimeoutMs: localTimeoutMs,
580 prefix: prefix,
581 },
582 );
583 },
584
585 /**
586 * Perform a request to an arbitrary URL.
587 * @param {Function} callback Optional. The callback to invoke on
588 * success/failure. See the promise return values for more information.
589 * @param {string} method The HTTP method e.g. "GET".
590 * @param {string} uri The HTTP URI
591 *
592 * @param {Object=} queryParams A dict of query params (these will NOT be
593 * urlencoded). If unspecified, there will be no query params.
594 *
595 * @param {Object} data The HTTP JSON body.
596 *
597 * @param {Object=} opts additional options
598 *
599 * @param {Number=} opts.localTimeoutMs The maximum amount of time to wait before
600 * timing out the request. If not specified, there is no timeout.
601 *
602 * @param {sting=} opts.prefix The full prefix to use e.g.
603 * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix.
604 *
605 * @param {Object=} opts.headers map of additional request headers
606 *
607 * @return {module:client.Promise} Resolves to <code>{data: {Object},
608 * headers: {Object}, code: {Number}}</code>.
609 * If <code>onlyData</code> is set, this will resolve to the <code>data</code>
610 * object only.
611 * @return {module:http-api.MatrixError} Rejects with an error if a problem
612 * occurred. This includes network problems and Matrix-specific error JSON.
613 */
614 requestOtherUrl: function(callback, method, uri, queryParams, data,
615 opts) {
616 if (opts === undefined || opts === null) {
617 opts = {};
618 } else if (isFinite(opts)) {
619 // opts used to be localTimeoutMs
620 opts = {
621 localTimeoutMs: opts,
622 };
623 }
624
625 return this._request(
626 callback, method, uri, queryParams, data, opts,
627 );
628 },
629
630 /**
631 * Form and return a homeserver request URL based on the given path
632 * params and prefix.
633 * @param {string} path The HTTP path <b>after</b> the supplied prefix e.g.
634 * "/createRoom".
635 * @param {Object} queryParams A dict of query params (these will NOT be
636 * urlencoded).
637 * @param {string} prefix The full prefix to use e.g.
638 * "/_matrix/client/v2_alpha".
639 * @return {string} URL
640 */
641 getUrl: function(path, queryParams, prefix) {
642 let queryString = "";
643 if (queryParams) {
644 queryString = "?" + utils.encodeParams(queryParams);
645 }
646 return this.opts.baseUrl + prefix + path + queryString;
647 },
648
649 /**
650 * @private
651 *
652 * @param {function} callback
653 * @param {string} method
654 * @param {string} uri
655 * @param {object} queryParams
656 * @param {object|string} data
657 * @param {object=} opts
658 *
659 * @param {boolean} [opts.json =true] Json-encode data before sending, and
660 * decode response on receipt. (We will still json-decode error
661 * responses, even if this is false.)
662 *
663 * @param {object=} opts.headers extra request headers
664 *
665 * @param {number=} opts.localTimeoutMs client-side timeout for the
666 * request. Default timeout if falsy.
667 *
668 * @param {function=} opts.bodyParser function to parse the body of the
669 * response before passing it to the promise and callback.
670 *
671 * @return {module:client.Promise} a promise which resolves to either the
672 * response object (if this.opts.onlyData is truthy), or the parsed
673 * body. Rejects
674 */
675 _request: function(callback, method, uri, queryParams, data, opts) {
676 if (callback !== undefined && !utils.isFunction(callback)) {
677 throw Error(
678 "Expected callback to be a function but got " + typeof callback,
679 );
680 }
681 opts = opts || {};
682
683 const self = this;
684 if (this.opts.extraParams) {
685 for (const key in this.opts.extraParams) {
686 if (!this.opts.extraParams.hasOwnProperty(key)) {
687 continue;
688 }
689 queryParams[key] = this.opts.extraParams[key];
690 }
691 }
692
693 const headers = utils.extend({}, opts.headers || {});
694 const json = opts.json === undefined ? true : opts.json;
695 let bodyParser = opts.bodyParser;
696
697 // we handle the json encoding/decoding here, because request and
698 // browser-request make a mess of it. Specifically, they attempt to
699 // json-decode plain-text error responses, which in turn means that the
700 // actual error gets swallowed by a SyntaxError.
701
702 if (json) {
703 if (data) {
704 data = JSON.stringify(data);
705 headers['content-type'] = 'application/json';
706 }
707
708 if (!headers['accept']) {
709 headers['accept'] = 'application/json';
710 }
711
712 if (bodyParser === undefined) {
713 bodyParser = function(rawBody) {
714 return JSON.parse(rawBody);
715 };
716 }
717 }
718
719 const defer = Promise.defer();
720
721 let timeoutId;
722 let timedOut = false;
723 let req;
724 const localTimeoutMs = opts.localTimeoutMs || this.opts.localTimeoutMs;
725
726 const resetTimeout = () => {
727 if (localTimeoutMs) {
728 if (timeoutId) {
729 callbacks.clearTimeout(timeoutId);
730 }
731 timeoutId = callbacks.setTimeout(function() {
732 timedOut = true;
733 if (req && req.abort) {
734 req.abort();
735 }
736 defer.reject(new module.exports.MatrixError({
737 error: "Locally timed out waiting for a response",
738 errcode: "ORG.MATRIX.JSSDK_TIMEOUT",
739 timeout: localTimeoutMs,
740 }));
741 }, localTimeoutMs);
742 }
743 };
744 resetTimeout();
745
746 const reqPromise = defer.promise;
747
748 try {
749 req = this.opts.request(
750 {
751 uri: uri,
752 method: method,
753 withCredentials: false,
754 qs: queryParams,
755 qsStringifyOptions: opts.qsStringifyOptions,
756 useQuerystring: true,
757 body: data,
758 json: false,
759 timeout: localTimeoutMs,
760 headers: headers || {},
761 _matrix_opts: this.opts,
762 },
763 function(err, response, body) {
764 if (localTimeoutMs) {
765 callbacks.clearTimeout(timeoutId);
766 if (timedOut) {
767 return; // already rejected promise
768 }
769 }
770
771 const handlerFn = requestCallback(
772 defer, callback, self.opts.onlyData,
773 bodyParser,
774 );
775 handlerFn(err, response, body);
776 },
777 );
778 if (req) {
779 // This will only work in a browser, where opts.request is the
780 // `browser-request` import. Currently `request` does not support progress
781 // updates - see https://github.com/request/request/pull/2346.
782 // `browser-request` returns an XHRHttpRequest which exposes `onprogress`
783 if ('onprogress' in req) {
784 req.onprogress = (e) => {
785 // Prevent the timeout from rejecting the deferred promise if progress is
786 // seen with the request
787 resetTimeout();
788 };
789 }
790
791 // FIXME: This is EVIL, but I can't think of a better way to expose
792 // abort() operations on underlying HTTP requests :(
793 if (req.abort) reqPromise.abort = req.abort.bind(req);
794 }
795 } catch (ex) {
796 defer.reject(ex);
797 if (callback) {
798 callback(ex);
799 }
800 }
801 return reqPromise;
802 },
803};
804
805/*
806 * Returns a callback that can be invoked by an HTTP request on completion,
807 * that will either resolve or reject the given defer as well as invoke the
808 * given userDefinedCallback (if any).
809 *
810 * HTTP errors are transformed into javascript errors and the deferred is rejected.
811 *
812 * If bodyParser is given, it is used to transform the body of the successful
813 * responses before passing to the defer/callback.
814 *
815 * If onlyData is true, the defer/callback is invoked with the body of the
816 * response, otherwise the result object (with `code` and `data` fields)
817 *
818 */
819const requestCallback = function(
820 defer, userDefinedCallback, onlyData,
821 bodyParser,
822) {
823 userDefinedCallback = userDefinedCallback || function() {};
824
825 return function(err, response, body) {
826 if (!err) {
827 try {
828 if (response.statusCode >= 400) {
829 err = parseErrorResponse(response, body);
830 } else if (bodyParser) {
831 body = bodyParser(body);
832 }
833 } catch (e) {
834 err = new Error(`Error parsing server response: ${e}`);
835 }
836 }
837
838 if (err) {
839 defer.reject(err);
840 userDefinedCallback(err);
841 } else {
842 const res = {
843 code: response.statusCode,
844
845 // XXX: why do we bother with this? it doesn't work for
846 // XMLHttpRequest, so clearly we don't use it.
847 headers: response.headers,
848 data: body,
849 };
850 defer.resolve(onlyData ? body : res);
851 userDefinedCallback(null, onlyData ? body : res);
852 }
853 };
854};
855
856/**
857 * Attempt to turn an HTTP error response into a Javascript Error.
858 *
859 * If it is a JSON response, we will parse it into a MatrixError. Otherwise
860 * we return a generic Error.
861 *
862 * @param {XMLHttpRequest|http.IncomingMessage} response response object
863 * @param {String} body raw body of the response
864 * @returns {Error}
865 */
866function parseErrorResponse(response, body) {
867 const httpStatus = response.statusCode;
868 const contentType = getResponseContentType(response);
869
870 let err;
871 if (contentType) {
872 if (contentType.type === 'application/json') {
873 err = new module.exports.MatrixError(JSON.parse(body));
874 } else if (contentType.type === 'text/plain') {
875 err = new Error(`Server returned ${httpStatus} error: ${body}`);
876 }
877 }
878
879 if (!err) {
880 err = new Error(`Server returned ${httpStatus} error`);
881 }
882 err.httpStatus = httpStatus;
883 return err;
884}
885
886
887/**
888 * extract the Content-Type header from the response object, and
889 * parse it to a `{type, parameters}` object.
890 *
891 * returns null if no content-type header could be found.
892 *
893 * @param {XMLHttpRequest|http.IncomingMessage} response response object
894 * @returns {{type: String, parameters: Object}?} parsed content-type header, or null if not found
895 */
896function getResponseContentType(response) {
897 let contentType;
898 if (response.getResponseHeader) {
899 // XMLHttpRequest provides getResponseHeader
900 contentType = response.getResponseHeader("Content-Type");
901 } else if (response.headers) {
902 // request provides http.IncomingMessage which has a message.headers map
903 contentType = response.headers['content-type'] || null;
904 }
905
906 if (!contentType) {
907 return null;
908 }
909
910 try {
911 return parseContentType(contentType);
912 } catch(e) {
913 throw new Error(`Error parsing Content-Type '${contentType}': ${e}`);
914 }
915}
916
917/**
918 * Construct a Matrix error. This is a JavaScript Error with additional
919 * information specific to the standard Matrix error response.
920 * @constructor
921 * @param {Object} errorJson The Matrix error JSON returned from the homeserver.
922 * @prop {string} errcode The Matrix 'errcode' value, e.g. "M_FORBIDDEN".
923 * @prop {string} name Same as MatrixError.errcode but with a default unknown string.
924 * @prop {string} message The Matrix 'error' value, e.g. "Missing token."
925 * @prop {Object} data The raw Matrix error JSON used to construct this object.
926 * @prop {integer} httpStatus The numeric HTTP status code given
927 */
928module.exports.MatrixError = function MatrixError(errorJson) {
929 errorJson = errorJson || {};
930 this.errcode = errorJson.errcode;
931 this.name = errorJson.errcode || "Unknown error code";
932 this.message = errorJson.error || "Unknown message";
933 this.data = errorJson;
934};
935module.exports.MatrixError.prototype = Object.create(Error.prototype);
936/** */
937module.exports.MatrixError.prototype.constructor = module.exports.MatrixError;