Home Reference Source

core/files.js

import Promise from 'es6-promise';
import map from 'lodash/map';
import assign from 'lodash/assign';
import isFunction from 'lodash/isFunction';
import isNumber from 'lodash/isNumber';
import url from 'url';
import { NetworkRequest, KinveyRequest, AuthType, RequestMethod, Headers } from './request';
import { KinveyError } from './errors';
import { KinveyObservable } from './observable';
import { Log } from './log';
import { isDefined } from './utils';
import { Query } from './query';
import { Client } from './client';

function randomInt(min, max) {
  return Math.floor(Math.random() * (max - min)) + min;
}

// Calculate where we should start the file upload
function getStartIndex(rangeHeader, max) {
  const start = rangeHeader ? parseInt(rangeHeader.split('-')[1], 10) + 1 : 0;
  return start >= max ? max - 1 : start;
}

/**
 * The FileStore class is used to find, save, update, remove, count and group files.
 */
export class FileStore {
  _clientInstance;

  /**
   * @private
   */
  // this has to be done at runtime,
  // because it doesn't exist when FileStore is being instantiated below
  get client() {
    if (!isDefined(this._clientInstance)) {
      this._clientInstance = Client.sharedInstance();
    }

    return this._clientInstance;
  }

  /**
   * @private
   * The pathname for the store.
   *
   * @return  {string}  Pathname
   */
  get pathname() {
    return `/blob/${this.client.appKey}`;
  }

  /**
   * Finds all files. A query can be optionally provided to return
   * a subset of all the files for your application or omitted to return all the files.
   * The number of files returned will adhere to the limits specified
   * at http://devcenter.kinvey.com/rest/guides/datastore#queryrestrictions. A
   * promise will be returned that will be resolved with the files or rejected with
   * an error.
   *
   * @param   {Query}                 [query]                                   Query used to filter result.
   * @param   {Object}                [options]                                 Options
   * @param   {boolean}               [options.tls=true]                        By default, all requests to retrieve files are made with https.
   *                                                                            By setting this flag to false, files are retrieved over unsecure http connections.
   * @param   {number}                [options.ttl]                             Specify a time to live for the _downloadURL to limit the amount of time the download url is valid.
   * @param   {boolean}               [options.download=false]                  Download the files
   * @return  {Promise<Object[]>}                                               An array of Kinvey file objects.
   */
  find(query, options = {}) {
    options = assign({ tls: true }, options);
    const queryStringObject = { tls: options.tls === true };

    if (isNumber(options.ttl)) {
      queryStringObject.ttl_in_seconds = parseInt(options.ttl, 10);
    }

    const stream = KinveyObservable.create((observer) => {
      // Check that the query is valid
      if (isDefined(query) && !(query instanceof Query)) {
        return observer.error(new KinveyError('Invalid query. It must be an instance of the Query class.'));
      }

      // Create the request
      const request = new KinveyRequest({
        method: RequestMethod.GET,
        authType: AuthType.Default,
        url: url.format({
          protocol: this.client.apiProtocol,
          host: this.client.apiHost,
          pathname: this.pathname,
          query: queryStringObject
        }),
        properties: options.properties,
        query: query,
        timeout: options.timeout,
        client: this.client
      });
      return request.execute()
        .then(response => response.data)
        .then(data => observer.next(data))
        .then(() => observer.complete())
        .catch(error => observer.error(error));
    });
    return stream.toPromise()
      .then((files) => {
        if (options.download === true) {
          return Promise.all(map(files, file => this.downloadByUrl(file._downloadURL, options)));
        }

        return files;
      });
  }

  findById(id, options) {
    return this.download(id, options);
  }

  /**
   * Download a file.
   *
   * @param   {string}                [name]                                    _id of the file to download.
   * @param   {Object}                [options]                                 Options
   * @param   {boolean}               [options.tls=true]                        By default, all requests to retrieve files are made with https.
   *                                                                            By setting this flag to false, files are retrieved over unsecure http connections.
   * @param   {number}                [options.ttl]                             Specify a time to live for the _downloadURL to limit the amount of time the download url is valid.
   * @return  {Promise<string>}                                                 A string representing the file.
  */
  download(name, options = {}) {
    options = assign({ tls: true }, options);
    const queryStringObject = { tls: options.tls === true };

    if (isNumber(options.ttl)) {
      queryStringObject.ttl_in_seconds = parseInt(options.ttl, 10);
    }

    const stream = KinveyObservable.create((observer) => {
      if (isDefined(name) === false) {
        observer.next(undefined);
        return observer.complete();
      }

      const request = new KinveyRequest({
        method: RequestMethod.GET,
        authType: AuthType.Default,
        url: url.format({
          protocol: this.client.apiProtocol,
          host: this.client.apiHost,
          pathname: `${this.pathname}/${name}`,
          query: queryStringObject
        }),
        properties: options.properties,
        timeout: options.timeout,
        client: this.client
      });
      return request.execute()
        .then(response => response.data)
        .then(data => observer.next(data))
        .then(() => observer.complete())
        .catch(error => observer.error(error));
    });
    return stream.toPromise()
      .then((file) => {
        if (options.stream === true) {
          return file;
        }

        options.mimeType = file.mimeType;
        return this.downloadByUrl(file._downloadURL, options);
      });
  }

  /**
   * Download a file using a url.
   *
   * @param   {string}        url                                           File download url
   * @param   {Object}        [options]                                     Options
   * @return  {Promise<string>}                                             File content.
  */
  downloadByUrl(url, options = {}) {
    const request = new NetworkRequest({
      method: RequestMethod.GET,
      url: url,
      timeout: options.timeout
    });
    return request.execute().then(response => response.data);
  }

  /**
   * Stream a file. A promise will be returned that will be resolved with the file or rejected with
   * an error.
   *
   * @param   {string}        name                                          File name
   * @param   {Object}        [options]                                     Options
   * @param   {Boolean}       [options.tls]                                 Use Transport Layer Security
   * @param   {Number}        [options.ttl]                                 Time To Live (in seconds)
   * @param   {DataPolicy}    [options.dataPolicy=DataPolicy.NetworkFirst]    Data policy
   * @param   {AuthType}      [options.authType=AuthType.Default]           Auth type
   * @return  {Promise}                                                     Promise
   *
   * @example
   * var files = new Kinvey.Files();
   * files.stream('BostonTeaParty.png', {
   *   tls: true, // Use transport layer security
   *   ttl: 60 * 60 * 24, // 1 day in seconds
   * }).then(function(file) {
   *   ...
   * }).catch(function(err) {
   *   ...
   * });
   */
  stream(name, options = {}) {
    options.stream = true;
    return this.download(name, options);
  }

  /**
   * Upload a file.
   *
   * @param {Blob|string} file  File content
   * @param {Object} [metadata={}] File metadata
   * @param {Object} [options={}] Options
   * @return {Promise<File>} A file entity.
   */
  upload(file, metadata = {}, options = {}) {
    metadata = this.transformMetadata(file, metadata);
    let kinveyFileData = null;

    return this.saveFileMetadata(options, metadata)
      .then((response) => {
        kinveyFileData = response.data;
        return this.makeStatusCheckRequest(response.data._uploadURL, response.data._requiredHeaders, metadata, options.timeout);
      })
      .then((response) => {
        Log.debug('File upload status check response', response);

        if (!response.isSuccess()) {
          return Promise.reject(response.error);
        }

        if (response.statusCode === 200 || response.statusCode === 201) {
          return response; // file is already uploaded
        }

        if (response.statusCode !== 308) {
          // TODO: Here we should handle redirects according to location header, but this generally shouldn't happen
          const error = new KinveyError('Unexpected response for upload file status check request.', false, response.statusCode, response.headers.get('X-Kinvey-Request-ID'));
          return Promise.reject(error);
        }

        const uploadOptions = {
          start: getStartIndex(response.headers.get('range'), metadata.size),
          timeout: options.timeout,
          maxBackoff: options.maxBackoff,
          headers: kinveyFileData._requiredHeaders
        };
        return this.retriableUpload(kinveyFileData._uploadURL, file, metadata, uploadOptions);
      })
      .then(() => {
        delete kinveyFileData._expiresAt;
        delete kinveyFileData._requiredHeaders;
        delete kinveyFileData._uploadURL;
        kinveyFileData._data = file;
        return kinveyFileData;
      });
  }

  /**
   * @private
   */
  transformMetadata(file, metadata) {
    const fileMetadata = assign({
      filename: file._filename || file.name,
      public: false,
      size: file.size || file.length,
      mimeType: file.mimeType || file.type || 'application/octet-stream'
    }, metadata);
    fileMetadata._filename = metadata.filename;
    delete fileMetadata.filename;
    fileMetadata._public = metadata.public;
    delete fileMetadata.public;
    return fileMetadata;
  }

  /**
   * Save the file to Kinvey
   *
   * @private
   */
  saveFileMetadata(options, metadata) {
    const isUpdate = isDefined(metadata._id);
    const request = new KinveyRequest({
      method: isUpdate ? RequestMethod.PUT : RequestMethod.POST,
      authType: AuthType.Default,
      headers: {
        'X-Kinvey-Content-Type': metadata.mimeType
      },
      url: url.format({
        protocol: this.client.apiProtocol,
        host: this.client.apiHost,
        pathname: isUpdate ? `${this.pathname}/${metadata._id}` : this.pathname
      }),
      properties: options.properties,
      timeout: options.timeout,
      body: metadata,
      client: this.client
    });
    return request.execute();
  }

  /**
   * @private
   */
  makeStatusCheckRequest(uploadUrl, requiredHeaders, metadata, timeout) {
    const headers = new Headers(requiredHeaders);
    headers.set('content-type', metadata.mimeType);
    headers.set('content-range', `bytes */${metadata.size}`);
    const request = new NetworkRequest({
      method: RequestMethod.PUT,
      url: uploadUrl,
      timeout: timeout,
      headers: headers
    });
    return request.execute();
  }

  /**
   * @private
   */
  retriableUpload(uploadUrl, file, metadata, options) {
    options = assign({
      count: 0,
      start: 0,
      maxBackoff: 32 * 1000
    }, options);

    Log.debug('Start file upload');
    Log.debug('File upload headers', options.headers);
    Log.debug('File upload upload url', url);
    Log.debug('File upload file', file);
    Log.debug('File upload metadata', metadata);
    Log.debug('File upload options', options);

    return this.makeUploadRequest(uploadUrl, file, metadata, options)
      .then((response) => {
        Log.debug('File upload response', response);

        if (response.isClientError()) {
          return Promise.reject(response.error);
        }
        if (!response.isSuccess() && !response.isServerError() && response.statusCode !== 308) {
          // TODO: Here we should handle redirects according to location header
          const error = new KinveyError('Unexpected response for upload file request.', false, response.statusCode, response.headers.get('X-Kinvey-Request-ID'));
          return Promise.reject(error);
        }

        return response;
      })
      .then((response) => {
        let backoff = 0;

        if (response.isServerError()) { // should retry
          Log.debug('File upload server error. Probably network congestion.', response.statusCode, response.data);
          backoff = (2 ** options.count) + randomInt(1, 1001); // Calculate the exponential backoff

          if (backoff >= options.maxBackoff) {
            return Promise.reject(response.error);
          }

          Log.debug(`File upload will try again in ${backoff} seconds.`);

          return new Promise((resolve) => {
            setTimeout(() => {
              options.count += 1;
              resolve(true);
            }, backoff);
          });
        }

        if (response.statusCode === 308) { // upload isn't complete, must upload the rest of the file
          Log.debug('File upload was incomplete (statusCode 308). Trying to upload the remainder of file.');
          options.start = getStartIndex(response.headers.get('range'), metadata.size);
          return new Promise((resolve) => {
            setTimeout(() => {
              options.count = 0;
              resolve(true);
            }, backoff);
          });
        }

        return new Promise((resolve) => {
          setTimeout(() => {
            resolve(false);
          }, backoff);
        });
      })
      .then((shouldRetry) => {
        if (shouldRetry) { // should continue with upload
          return this.retriableUpload(uploadUrl, file, metadata, options);
        }

        return null;
      });
  }

  /**
   * @protected
   */
  makeUploadRequest(uploadUrl, file, metadata, options) {
    const headers = new Headers(options.headers);
    headers.set('content-type', metadata.mimeType);
    headers.set('content-range', `bytes ${options.start}-${metadata.size - 1}/${metadata.size}`);
    const request = new NetworkRequest({
      method: RequestMethod.PUT,
      url: uploadUrl,
      headers: headers,
      body: isFunction(file.slice) ? file.slice(options.start) : file,
      timeout: options.timeout
    });
    return request.execute();
  }

  /**
   * @private
   */
  create(file, metadata, options) {
    return this.upload(file, metadata, options);
  }

  /**
   * @private
   */
  update(file, metadata, options) {
    return this.upload(file, metadata, options);
  }

  removeById(id, options = {}) {
    const stream = KinveyObservable.create((observer) => {
      try {
        if (isDefined(id) === false) {
          observer.next(undefined);
          return observer.complete();
        }

        const request = new KinveyRequest({
          method: RequestMethod.DELETE,
          authType: AuthType.Default,
          url: url.format({
            protocol: this.client.apiProtocol,
            host: this.client.apiHost,
            pathname: `${this.pathname}/${id}`
          }),
          properties: options.properties,
          timeout: options.timeout
        });
        return request.execute()
          .then(response => response.data)
          .then(data => observer.next(data))
          .then(() => observer.complete())
          .catch(error => observer.error(error));
      } catch (error) {
        return observer.error(error);
      }
    });

    return stream.toPromise();
  }

  /**
   * @private
   */
  remove() {
    throw new KinveyError('Please use removeById() to remove files one by one.');
  }
}

/**
 * @private
 */
export const Files = new FileStore();