index.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
  1. const { h, Component } = require('preact')
  2. const AuthView = require('./AuthView')
  3. const Browser = require('./Browser')
  4. const LoaderView = require('./Loader')
  5. const generateFileID = require('@uppy/utils/lib/generateFileID')
  6. const getFileType = require('@uppy/utils/lib/getFileType')
  7. const isPreviewSupported = require('@uppy/utils/lib/isPreviewSupported')
  8. /**
  9. * Array.prototype.findIndex ponyfill for old browsers.
  10. */
  11. function findIndex (array, predicate) {
  12. for (let i = 0; i < array.length; i++) {
  13. if (predicate(array[i])) return i
  14. }
  15. return -1
  16. }
  17. // location.origin does not exist in IE
  18. function getOrigin () {
  19. if ('origin' in location) {
  20. return location.origin // eslint-disable-line compat/compat
  21. }
  22. return `${location.protocol}//${location.hostname}${location.port ? `:${location.port}` : ''}`
  23. }
  24. class CloseWrapper extends Component {
  25. componentWillUnmount () {
  26. this.props.onUnmount()
  27. }
  28. render () {
  29. return this.props.children[0]
  30. }
  31. }
  32. /**
  33. * Class to easily generate generic views for Provider plugins
  34. */
  35. module.exports = class ProviderView {
  36. static VERSION = require('../package.json').version
  37. /**
  38. * @param {object} plugin instance of the plugin
  39. * @param {object} opts
  40. */
  41. constructor (plugin, opts) {
  42. this.plugin = plugin
  43. this.provider = opts.provider
  44. // set default options
  45. const defaultOptions = {
  46. viewType: 'list',
  47. showTitles: true,
  48. showFilter: true,
  49. showBreadcrumbs: true
  50. }
  51. // merge default options with the ones set by user
  52. this.opts = { ...defaultOptions, ...opts }
  53. // Logic
  54. this.addFile = this.addFile.bind(this)
  55. this.filterItems = this.filterItems.bind(this)
  56. this.filterQuery = this.filterQuery.bind(this)
  57. this.toggleSearch = this.toggleSearch.bind(this)
  58. this.getFolder = this.getFolder.bind(this)
  59. this.getNextFolder = this.getNextFolder.bind(this)
  60. this.logout = this.logout.bind(this)
  61. this.preFirstRender = this.preFirstRender.bind(this)
  62. this.handleAuth = this.handleAuth.bind(this)
  63. this.sortByTitle = this.sortByTitle.bind(this)
  64. this.sortByDate = this.sortByDate.bind(this)
  65. this.isActiveRow = this.isActiveRow.bind(this)
  66. this.isChecked = this.isChecked.bind(this)
  67. this.toggleCheckbox = this.toggleCheckbox.bind(this)
  68. this.handleError = this.handleError.bind(this)
  69. this.handleScroll = this.handleScroll.bind(this)
  70. this.listAllFiles = this.listAllFiles.bind(this)
  71. this.donePicking = this.donePicking.bind(this)
  72. this.cancelPicking = this.cancelPicking.bind(this)
  73. this.clearSelection = this.clearSelection.bind(this)
  74. // Visual
  75. this.render = this.render.bind(this)
  76. this.clearSelection()
  77. // Set default state for the plugin
  78. this.plugin.setPluginState({
  79. authenticated: false,
  80. files: [],
  81. folders: [],
  82. directories: [],
  83. activeRow: -1,
  84. filterInput: '',
  85. isSearchVisible: false
  86. })
  87. }
  88. tearDown () {
  89. // Nothing.
  90. }
  91. _updateFilesAndFolders (res, files, folders) {
  92. this.nextPagePath = res.nextPagePath
  93. res.items.forEach((item) => {
  94. if (item.isFolder) {
  95. folders.push(item)
  96. } else {
  97. files.push(item)
  98. }
  99. })
  100. this.plugin.setPluginState({ folders, files })
  101. }
  102. /**
  103. * Called only the first time the provider view is rendered.
  104. * Kind of like an init function.
  105. */
  106. preFirstRender () {
  107. this.plugin.setPluginState({ didFirstRender: true })
  108. this.plugin.onFirstRender()
  109. }
  110. /**
  111. * Based on folder ID, fetch a new folder and update it to state
  112. *
  113. * @param {string} id Folder id
  114. * @returns {Promise} Folders/files in folder
  115. */
  116. getFolder (id, name) {
  117. return this._loaderWrapper(
  118. this.provider.list(id),
  119. (res) => {
  120. const folders = []
  121. const files = []
  122. let updatedDirectories
  123. const state = this.plugin.getPluginState()
  124. const index = findIndex(state.directories, (dir) => id === dir.id)
  125. if (index !== -1) {
  126. updatedDirectories = state.directories.slice(0, index + 1)
  127. } else {
  128. updatedDirectories = state.directories.concat([{ id, title: name }])
  129. }
  130. this.username = this.username ? this.username : res.username
  131. this._updateFilesAndFolders(res, files, folders)
  132. this.plugin.setPluginState({ directories: updatedDirectories })
  133. },
  134. this.handleError)
  135. }
  136. /**
  137. * Fetches new folder
  138. *
  139. * @param {object} Folder
  140. * @param {string} title Folder title
  141. */
  142. getNextFolder (folder) {
  143. this.getFolder(folder.requestPath, folder.name)
  144. this.lastCheckbox = undefined
  145. }
  146. addFile (file) {
  147. const tagFile = {
  148. id: this.providerFileToId(file),
  149. source: this.plugin.id,
  150. data: file,
  151. name: file.name || file.id,
  152. type: file.mimeType,
  153. isRemote: true,
  154. body: {
  155. fileId: file.id
  156. },
  157. remote: {
  158. companionUrl: this.plugin.opts.companionUrl,
  159. url: `${this.provider.fileUrl(file.requestPath)}`,
  160. body: {
  161. fileId: file.id
  162. },
  163. providerOptions: this.provider.opts
  164. }
  165. }
  166. const fileType = getFileType(tagFile)
  167. // TODO Should we just always use the thumbnail URL if it exists?
  168. if (fileType && isPreviewSupported(fileType)) {
  169. tagFile.preview = file.thumbnail
  170. }
  171. this.plugin.uppy.log('Adding remote file')
  172. try {
  173. this.plugin.uppy.addFile(tagFile)
  174. } catch (err) {
  175. if (!err.isRestriction) {
  176. this.plugin.uppy.log(err)
  177. }
  178. }
  179. }
  180. removeFile (id) {
  181. const { currentSelection } = this.plugin.getPluginState()
  182. this.plugin.setPluginState({
  183. currentSelection: currentSelection.filter((file) => file.id !== id)
  184. })
  185. }
  186. /**
  187. * Removes session token on client side.
  188. */
  189. logout () {
  190. this.provider.logout()
  191. .then((res) => {
  192. if (res.ok) {
  193. if (!res.revoked) {
  194. const message = this.plugin.uppy.i18n('companionUnauthorizeHint', {
  195. provider: this.plugin.title,
  196. url: res.manual_revoke_url
  197. })
  198. this.plugin.uppy.info(message, 'info', 7000)
  199. }
  200. const newState = {
  201. authenticated: false,
  202. files: [],
  203. folders: [],
  204. directories: []
  205. }
  206. this.plugin.setPluginState(newState)
  207. }
  208. }).catch(this.handleError)
  209. }
  210. filterQuery (e) {
  211. const state = this.plugin.getPluginState()
  212. this.plugin.setPluginState(Object.assign({}, state, {
  213. filterInput: e ? e.target.value : ''
  214. }))
  215. }
  216. toggleSearch (inputEl) {
  217. const state = this.plugin.getPluginState()
  218. this.plugin.setPluginState({
  219. isSearchVisible: !state.isSearchVisible,
  220. filterInput: ''
  221. })
  222. }
  223. filterItems (items) {
  224. const state = this.plugin.getPluginState()
  225. if (!state.filterInput || state.filterInput === '') {
  226. return items
  227. }
  228. return items.filter((folder) => {
  229. return folder.name.toLowerCase().indexOf(state.filterInput.toLowerCase()) !== -1
  230. })
  231. }
  232. sortByTitle () {
  233. const state = Object.assign({}, this.plugin.getPluginState())
  234. const { files, folders, sorting } = state
  235. const sortedFiles = files.sort((fileA, fileB) => {
  236. if (sorting === 'titleDescending') {
  237. return fileB.name.localeCompare(fileA.name)
  238. }
  239. return fileA.name.localeCompare(fileB.name)
  240. })
  241. const sortedFolders = folders.sort((folderA, folderB) => {
  242. if (sorting === 'titleDescending') {
  243. return folderB.name.localeCompare(folderA.name)
  244. }
  245. return folderA.name.localeCompare(folderB.name)
  246. })
  247. this.plugin.setPluginState(Object.assign({}, state, {
  248. files: sortedFiles,
  249. folders: sortedFolders,
  250. sorting: (sorting === 'titleDescending') ? 'titleAscending' : 'titleDescending'
  251. }))
  252. }
  253. sortByDate () {
  254. const state = Object.assign({}, this.plugin.getPluginState())
  255. const { files, folders, sorting } = state
  256. const sortedFiles = files.sort((fileA, fileB) => {
  257. const a = new Date(fileA.modifiedDate)
  258. const b = new Date(fileB.modifiedDate)
  259. if (sorting === 'dateDescending') {
  260. return a > b ? -1 : a < b ? 1 : 0
  261. }
  262. return a > b ? 1 : a < b ? -1 : 0
  263. })
  264. const sortedFolders = folders.sort((folderA, folderB) => {
  265. const a = new Date(folderA.modifiedDate)
  266. const b = new Date(folderB.modifiedDate)
  267. if (sorting === 'dateDescending') {
  268. return a > b ? -1 : a < b ? 1 : 0
  269. }
  270. return a > b ? 1 : a < b ? -1 : 0
  271. })
  272. this.plugin.setPluginState(Object.assign({}, state, {
  273. files: sortedFiles,
  274. folders: sortedFolders,
  275. sorting: (sorting === 'dateDescending') ? 'dateAscending' : 'dateDescending'
  276. }))
  277. }
  278. sortBySize () {
  279. const state = Object.assign({}, this.plugin.getPluginState())
  280. const { files, sorting } = state
  281. // check that plugin supports file sizes
  282. if (!files.length || !this.plugin.getItemData(files[0]).size) {
  283. return
  284. }
  285. const sortedFiles = files.sort((fileA, fileB) => {
  286. const a = fileA.size
  287. const b = fileB.size
  288. if (sorting === 'sizeDescending') {
  289. return a > b ? -1 : a < b ? 1 : 0
  290. }
  291. return a > b ? 1 : a < b ? -1 : 0
  292. })
  293. this.plugin.setPluginState(Object.assign({}, state, {
  294. files: sortedFiles,
  295. sorting: (sorting === 'sizeDescending') ? 'sizeAscending' : 'sizeDescending'
  296. }))
  297. }
  298. isActiveRow (file) {
  299. return this.plugin.getPluginState().activeRow === this.plugin.getItemId(file)
  300. }
  301. isChecked (file) {
  302. const { currentSelection } = this.plugin.getPluginState()
  303. // comparing id instead of the file object, because the reference to the object
  304. // changes when we switch folders, and the file list is updated
  305. return currentSelection.some((item) => item.id === file.id)
  306. }
  307. /**
  308. * Adds all files found inside of specified folder.
  309. *
  310. * Uses separated state while folder contents are being fetched and
  311. * mantains list of selected folders, which are separated from files.
  312. */
  313. addFolder (folder) {
  314. const folderId = this.providerFileToId(folder)
  315. let state = this.plugin.getPluginState()
  316. const folders = state.selectedFolders || {}
  317. if (folderId in folders && folders[folderId].loading) {
  318. return
  319. }
  320. folders[folderId] = { loading: true, files: [] }
  321. this.plugin.setPluginState({ selectedFolders: folders })
  322. return this.listAllFiles(folder.requestPath).then((files) => {
  323. files.forEach((file) => {
  324. this.addFile(file)
  325. })
  326. const ids = files.map(this.providerFileToId)
  327. state = this.plugin.getPluginState()
  328. state.selectedFolders[folderId] = { loading: false, files: ids }
  329. this.plugin.setPluginState({ selectedFolders: folders })
  330. let message
  331. if (files.length) {
  332. message = this.plugin.uppy.i18n('folderAdded', {
  333. smart_count: files.length, folder: folder.name
  334. })
  335. } else {
  336. message = this.plugin.uppy.i18n('emptyFolderAdded')
  337. }
  338. this.plugin.uppy.info(message)
  339. }).catch((e) => {
  340. state = this.plugin.getPluginState()
  341. delete state.selectedFolders[folderId]
  342. this.plugin.setPluginState({ selectedFolders: state.selectedFolders })
  343. this.handleError(e)
  344. })
  345. }
  346. /**
  347. * Toggles file/folder checkbox to on/off state while updating files list.
  348. *
  349. * Note that some extra complexity comes from supporting shift+click to
  350. * toggle multiple checkboxes at once, which is done by getting all files
  351. * in between last checked file and current one.
  352. */
  353. toggleCheckbox (e, file) {
  354. e.stopPropagation()
  355. e.preventDefault()
  356. e.currentTarget.focus()
  357. const { folders, files } = this.plugin.getPluginState()
  358. const items = this.filterItems(folders.concat(files))
  359. // Shift-clicking selects a single consecutive list of items
  360. // starting at the previous click and deselects everything else.
  361. if (this.lastCheckbox && e.shiftKey) {
  362. let currentSelection
  363. const prevIndex = items.indexOf(this.lastCheckbox)
  364. const currentIndex = items.indexOf(file)
  365. if (prevIndex < currentIndex) {
  366. currentSelection = items.slice(prevIndex, currentIndex + 1)
  367. } else {
  368. currentSelection = items.slice(currentIndex, prevIndex + 1)
  369. }
  370. this.plugin.setPluginState({ currentSelection })
  371. return
  372. }
  373. this.lastCheckbox = file
  374. const { currentSelection } = this.plugin.getPluginState()
  375. if (this.isChecked(file)) {
  376. this.plugin.setPluginState({
  377. currentSelection: currentSelection.filter((item) => item.id !== file.id)
  378. })
  379. } else {
  380. this.plugin.setPluginState({
  381. currentSelection: currentSelection.concat([file])
  382. })
  383. }
  384. }
  385. providerFileToId (file) {
  386. return generateFileID({
  387. data: file,
  388. name: file.name || file.id,
  389. type: file.mimeType
  390. })
  391. }
  392. handleAuth () {
  393. const authState = btoa(JSON.stringify({ origin: getOrigin() }))
  394. const clientVersion = encodeURIComponent(`@uppy/provider-views=${ProviderView.VERSION}`)
  395. const link = `${this.provider.authUrl()}?state=${authState}&uppyVersions=${clientVersion}`
  396. const authWindow = window.open(link, '_blank')
  397. const handleToken = (e) => {
  398. if (!this._isOriginAllowed(e.origin, this.plugin.opts.companionAllowedHosts) || e.source !== authWindow) {
  399. this.plugin.uppy.log(`rejecting event from ${e.origin} vs allowed pattern ${this.plugin.opts.companionAllowedHosts}`)
  400. return
  401. }
  402. // Check if it's a string before doing the JSON.parse to maintain support
  403. // for older Companion versions that used object references
  404. const data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data
  405. if (!data.token) {
  406. this.plugin.uppy.log('did not receive token from auth window')
  407. return
  408. }
  409. authWindow.close()
  410. window.removeEventListener('message', handleToken)
  411. this.provider.setAuthToken(data.token)
  412. this.preFirstRender()
  413. }
  414. window.addEventListener('message', handleToken)
  415. }
  416. _isOriginAllowed (origin, allowedOrigin) {
  417. const getRegex = (value) => {
  418. if (typeof value === 'string') {
  419. return new RegExp(`^${value}$`)
  420. } else if (value instanceof RegExp) {
  421. return value
  422. }
  423. }
  424. const patterns = Array.isArray(allowedOrigin) ? allowedOrigin.map(getRegex) : [getRegex(allowedOrigin)]
  425. return patterns
  426. .filter((pattern) => pattern != null) // loose comparison to catch undefined
  427. .some((pattern) => pattern.test(origin) || pattern.test(`${origin}/`)) // allowing for trailing '/'
  428. }
  429. handleError (error) {
  430. const uppy = this.plugin.uppy
  431. uppy.log(error.toString())
  432. if (error.isAuthError) {
  433. return
  434. }
  435. const message = uppy.i18n('companionError')
  436. uppy.info({ message: message, details: error.toString() }, 'error', 5000)
  437. }
  438. handleScroll (e) {
  439. const scrollPos = e.target.scrollHeight - (e.target.scrollTop + e.target.offsetHeight)
  440. const path = this.nextPagePath || null
  441. if (scrollPos < 50 && path && !this._isHandlingScroll) {
  442. this.provider.list(path)
  443. .then((res) => {
  444. const { files, folders } = this.plugin.getPluginState()
  445. this._updateFilesAndFolders(res, files, folders)
  446. }).catch(this.handleError)
  447. .then(() => { this._isHandlingScroll = false }) // always called
  448. this._isHandlingScroll = true
  449. }
  450. }
  451. listAllFiles (path, files = null) {
  452. files = files || []
  453. return new Promise((resolve, reject) => {
  454. this.provider.list(path).then((res) => {
  455. res.items.forEach((item) => {
  456. if (!item.isFolder) {
  457. files.push(item)
  458. }
  459. })
  460. const moreFiles = res.nextPagePath || null
  461. if (moreFiles) {
  462. return this.listAllFiles(moreFiles, files)
  463. .then((files) => resolve(files))
  464. .catch(e => reject(e))
  465. } else {
  466. return resolve(files)
  467. }
  468. }).catch(e => reject(e))
  469. })
  470. }
  471. donePicking () {
  472. const { currentSelection } = this.plugin.getPluginState()
  473. const promises = currentSelection.map((file) => {
  474. if (file.isFolder) {
  475. return this.addFolder(file)
  476. } else {
  477. return this.addFile(file)
  478. }
  479. })
  480. this._loaderWrapper(Promise.all(promises), () => {
  481. this.clearSelection()
  482. }, () => {})
  483. }
  484. cancelPicking () {
  485. this.clearSelection()
  486. const dashboard = this.plugin.uppy.getPlugin('Dashboard')
  487. if (dashboard) dashboard.hideAllPanels()
  488. }
  489. clearSelection () {
  490. this.plugin.setPluginState({ currentSelection: [] })
  491. }
  492. // displays loader view while asynchronous request is being made.
  493. _loaderWrapper (promise, then, catch_) {
  494. promise
  495. .then((result) => {
  496. this.plugin.setPluginState({ loading: false })
  497. then(result)
  498. }).catch((err) => {
  499. this.plugin.setPluginState({ loading: false })
  500. catch_(err)
  501. })
  502. this.plugin.setPluginState({ loading: true })
  503. }
  504. render (state, viewOptions = {}) {
  505. const { authenticated, didFirstRender } = this.plugin.getPluginState()
  506. if (!didFirstRender) {
  507. this.preFirstRender()
  508. }
  509. // reload pluginState for "loading" attribute because it might
  510. // have changed above.
  511. if (this.plugin.getPluginState().loading) {
  512. return (
  513. <CloseWrapper onUnmount={this.clearSelection}>
  514. <LoaderView i18n={this.plugin.uppy.i18n} />
  515. </CloseWrapper>
  516. )
  517. }
  518. if (!authenticated) {
  519. return (
  520. <CloseWrapper onUnmount={this.clearSelection}>
  521. <AuthView
  522. pluginName={this.plugin.title}
  523. pluginIcon={this.plugin.icon}
  524. handleAuth={this.handleAuth}
  525. i18n={this.plugin.uppy.i18n}
  526. i18nArray={this.plugin.uppy.i18nArray}
  527. />
  528. </CloseWrapper>
  529. )
  530. }
  531. const targetViewOptions = { ...this.opts, ...viewOptions }
  532. const browserProps = Object.assign({}, this.plugin.getPluginState(), {
  533. username: this.username,
  534. getNextFolder: this.getNextFolder,
  535. getFolder: this.getFolder,
  536. filterItems: this.filterItems,
  537. filterQuery: this.filterQuery,
  538. toggleSearch: this.toggleSearch,
  539. sortByTitle: this.sortByTitle,
  540. sortByDate: this.sortByDate,
  541. logout: this.logout,
  542. isActiveRow: this.isActiveRow,
  543. isChecked: this.isChecked,
  544. toggleCheckbox: this.toggleCheckbox,
  545. handleScroll: this.handleScroll,
  546. listAllFiles: this.listAllFiles,
  547. done: this.donePicking,
  548. cancel: this.cancelPicking,
  549. title: this.plugin.title,
  550. viewType: targetViewOptions.viewType,
  551. showTitles: targetViewOptions.showTitles,
  552. showFilter: targetViewOptions.showFilter,
  553. showBreadcrumbs: targetViewOptions.showBreadcrumbs,
  554. pluginIcon: this.plugin.icon,
  555. i18n: this.plugin.uppy.i18n
  556. })
  557. return (
  558. <CloseWrapper onUnmount={this.clearSelection}>
  559. <Browser {...browserProps} />
  560. </CloseWrapper>
  561. )
  562. }
  563. }