large-file.js

  1. /**
  2. * @file Utilities for uploading and downloading files.
  3. *
  4. * @copyright 2015-2023 Tinode LLC.
  5. */
  6. 'use strict';
  7. import CommError from './comm-error.js';
  8. import {
  9. isUrlRelative,
  10. jsonParseHelper
  11. } from './utils.js';
  12. let XHRProvider;
  13. /**
  14. * @class LargeFileHelper - utilities for uploading and downloading files out of band.
  15. * Don't instantiate this class directly. Use {Tinode.getLargeFileHelper} instead.
  16. * @memberof Tinode
  17. *
  18. * @param {Tinode} tinode - the main Tinode object.
  19. * @param {string} version - protocol version, i.e. '0'.
  20. */
  21. export default class LargeFileHelper {
  22. constructor(tinode, version) {
  23. this._tinode = tinode;
  24. this._version = version;
  25. this._apiKey = tinode._apiKey;
  26. this._authToken = tinode.getAuthToken();
  27. // Ongoing requests.
  28. this.xhr = [];
  29. }
  30. /**
  31. * Start uploading the file to an endpoint at baseUrl.
  32. *
  33. * @memberof Tinode.LargeFileHelper#
  34. *
  35. * @param {string} baseUrl base URL of upload server.
  36. * @param {File|Blob} data data to upload.
  37. * @param {string} avatarFor topic name if the upload represents an avatar.
  38. * @param {Callback} onProgress callback. Takes one {float} parameter 0..1
  39. * @param {Callback} onSuccess callback. Called when the file is successfully uploaded.
  40. * @param {Callback} onFailure callback. Called in case of a failure.
  41. *
  42. * @returns {Promise} resolved/rejected when the upload is completed/failed.
  43. */
  44. uploadWithBaseUrl(baseUrl, data, avatarFor, onProgress, onSuccess, onFailure) {
  45. let url = `/v${this._version}/file/u/`;
  46. if (baseUrl) {
  47. let base = baseUrl;
  48. if (base.endsWith('/')) {
  49. // Removing trailing slash.
  50. base = base.slice(0, -1);
  51. }
  52. if (base.startsWith('http://') || base.startsWith('https://')) {
  53. url = base + url;
  54. } else {
  55. throw new Error(`Invalid base URL '${baseUrl}'`);
  56. }
  57. }
  58. const instance = this;
  59. const xhr = new XHRProvider();
  60. this.xhr.push(xhr);
  61. xhr.open('POST', url, true);
  62. xhr.setRequestHeader('X-Tinode-APIKey', this._apiKey);
  63. if (this._authToken) {
  64. xhr.setRequestHeader('X-Tinode-Auth', `Token ${this._authToken.token}`);
  65. }
  66. let toResolve = null;
  67. let toReject = null;
  68. const result = new Promise((resolve, reject) => {
  69. toResolve = resolve;
  70. toReject = reject;
  71. });
  72. xhr.upload.onprogress = e => {
  73. if (e.lengthComputable) {
  74. if (onProgress) {
  75. onProgress(e.loaded / e.total);
  76. }
  77. if (this.onProgress) {
  78. this.onProgress(e.loaded / e.total);
  79. }
  80. }
  81. };
  82. xhr.onload = function() {
  83. let pkt;
  84. try {
  85. pkt = JSON.parse(this.response, jsonParseHelper);
  86. } catch (err) {
  87. instance._tinode.logger("ERROR: Invalid server response in LargeFileHelper", this.response);
  88. pkt = {
  89. ctrl: {
  90. code: this.status,
  91. text: this.statusText
  92. }
  93. };
  94. }
  95. if (this.status >= 200 && this.status < 300) {
  96. if (toResolve) {
  97. toResolve(pkt.ctrl.params.url);
  98. }
  99. if (onSuccess) {
  100. onSuccess(pkt.ctrl);
  101. }
  102. } else if (this.status >= 400) {
  103. if (toReject) {
  104. toReject(new CommError(pkt.ctrl.text, pkt.ctrl.code));
  105. }
  106. if (onFailure) {
  107. onFailure(pkt.ctrl);
  108. }
  109. } else {
  110. instance._tinode.logger("ERROR: Unexpected server response status", this.status, this.response);
  111. }
  112. };
  113. xhr.onerror = function(e) {
  114. if (toReject) {
  115. toReject(e || new Error("failed"));
  116. }
  117. if (onFailure) {
  118. onFailure(null);
  119. }
  120. };
  121. xhr.onabort = function(e) {
  122. if (toReject) {
  123. toReject(new Error("upload cancelled by user"));
  124. }
  125. if (onFailure) {
  126. onFailure(null);
  127. }
  128. };
  129. try {
  130. const form = new FormData();
  131. form.append('file', data);
  132. form.set('id', this._tinode.getNextUniqueId());
  133. if (avatarFor) {
  134. form.set('topic', avatarFor);
  135. }
  136. xhr.send(form);
  137. } catch (err) {
  138. if (toReject) {
  139. toReject(err);
  140. }
  141. if (onFailure) {
  142. onFailure(null);
  143. }
  144. }
  145. return result;
  146. }
  147. /**
  148. * Start uploading the file to default endpoint.
  149. *
  150. * @memberof Tinode.LargeFileHelper#
  151. *
  152. * @param {File|Blob} data to upload
  153. * @param {string} avatarFor topic name if the upload represents an avatar.
  154. * @param {Callback} onProgress callback. Takes one {float} parameter 0..1
  155. * @param {Callback} onSuccess callback. Called when the file is successfully uploaded.
  156. * @param {Callback} onFailure callback. Called in case of a failure.
  157. *
  158. * @returns {Promise} resolved/rejected when the upload is completed/failed.
  159. */
  160. upload(data, avatarFor, onProgress, onSuccess, onFailure) {
  161. const baseUrl = (this._tinode._secure ? 'https://' : 'http://') + this._tinode._host;
  162. return this.uploadWithBaseUrl(baseUrl, data, avatarFor, onProgress, onSuccess, onFailure);
  163. }
  164. /**
  165. * Download the file from a given URL using GET request. This method works with the Tinode server only.
  166. *
  167. * @memberof Tinode.LargeFileHelper#
  168. *
  169. * @param {string} relativeUrl - URL to download the file from. Must be relative url, i.e. must not contain the host.
  170. * @param {string=} filename - file name to use for the downloaded file.
  171. *
  172. * @returns {Promise} resolved/rejected when the download is completed/failed.
  173. */
  174. download(relativeUrl, filename, mimetype, onProgress, onError) {
  175. if (!isUrlRelative(relativeUrl)) {
  176. // As a security measure refuse to download from an absolute URL.
  177. if (onError) {
  178. onError(`The URL '${relativeUrl}' must be relative, not absolute`);
  179. }
  180. return;
  181. }
  182. if (!this._authToken) {
  183. if (onError) {
  184. onError("Must authenticate first");
  185. }
  186. return;
  187. }
  188. const instance = this;
  189. const xhr = new XHRProvider();
  190. this.xhr.push(xhr);
  191. // Get data as blob (stored by the browser as a temporary file).
  192. xhr.open('GET', relativeUrl, true);
  193. xhr.setRequestHeader('X-Tinode-APIKey', this._apiKey);
  194. xhr.setRequestHeader('X-Tinode-Auth', 'Token ' + this._authToken.token);
  195. xhr.responseType = 'blob';
  196. xhr.onprogress = function(e) {
  197. if (onProgress) {
  198. // Passing e.loaded instead of e.loaded/e.total because e.total
  199. // is always 0 with gzip compression enabled by the server.
  200. onProgress(e.loaded);
  201. }
  202. };
  203. let toResolve = null;
  204. let toReject = null;
  205. const result = new Promise((resolve, reject) => {
  206. toResolve = resolve;
  207. toReject = reject;
  208. });
  209. // The blob needs to be saved as file. There is no known way to
  210. // save the blob as file other than to fake a click on an <a href... download=...>.
  211. xhr.onload = function() {
  212. if (this.status == 200) {
  213. const link = document.createElement('a');
  214. // URL.createObjectURL is not available in non-browser environment. This call will fail.
  215. link.href = window.URL.createObjectURL(new Blob([this.response], {
  216. type: mimetype
  217. }));
  218. link.style.display = 'none';
  219. link.setAttribute('download', filename);
  220. document.body.appendChild(link);
  221. link.click();
  222. document.body.removeChild(link);
  223. window.URL.revokeObjectURL(link.href);
  224. if (toResolve) {
  225. toResolve();
  226. }
  227. } else if (this.status >= 400 && toReject) {
  228. // The this.responseText is undefined, must use this.response which is a blob.
  229. // Need to convert this.response to JSON. The blob can only be accessed by the
  230. // FileReader.
  231. const reader = new FileReader();
  232. reader.onload = function() {
  233. try {
  234. const pkt = JSON.parse(this.result, jsonParseHelper);
  235. toReject(new CommError(pkt.ctrl.text, pkt.ctrl.code));
  236. } catch (err) {
  237. instance._tinode.logger("ERROR: Invalid server response in LargeFileHelper", this.result);
  238. toReject(err);
  239. }
  240. };
  241. reader.readAsText(this.response);
  242. }
  243. };
  244. xhr.onerror = function(e) {
  245. if (toReject) {
  246. toReject(new Error("failed"));
  247. }
  248. if (onError) {
  249. onError(e);
  250. }
  251. };
  252. xhr.onabort = function() {
  253. if (toReject) {
  254. toReject(null);
  255. }
  256. };
  257. try {
  258. xhr.send();
  259. } catch (err) {
  260. if (toReject) {
  261. toReject(err);
  262. }
  263. if (onError) {
  264. onError(err);
  265. }
  266. }
  267. return result;
  268. }
  269. /**
  270. * Try to cancel all ongoing uploads or downloads.
  271. * @memberof Tinode.LargeFileHelper#
  272. */
  273. cancel() {
  274. this.xhr.forEach(req => {
  275. if (req.readyState < 4) {
  276. req.abort();
  277. }
  278. });
  279. }
  280. /**
  281. * To use LargeFileHelper in a non browser context, supply XMLHttpRequest provider.
  282. * @static
  283. * @memberof LargeFileHelper
  284. * @param xhrProvider XMLHttpRequest provider, e.g. for node <code>require('xhr')</code>.
  285. */
  286. static setNetworkProvider(xhrProvider) {
  287. XHRProvider = xhrProvider;
  288. }
  289. }