file.js

const _ = require('underscore');
const cos = require('./uploader/cos');
const qiniu = require('./uploader/qiniu');
const s3 = require('./uploader/s3');
const AVError = require('./error');
const { request, _request: AVRequest } = require('./request');
const { tap, transformFetchOptions } = require('./utils');
const debug = require('debug')('leancloud:file');
const parseBase64 = require('./utils/parse-base64');

module.exports = function(AV) {
  // port from browserify path module
  // since react-native packager won't shim node modules.
  const extname = path => {
    if (!_.isString(path)) return '';
    return path.match(
      /^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/
    )[4];
  };

  const b64Digit = number => {
    if (number < 26) {
      return String.fromCharCode(65 + number);
    }
    if (number < 52) {
      return String.fromCharCode(97 + (number - 26));
    }
    if (number < 62) {
      return String.fromCharCode(48 + (number - 52));
    }
    if (number === 62) {
      return '+';
    }
    if (number === 63) {
      return '/';
    }
    throw new Error('Tried to encode large digit ' + number + ' in base64.');
  };

  var encodeBase64 = function(array) {
    var chunks = [];
    chunks.length = Math.ceil(array.length / 3);
    _.times(chunks.length, function(i) {
      var b1 = array[i * 3];
      var b2 = array[i * 3 + 1] || 0;
      var b3 = array[i * 3 + 2] || 0;

      var has2 = i * 3 + 1 < array.length;
      var has3 = i * 3 + 2 < array.length;

      chunks[i] = [
        b64Digit((b1 >> 2) & 0x3f),
        b64Digit(((b1 << 4) & 0x30) | ((b2 >> 4) & 0x0f)),
        has2 ? b64Digit(((b2 << 2) & 0x3c) | ((b3 >> 6) & 0x03)) : '=',
        has3 ? b64Digit(b3 & 0x3f) : '=',
      ].join('');
    });
    return chunks.join('');
  };

  /**
   * An AV.File is a local representation of a file that is saved to the AV
   * cloud.
   * @param name {String} The file's name. This will change to a unique value
   *     once the file has finished saving.
   * @param data {Array} The data for the file, as either:
   *     1. an Array of byte value Numbers, or
   *     2. an Object like { base64: "..." } with a base64-encoded String.
   *     3. a Blob(File) selected with a file upload control in a browser.
   *     4. an Object like { blob: {uri: "..."} } that mimics Blob
   *        in some non-browser environments such as React Native.
   *     5. a Buffer in Node.js runtime.
   *     6. a Stream in Node.js runtime.
   *
   *        For example:<pre>
   * var fileUploadControl = $("#profilePhotoFileUpload")[0];
   * if (fileUploadControl.files.length > 0) {
   *   var file = fileUploadControl.files[0];
   *   var name = "photo.jpg";
   *   var file = new AV.File(name, file);
   *   file.save().then(function() {
   *     // The file has been saved to AV.
   *   }, function(error) {
   *     // The file either could not be read, or could not be saved to AV.
   *   });
   * }</pre>
   *
   * @class
   * @param [mimeType] {String} Content-Type header to use for the file. If
   *     this is omitted, the content type will be inferred from the name's
   *     extension.
   */
  AV.File = function(name, data, mimeType) {
    this.attributes = {
      name,
      url: '',
      metaData: {},
      // 用来存储转换后要上传的 base64 String
      base64: '',
    };

    if (_.isString(data)) {
      throw new TypeError(
        'Creating an AV.File from a String is not yet supported.'
      );
    }
    if (_.isArray(data)) {
      this.attributes.metaData.size = data.length;
      data = { base64: encodeBase64(data) };
    }

    this._extName = '';
    this._data = data;
    this._uploadHeaders = {};

    if (data && data.blob && typeof data.blob.uri === 'string') {
      this._extName = extname(data.blob.uri);
    }

    if (typeof Blob !== 'undefined' && data instanceof Blob) {
      if (data.size) {
        this.attributes.metaData.size = data.size;
      }
      if (data.name) {
        this._extName = extname(data.name);
      }
    }

    /* NODE-ONLY:start */
    if (data instanceof require('stream') && data.path) {
      this._extName = extname(data.path);
    }
    if (Buffer.isBuffer(data)) {
      this.attributes.metaData.size = data.length;
    }
    /* NODE-ONLY:end */

    let owner;
    if (data && data.owner) {
      owner = data.owner;
    } else if (!AV._config.disableCurrentUser) {
      try {
        owner = AV.User.current();
      } catch (error) {
        if ('SYNC_API_NOT_AVAILABLE' !== error.code) {
          throw error;
        }
      }
    }

    this.attributes.metaData.owner = owner ? owner.id : 'unknown';

    this.set('mime_type', mimeType);
  };

  /**
   * Creates a fresh AV.File object with exists url for saving to AVOS Cloud.
   * @param {String} name the file name
   * @param {String} url the file url.
   * @param {Object} [metaData] the file metadata object.
   * @param {String} [type] Content-Type header to use for the file. If
   *     this is omitted, the content type will be inferred from the name's
   *     extension.
   * @return {AV.File} the file object
   */
  AV.File.withURL = function(name, url, metaData, type) {
    if (!name || !url) {
      throw new Error('Please provide file name and url');
    }
    var file = new AV.File(name, null, type);
    //copy metaData properties to file.
    if (metaData) {
      for (var prop in metaData) {
        if (!file.attributes.metaData[prop])
          file.attributes.metaData[prop] = metaData[prop];
      }
    }
    file.attributes.url = url;
    //Mark the file is from external source.
    file.attributes.metaData.__source = 'external';
    file.attributes.metaData.size = 0;
    return file;
  };

  /**
   * Creates a file object with exists objectId.
   * @param {String} objectId The objectId string
   * @return {AV.File} the file object
   */
  AV.File.createWithoutData = function(objectId) {
    if (!objectId) {
      throw new TypeError('The objectId must be provided');
    }
    var file = new AV.File();
    file.id = objectId;
    return file;
  };

  /**
   * Request file censor.
   * @since 4.13.0
   * @param {String} objectId
   * @return {Promise.<string>}
   */
  AV.File.censor = function(objectId) {
    if (!AV._config.masterKey) {
      throw new Error('Cannot censor a file without masterKey');
    }
    return request({
      method: 'POST',
      path: `/files/${objectId}/censor`,
      authOptions: { useMasterKey: true },
    }).then(res => res.censorResult);
  };

  _.extend(
    AV.File.prototype,
    /** @lends AV.File.prototype */ {
      className: '_File',

      _toFullJSON(seenObjects, full = true) {
        var json = _.clone(this.attributes);
        AV._objectEach(json, function(val, key) {
          json[key] = AV._encode(val, seenObjects, undefined, full);
        });
        AV._objectEach(this._operations, function(val, key) {
          json[key] = val;
        });

        if (_.has(this, 'id')) {
          json.objectId = this.id;
        }
        ['createdAt', 'updatedAt'].forEach(key => {
          if (_.has(this, key)) {
            const val = this[key];
            json[key] = _.isDate(val) ? val.toJSON() : val;
          }
        });
        if (full) {
          json.__type = 'File';
        }
        return json;
      },

      /**
       * Returns a JSON version of the file with meta data.
       * Inverse to {@link AV.parseJSON}
       * @since 3.0.0
       * @return {Object}
       */
      toFullJSON(seenObjects = []) {
        return this._toFullJSON(seenObjects);
      },

      /**
       * Returns a JSON version of the object.
       * @return {Object}
       */
      toJSON(key, holder, seenObjects = [this]) {
        return this._toFullJSON(seenObjects, false);
      },

      /**
       * Gets a Pointer referencing this file.
       * @private
       */
      _toPointer() {
        return {
          __type: 'Pointer',
          className: this.className,
          objectId: this.id,
        };
      },

      /**
       * Returns the ACL for this file.
       * @returns {AV.ACL} An instance of AV.ACL.
       */
      getACL() {
        return this._acl;
      },

      /**
       * Sets the ACL to be used for this file.
       * @param {AV.ACL} acl An instance of AV.ACL.
       */
      setACL(acl) {
        if (!(acl instanceof AV.ACL)) {
          return new AVError(AVError.OTHER_CAUSE, 'ACL must be a AV.ACL.');
        }
        this._acl = acl;
        return this;
      },

      /**
       * Gets the name of the file. Before save is called, this is the filename
       * given by the user. After save is called, that name gets prefixed with a
       * unique identifier.
       */
      name() {
        return this.get('name');
      },

      /**
       * Gets the url of the file. It is only available after you save the file or
       * after you get the file from a AV.Object.
       * @return {String}
       */
      url() {
        return this.get('url');
      },

      /**
       * Gets the attributs of the file object.
       * @param {String} The attribute name which want to get.
       * @returns {Any}
       */
      get(attrName) {
        switch (attrName) {
          case 'objectId':
            return this.id;
          case 'url':
          case 'name':
          case 'mime_type':
          case 'metaData':
          case 'createdAt':
          case 'updatedAt':
            return this.attributes[attrName];
          default:
            return this.attributes.metaData[attrName];
        }
      },

      /**
       * Set the metaData of the file object.
       * @param {Object} Object is an key value Object for setting metaData.
       * @param {String} attr is an optional metadata key.
       * @param {Object} value is an optional metadata value.
       * @returns {String|Number|Array|Object}
       */
      set(...args) {
        const set = (attrName, value) => {
          switch (attrName) {
            case 'name':
            case 'url':
            case 'mime_type':
            case 'base64':
            case 'metaData':
              this.attributes[attrName] = value;
              break;
            default:
              // File 并非一个 AVObject,不能完全自定义其他属性,所以只能都放在 metaData 上面
              this.attributes.metaData[attrName] = value;
              break;
          }
        };

        switch (args.length) {
          case 1:
            // 传入一个 Object
            for (var k in args[0]) {
              set(k, args[0][k]);
            }
            break;
          case 2:
            set(args[0], args[1]);
            break;
        }
        return this;
      },

      /**
       * Set a header for the upload request.
       * For more infomation, go to https://url.leanapp.cn/avfile-upload-headers
       *
       * @param {String} key header key
       * @param {String} value header value
       * @return {AV.File} this
       */
      setUploadHeader(key, value) {
        this._uploadHeaders[key] = value;
        return this;
      },

      /**
       * <p>Returns the file's metadata JSON object if no arguments is given.Returns the
       * metadata value if a key is given.Set metadata value if key and value are both given.</p>
       * <p><pre>
       *  var metadata = file.metaData(); //Get metadata JSON object.
       *  var size = file.metaData('size');  // Get the size metadata value.
       *  file.metaData('format', 'jpeg'); //set metadata attribute and value.
       *</pre></p>
       * @return {Object} The file's metadata JSON object.
       * @param {String} attr an optional metadata key.
       * @param {Object} value an optional metadata value.
       **/
      metaData(attr, value) {
        if (attr && value) {
          this.attributes.metaData[attr] = value;
          return this;
        } else if (attr && !value) {
          return this.attributes.metaData[attr];
        } else {
          return this.attributes.metaData;
        }
      },

      /**
       * 如果文件是图片,获取图片的缩略图URL。可以传入宽度、高度、质量、格式等参数。
       * @return {String} 缩略图URL
       * @param {Number} width 宽度,单位:像素
       * @param {Number} heigth 高度,单位:像素
       * @param {Number} quality 质量,1-100的数字,默认100
       * @param {Number} scaleToFit 是否将图片自适应大小。默认为true。
       * @param {String} fmt 格式,默认为png,也可以为jpeg,gif等格式。
       */

      thumbnailURL(
        width,
        height,
        quality = 100,
        scaleToFit = true,
        fmt = 'png'
      ) {
        const url = this.attributes.url;
        if (!url) {
          throw new Error('Invalid url.');
        }
        if (!width || !height || width <= 0 || height <= 0) {
          throw new Error('Invalid width or height value.');
        }
        if (quality <= 0 || quality > 100) {
          throw new Error('Invalid quality value.');
        }
        const mode = scaleToFit ? 2 : 1;
        return (
          url +
          '?imageView/' +
          mode +
          '/w/' +
          width +
          '/h/' +
          height +
          '/q/' +
          quality +
          '/format/' +
          fmt
        );
      },

      /**
       * Returns the file's size.
       * @return {Number} The file's size in bytes.
       **/
      size() {
        return this.metaData().size;
      },

      /**
       * Returns the file's owner.
       * @return {String} The file's owner id.
       */
      ownerId() {
        return this.metaData().owner;
      },

      /**
       * Destroy the file.
       * @param {AuthOptions} options
       * @return {Promise} A promise that is fulfilled when the destroy
       *     completes.
       */
      destroy(options) {
        if (!this.id) {
          return Promise.reject(new Error('The file id does not eixst.'));
        }
        var request = AVRequest(
          'files',
          null,
          this.id,
          'DELETE',
          null,
          options
        );
        return request;
      },

      /**
       * Request Qiniu upload token
       * @param {string} type
       * @return {Promise} Resolved with the response
       * @private
       */
      _fileToken(type, authOptions) {
        let name = this.attributes.name;

        let extName = extname(name);
        if (!extName && this._extName) {
          name += this._extName;
          extName = this._extName;
        }
        const data = {
          name,
          keep_file_name: authOptions.keepFileName,
          key: authOptions.key,
          ACL: this._acl,
          mime_type: type,
          metaData: this.attributes.metaData,
        };
        return AVRequest('fileTokens', null, null, 'POST', data, authOptions);
      },

      /**
       * @callback UploadProgressCallback
       * @param {XMLHttpRequestProgressEvent} event - The progress event with 'loaded' and 'total' attributes
       */
      /**
       * Saves the file to the AV cloud.
       * @param {AuthOptions} [options] AuthOptions plus:
       * @param {UploadProgressCallback} [options.onprogress] 文件上传进度,在 Node.js 中无效,回调参数说明详见 {@link UploadProgressCallback}。
       * @param {boolean} [options.keepFileName = false] 保留下载文件的文件名。
       * @param {string} [options.key] 指定文件的 key。设置该选项需要使用 masterKey
       * @return {Promise} Promise that is resolved when the save finishes.
       */
      save(options = {}) {
        if (this.id) {
          throw new Error('File is already saved.');
        }
        if (!this._previousSave) {
          if (this._data) {
            let mimeType = this.get('mime_type');
            this._previousSave = this._fileToken(mimeType, options).then(
              uploadInfo => {
                if (uploadInfo.mime_type) {
                  mimeType = uploadInfo.mime_type;
                  this.set('mime_type', mimeType);
                }
                this._token = uploadInfo.token;
                return Promise.resolve()
                  .then(() => {
                    const data = this._data;
                    if (data && data.base64) {
                      return parseBase64(data.base64, mimeType);
                    }
                    if (data && data.blob) {
                      if (!data.blob.type && mimeType) {
                        data.blob.type = mimeType;
                      }
                      if (!data.blob.name) {
                        data.blob.name = this.get('name');
                      }
                      return data.blob;
                    }
                    if (typeof Blob !== 'undefined' && data instanceof Blob) {
                      return data;
                    }
                    /* NODE-ONLY:start */
                    if (data instanceof require('stream')) {
                      return data;
                    }
                    if (Buffer.isBuffer(data)) {
                      return data;
                    }
                    /* NODE-ONLY:end */
                    throw new TypeError('malformed file data');
                  })
                  .then(data => {
                    const _options = _.extend({}, options);
                    // filter out download progress events
                    if (options.onprogress) {
                      _options.onprogress = event => {
                        if (event.direction === 'download') return;
                        return options.onprogress(event);
                      };
                    }
                    switch (uploadInfo.provider) {
                      case 's3':
                        return s3(uploadInfo, data, this, _options);
                      case 'qcloud':
                        return cos(uploadInfo, data, this, _options);
                      case 'qiniu':
                      default:
                        return qiniu(uploadInfo, data, this, _options);
                    }
                  })
                  .then(tap(() => this._callback(true)), error => {
                    this._callback(false);
                    throw error;
                  });
              }
            );
          } else if (
            this.attributes.url &&
            this.attributes.metaData.__source === 'external'
          ) {
            // external link file.
            const data = {
              name: this.attributes.name,
              ACL: this._acl,
              metaData: this.attributes.metaData,
              mime_type: this.mimeType,
              url: this.attributes.url,
            };
            this._previousSave = AVRequest(
              'files',
              null,
              null,
              'post',
              data,
              options
            ).then(response => {
              this.id = response.objectId;
              return this;
            });
          }
        }
        return this._previousSave;
      },

      _callback(success) {
        AVRequest('fileCallback', null, null, 'post', {
          token: this._token,
          result: success,
        }).catch(debug);
        delete this._token;
        delete this._data;
      },

      /**
       * fetch the file from server. If the server's representation of the
       * model differs from its current attributes, they will be overriden,
       * @param {Object} fetchOptions Optional options to set 'keys',
       *      'include' and 'includeACL' option.
       * @param {AuthOptions} options
       * @return {Promise} A promise that is fulfilled when the fetch
       *     completes.
       */
      fetch(fetchOptions, options) {
        if (!this.id) {
          throw new Error('Cannot fetch unsaved file');
        }
        var request = AVRequest(
          'files',
          null,
          this.id,
          'GET',
          transformFetchOptions(fetchOptions),
          options
        );
        return request.then(this._finishFetch.bind(this));
      },
      _finishFetch(response) {
        var value = AV.Object.prototype.parse(response);
        value.attributes = {
          name: value.name,
          url: value.url,
          mime_type: value.mime_type,
          bucket: value.bucket,
        };
        value.attributes.metaData = value.metaData || {};
        value.id = value.objectId;
        // clean
        delete value.objectId;
        delete value.metaData;
        delete value.url;
        delete value.name;
        delete value.mime_type;
        delete value.bucket;
        _.extend(this, value);
        return this;
      },

      /**
       * Request file censor
       * @since 4.13.0
       * @return {Promise.<string>}
       */
      censor() {
        if (!this.id) {
          throw new Error('Cannot censor an unsaved file');
        }
        return AV.File.censor(this.id);
      },
    }
  );
};