large-file.js

/**
 * @file Utilities for uploading and downloading files.
 *
 * @copyright 2015-2023 Tinode LLC.
 */
'use strict';

import CommError from './comm-error.js';
import {
  isUrlRelative,
  jsonParseHelper
} from './utils.js';

let XHRProvider;

/**
 * @class LargeFileHelper - utilities for uploading and downloading files out of band.
 * Don't instantiate this class directly. Use {Tinode.getLargeFileHelper} instead.
 * @memberof Tinode
 *
 * @param {Tinode} tinode - the main Tinode object.
 * @param {string} version - protocol version, i.e. '0'.
 */
export default class LargeFileHelper {
  constructor(tinode, version) {
    this._tinode = tinode;
    this._version = version;

    this._apiKey = tinode._apiKey;
    this._authToken = tinode.getAuthToken();

    // Ongoing requests.
    this.xhr = [];
  }

  /**
   * Start uploading the file to an endpoint at baseUrl.
   *
   * @memberof Tinode.LargeFileHelper#
   *
   * @param {string} baseUrl base URL of upload server.
   * @param {File|Blob} data data to upload.
   * @param {string} avatarFor topic name if the upload represents an avatar.
   * @param {Callback} onProgress callback. Takes one {float} parameter 0..1
   * @param {Callback} onSuccess callback. Called when the file is successfully uploaded.
   * @param {Callback} onFailure callback. Called in case of a failure.
   *
   * @returns {Promise} resolved/rejected when the upload is completed/failed.
   */
  uploadWithBaseUrl(baseUrl, data, avatarFor, onProgress, onSuccess, onFailure) {
    let url = `/v${this._version}/file/u/`;
    if (baseUrl) {
      let base = baseUrl;
      if (base.endsWith('/')) {
        // Removing trailing slash.
        base = base.slice(0, -1);
      }
      if (base.startsWith('http://') || base.startsWith('https://')) {
        url = base + url;
      } else {
        throw new Error(`Invalid base URL '${baseUrl}'`);
      }
    }

    const instance = this;
    const xhr = new XHRProvider();
    this.xhr.push(xhr);

    xhr.open('POST', url, true);
    xhr.setRequestHeader('X-Tinode-APIKey', this._apiKey);
    if (this._authToken) {
      xhr.setRequestHeader('X-Tinode-Auth', `Token ${this._authToken.token}`);
    }

    let toResolve = null;
    let toReject = null;

    const result = new Promise((resolve, reject) => {
      toResolve = resolve;
      toReject = reject;
    });

    xhr.upload.onprogress = e => {
      if (e.lengthComputable) {
        if (onProgress) {
          onProgress(e.loaded / e.total);
        }
        if (this.onProgress) {
          this.onProgress(e.loaded / e.total);
        }
      }
    };

    xhr.onload = function() {
      let pkt;
      try {
        pkt = JSON.parse(this.response, jsonParseHelper);
      } catch (err) {
        instance._tinode.logger("ERROR: Invalid server response in LargeFileHelper", this.response);
        pkt = {
          ctrl: {
            code: this.status,
            text: this.statusText
          }
        };
      }

      if (this.status >= 200 && this.status < 300) {
        if (toResolve) {
          toResolve(pkt.ctrl.params.url);
        }
        if (onSuccess) {
          onSuccess(pkt.ctrl);
        }
      } else if (this.status >= 400) {
        if (toReject) {
          toReject(new CommError(pkt.ctrl.text, pkt.ctrl.code));
        }
        if (onFailure) {
          onFailure(pkt.ctrl);
        }
      } else {
        instance._tinode.logger("ERROR: Unexpected server response status", this.status, this.response);
      }
    };

    xhr.onerror = function(e) {
      if (toReject) {
        toReject(e || new Error("failed"));
      }
      if (onFailure) {
        onFailure(null);
      }
    };

    xhr.onabort = function(e) {
      if (toReject) {
        toReject(new Error("upload cancelled by user"));
      }
      if (onFailure) {
        onFailure(null);
      }
    };

    try {
      const form = new FormData();
      form.append('file', data);
      form.set('id', this._tinode.getNextUniqueId());
      if (avatarFor) {
        form.set('topic', avatarFor);
      }
      xhr.send(form);
    } catch (err) {
      if (toReject) {
        toReject(err);
      }
      if (onFailure) {
        onFailure(null);
      }
    }

    return result;
  }
  /**
   * Start uploading the file to default endpoint.
   *
   * @memberof Tinode.LargeFileHelper#
   *
   * @param {File|Blob} data to upload
   * @param {string} avatarFor topic name if the upload represents an avatar.
   * @param {Callback} onProgress callback. Takes one {float} parameter 0..1
   * @param {Callback} onSuccess callback. Called when the file is successfully uploaded.
   * @param {Callback} onFailure callback. Called in case of a failure.
   *
   * @returns {Promise} resolved/rejected when the upload is completed/failed.
   */
  upload(data, avatarFor, onProgress, onSuccess, onFailure) {
    const baseUrl = (this._tinode._secure ? 'https://' : 'http://') + this._tinode._host;
    return this.uploadWithBaseUrl(baseUrl, data, avatarFor, onProgress, onSuccess, onFailure);
  }
  /**
   * Download the file from a given URL using GET request. This method works with the Tinode server only.
   *
   * @memberof Tinode.LargeFileHelper#
   *
   * @param {string} relativeUrl - URL to download the file from. Must be relative url, i.e. must not contain the host.
   * @param {string=} filename - file name to use for the downloaded file.
   *
   * @returns {Promise} resolved/rejected when the download is completed/failed.
   */
  download(relativeUrl, filename, mimetype, onProgress, onError) {
    if (!isUrlRelative(relativeUrl)) {
      // As a security measure refuse to download from an absolute URL.
      if (onError) {
        onError(`The URL '${relativeUrl}' must be relative, not absolute`);
      }
      return;
    }
    if (!this._authToken) {
      if (onError) {
        onError("Must authenticate first");
      }
      return;
    }
    const instance = this;

    const xhr = new XHRProvider();
    this.xhr.push(xhr);

    // Get data as blob (stored by the browser as a temporary file).
    xhr.open('GET', relativeUrl, true);
    xhr.setRequestHeader('X-Tinode-APIKey', this._apiKey);
    xhr.setRequestHeader('X-Tinode-Auth', 'Token ' + this._authToken.token);
    xhr.responseType = 'blob';

    xhr.onprogress = function(e) {
      if (onProgress) {
        // Passing e.loaded instead of e.loaded/e.total because e.total
        // is always 0 with gzip compression enabled by the server.
        onProgress(e.loaded);
      }
    };

    let toResolve = null;
    let toReject = null;

    const result = new Promise((resolve, reject) => {
      toResolve = resolve;
      toReject = reject;
    });

    // The blob needs to be saved as file. There is no known way to
    // save the blob as file other than to fake a click on an <a href... download=...>.
    xhr.onload = function() {
      if (this.status == 200) {
        const link = document.createElement('a');
        // URL.createObjectURL is not available in non-browser environment. This call will fail.
        link.href = window.URL.createObjectURL(new Blob([this.response], {
          type: mimetype
        }));
        link.style.display = 'none';
        link.setAttribute('download', filename);
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        window.URL.revokeObjectURL(link.href);
        if (toResolve) {
          toResolve();
        }
      } else if (this.status >= 400 && toReject) {
        // The this.responseText is undefined, must use this.response which is a blob.
        // Need to convert this.response to JSON. The blob can only be accessed by the
        // FileReader.
        const reader = new FileReader();
        reader.onload = function() {
          try {
            const pkt = JSON.parse(this.result, jsonParseHelper);
            toReject(new CommError(pkt.ctrl.text, pkt.ctrl.code));
          } catch (err) {
            instance._tinode.logger("ERROR: Invalid server response in LargeFileHelper", this.result);
            toReject(err);
          }
        };
        reader.readAsText(this.response);
      }
    };

    xhr.onerror = function(e) {
      if (toReject) {
        toReject(new Error("failed"));
      }
      if (onError) {
        onError(e);
      }
    };

    xhr.onabort = function() {
      if (toReject) {
        toReject(null);
      }
    };

    try {
      xhr.send();
    } catch (err) {
      if (toReject) {
        toReject(err);
      }
      if (onError) {
        onError(err);
      }
    }

    return result;
  }
  /**
   * Try to cancel all ongoing uploads or downloads.
   * @memberof Tinode.LargeFileHelper#
   */
  cancel() {
    this.xhr.forEach(req => {
      if (req.readyState < 4) {
        req.abort();
      }
    });
  }
  /**
   * To use LargeFileHelper in a non browser context, supply XMLHttpRequest provider.
   * @static
   * @memberof LargeFileHelper
   * @param xhrProvider XMLHttpRequest provider, e.g. for node <code>require('xhr')</code>.
   */
  static setNetworkProvider(xhrProvider) {
    XHRProvider = xhrProvider;
  }
}