index.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. const Translator = require('../../core/Translator')
  2. const Plugin = require('../Plugin')
  3. const Client = require('./Client')
  4. const StatusSocket = require('./Socket')
  5. /**
  6. * Upload files to Transloadit using Tus.
  7. */
  8. module.exports = class Transloadit extends Plugin {
  9. constructor (core, opts) {
  10. super(core, opts)
  11. this.type = 'uploader'
  12. this.id = 'Transloadit'
  13. this.title = 'Transloadit'
  14. const defaultLocale = {
  15. strings: {
  16. creatingAssembly: 'Preparing upload...',
  17. creatingAssemblyFailed: 'Transloadit: Could not create assembly',
  18. encoding: 'Encoding...'
  19. }
  20. }
  21. const defaultOptions = {
  22. waitForEncoding: false,
  23. waitForMetadata: false,
  24. alwaysRunAssembly: false, // TODO name
  25. importFromUploadURLs: false,
  26. signature: null,
  27. params: null,
  28. fields: {},
  29. getAssemblyOptions (file, options) {
  30. return {
  31. params: options.params,
  32. signature: options.signature,
  33. fields: options.fields
  34. }
  35. },
  36. locale: defaultLocale
  37. }
  38. this.opts = Object.assign({}, defaultOptions, opts)
  39. this.locale = Object.assign({}, defaultLocale, this.opts.locale)
  40. this.locale.strings = Object.assign({}, defaultLocale.strings, this.opts.locale.strings)
  41. this.translator = new Translator({ locale: this.locale })
  42. this.i18n = this.translator.translate.bind(this.translator)
  43. this.prepareUpload = this.prepareUpload.bind(this)
  44. this.afterUpload = this.afterUpload.bind(this)
  45. this.onFileUploadURLAvailable = this.onFileUploadURLAvailable.bind(this)
  46. if (this.opts.params) {
  47. this.validateParams(this.opts.params)
  48. }
  49. this.client = new Client()
  50. this.sockets = {}
  51. }
  52. validateParams (params) {
  53. if (!params) {
  54. throw new Error('Transloadit: The `params` option is required.')
  55. }
  56. if (typeof params === 'string') {
  57. try {
  58. params = JSON.parse(params)
  59. } catch (err) {
  60. // Tell the user that this is not an Uppy bug!
  61. err.message = 'Transloadit: The `params` option is a malformed JSON string: ' +
  62. err.message
  63. throw err
  64. }
  65. }
  66. if (!params.auth || !params.auth.key) {
  67. throw new Error('Transloadit: The `params.auth.key` option is required. ' +
  68. 'You can find your Transloadit API key at https://transloadit.com/accounts/credentials.')
  69. }
  70. }
  71. getAssemblyOptions (fileIDs) {
  72. const options = this.opts
  73. return Promise.all(
  74. fileIDs.map((fileID) => {
  75. const file = this.core.getFile(fileID)
  76. const promise = Promise.resolve()
  77. .then(() => options.getAssemblyOptions(file, options))
  78. return promise.then((assemblyOptions) => {
  79. this.validateParams(assemblyOptions.params)
  80. return {
  81. fileIDs: [fileID],
  82. options: assemblyOptions
  83. }
  84. })
  85. })
  86. )
  87. }
  88. dedupeAssemblyOptions (list) {
  89. const dedupeMap = Object.create(null)
  90. list.forEach(({ fileIDs, options }) => {
  91. const id = JSON.stringify(options)
  92. if (dedupeMap[id]) {
  93. dedupeMap[id].fileIDs.push(...fileIDs)
  94. } else {
  95. dedupeMap[id] = {
  96. options,
  97. fileIDs: [...fileIDs]
  98. }
  99. }
  100. })
  101. return Object.keys(dedupeMap).map((id) => dedupeMap[id])
  102. }
  103. createAssembly (fileIDs, uploadID, options) {
  104. const pluginOptions = this.opts
  105. this.core.log('Transloadit: create assembly')
  106. return this.client.createAssembly({
  107. params: options.params,
  108. fields: options.fields,
  109. expectedFiles: fileIDs.length,
  110. signature: options.signature
  111. }).then((assembly) => {
  112. // Store the list of assemblies related to this upload.
  113. const state = this.getPluginState()
  114. const assemblyList = state.uploadsAssemblies[uploadID]
  115. const uploadsAssemblies = Object.assign({}, state.uploadsAssemblies, {
  116. [uploadID]: assemblyList.concat([ assembly.assembly_id ])
  117. })
  118. this.setPluginState({
  119. assemblies: Object.assign(state.assemblies, {
  120. [assembly.assembly_id]: assembly
  121. }),
  122. uploadsAssemblies
  123. })
  124. function attachAssemblyMetadata (file, assembly) {
  125. // Attach meta parameters for the Tus plugin. See:
  126. // https://github.com/tus/tusd/wiki/Uploading-to-Transloadit-using-tus#uploading-using-tus
  127. // TODO Should this `meta` be moved to a `tus.meta` property instead?
  128. const tlMeta = {
  129. assembly_url: assembly.assembly_url,
  130. filename: file.name,
  131. fieldname: 'file'
  132. }
  133. const meta = Object.assign({}, file.meta, tlMeta)
  134. // Add assembly-specific Tus endpoint.
  135. const tus = Object.assign({}, file.tus, {
  136. endpoint: assembly.tus_url,
  137. // Only send assembly metadata to the tus endpoint.
  138. metaFields: Object.keys(tlMeta),
  139. // Make sure tus doesn't resume a previous upload.
  140. uploadUrl: null
  141. })
  142. const transloadit = {
  143. assembly: assembly.assembly_id
  144. }
  145. const newFile = Object.assign({}, file, { transloadit })
  146. // Only configure the Tus plugin if we are uploading straight to Transloadit (the default).
  147. if (!pluginOptions.importFromUploadURLs) {
  148. Object.assign(newFile, { meta, tus })
  149. }
  150. return newFile
  151. }
  152. const files = Object.assign({}, this.core.state.files)
  153. fileIDs.forEach((id) => {
  154. files[id] = attachAssemblyMetadata(files[id], assembly)
  155. })
  156. this.core.setState({ files })
  157. this.core.emit('transloadit:assembly-created', assembly, fileIDs)
  158. return this.connectSocket(assembly)
  159. .then(() => assembly)
  160. }).then((assembly) => {
  161. this.core.log('Transloadit: Created assembly')
  162. return assembly
  163. }).catch((err) => {
  164. this.core.info(this.i18n('creatingAssemblyFailed'), 'error', 0)
  165. // Reject the promise.
  166. throw err
  167. })
  168. }
  169. shouldWait () {
  170. return this.opts.waitForEncoding || this.opts.waitForMetadata
  171. }
  172. /**
  173. * Used when `importFromUploadURLs` is enabled: reserves all files in
  174. * the assembly.
  175. */
  176. reserveFiles (assembly, fileIDs) {
  177. return Promise.all(fileIDs.map((fileID) => {
  178. const file = this.core.getFile(fileID)
  179. return this.client.reserveFile(assembly, file)
  180. }))
  181. }
  182. /**
  183. * Used when `importFromUploadURLs` is enabled: adds files to the assembly
  184. * once they have been fully uploaded.
  185. */
  186. onFileUploadURLAvailable (fileID) {
  187. const file = this.core.getFile(fileID)
  188. if (!file || !file.transloadit || !file.transloadit.assembly) {
  189. return
  190. }
  191. const state = this.getPluginState()
  192. const assembly = state.assemblies[file.transloadit.assembly]
  193. this.client.addFile(assembly, file).catch((err) => {
  194. this.core.log(err)
  195. this.core.emit('transloadit:import-error', assembly, file.id, err)
  196. })
  197. }
  198. findFile (uploadedFile) {
  199. const files = this.core.state.files
  200. for (const id in files) {
  201. if (!files.hasOwnProperty(id)) {
  202. continue
  203. }
  204. if (files[id].uploadURL === uploadedFile.tus_upload_url) {
  205. return files[id]
  206. }
  207. }
  208. }
  209. onFileUploadComplete (assemblyId, uploadedFile) {
  210. const state = this.getPluginState()
  211. const file = this.findFile(uploadedFile)
  212. this.setPluginState({
  213. files: Object.assign({}, state.files, {
  214. [uploadedFile.id]: {
  215. id: file.id,
  216. uploadedFile
  217. }
  218. })
  219. })
  220. this.core.emit('transloadit:upload', uploadedFile, this.getAssembly(assemblyId))
  221. }
  222. onResult (assemblyId, stepName, result) {
  223. const state = this.getPluginState()
  224. const file = state.files[result.original_id]
  225. // The `file` may not exist if an import robot was used instead of a file upload.
  226. result.localId = file ? file.id : null
  227. this.setPluginState({
  228. results: state.results.concat(result)
  229. })
  230. this.core.emit('transloadit:result', stepName, result, this.getAssembly(assemblyId))
  231. }
  232. onAssemblyFinished (url) {
  233. this.client.getAssemblyStatus(url).then((assembly) => {
  234. const state = this.getPluginState()
  235. this.setPluginState({
  236. assemblies: Object.assign({}, state.assemblies, {
  237. [assembly.assembly_id]: assembly
  238. })
  239. })
  240. this.core.emit('transloadit:complete', assembly)
  241. })
  242. }
  243. connectSocket (assembly) {
  244. const socket = new StatusSocket(
  245. assembly.websocket_url,
  246. assembly
  247. )
  248. this.sockets[assembly.assembly_id] = socket
  249. socket.on('upload', this.onFileUploadComplete.bind(this, assembly.assembly_id))
  250. socket.on('error', (error) => {
  251. this.core.emit('transloadit:assembly-error', assembly, error)
  252. })
  253. if (this.opts.waitForEncoding) {
  254. socket.on('result', this.onResult.bind(this, assembly.assembly_id))
  255. }
  256. if (this.opts.waitForEncoding) {
  257. socket.on('finished', () => {
  258. this.onAssemblyFinished(assembly.assembly_ssl_url)
  259. })
  260. } else if (this.opts.waitForMetadata) {
  261. socket.on('metadata', () => {
  262. this.onAssemblyFinished(assembly.assembly_ssl_url)
  263. this.core.emit('transloadit:complete', assembly)
  264. })
  265. }
  266. return new Promise((resolve, reject) => {
  267. socket.on('connect', resolve)
  268. socket.on('error', reject)
  269. }).then(() => {
  270. this.core.log('Transloadit: Socket is ready')
  271. })
  272. }
  273. prepareUpload (fileIDs, uploadID) {
  274. fileIDs.forEach((fileID) => {
  275. this.core.emit('core:preprocess-progress', fileID, {
  276. mode: 'indeterminate',
  277. message: this.i18n('creatingAssembly')
  278. })
  279. })
  280. const createAssembly = ({ fileIDs, options }) => {
  281. return this.createAssembly(fileIDs, uploadID, options).then((assembly) => {
  282. if (this.opts.importFromUploadURLs) {
  283. return this.reserveFiles(assembly, fileIDs)
  284. }
  285. }).then(() => {
  286. fileIDs.forEach((fileID) => {
  287. this.core.emit('core:preprocess-complete', fileID)
  288. })
  289. })
  290. }
  291. const state = this.getPluginState()
  292. const uploadsAssemblies = Object.assign({},
  293. state.uploadsAssemblies,
  294. { [uploadID]: [] })
  295. this.setPluginState({ uploadsAssemblies })
  296. let optionsPromise
  297. if (fileIDs.length > 0) {
  298. optionsPromise = this.getAssemblyOptions(fileIDs)
  299. .then((allOptions) => this.dedupeAssemblyOptions(allOptions))
  300. } else if (this.opts.alwaysRunAssembly) {
  301. optionsPromise = Promise.resolve(
  302. this.opts.getAssemblyOptions(null, this.opts)
  303. ).then((options) => {
  304. this.validateParams(options.params)
  305. return [
  306. { fileIDs, options }
  307. ]
  308. })
  309. } else {
  310. // If there are no files and we do not `alwaysRunAssembly`,
  311. // don't do anything.
  312. return Promise.resolve()
  313. }
  314. return optionsPromise.then((assemblies) => Promise.all(
  315. assemblies.map(createAssembly)
  316. ))
  317. }
  318. afterUpload (fileIDs, uploadID) {
  319. const state = this.getPluginState()
  320. const assemblyIDs = state.uploadsAssemblies[uploadID]
  321. // If we don't have to wait for encoding metadata or results, we can close
  322. // the socket immediately and finish the upload.
  323. if (!this.shouldWait()) {
  324. assemblyIDs.forEach((assemblyID) => {
  325. const socket = this.sockets[assemblyID]
  326. socket.close()
  327. })
  328. return Promise.resolve()
  329. }
  330. // If no assemblies were created for this upload, we also do not have to wait.
  331. // There's also no sockets or anything to close, so just return immediately.
  332. if (assemblyIDs.length === 0) {
  333. return Promise.resolve()
  334. }
  335. let finishedAssemblies = 0
  336. return new Promise((resolve, reject) => {
  337. fileIDs.forEach((fileID) => {
  338. this.core.emit('core:postprocess-progress', fileID, {
  339. mode: 'indeterminate',
  340. message: this.i18n('encoding')
  341. })
  342. })
  343. const onAssemblyFinished = (assembly) => {
  344. // An assembly for a different upload just finished. We can ignore it.
  345. if (assemblyIDs.indexOf(assembly.assembly_id) === -1) {
  346. return
  347. }
  348. // TODO set the `file.uploadURL` to a result?
  349. // We will probably need an option here so the plugin user can tell us
  350. // which result to pick…?
  351. const files = this.getAssemblyFiles(assembly.assembly_id)
  352. files.forEach((file) => {
  353. this.core.emit('core:postprocess-complete', file.id)
  354. })
  355. finishedAssemblies += 1
  356. if (finishedAssemblies === assemblyIDs.length) {
  357. // We're done, these listeners can be removed
  358. removeListeners()
  359. resolve()
  360. }
  361. }
  362. const onAssemblyError = (assembly, error) => {
  363. // An assembly for a different upload just finished. We can ignore it.
  364. if (assemblyIDs.indexOf(assembly.assembly_id) === -1) {
  365. return
  366. }
  367. // Clear postprocessing state for all our files.
  368. const files = this.getAssemblyFiles(assembly.assembly_id)
  369. files.forEach((file) => {
  370. // TODO Maybe make a postprocess-error event here?
  371. this.core.emit('core:upload-error', file.id, error)
  372. this.core.emit('core:postprocess-complete', file.id)
  373. })
  374. // Should we remove the listeners here or should we keep handling finished
  375. // assemblies?
  376. // Doing this for now so that it's not possible to receive more postprocessing
  377. // events once the upload has failed.
  378. removeListeners()
  379. // Reject the `afterUpload()` promise.
  380. reject(error)
  381. }
  382. const onImportError = (assembly, fileID, error) => {
  383. if (assemblyIDs.indexOf(assembly.assembly_id) === -1) {
  384. return
  385. }
  386. // Not sure if we should be doing something when it's just one file failing.
  387. // ATM, the only options are 1) ignoring or 2) failing the entire upload.
  388. // I think failing the upload is better than silently ignoring.
  389. // In the future we should maybe have a way to resolve uploads with some failures,
  390. // like returning an object with `{ successful, failed }` uploads.
  391. onAssemblyError(assembly, error)
  392. }
  393. const removeListeners = () => {
  394. this.core.off('transloadit:complete', onAssemblyFinished)
  395. this.core.off('transloadit:assembly-error', onAssemblyError)
  396. this.core.off('transloadit:import-error', onImportError)
  397. }
  398. this.core.on('transloadit:complete', onAssemblyFinished)
  399. this.core.on('transloadit:assembly-error', onAssemblyError)
  400. this.core.on('transloadit:import-error', onImportError)
  401. }).then(() => {
  402. // Clean up uploadID → assemblyIDs, they're no longer going to be used anywhere.
  403. const state = this.getPluginState()
  404. const uploadsAssemblies = Object.assign({}, state.uploadsAssemblies)
  405. delete uploadsAssemblies[uploadID]
  406. this.setPluginState({ uploadsAssemblies })
  407. })
  408. }
  409. install () {
  410. this.core.addPreProcessor(this.prepareUpload)
  411. this.core.addPostProcessor(this.afterUpload)
  412. if (this.opts.importFromUploadURLs) {
  413. this.core.on('core:upload-success', this.onFileUploadURLAvailable)
  414. }
  415. this.setPluginState({
  416. // Contains assembly status objects, indexed by their ID.
  417. assemblies: {},
  418. // Contains arrays of assembly IDs, indexed by the upload ID that they belong to.
  419. uploadsAssemblies: {},
  420. // Contains file data from Transloadit, indexed by their Transloadit-assigned ID.
  421. files: {},
  422. // Contains result data from Transloadit.
  423. results: []
  424. })
  425. }
  426. uninstall () {
  427. this.core.removePreProcessor(this.prepareUpload)
  428. this.core.removePostProcessor(this.afterUpload)
  429. if (this.opts.importFromUploadURLs) {
  430. this.core.off('core:upload-success', this.onFileUploadURLAvailable)
  431. }
  432. }
  433. getAssembly (id) {
  434. const state = this.getPluginState()
  435. return state.assemblies[id]
  436. }
  437. getAssemblyFiles (assemblyID) {
  438. const fileIDs = Object.keys(this.core.state.files)
  439. return fileIDs.map((fileID) => {
  440. return this.core.getFile(fileID)
  441. }).filter((file) => {
  442. return file && file.transloadit && file.transloadit.assembly === assemblyID
  443. })
  444. }
  445. }