UNPKG

uppy

Version:

Almost as cute as a Puppy :dog:

515 lines (442 loc) 16.1 kB
const Plugin = require('../Plugin') const Client = require('./Client') const StatusSocket = require('./Socket') /** * Upload files to Transloadit using Tus. */ module.exports = class Transloadit extends Plugin { constructor (core, opts) { super(core, opts) this.type = 'uploader' this.id = 'Transloadit' this.title = 'Transloadit' const defaultLocale = { strings: { creatingAssembly: 'Preparing upload...', creatingAssemblyFailed: 'Transloadit: Could not create assembly', encoding: 'Encoding...' } } const defaultOptions = { waitForEncoding: false, waitForMetadata: false, alwaysRunAssembly: false, // TODO name importFromUploadURLs: false, signature: null, params: null, fields: {}, getAssemblyOptions (file, options) { return { params: options.params, signature: options.signature, fields: options.fields } }, locale: defaultLocale } this.opts = Object.assign({}, defaultOptions, opts) this.locale = Object.assign({}, defaultLocale, this.opts.locale) this.locale.strings = Object.assign({}, defaultLocale.strings, this.opts.locale.strings) this.prepareUpload = this.prepareUpload.bind(this) this.afterUpload = this.afterUpload.bind(this) this.onFileUploadURLAvailable = this.onFileUploadURLAvailable.bind(this) if (this.opts.params) { this.validateParams(this.opts.params) } this.client = new Client() this.sockets = {} } validateParams (params) { if (!params) { throw new Error('Transloadit: The `params` option is required.') } if (typeof params === 'string') { try { params = JSON.parse(params) } catch (err) { // Tell the user that this is not an Uppy bug! err.message = 'Transloadit: The `params` option is a malformed JSON string: ' + err.message throw err } } if (!params.auth || !params.auth.key) { throw new Error('Transloadit: The `params.auth.key` option is required. ' + 'You can find your Transloadit API key at https://transloadit.com/accounts/credentials.') } } getAssemblyOptions (fileIDs) { const options = this.opts return Promise.all( fileIDs.map((fileID) => { const file = this.core.getFile(fileID) const promise = Promise.resolve() .then(() => options.getAssemblyOptions(file, options)) return promise.then((assemblyOptions) => { this.validateParams(assemblyOptions.params) return { fileIDs: [fileID], options: assemblyOptions } }) }) ) } dedupeAssemblyOptions (list) { const dedupeMap = Object.create(null) list.forEach(({ fileIDs, options }) => { const id = JSON.stringify(options) if (dedupeMap[id]) { dedupeMap[id].fileIDs.push(...fileIDs) } else { dedupeMap[id] = { options, fileIDs: [...fileIDs] } } }) return Object.keys(dedupeMap).map((id) => dedupeMap[id]) } createAssembly (fileIDs, uploadID, options) { const pluginOptions = this.opts this.core.log('Transloadit: create assembly') return this.client.createAssembly({ params: options.params, fields: options.fields, expectedFiles: fileIDs.length, signature: options.signature }).then((assembly) => { // Store the list of assemblies related to this upload. const state = this.core.state.transloadit const assemblyList = state.uploadsAssemblies[uploadID] const uploadsAssemblies = Object.assign({}, state.uploadsAssemblies, { [uploadID]: assemblyList.concat([ assembly.assembly_id ]) }) this.setPluginState({ assemblies: Object.assign(state.assemblies, { [assembly.assembly_id]: assembly }), uploadsAssemblies }) function attachAssemblyMetadata (file, assembly) { // Attach meta parameters for the Tus plugin. See: // https://github.com/tus/tusd/wiki/Uploading-to-Transloadit-using-tus#uploading-using-tus // TODO Should this `meta` be moved to a `tus.meta` property instead? const tlMeta = { assembly_url: assembly.assembly_url, filename: file.name, fieldname: 'file' } const meta = Object.assign({}, file.meta, tlMeta) // Add assembly-specific Tus endpoint. const tus = Object.assign({}, file.tus, { endpoint: assembly.tus_url, // Only send assembly metadata to the tus endpoint. metaFields: Object.keys(tlMeta) }) const transloadit = { assembly: assembly.assembly_id } const newFile = Object.assign({}, file, { transloadit }) // Only configure the Tus plugin if we are uploading straight to Transloadit (the default). if (!pluginOptions.importFromUploadURLs) { Object.assign(newFile, { meta, tus }) } return newFile } const files = Object.assign({}, this.core.state.files) fileIDs.forEach((id) => { files[id] = attachAssemblyMetadata(files[id], assembly) }) this.core.setState({ files }) this.core.emit('transloadit:assembly-created', assembly, fileIDs) return this.connectSocket(assembly) .then(() => assembly) }).then((assembly) => { this.core.log('Transloadit: Created assembly') return assembly }).catch((err) => { this.core.info(pluginOptions.locale.strings.creatingAssemblyFailed, 'error', 0) // Reject the promise. throw err }) } shouldWait () { return this.opts.waitForEncoding || this.opts.waitForMetadata } /** * Used when `importFromUploadURLs` is enabled: reserves all files in * the assembly. */ reserveFiles (assembly, fileIDs) { return Promise.all(fileIDs.map((fileID) => { const file = this.core.getFile(fileID) return this.client.reserveFile(assembly, file) })) } /** * Used when `importFromUploadURLs` is enabled: adds files to the assembly * once they have been fully uploaded. */ onFileUploadURLAvailable (fileID) { const file = this.core.getFile(fileID) if (!file || !file.transloadit || !file.transloadit.assembly) { return } const state = this.core.state.transloadit const assembly = state.assemblies[file.transloadit.assembly] this.client.addFile(assembly, file).catch((err) => { this.core.log(err) this.core.emit('transloadit:import-error', assembly, file.id, err) }) } findFile (uploadedFile) { const files = this.core.state.files for (const id in files) { if (!files.hasOwnProperty(id)) { continue } if (files[id].uploadURL === uploadedFile.tus_upload_url) { return files[id] } } } onFileUploadComplete (assemblyId, uploadedFile) { const state = this.core.state.transloadit const file = this.findFile(uploadedFile) this.setPluginState({ files: Object.assign({}, state.files, { [uploadedFile.id]: { id: file.id, uploadedFile } }) }) this.core.emit('transloadit:upload', uploadedFile, this.getAssembly(assemblyId)) } onResult (assemblyId, stepName, result) { const state = this.core.state.transloadit const file = state.files[result.original_id] // The `file` may not exist if an import robot was used instead of a file upload. result.localId = file ? file.id : null this.setPluginState({ results: state.results.concat(result) }) this.core.emit('transloadit:result', stepName, result, this.getAssembly(assemblyId)) } onAssemblyFinished (url) { this.client.getAssemblyStatus(url).then((assembly) => { const state = this.core.state.transloadit this.setPluginState({ assemblies: Object.assign({}, state.assemblies, { [assembly.assembly_id]: assembly }) }) this.core.emit('transloadit:complete', assembly) }) } connectSocket (assembly) { const socket = new StatusSocket( assembly.websocket_url, assembly ) this.sockets[assembly.assembly_id] = socket socket.on('upload', this.onFileUploadComplete.bind(this, assembly.assembly_id)) socket.on('error', (error) => { this.core.emit('transloadit:assembly-error', assembly, error) }) if (this.opts.waitForEncoding) { socket.on('result', this.onResult.bind(this, assembly.assembly_id)) } if (this.opts.waitForEncoding) { socket.on('finished', () => { this.onAssemblyFinished(assembly.assembly_ssl_url) }) } else if (this.opts.waitForMetadata) { socket.on('metadata', () => { this.onAssemblyFinished(assembly.assembly_ssl_url) this.core.emit('transloadit:complete', assembly) }) } return new Promise((resolve, reject) => { socket.on('connect', resolve) socket.on('error', reject) }).then(() => { this.core.log('Transloadit: Socket is ready') }) } prepareUpload (fileIDs, uploadID) { fileIDs.forEach((fileID) => { this.core.emit('core:preprocess-progress', fileID, { mode: 'indeterminate', message: this.opts.locale.strings.creatingAssembly }) }) const createAssembly = ({ fileIDs, options }) => { return this.createAssembly(fileIDs, uploadID, options).then((assembly) => { if (this.opts.importFromUploadURLs) { return this.reserveFiles(assembly, fileIDs) } }).then(() => { fileIDs.forEach((fileID) => { this.core.emit('core:preprocess-complete', fileID) }) }) } const state = this.core.state.transloadit const uploadsAssemblies = Object.assign({}, state.uploadsAssemblies, { [uploadID]: [] }) this.setPluginState({ uploadsAssemblies }) let optionsPromise if (fileIDs.length > 0) { optionsPromise = this.getAssemblyOptions(fileIDs) .then((allOptions) => this.dedupeAssemblyOptions(allOptions)) } else if (this.opts.alwaysRunAssembly) { optionsPromise = Promise.resolve( this.opts.getAssemblyOptions(null, this.opts) ).then((options) => { this.validateParams(options.params) return [ { fileIDs, options } ] }) } else { // If there are no files and we do not `alwaysRunAssembly`, // don't do anything. return Promise.resolve() } return optionsPromise.then((assemblies) => Promise.all( assemblies.map(createAssembly) )) } afterUpload (fileIDs, uploadID) { const state = this.core.state.transloadit const assemblyIDs = state.uploadsAssemblies[uploadID] // If we don't have to wait for encoding metadata or results, we can close // the socket immediately and finish the upload. if (!this.shouldWait()) { assemblyIDs.forEach((assemblyID) => { const socket = this.sockets[assemblyID] socket.close() }) return Promise.resolve() } // If no assemblies were created for this upload, we also do not have to wait. // There's also no sockets or anything to close, so just return immediately. if (assemblyIDs.length === 0) { return Promise.resolve() } let finishedAssemblies = 0 return new Promise((resolve, reject) => { fileIDs.forEach((fileID) => { this.core.emit('core:postprocess-progress', fileID, { mode: 'indeterminate', message: this.opts.locale.strings.encoding }) }) const onAssemblyFinished = (assembly) => { // An assembly for a different upload just finished. We can ignore it. if (assemblyIDs.indexOf(assembly.assembly_id) === -1) { return } // TODO set the `file.uploadURL` to a result? // We will probably need an option here so the plugin user can tell us // which result to pick…? const files = this.getAssemblyFiles(assembly.assembly_id) files.forEach((file) => { this.core.emit('core:postprocess-complete', file.id) }) finishedAssemblies += 1 if (finishedAssemblies === assemblyIDs.length) { // We're done, these listeners can be removed removeListeners() resolve() } } const onAssemblyError = (assembly, error) => { // An assembly for a different upload just finished. We can ignore it. if (assemblyIDs.indexOf(assembly.assembly_id) === -1) { return } // Clear postprocessing state for all our files. const files = this.getAssemblyFiles(assembly.assembly_id) files.forEach((file) => { this.core.emit('core:postprocess-complete', file.id) }) // Should we remove the listeners here or should we keep handling finished // assemblies? // Doing this for now so that it's not possible to receive more postprocessing // events once the upload has failed. removeListeners() // Reject the `afterUpload()` promise. reject(error) } const onImportError = (assembly, fileID, error) => { if (assemblyIDs.indexOf(assembly.assembly_id) === -1) { return } // Not sure if we should be doing something when it's just one file failing. // ATM, the only options are 1) ignoring or 2) failing the entire upload. // I think failing the upload is better than silently ignoring. // In the future we should maybe have a way to resolve uploads with some failures, // like returning an object with `{ successful, failed }` uploads. onAssemblyError(assembly, error) } const removeListeners = () => { this.core.off('transloadit:complete', onAssemblyFinished) this.core.off('transloadit:assembly-error', onAssemblyError) this.core.off('transloadit:import-error', onImportError) } this.core.on('transloadit:complete', onAssemblyFinished) this.core.on('transloadit:assembly-error', onAssemblyError) this.core.on('transloadit:import-error', onImportError) }).then(() => { // Clean up uploadID → assemblyIDs, they're no longer going to be used anywhere. const state = this.core.state.transloadit const uploadsAssemblies = Object.assign({}, state.uploadsAssemblies) delete uploadsAssemblies[uploadID] this.setPluginState({ uploadsAssemblies }) }) } install () { this.core.addPreProcessor(this.prepareUpload) this.core.addPostProcessor(this.afterUpload) if (this.opts.importFromUploadURLs) { this.core.on('core:upload-success', this.onFileUploadURLAvailable) } this.setPluginState({ // Contains assembly status objects, indexed by their ID. assemblies: {}, // Contains arrays of assembly IDs, indexed by the upload ID that they belong to. uploadsAssemblies: {}, // Contains file data from Transloadit, indexed by their Transloadit-assigned ID. files: {}, // Contains result data from Transloadit. results: [] }) } uninstall () { this.core.removePreProcessor(this.prepareUpload) this.core.removePostProcessor(this.afterUpload) if (this.opts.importFromUploadURLs) { this.core.off('core:upload-success', this.onFileUploadURLAvailable) } } getAssembly (id) { const state = this.core.state.transloadit return state.assemblies[id] } getAssemblyFiles (assemblyID) { const fileIDs = Object.keys(this.core.state.files) return fileIDs.map((fileID) => { return this.core.getFile(fileID) }).filter((file) => { return file && file.transloadit && file.transloadit.assembly === assemblyID }) } setPluginState (newState) { const transloadit = Object.assign({}, this.core.state.transloadit, newState) this.core.setState({ transloadit }) } }