TryGhost/Ghost

View on GitHub
ghost/admin/app/components/gh-uploader.js

Summary

Maintainability
C
1 day
Test Coverage
import Component from '@ember/component';
import EmberObject, {get} from '@ember/object';
import classic from 'ember-classic-decorator';
import ghostPaths from 'ghost-admin/utils/ghost-paths';
import {
    ICON_EXTENSIONS,
    ICON_MIME_TYPES,
    IMAGE_EXTENSIONS,
    IMAGE_MIME_TYPES
} from 'ghost-admin/components/gh-image-uploader';
import {all, task} from 'ember-concurrency';
import {isArray} from '@ember/array';
import {isEmpty} from '@ember/utils';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';

// TODO: this is designed to be a more re-usable/composable upload component, it
// should be able to replace the duplicated upload logic in:
// - gh-image-uploader
// - gh-file-uploader
//
// In order to support the above components we'll need to introduce an
// "allowMultiple" attribute so that single-image uploads don't allow multiple
// simultaneous uploads

const MAX_SIMULTANEOUS_UPLOADS = 2;

/**
 * Result from a file upload
 * @typedef {Object} UploadResult
 * @property {string} fileName - file name, eg "my-image.png"
 * @property {string} url - url relative to Ghost root,eg "/content/images/2017/05/my-image.png"
 */

@classic
class UploadTracker extends EmberObject {
    file = null;
    total = 0;
    loaded = 0;

    init() {
        super.init(...arguments);
        this.total = this.file && this.file.size || 0;
    }

    update({loaded, total}) {
        this.total = total;
        this.loaded = loaded;
    }
}

export default Component.extend({
    ajax: service(),

    tagName: '',

    // Public attributes
    accept: '',
    extensions: '',
    files: null,
    paramName: 'file',
    paramsHash: null,
    resourceName: 'images',
    uploadUrl: null,
    requestMethod: 'post',

    // Interal attributes
    errors: null, // [{fileName: 'x', message: 'y'}, ...]
    totalSize: 0,
    uploadedSize: 0,
    uploadPercentage: 0,
    uploadUrls: null, // [{filename: 'x', url: 'y'}],

    // Private
    _defaultUploadUrl: '/images/upload/',
    _files: null,
    _uploadTrackers: null,

    // Closure actions
    onCancel() {},
    onComplete() {},
    onFailed() {},
    onStart() {},
    onUploadStart() {},
    onUploadFailure() {},
    onUploadSuccess() {},

    // Optional closure actions
    // validate(file) {}

    init() {
        this._super(...arguments);
        this.set('errors', []);
        this.set('uploadUrls', []);
        this._uploadTrackers = [];

        if (!this.paramsHash) {
            this.set('paramsHash', {purpose: 'image'});
        }

        this.set('imageExtensions', IMAGE_EXTENSIONS);
        this.set('imageMimeTypes', IMAGE_MIME_TYPES);
        this.set('iconExtensions', ICON_EXTENSIONS);
        this.set('iconMimeTypes', ICON_MIME_TYPES);
    },

    didReceiveAttrs() {
        this._super(...arguments);

        // set up any defaults
        if (!this.uploadUrl) {
            this.set('uploadUrl', this._defaultUploadUrl);
        }

        // if we have new files, validate and start an upload
        if (this.files?.length) {
            this._setFiles(this.files);
        }
    },

    actions: {
        registerFileInput(input) {
            this.fileInput = input;
        },

        triggerFileDialog() {
            if (!this.fileInput) {
                // eslint-disable-next-line
                console.error('When using uploader.triggerFileDialog you must call uploader.registerFileInput first');
                return;
            }

            this.fileInput.click();
        },

        setFiles(files, resetInput) {
            this._setFiles(files);

            if (resetInput) {
                this.fileInput = resetInput();
            }
        },

        cancel() {
            this._reset();
            this.onCancel();
        }
    },

    _setFiles(files) {
        this.set('files', files);

        if (files && files !== this._files) {
            if (this.get('_uploadFiles.isRunning')) {
                // eslint-disable-next-line
                console.error('Adding new files whilst an upload is in progress is not supported.');
            }

            this._files = files;

            // we cancel early if any file fails client-side validation
            if (this._validate()) {
                this._uploadFiles.perform(files);
            }
        }
    },

    _validate() {
        let files = this.files;
        let validate = this.validate || this._defaultValidator.bind(this);
        let ok = [];
        let errors = [];

        // NOTE: for...of loop results in a transpilation that errors in Edge,
        // once we drop IE11 support we should be able to use native for...of
        for (let i = 0; i < files.length; i += 1) {
            let file = files[i];
            let result = validate(file);
            if (result === true) {
                ok.push(file);
            } else {
                errors.push({fileName: file.name, message: result});
            }
        }

        if (isEmpty(errors)) {
            return true;
        }

        this.set('errors', errors);
        this.onFailed(errors);
        return false;
    },

    // we only check the file extension by default because IE doesn't always
    // expose the mime-type, we'll rely on the API for final validation
    _defaultValidator(file) {
        let extensions = this.extensions;
        let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name);

        // if extensions is falsy exit early and accept all files
        if (!extensions) {
            return true;
        }

        if (!isArray(extensions)) {
            extensions = extensions.split(',');
        }

        if (!extension || extensions.indexOf(extension.toLowerCase()) === -1) {
            let validExtensions = `.${extensions.join(', .').toUpperCase()}`;
            return `The file type you uploaded is not supported. Please use ${validExtensions}`;
        }

        return true;
    },

    _uploadFiles: task(function* (files) {
        let uploads = [];

        this._reset();
        this.onStart(files);

        // NOTE: for...of loop results in a transpilation that errors in Edge,
        // once we drop IE11 support we should be able to use native for...of
        for (let i = 0; i < files.length; i += 1) {
            let file = files[i];
            let tracker = UploadTracker.create({file});

            this._uploadTrackers.pushObject(tracker);
            uploads.push(this._uploadFile.perform(tracker, file, i));
        }

        // populates this.errors and this.uploadUrls
        yield all(uploads);

        if (!isEmpty(this.errors)) {
            this.onFailed(this.errors);
        }

        this.onComplete(this.uploadUrls);
    }).drop(),

    // eslint-disable-next-line ghost/ember/order-in-components
    _uploadFile: task(function* (tracker, file, index) {
        let ajax = this.ajax;
        let formData = this._getFormData(file);
        let url = `${ghostPaths().apiRoot}${this.uploadUrl}`;
        let metadata = null;

        try {
            metadata = yield Promise.resolve(this.onUploadStart(file));

            let response = yield ajax[this.requestMethod](url, {
                data: formData,
                processData: false,
                contentType: false,
                dataType: 'text',
                xhr: () => {
                    let xhr = new window.XMLHttpRequest();

                    xhr.upload.addEventListener('progress', (event) => {
                        run(() => {
                            tracker.update(event);
                            this._updateProgress();
                        });
                    }, false);

                    return xhr;
                }
            });

            // force tracker progress to 100% in case we didn't get a final event,
            // eg. when using mirage
            tracker.update({loaded: file.size, total: file.size});
            this._updateProgress();

            let uploadResponse;
            let responseUrl;

            try {
                uploadResponse = JSON.parse(response);
            } catch (e) {
                if (!(e instanceof SyntaxError)) {
                    throw e;
                }
            }

            if (uploadResponse) {
                let resource = get(uploadResponse, this.resourceName);
                if (resource && isArray(resource) && resource[0]) {
                    responseUrl = get(resource[0], 'url');
                }
            }

            let result = {
                url: responseUrl,
                fileName: file.name
            };

            this.uploadUrls[index] = result;
            this.onUploadSuccess(result, metadata);

            return true;
        } catch (error) {
            // grab custom error message if present
            let message = error.payload && error.payload.errors && error.payload.errors[0].message || '';
            let context = error.payload && error.payload.errors && error.payload.errors[0].context || '';

            // fall back to EmberData/ember-ajax default message for error type
            if (!message) {
                message = error.message;
            }

            let result = {
                message,
                context,
                fileName: file.name
            };

            // TODO: check for or expose known error types?
            this.errors.pushObject(result);
            this.onUploadFailure(result, metadata);
        }
    }).maxConcurrency(MAX_SIMULTANEOUS_UPLOADS).enqueue(),

    // NOTE: this is necessary because the API doesn't accept direct file uploads
    _getFormData(file) {
        let formData = new FormData();
        formData.append(this.paramName, file, file.name);

        Object.keys(this.paramsHash || {}).forEach((key) => {
            formData.append(key, this.paramsHash[key]);
        });

        return formData;
    },

    // TODO: this was needed because using CPs directly resulted in infrequent updates
    // - I think this was because updates were being wrapped up to save
    // computation but that hypothesis needs testing
    _updateProgress() {
        if (this.isDestroyed || this.isDestroying) {
            return;
        }

        let trackers = this._uploadTrackers;
        let totalSize = trackers.reduce((total, tracker) => total + tracker.get('total'), 0);
        let uploadedSize = trackers.reduce((total, tracker) => total + tracker.get('loaded'), 0);

        this.set('totalSize', totalSize);
        this.set('uploadedSize', uploadedSize);

        if (totalSize === 0 || uploadedSize === 0) {
            return;
        }

        let uploadPercentage = Math.round((uploadedSize / totalSize) * 100);
        this.set('uploadPercentage', uploadPercentage);
    },

    _reset() {
        this.set('errors', []);
        this.set('totalSize', 0);
        this.set('uploadedSize', 0);
        this.set('uploadPercentage', 0);
        this.set('uploadUrls', []);
        this._uploadTrackers = [];
    }
});