Browse Source

Companion: rewrite `request` and `purest` to `got` (#3953)

* rewrite to async

* rewrite box and dropbox to got

(not yet working due to jest esm issues)

* downgrade got

* update developer notes

* rewrite

- rewrite remaining providers to got
- rewrite to async/await
- pull out adapt code into adapters
- provider/companion tests still todo

* add zoom to dev dashboard

* rewrites

- rewrite remaining providers to got and reuse code
- port tests
- remove request
- remove purest
- rewrite periodic ping job to got
- rewrite uploader to got
- rewrite "url" to got
- rewrite getRedirectEvaluator/request to got
- rewrite http/https agent/request to got
- rewrite credentials.js to got
- fix "todo: handle failures differently to return 400 for this case instead"
- add test for http/https agent
- improve test for credentials (remote/local)
- make /zoom/logout return 424 instead of 500 on credentials error
- remove useless http-agent tests
- fix various eslint warnings

* work around ts error

* remove forgotten change
Mikael Finstad 2 years ago
parent
commit
35812ca378
46 changed files with 1892 additions and 2317 deletions
  1. 11 1
      .github/CONTRIBUTING.md
  2. 1 3
      packages/@uppy/companion/ARCHITECTURE.md
  3. 2 3
      packages/@uppy/companion/package.json
  4. 52 50
      packages/@uppy/companion/src/server/Uploader.js
  5. 1 2
      packages/@uppy/companion/src/server/controllers/thumbnail.js
  6. 10 28
      packages/@uppy/companion/src/server/controllers/url.js
  7. 50 37
      packages/@uppy/companion/src/server/helpers/request.js
  8. 27 22
      packages/@uppy/companion/src/server/helpers/utils.js
  9. 2 7
      packages/@uppy/companion/src/server/jobs.js
  10. 1 1
      packages/@uppy/companion/src/server/provider/Provider.js
  11. 35 13
      packages/@uppy/companion/src/server/provider/box/adapter.js
  12. 66 160
      packages/@uppy/companion/src/server/provider/box/index.js
  13. 13 25
      packages/@uppy/companion/src/server/provider/credentials.js
  14. 94 42
      packages/@uppy/companion/src/server/provider/drive/adapter.js
  15. 101 225
      packages/@uppy/companion/src/server/provider/drive/index.js
  16. 34 13
      packages/@uppy/companion/src/server/provider/dropbox/adapter.js
  17. 64 141
      packages/@uppy/companion/src/server/provider/dropbox/index.js
  18. 37 16
      packages/@uppy/companion/src/server/provider/facebook/adapter.js
  19. 59 153
      packages/@uppy/companion/src/server/provider/facebook/index.js
  20. 1 27
      packages/@uppy/companion/src/server/provider/index.js
  21. 31 10
      packages/@uppy/companion/src/server/provider/instagram/graph/adapter.js
  22. 49 124
      packages/@uppy/companion/src/server/provider/instagram/graph/index.js
  23. 38 16
      packages/@uppy/companion/src/server/provider/onedrive/adapter.js
  24. 49 103
      packages/@uppy/companion/src/server/provider/onedrive/index.js
  25. 40 0
      packages/@uppy/companion/src/server/provider/providerErrors.js
  26. 40 11
      packages/@uppy/companion/src/server/provider/unsplash/adapter.js
  27. 39 122
      packages/@uppy/companion/src/server/provider/unsplash/index.js
  28. 98 17
      packages/@uppy/companion/src/server/provider/zoom/adapter.js
  29. 113 284
      packages/@uppy/companion/src/server/provider/zoom/index.js
  30. 0 76
      packages/@uppy/companion/test/__mocks__/purest.js
  31. 63 20
      packages/@uppy/companion/test/__tests__/companion.js
  32. 34 38
      packages/@uppy/companion/test/__tests__/credentials.js
  33. 10 2
      packages/@uppy/companion/test/__tests__/deauthorization.js
  34. 22 82
      packages/@uppy/companion/test/__tests__/http-agent.js
  35. 335 34
      packages/@uppy/companion/test/__tests__/providers.js
  36. 0 34
      packages/@uppy/companion/test/fixtures/box.js
  37. 17 46
      packages/@uppy/companion/test/fixtures/drive.js
  38. 0 60
      packages/@uppy/companion/test/fixtures/dropbox.js
  39. 0 45
      packages/@uppy/companion/test/fixtures/facebook.js
  40. 15 7
      packages/@uppy/companion/test/fixtures/index.js
  41. 0 34
      packages/@uppy/companion/test/fixtures/instagram.js
  42. 0 56
      packages/@uppy/companion/test/fixtures/onedrive.js
  43. 45 65
      packages/@uppy/companion/test/fixtures/zoom.js
  44. 6 4
      packages/@uppy/companion/test/mockserver.js
  45. 1 1
      private/dev/Dashboard.js
  46. 186 57
      yarn.lock

+ 11 - 1
.github/CONTRIBUTING.md

@@ -100,7 +100,7 @@ The following steps describe the actions that take place when a user Authenticat
 
 ### Instagram integration
 
-Even though facebook [allows using](https://developers.facebook.com/blog/post/2018/06/08/enforce-https-facebook-login/) http://localhost in dev mode, Instagram doesn’t seem to support that, and seems to need a publically available domain name with HTTPS.
+Even though facebook [allows using](https://developers.facebook.com/blog/post/2018/06/08/enforce-https-facebook-login/) http://localhost in dev mode, Instagram doesn’t seem to support that, and seems to need a publically available domain name with HTTPS. So we will tunnel requests to localhost using `ngrok`.
 
 Make sure that you are using a development facebook app at <https://developers.facebook.com/apps>
 
@@ -109,6 +109,8 @@ Go to “Instagram Basic Display” and find `Instagram App ID` and `Instagram A
     COMPANION_INSTAGRAM_KEY="Instagram App ID"
     COMPANION_INSTAGRAM_SECRET="Instagram App Secret"
 
+**Note!** `ngrok` seems to be blocked by Instagram now, so you may have to find an alternative.
+
 Run
 
 ```bash
@@ -136,6 +138,14 @@ Tester invites -> Accept
 
 Now you should be able to test the Instagram integration.
 
+## Zoom
+
+See above Instagram instructions for setting up a tunnel, but replace `instagram` with `zoom` in the URL. Note that **you also have to add the OAuth redirect URL to `OAuth allow list`** in the Zoom Oauth app settings or it will not work.
+
+Add the following scopes: `recording:read`, `user:read`, `user_info:read`
+
+To test recording a meeting, you need to sign up for a Zoom Pro trial (can be cancelled later), for example using their iOS app.
+
 ## Releases
 
 Before doing a release, check that the examples on the website work:

+ 1 - 3
packages/@uppy/companion/ARCHITECTURE.md

@@ -60,14 +60,12 @@ These controllers are generalized to work for any provider. The provider specifi
 
 To add a new provider to Companion, you need to do two things: add the provider config to `config/grant.js`, and then create a new file in `server/providers` that describes how to interface with the provider’s API.
 
-We are using a library called [purest](https://github.com/simov/purest) to make it easier to interface with third party APIs. Instead of dealing with each single provider’s client library/SDK, we use Purest, a “generic REST API client library” that gives us a consistent, “generic” API to interface with any provider. This makes life a lot easier.
+We are using a library called [got](https://github.com/sindresorhus/got) to make it easier to interface with third party APIs.
 
 Since each API works differently, we need to describe how to `download` and `list` files from the provider in a file within `server/providers`. The name of the file should be the same as what endpoint it will use. For example, `server/providers/foobar.js` if the client requests a list of files from `https://our-server/foobar/list`.
 
 **Note:** As of right now, you only need to implement `YourProvider.prototype.list` and `YourProvider.prototype.download` for each provider, I believe. `stats` seems to be used by Dropbox to get a list of files, so that’s required there, but `upload` is optional unless you all decide to allow uploading to third parties. I got that code from an example.
 
-This whole approach was inspired by an example from `purest 2.x`. Keep in mind that we’re using `3.x`, so the API is different, but here is the example for reference: <https://github.com/simov/purest/tree/2.x/examples/storage>
-
 ## WebSockets
 
 Companion uses WebSockets to transfer `progress` events to the client during file transfers. It’s only set up to transfer progress during Tus uploads to the target server.

+ 2 - 3
packages/@uppy/companion/package.json

@@ -28,7 +28,6 @@
   ],
   "bin": "./bin/companion",
   "dependencies": {
-    "@purest/providers": "1.0.1",
     "atob": "2.1.2",
     "aws-sdk": "^2.1038.0",
     "body-parser": "1.19.0",
@@ -44,6 +43,8 @@
     "express-prom-bundle": "6.3.0",
     "express-request-id": "1.4.1",
     "express-session": "1.17.1",
+    "form-data": "^3.0.0",
+    "got": "11",
     "grant": "4.7.0",
     "helmet": "^4.6.0",
     "ipaddr.js": "^2.0.1",
@@ -56,9 +57,7 @@
     "ms": "2.1.2",
     "node-schedule": "1.3.2",
     "prom-client": "12.0.0",
-    "purest": "3.1.0",
     "redis": "4.2.0",
-    "request": "2.88.2",
     "semver": "6.3.0",
     "serialize-error": "^2.1.0",
     "serialize-javascript": "^6.0.0",

+ 52 - 50
packages/@uppy/companion/src/server/Uploader.js

@@ -2,11 +2,12 @@
 const tus = require('tus-js-client')
 const { randomUUID } = require('node:crypto')
 const validator = require('validator')
-const request = require('request')
+const got = require('got').default
 const { pipeline: pipelineCb } = require('node:stream')
 const { join } = require('node:path')
 const fs = require('node:fs')
 const { promisify } = require('node:util')
+const FormData = require('form-data')
 
 // TODO move to `require('streams/promises').pipeline` when dropping support for Node.js 14.x.
 const pipeline = promisify(pipelineCb)
@@ -557,6 +558,18 @@ class Uploader {
       throw new Error('No multipart endpoint set')
     }
 
+    function getRespObj (response) {
+      // remove browser forbidden headers
+      const { 'set-cookie': deleted, 'set-cookie2': deleted2, ...responseHeaders } = response.headers
+
+      return {
+        responseText: response.body,
+        status: response.statusCode,
+        statusText: response.statusMessage,
+        headers: responseHeaders,
+      }
+    }
+
     // upload progress
     let bytesUploaded = 0
     stream.on('data', (data) => {
@@ -564,66 +577,55 @@ class Uploader {
       this.onProgress(bytesUploaded, undefined)
     })
 
-    const httpMethod = (this.options.httpMethod || '').toLowerCase() === 'put' ? 'put' : 'post'
-    const headers = headerSanitize(this.options.headers)
-    const reqOptions = { url: this.options.endpoint, headers, encoding: null }
-    const runRequest = request[httpMethod]
+    const url = this.options.endpoint
+    const reqOptions = {
+      headers: headerSanitize(this.options.headers),
+    }
 
     if (this.options.useFormData) {
-      reqOptions.formData = {
-        ...this.options.metadata,
-        [this.options.fieldname]: {
-          value: stream,
-          options: {
-            filename: this.uploadFileName,
-            contentType: this.options.metadata.type,
-            knownLength: this.size,
-          },
-        },
-      }
+      // todo refactor once upgraded to got 12
+      const formData = new FormData()
+
+      Object.entries(this.options.metadata).forEach(([key, value]) => formData.append(key, value))
+
+      formData.append(this.options.fieldname, stream, {
+        filename: this.uploadFileName,
+        contentType: this.options.metadata.type,
+        knownLength: this.size,
+      })
+
+      reqOptions.body = formData
     } else {
       reqOptions.headers['content-length'] = this.size
       reqOptions.body = stream
     }
 
-    const { response, body } = await new Promise((resolve, reject) => {
-      runRequest(reqOptions, (error, response2, body2) => {
-        if (error) {
-          logger.error(error, 'upload.multipart.error')
-          reject(error)
-          return
-        }
-
-        resolve({ response: response2, body: body2 })
-      })
-    })
-
-    // remove browser forbidden headers
-    delete response.headers['set-cookie']
-    delete response.headers['set-cookie2']
+    try {
+      const httpMethod = (this.options.httpMethod || '').toLowerCase() === 'put' ? 'put' : 'post'
+      const runRequest = got[httpMethod]
 
-    const respObj = {
-      responseText: body.toString(),
-      status: response.statusCode,
-      statusText: response.statusMessage,
-      headers: response.headers,
-    }
+      const response = await runRequest(url, reqOptions)
 
-    if (response.statusCode >= 400) {
-      logger.error(`upload failed with status: ${response.statusCode}`, 'upload.multipart.error')
-      const err = new Error(response.statusMessage)
-      // @ts-ignore
-      err.extraData = respObj
-      throw err
-    }
+      if (bytesUploaded !== this.size) {
+        const errMsg = `uploaded only ${bytesUploaded} of ${this.size} with status: ${response.statusCode}`
+        logger.error(errMsg, 'upload.multipart.mismatch.error')
+        throw new Error(errMsg)
+      }
 
-    if (bytesUploaded !== this.size) {
-      const errMsg = `uploaded only ${bytesUploaded} of ${this.size} with status: ${response.statusCode}`
-      logger.error(errMsg, 'upload.multipart.mismatch.error')
-      throw new Error(errMsg)
+      return {
+        url: null,
+        extraData: { response: getRespObj(response), bytesUploaded },
+      }
+    } catch (err) {
+      logger.error(err, 'upload.multipart.error')
+      const statusCode = err.response?.statusCode
+      if (statusCode != null) {
+        throw Object.assign(new Error(err.statusMessage), {
+          extraData: getRespObj(err.response),
+        })
+      }
+      throw new Error('Unknown multipart upload error')
     }
-
-    return { url: null, extraData: { response: respObj, bytesUploaded } }
   }
 
   /**

+ 1 - 2
packages/@uppy/companion/src/server/controllers/thumbnail.js

@@ -4,8 +4,7 @@
  * @param {object} res
  */
 async function thumbnail (req, res, next) {
-  const { providerName } = req.params
-  const { id } = req.params
+  const { providerName, id } = req.params
   const token = req.companion.providerTokens[providerName]
   const { provider } = req.companion
 

+ 10 - 28
packages/@uppy/companion/src/server/controllers/url.js

@@ -1,10 +1,9 @@
 const router = require('express').Router
-const request = require('request')
-const { URL } = require('node:url')
 const validator = require('validator')
 
 const { startDownUpload } = require('../helpers/upload')
-const { getURLMeta, getRedirectEvaluator, getProtectedHttpAgent } = require('../helpers/request')
+const { prepareStream } = require('../helpers/utils')
+const { getURLMeta, getProtectedGot } = require('../helpers/request')
 const logger = require('../logger')
 
 /**
@@ -46,32 +45,15 @@ const validateURL = (url, ignoreTld) => {
  * @returns {Promise}
  */
 const downloadURL = async (url, blockLocalIPs, traceId) => {
-  const opts = {
-    uri: url,
-    method: 'GET',
-    followRedirect: getRedirectEvaluator(url, blockLocalIPs),
-    agentClass: getProtectedHttpAgent((new URL(url)).protocol, blockLocalIPs),
+  try {
+    const protectedGot = getProtectedGot({ url, blockLocalIPs })
+    const stream = protectedGot.stream.get(url, { responseType: 'json' })
+    await prepareStream(stream)
+    return stream
+  } catch (err) {
+    logger.error(err, 'controller.url.download.error', traceId)
+    throw err
   }
-
-  return new Promise((resolve, reject) => {
-    const req = request(opts)
-      .on('response', (resp) => {
-        if (resp.statusCode >= 300) {
-          req.abort() // No need to keep request
-          reject(new Error(`URL server responded with status: ${resp.statusCode}`))
-          return
-        }
-
-        // Don't allow any more data to flow yet.
-        // https://github.com/request/request/issues/1990#issuecomment-184712275
-        resp.pause()
-        resolve(resp)
-      })
-      .on('error', (err) => {
-        logger.error(err, 'controller.url.download.error', traceId)
-        reject(err)
-      })
-  })
 }
 
 /**

+ 50 - 37
packages/@uppy/companion/src/server/helpers/request.js

@@ -3,8 +3,8 @@ const http = require('node:http')
 const https = require('node:https')
 const { URL } = require('node:url')
 const dns = require('node:dns')
-const request = require('request')
 const ipaddr = require('ipaddr.js')
+const got = require('got').default
 
 const logger = require('../logger')
 
@@ -17,16 +17,15 @@ const isDisallowedIP = (ipAddress) => ipaddr.parse(ipAddress).range() !== 'unica
 
 module.exports.FORBIDDEN_IP_ADDRESS = FORBIDDEN_IP_ADDRESS
 
-module.exports.getRedirectEvaluator = (rawRequestURL, blockPrivateIPs) => {
+module.exports.getRedirectEvaluator = (rawRequestURL, isEnabled) => {
   const requestURL = new URL(rawRequestURL)
-  return (res) => {
-    if (!blockPrivateIPs) {
-      return true
-    }
+
+  return ({ headers }) => {
+    if (!isEnabled) return true
 
     let redirectURL = null
     try {
-      redirectURL = new URL(res.headers.location, requestURL)
+      redirectURL = new URL(headers.location, requestURL)
     } catch (err) {
       return false
     }
@@ -87,16 +86,30 @@ class HttpsAgent extends https.Agent {
  * Returns http Agent that will prevent requests to private IPs (to preven SSRF)
  *
  * @param {string} protocol http or http: or https: or https protocol needed for the request
- * @param {boolean} blockPrivateIPs if set to false, this protection will be disabled
  */
-module.exports.getProtectedHttpAgent = (protocol, blockPrivateIPs) => {
-  if (blockPrivateIPs) {
-    return protocol.startsWith('https') ? HttpsAgent : HttpAgent
+module.exports.getProtectedHttpAgent = (protocol) => {
+  return protocol.startsWith('https') ? HttpsAgent : HttpAgent
+}
+
+function getProtectedGot ({ url, blockLocalIPs }) {
+  const httpAgent = new (module.exports.getProtectedHttpAgent('http'))()
+  const httpsAgent = new (module.exports.getProtectedHttpAgent('https'))()
+
+  const redirectEvaluator = module.exports.getRedirectEvaluator(url, blockLocalIPs)
+
+  const beforeRedirect = (options, response) => {
+    const allowRedirect = redirectEvaluator(response)
+    if (!allowRedirect) {
+      throw new Error(`Redirect evaluator does not allow the redirect to ${response.headers.location}`)
+    }
   }
 
-  return protocol.startsWith('https') ? https.Agent : http.Agent
+  // @ts-ignore
+  return got.extend({ hooks: { beforeRedirect: [beforeRedirect] }, agent: { http: httpAgent, https: httpsAgent } })
 }
 
+module.exports.getProtectedGot = getProtectedGot
+
 /**
  * Gets the size and content type of a url's content
  *
@@ -105,31 +118,30 @@ module.exports.getProtectedHttpAgent = (protocol, blockPrivateIPs) => {
  * @returns {Promise<{type: string, size: number}>}
  */
 exports.getURLMeta = async (url, blockLocalIPs = false) => {
-  const requestWithMethod = async (method) => new Promise((resolve, reject) => {
-    const opts = {
-      uri: url,
-      method,
-      followRedirect: exports.getRedirectEvaluator(url, blockLocalIPs),
-      agentClass: exports.getProtectedHttpAgent((new URL(url)).protocol, blockLocalIPs),
-    }
-
-    const req = request(opts, (err) => {
-      if (err) reject(err)
-    })
-    req.on('response', (response) => {
-      // Can be undefined for unknown length URLs, e.g. transfer-encoding: chunked
-      const contentLength = parseInt(response.headers['content-length'], 10)
-
-      // No need to get the rest of the response, as we only want header (not really relevant for HEAD, but why not)
-      req.abort()
-
-      resolve({
-        type: response.headers['content-type'],
-        size: Number.isNaN(contentLength) ? null : contentLength,
-        statusCode: response.statusCode,
-      })
-    })
-  })
+  async function requestWithMethod (method) {
+    const protectedGot = getProtectedGot({ url, blockLocalIPs })
+    const stream = protectedGot.stream(url, { method, throwHttpErrors: false })
+
+    return new Promise((resolve, reject) => (
+      stream
+        .on('response', (response) => {
+          // Can be undefined for unknown length URLs, e.g. transfer-encoding: chunked
+          const contentLength = parseInt(response.headers['content-length'], 10)
+
+          // No need to get the rest of the response, as we only want header (not really relevant for HEAD, but why not)
+          stream.destroy()
+
+          resolve({
+            type: response.headers['content-type'],
+            size: Number.isNaN(contentLength) ? null : contentLength,
+            statusCode: response.statusCode,
+          })
+        })
+        .on('error', (err) => {
+          reject(err)
+        })
+    ))
+  }
 
   // We prefer to use a HEAD request, as it doesn't download the content. If the URL doesn't
   // support HEAD, or doesn't follow the spec and provide the correct Content-Length, we
@@ -140,6 +152,7 @@ exports.getURLMeta = async (url, blockLocalIPs = false) => {
   // (e.g. HEAD doesn't work on signed S3 URLs)
   // We look for status codes in the 400 and 500 ranges here, as 3xx errors are
   // unlikely to have to do with our choice of method
+  // todo add unit test for this
   if (urlMeta.statusCode >= 400 || urlMeta.size === 0 || urlMeta.size == null) {
     urlMeta = await requestWithMethod('GET')
   }

+ 27 - 22
packages/@uppy/companion/src/server/helpers/utils.js

@@ -142,27 +142,32 @@ module.exports.decrypt = (encrypted, secret) => {
   return decrypted
 }
 
-// This is a helper that will wait for the headers of a request,
-// then it will pause the response, so that the stream is ready to be attached/piped in the uploader.
-// If we don't pause it will lose some data.
-module.exports.requestStream = async (req, convertResponseToError) => {
-  const resp = await new Promise((resolve, reject) => (
-    req
-      .on('response', (response) => {
-        // Don't allow any more data to flow yet.
-        // https://github.com/request/request/issues/1990#issuecomment-184712275
-        response.pause()
-        resolve(response)
-      })
-      .on('error', reject)
-  ))
-
-  if (resp.statusCode !== 200) {
-    req.abort() // Or we will leak memory (the stream is paused)
-    throw await convertResponseToError(resp)
-  }
+module.exports.defaultGetKey = (req, filename) => `${crypto.randomUUID()}-${filename}`
 
-  return { stream: resp }
-}
+module.exports.prepareStream = async (stream) => new Promise((resolve, reject) => (
+  stream
+    .on('response', () => {
+      // Don't allow any more data to flow yet.
+      // https://github.com/request/request/issues/1990#issuecomment-184712275
+      stream.pause()
+      resolve()
+    })
+    .on('error', (err) => {
+      // got doesn't parse body as JSON on http error (responseType: 'json' is ignored and it instead becomes a string)
+      if (err?.request?.options?.responseType === 'json' && typeof err?.response?.body === 'string') {
+        try {
+          // todo unit test this
+          reject(Object.assign(new Error(), { response: { body: JSON.parse(err.response.body) } }))
+        } catch (err2) {
+          reject(err)
+        }
+      } else {
+        reject(err)
+      }
+    })
+))
 
-module.exports.defaultGetKey = (req, filename) => `${crypto.randomUUID()}-${filename}`
+module.exports.getBasicAuthHeader = (key, secret) => {
+  const base64 = Buffer.from(`${key}:${secret}`, 'binary').toString('base64')
+  return `Basic ${base64}`
+}

+ 2 - 7
packages/@uppy/companion/src/server/jobs.js

@@ -2,7 +2,7 @@ const schedule = require('node-schedule')
 const fs = require('node:fs')
 const path = require('node:path')
 const { promisify } = require('node:util')
-const request = require('request')
+const got = require('got').default
 
 const { FILE_NAME_PREFIX } = require('./Uploader')
 const logger = require('./logger')
@@ -65,12 +65,7 @@ async function runPeriodicPing ({ urls, payload, requestTimeout }) {
   // Run requests in parallel
   await Promise.all(urls.map(async (url) => {
     try {
-      // TODO rewrite to use a non-deprecated request library
-      const opts = { url, timeout: requestTimeout }
-      opts.body = payload
-      opts.json = true
-      const response = await promisify(request.post)(opts)
-      if (response.statusCode !== 200) throw new Error(`Status code was ${response.statusCode}`)
+      await got.post(url, { json: payload, timeout: { request: requestTimeout } })
     } catch (err) {
       logger.warn(err, 'jobs.periodic.ping')
     }

+ 1 - 1
packages/@uppy/companion/src/server/provider/Provider.js

@@ -65,7 +65,7 @@ class Provider {
    * @returns {Promise}
    */
   async deauthorizationCallback (options) { // eslint-disable-line no-unused-vars
-    // @todo consider doing something like cb(new NotImplementedError()) instead
+    // @todo consider doing something like throw new NotImplementedError() instead
     throw new Error('method not implemented')
   }
 

+ 35 - 13
packages/@uppy/companion/src/server/provider/box/adapter.js

@@ -1,50 +1,72 @@
 const mime = require('mime-types')
 const querystring = require('node:querystring')
 
-exports.isFolder = (item) => {
+const isFolder = (item) => {
   return item.type === 'folder'
 }
 
-exports.getItemSize = (item) => {
+const getItemSize = (item) => {
   return item.size
 }
 
-exports.getItemIcon = (item) => {
+const getItemIcon = (item) => {
   return item.type
 }
 
-exports.getItemSubList = (item) => {
+const getItemSubList = (item) => {
   return item.entries
 }
 
-exports.getItemName = (item) => {
+const getItemName = (item) => {
   return item.name || ''
 }
 
-exports.getMimeType = (item) => {
-  return mime.lookup(exports.getItemName(item)) || null
+const getMimeType = (item) => {
+  return mime.lookup(getItemName(item)) || null
 }
 
-exports.getItemId = (item) => {
+const getItemId = (item) => {
   return item.id
 }
 
-exports.getItemRequestPath = (item) => {
+const getItemRequestPath = (item) => {
   return item.id
 }
 
-exports.getItemModifiedDate = (item) => {
+const getItemModifiedDate = (item) => {
   return item.modified_at
 }
 
-exports.getItemThumbnailUrl = (item) => {
-  return `/box/thumbnail/${exports.getItemRequestPath(item)}`
+const getItemThumbnailUrl = (item) => {
+  return `/box/thumbnail/${getItemRequestPath(item)}`
 }
 
-exports.getNextPagePath = (data) => {
+const getNextPagePath = (data) => {
   if (data.total_count < data.limit || data.offset + data.limit > data.total_count) {
     return null
   }
   const query = { cursor: data.offset + data.limit }
   return `?${querystring.stringify(query)}`
 }
+
+module.exports = function adaptData (res, username, companion) {
+  const data = { username, items: [] }
+  const items = getItemSubList(res)
+  items.forEach((item) => {
+    data.items.push({
+      isFolder: isFolder(item),
+      icon: getItemIcon(item),
+      name: getItemName(item),
+      mimeType: getMimeType(item),
+      id: getItemId(item),
+      thumbnail: companion.buildURL(getItemThumbnailUrl(item), true),
+      requestPath: getItemRequestPath(item),
+      modifiedDate: getItemModifiedDate(item),
+      size: getItemSize(item),
+    })
+  })
+
+  data.nextPagePath = getNextPagePath(res)
+
+  return data
+}

+ 66 - 160
packages/@uppy/companion/src/server/provider/box/index.js

@@ -1,16 +1,29 @@
-const request = require('request')
-const purest = require('purest')({ request })
-const { promisify } = require('node:util')
+const got = require('got').default
 
 const Provider = require('../Provider')
-const logger = require('../../logger')
-const adapter = require('./adapter')
-const { ProviderApiError, ProviderAuthError } = require('../error')
-const { requestStream } = require('../../helpers/utils')
+const adaptData = require('./adapter')
+const { withProviderErrorHandling } = require('../providerErrors')
+const { prepareStream } = require('../../helpers/utils')
 
 const BOX_FILES_FIELDS = 'id,modified_at,name,permissions,size,type'
 const BOX_THUMBNAIL_SIZE = 256
 
+const getClient = ({ token }) => got.extend({
+  prefixUrl: 'https://api.box.com/2.0',
+  headers: {
+    authorization: `Bearer ${token}`,
+  },
+})
+
+async function getUserInfo ({ token }) {
+  return getClient({ token }).get('users/me', { responseType: 'json' }).json()
+}
+
+async function list ({ directory, query, token }) {
+  const rootFolderID = '0'
+  return getClient({ token }).get(`folders/${directory || rootFolderID}/items`, { searchParams: { fields: BOX_FILES_FIELDS, offset: query.cursor }, responseType: 'json' }).json()
+}
+
 /**
  * Adapter for API https://developer.box.com/reference/
  */
@@ -18,10 +31,6 @@ class Box extends Provider {
   constructor (options) {
     super(options)
     this.authProvider = Box.authProvider
-    this.client = purest({
-      ...options,
-      provider: Box.authProvider,
-    })
     // needed for the thumbnails fetched via companion
     this.needsCookieAuth = true
   }
@@ -30,13 +39,6 @@ class Box extends Provider {
     return 'box'
   }
 
-  _userInfo ({ token }, done) {
-    this.client
-      .get('users/me')
-      .auth(token)
-      .request(done)
-  }
-
   /**
    * Lists files and folders from Box API
    *
@@ -45,75 +47,30 @@ class Box extends Provider {
    * @param {any} options.query
    * @param {string} options.token
    * @param {unknown} options.companion
-   * @param {Function} done
    */
-  _list ({ directory, token, query, companion }, done) {
-    const rootFolderID = '0'
-    const path = `folders/${directory || rootFolderID}/items`
-
-    this.client
-      .get(path)
-      .qs({ fields: BOX_FILES_FIELDS, offset: query.cursor })
-      .auth(token)
-      .request((err, resp, body) => {
-        if (err || resp.statusCode !== 200) {
-          err = this._error(err, resp)
-          logger.error(err, 'provider.box.list.error')
-          return done(err)
-        }
-        this._userInfo({ token }, (err, infoResp) => {
-          if (err || infoResp.statusCode !== 200) {
-            err = this._error(err, infoResp)
-            logger.error(err, 'provider.token.user.error')
-            return done(err)
-          }
-          done(null, this.adaptData(body, infoResp.body.login, companion))
-        })
-      })
+  async list ({ directory, token, query, companion }) {
+    return this.#withErrorHandling('provider.box.list.error', async () => {
+      const [userInfo, files] = await Promise.all([
+        getUserInfo({ token }),
+        list({ directory, query, token }),
+      ])
+
+      return adaptData(files, userInfo.login, companion)
+    })
   }
 
   async download ({ id, token }) {
-    try {
-      const req = this.client
-        .get(`files/${id}/content`)
-        .auth(token)
-        .request()
-      return await requestStream(req, async (res) => this._error(null, res))
-    } catch (err) {
-      logger.error(err, 'provider.box.download.error')
-      throw err
-    }
+    return this.#withErrorHandling('provider.box.download.error', async () => {
+      const stream = getClient({ token }).stream.get(`files/${id}/content`, { responseType: 'json' })
+
+      await prepareStream(stream)
+      return { stream }
+    })
   }
 
   async thumbnail ({ id, token }) {
-    const maxRetryTime = 10
-    const extension = 'jpg' // set to png to more easily reproduce http 202 retry-after
-    let remainingRetryTime = maxRetryTime
-
-    const tryGetThumbnail = async () => {
-      const req = this.client
-        .get(`files/${id}/thumbnail.${extension}`)
-        .qs({ max_height: BOX_THUMBNAIL_SIZE, max_width: BOX_THUMBNAIL_SIZE })
-        .auth(token)
-        .request()
-
-      // See also requestStream
-      const resp = await new Promise((resolve, reject) => (
-        req
-          .on('response', (response) => {
-            // Don't allow any more data to flow yet.
-            // https://github.com/request/request/issues/1990#issuecomment-184712275
-            response.pause()
-            resolve(response)
-          })
-          .on('error', reject)
-      ))
-
-      if (resp.statusCode === 200) {
-        return { stream: resp }
-      }
-
-      req.abort() // Or we will leak memory (the stream is paused and we're not using this response stream anymore)
+    return this.#withErrorHandling('provider.box.thumbnail.error', async () => {
+      const extension = 'jpg' // you can set this to png to more easily reproduce http 202 retry-after
 
       // From box API docs:
       // Sometimes generating a thumbnail can take a few seconds.
@@ -124,100 +81,49 @@ class Box extends Provider {
       // At that time, retry this endpoint to retrieve the thumbnail.
       //
       // This can be reproduced more easily by changing extension to png and trying on a newly uploaded image
-      const retryAfter = parseInt(resp.headers['retry-after'], 10)
-      if (!Number.isNaN(retryAfter)) {
-        const retryInSec = Math.min(remainingRetryTime, retryAfter)
-        if (retryInSec <= 0) throw new ProviderApiError('Timed out waiting for thumbnail', 504)
-        logger.debug(`Need to retry box thumbnail in ${retryInSec} sec`)
-        remainingRetryTime -= retryInSec
-        await new Promise((resolve) => setTimeout(resolve, retryInSec * 1000))
-        return tryGetThumbnail()
-      }
-
-      // we have an error status code, throw
-      throw this._error(null, resp)
-    }
-
-    try {
-      return await tryGetThumbnail()
-    } catch (err) {
-      logger.error(err, 'provider.box.thumbnail.error')
-      throw err
-    }
-  }
-
-  _size ({ id, token }, done) {
-    return this.client
-      .get(`files/${id}`)
-      .auth(token)
-      .request((err, resp, body) => {
-        if (err || resp.statusCode !== 200) {
-          err = this._error(err, resp)
-          logger.error(err, 'provider.box.size.error')
-          return done(err)
-        }
-        done(null, parseInt(body.size, 10))
+      const stream = getClient({ token }).stream.get(`files/${id}/thumbnail.${extension}`, {
+        searchParams: { max_height: BOX_THUMBNAIL_SIZE, max_width: BOX_THUMBNAIL_SIZE },
+        responseType: 'json',
       })
+
+      await prepareStream(stream)
+      return { stream }
+    })
   }
 
-  _logout ({ companion, token }, done) {
-    const { key, secret } = companion.options.providerOptions.box
+  async size ({ id, token }) {
+    return this.#withErrorHandling('provider.box.size.error', async () => {
+      const { size } = await getClient({ token }).get(`files/${id}`, { responseType: 'json' }).json()
+      return parseInt(size, 10)
+    })
+  }
 
-    return this.client
-      .post('https://api.box.com/oauth2/revoke')
-      .options({
-        formData: {
+  logout ({ companion, token }) {
+    return this.#withErrorHandling('provider.box.logout.error', async () => {
+      const { key, secret } = companion.options.providerOptions.box
+      await getClient({ token }).post('oauth2/revoke', {
+        prefixUrl: 'https://api.box.com',
+        form: {
           client_id: key,
           client_secret: secret,
           token,
         },
+        responseType: 'json',
       })
-      .auth(token)
-      .request((err, resp) => {
-        if (err || resp.statusCode !== 200) {
-          logger.error(err, 'provider.box.logout.error')
-          done(this._error(err, resp))
-          return
-        }
-        done(null, { revoked: true })
-      })
-  }
 
-  adaptData (res, username, companion) {
-    const data = { username, items: [] }
-    const items = adapter.getItemSubList(res)
-    items.forEach((item) => {
-      data.items.push({
-        isFolder: adapter.isFolder(item),
-        icon: adapter.getItemIcon(item),
-        name: adapter.getItemName(item),
-        mimeType: adapter.getMimeType(item),
-        id: adapter.getItemId(item),
-        thumbnail: companion.buildURL(adapter.getItemThumbnailUrl(item), true),
-        requestPath: adapter.getItemRequestPath(item),
-        modifiedDate: adapter.getItemModifiedDate(item),
-        size: adapter.getItemSize(item),
-      })
+      return { revoked: true }
     })
-
-    data.nextPagePath = adapter.getNextPagePath(res)
-
-    return data
   }
 
-  _error (err, resp) {
-    if (resp) {
-      const fallbackMessage = `request to ${this.authProvider} returned ${resp.statusCode}`
-      const errMsg = (resp.body || {}).message ? resp.body.message : fallbackMessage
-      return resp.statusCode === 401 ? new ProviderAuthError() : new ProviderApiError(errMsg, resp.statusCode)
-    }
-
-    return err
+  async #withErrorHandling (tag, fn) {
+    return withProviderErrorHandling({
+      fn,
+      tag,
+      providerName: this.authProvider,
+      isAuthError: (response) => response.statusCode === 401,
+      getJsonErrorMessage: (body) => body?.message,
+    })
   }
 }
 
-Box.prototype.list = promisify(Box.prototype._list)
-Box.prototype.size = promisify(Box.prototype._size)
-Box.prototype.logout = promisify(Box.prototype._logout)
-
 module.exports = Box

+ 13 - 25
packages/@uppy/companion/src/server/provider/credentials.js

@@ -1,4 +1,4 @@
-const request = require('request')
+const got = require('got').default
 const atob = require('atob')
 const { htmlEscape } = require('escape-goat')
 const logger = require('../logger')
@@ -12,30 +12,18 @@ const Provider = require('./Provider')
  * @param {string} providerName
  * @param {object|null} credentialRequestParams - null asks for default credentials.
  */
-function fetchKeys (url, providerName, credentialRequestParams) {
-  return new Promise((resolve, reject) => {
-    const options = {
-      body: {
-        provider: providerName,
-        parameters: credentialRequestParams,
-      },
-      json: true,
-    }
-    request.post(url, options, (requestErr, resp, body) => {
-      if (requestErr) {
-        logger.error(requestErr, 'credentials.fetch.fail')
-        return reject(requestErr)
-      }
-
-      if (resp.statusCode !== 200 || !body.credentials) {
-        const err = new Error(`received status: ${resp.statusCode} with no credentials`)
-        logger.error(err, 'credentials.fetch.fail')
-        return reject(err)
-      }
-
-      return resolve(body.credentials)
-    })
-  })
+async function fetchKeys (url, providerName, credentialRequestParams) {
+  try {
+    const { credentials } = await got.post(url, {
+      json: { provider: providerName, parameters: credentialRequestParams },
+    }).json()
+
+    if (!credentials) throw new Error('Received no remote credentials')
+    return credentials
+  } catch (err) {
+    logger.error(err, 'credentials.fetch.fail')
+    throw err
+  }
 }
 
 /**

+ 94 - 42
packages/@uppy/companion/src/server/provider/drive/adapter.js

@@ -2,7 +2,7 @@ const querystring = require('node:querystring')
 
 // @todo use the "about" endpoint to get the username instead
 // see: https://developers.google.com/drive/api/v2/reference/about/get
-exports.getUsername = (data) => {
+const getUsername = (data) => {
   for (const item of data.files) {
     if (item.ownedByMe && item.permissions) {
       for (const permission of item.permissions) {
@@ -15,20 +15,28 @@ exports.getUsername = (data) => {
   return undefined
 }
 
-exports.isFolder = (item) => {
-  return item.mimeType === 'application/vnd.google-apps.folder' || exports.isSharedDrive(item)
+exports.isGsuiteFile = (mimeType) => {
+  return mimeType && mimeType.startsWith('application/vnd.google')
+}
+
+const isSharedDrive = (item) => {
+  return item.kind === 'drive#drive'
+}
+
+const isFolder = (item) => {
+  return item.mimeType === 'application/vnd.google-apps.folder' || isSharedDrive(item)
 }
 
 exports.isShortcut = (mimeType) => {
   return mimeType === 'application/vnd.google-apps.shortcut'
 }
 
-exports.getItemSize = (item) => {
+const getItemSize = (item) => {
   return parseInt(item.size, 10)
 }
 
-exports.getItemIcon = (item) => {
-  if (exports.isSharedDrive(item)) {
+const getItemIcon = (item) => {
+  if (isSharedDrive(item)) {
     const size = '=w16-h16-n'
     const sizeParamRegex = /=[-whncsp0-9]*$/
     return item.backgroundImageLink.match(sizeParamRegex)
@@ -44,7 +52,7 @@ exports.getItemIcon = (item) => {
   return item.iconLink
 }
 
-exports.getItemSubList = (item) => {
+const getItemSubList = (item) => {
   const allowedGSuiteTypes = [
     'application/vnd.google-apps.document',
     'application/vnd.google-apps.drawing',
@@ -55,11 +63,11 @@ exports.getItemSubList = (item) => {
   ]
 
   return item.files.filter((i) => {
-    return exports.isFolder(i) || !exports.isGsuiteFile(i.mimeType) || allowedGSuiteTypes.includes(i.mimeType)
+    return isFolder(i) || !exports.isGsuiteFile(i.mimeType) || allowedGSuiteTypes.includes(i.mimeType)
   })
 }
 
-exports.getItemName = (item) => {
+const getItemName = (item) => {
   const extensionMaps = {
     'application/vnd.google-apps.document': '.docx',
     'application/vnd.google-apps.drawing': '.png',
@@ -76,41 +84,49 @@ exports.getItemName = (item) => {
   return item.name ? item.name : '/'
 }
 
-function getMimeType (mimeType) {
+exports.getGsuiteExportType = (mimeType) => {
+  const typeMaps = {
+    'application/vnd.google-apps.document': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+    'application/vnd.google-apps.drawing': 'image/png',
+    'application/vnd.google-apps.script': 'application/vnd.google-apps.script+json',
+    'application/vnd.google-apps.spreadsheet': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+    'application/vnd.google-apps.presentation': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+  }
+
+  return typeMaps[mimeType] || 'application/pdf'
+}
+
+function getMimeType2 (mimeType) {
   if (exports.isGsuiteFile(mimeType)) {
     return exports.getGsuiteExportType(mimeType)
   }
   return mimeType
 }
 
-exports.getMimeType = (item) => {
+const getMimeType = (item) => {
   if (exports.isShortcut(item.mimeType)) {
-    return getMimeType(item.shortcutDetails.targetMimeType)
+    return getMimeType2(item.shortcutDetails.targetMimeType)
   }
-  return getMimeType(item.mimeType)
+  return getMimeType2(item.mimeType)
 }
 
-exports.getItemId = (item) => {
+const getItemId = (item) => {
   return item.id
 }
 
-exports.getItemRequestPath = (item) => {
+const getItemRequestPath = (item) => {
   return item.id
 }
 
-exports.getItemModifiedDate = (item) => {
+const getItemModifiedDate = (item) => {
   return item.modifiedTime
 }
 
-exports.getItemThumbnailUrl = (item) => {
+const getItemThumbnailUrl = (item) => {
   return item.thumbnailLink
 }
 
-exports.isSharedDrive = (item) => {
-  return item.kind === 'drive#drive'
-}
-
-exports.getNextPagePath = (data, currentQuery, currentPath) => {
+const getNextPagePath = (data, currentQuery, currentPath) => {
   if (!data.nextPageToken) {
     return null
   }
@@ -118,32 +134,68 @@ exports.getNextPagePath = (data, currentQuery, currentPath) => {
   return `${currentPath}?${querystring.stringify(query)}`
 }
 
-exports.isGsuiteFile = (mimeType) => {
-  return mimeType && mimeType.startsWith('application/vnd.google')
-}
+const getImageHeight = (item) => item.imageMediaMetadata && item.imageMediaMetadata.height
 
-exports.getGsuiteExportType = (mimeType) => {
-  const typeMaps = {
-    'application/vnd.google-apps.document': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
-    'application/vnd.google-apps.drawing': 'image/png',
-    'application/vnd.google-apps.script': 'application/vnd.google-apps.script+json',
-    'application/vnd.google-apps.spreadsheet': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
-    'application/vnd.google-apps.presentation': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
-  }
+const getImageWidth = (item) => item.imageMediaMetadata && item.imageMediaMetadata.width
 
-  return typeMaps[mimeType] || 'application/pdf'
-}
+const getImageRotation = (item) => item.imageMediaMetadata && item.imageMediaMetadata.rotation
+
+const getImageDate = (item) => item.imageMediaMetadata && item.imageMediaMetadata.date
+
+const getVideoHeight = (item) => item.videoMediaMetadata && item.videoMediaMetadata.height
 
-exports.getImageHeight = (item) => item.imageMediaMetadata && item.imageMediaMetadata.height
+const getVideoWidth = (item) => item.videoMediaMetadata && item.videoMediaMetadata.width
 
-exports.getImageWidth = (item) => item.imageMediaMetadata && item.imageMediaMetadata.width
+const getVideoDurationMillis = (item) => item.videoMediaMetadata && item.videoMediaMetadata.durationMillis
 
-exports.getImageRotation = (item) => item.imageMediaMetadata && item.imageMediaMetadata.rotation
+// Hopefully this name will not be used by Google
+exports.VIRTUAL_SHARED_DIR = 'shared-with-me'
 
-exports.getImageDate = (item) => item.imageMediaMetadata && item.imageMediaMetadata.date
+exports.adaptData = (listFilesResp, sharedDrivesResp, directory, query, showSharedWithMe) => {
+  const adaptItem = (item) => ({
+    isFolder: isFolder(item),
+    icon: getItemIcon(item),
+    name: getItemName(item),
+    mimeType: getMimeType(item),
+    id: getItemId(item),
+    thumbnail: getItemThumbnailUrl(item),
+    requestPath: getItemRequestPath(item),
+    modifiedDate: getItemModifiedDate(item),
+    size: getItemSize(item),
+    custom: {
+      isSharedDrive: isSharedDrive(item),
+      imageHeight: getImageHeight(item),
+      imageWidth: getImageWidth(item),
+      imageRotation: getImageRotation(item),
+      imageDateTime: getImageDate(item),
+      videoHeight: getVideoHeight(item),
+      videoWidth: getVideoWidth(item),
+      videoDurationMillis: getVideoDurationMillis(item),
+    },
+  })
 
-exports.getVideoHeight = (item) => item.videoMediaMetadata && item.videoMediaMetadata.height
+  const items = getItemSubList(listFilesResp)
+  const sharedDrives = sharedDrivesResp ? sharedDrivesResp.drives || [] : []
+
+  // “Shared with me” is a list of shared documents,
+  // not the same as sharedDrives
+  const virtualItem = showSharedWithMe && ({
+    isFolder: true,
+    icon: 'folder',
+    name: 'Shared with me',
+    mimeType: 'application/vnd.google-apps.folder',
+    id: exports.VIRTUAL_SHARED_DIR,
+    requestPath: exports.VIRTUAL_SHARED_DIR,
+  })
 
-exports.getVideoWidth = (item) => item.videoMediaMetadata && item.videoMediaMetadata.width
+  const adaptedItems = [
+    ...(virtualItem ? [virtualItem] : []), // shared folder first
+    ...([...sharedDrives, ...items].map(adaptItem)),
+  ]
 
-exports.getVideoDurationMillis = (item) => item.videoMediaMetadata && item.videoMediaMetadata.durationMillis
+  return {
+    username: getUsername(listFilesResp),
+    items: adaptedItems,
+    nextPagePath: getNextPagePath(listFilesResp, query, directory),
+  }
+}

+ 101 - 225
packages/@uppy/companion/src/server/provider/drive/index.js

@@ -1,80 +1,35 @@
-/* eslint-disable no-underscore-dangle */
-const request = require('request')
-const purest = require('purest')({ request })
-const { promisify } = require('node:util')
+const got = require('got').default
 
 const Provider = require('../Provider')
 const logger = require('../../logger')
-const adapter = require('./adapter')
-const { ProviderApiError, ProviderAuthError } = require('../error')
-const { requestStream } = require('../../helpers/utils')
+const { VIRTUAL_SHARED_DIR, adaptData, isShortcut, isGsuiteFile, getGsuiteExportType } = require('./adapter')
+const { withProviderErrorHandling } = require('../providerErrors')
+const { prepareStream } = require('../../helpers/utils')
 
 const DRIVE_FILE_FIELDS = 'kind,id,imageMediaMetadata,name,mimeType,ownedByMe,permissions(role,emailAddress),size,modifiedTime,iconLink,thumbnailLink,teamDriveId,videoMediaMetadata,shortcutDetails(targetId,targetMimeType)'
 const DRIVE_FILES_FIELDS = `kind,nextPageToken,incompleteSearch,files(${DRIVE_FILE_FIELDS})`
 // using wildcard to get all 'drive' fields because specifying fields seems no to work for the /drives endpoint
 const SHARED_DRIVE_FIELDS = '*'
 
-// Hopefully this name will not be used by Google
-const VIRTUAL_SHARED_DIR = 'shared-with-me'
-
-async function waitForFailedResponse (resp) {
-  const buf = await new Promise((resolve) => {
-    let data = ''
-    resp.on('data', (chunk) => {
-      data += chunk
-    }).on('end', () => resolve(data))
-    resp.resume()
-  })
-  return JSON.parse(buf.toString())
-}
+const getClient = ({ token }) => got.extend({
+  prefixUrl: 'https://www.googleapis.com/drive/v3',
+  headers: {
+    authorization: `Bearer ${token}`,
+  },
+})
 
-function adaptData (listFilesResp, sharedDrivesResp, directory, query, showSharedWithMe) {
-  const adaptItem = (item) => ({
-    isFolder: adapter.isFolder(item),
-    icon: adapter.getItemIcon(item),
-    name: adapter.getItemName(item),
-    mimeType: adapter.getMimeType(item),
-    id: adapter.getItemId(item),
-    thumbnail: adapter.getItemThumbnailUrl(item),
-    requestPath: adapter.getItemRequestPath(item),
-    modifiedDate: adapter.getItemModifiedDate(item),
-    size: adapter.getItemSize(item),
-    custom: {
-      isSharedDrive: adapter.isSharedDrive(item),
-      imageHeight: adapter.getImageHeight(item),
-      imageWidth: adapter.getImageWidth(item),
-      imageRotation: adapter.getImageRotation(item),
-      imageDateTime: adapter.getImageDate(item),
-      videoHeight: adapter.getVideoHeight(item),
-      videoWidth: adapter.getVideoWidth(item),
-      videoDurationMillis: adapter.getVideoDurationMillis(item),
-    },
-  })
-
-  const items = adapter.getItemSubList(listFilesResp)
-  const sharedDrives = sharedDrivesResp ? sharedDrivesResp.drives || [] : []
-
-  // “Shared with me” is a list of shared documents,
-  // not the same as sharedDrives
-  const virtualItem = showSharedWithMe && ({
-    isFolder: true,
-    icon: 'folder',
-    name: 'Shared with me',
-    mimeType: 'application/vnd.google-apps.folder',
-    id: VIRTUAL_SHARED_DIR,
-    requestPath: VIRTUAL_SHARED_DIR,
-  })
-
-  const adaptedItems = [
-    ...(virtualItem ? [virtualItem] : []), // shared folder first
-    ...([...sharedDrives, ...items].map(adaptItem)),
-  ]
-
-  return {
-    username: adapter.getUsername(listFilesResp),
-    items: adaptedItems,
-    nextPagePath: adapter.getNextPagePath(listFilesResp, query, directory),
-  }
+async function getStats ({ id, token }) {
+  const client = getClient({ token })
+
+  const getStatsInner = async (statsOfId) => (
+    client.get(`files/${encodeURIComponent(statsOfId)}`, { searchParams: { fields: DRIVE_FILE_FIELDS, supportsAllDrives: true }, responseType: 'json' }).json()
+  )
+
+  const stats = await getStatsInner(id)
+
+  // If it is a shortcut, we need to get stats again on the target
+  if (isShortcut(stats.mimeType)) return getStatsInner(stats.shortcutDetails.targetId)
+  return stats
 }
 
 /**
@@ -84,13 +39,6 @@ class Drive extends Provider {
   constructor (options) {
     super(options)
     this.authProvider = Drive.authProvider
-
-    this.client = purest({
-      ...options,
-      provider: Drive.authProvider,
-      alias: 'drive',
-      version: 'v3',
-    })
   }
 
   static get authProvider () {
@@ -98,148 +46,83 @@ class Drive extends Provider {
   }
 
   async list (options) {
-    const directory = options.directory || 'root'
-    const query = options.query || {}
+    return this.#withErrorHandling('provider.drive.list.error', async () => {
+      const directory = options.directory || 'root'
+      const query = options.query || {}
+      const { token } = options
 
-    const { client } = this
-    const handleErrorResponse = this._error.bind(this)
+      const isRoot = directory === 'root'
+      const isVirtualSharedDirRoot = directory === VIRTUAL_SHARED_DIR
 
-    const isRoot = directory === 'root'
-    const isVirtualSharedDirRoot = directory === VIRTUAL_SHARED_DIR
+      const client = getClient({ token })
 
-    async function fetchSharedDrives (pageToken = null) {
-      try {
+      async function fetchSharedDrives (pageToken = null) {
         const shouldListSharedDrives = isRoot && !query.cursor
         if (!shouldListSharedDrives) return undefined
 
-        const resp = await new Promise((resolve, reject) => client
-          .get('drives')
-          .qs({ fields: SHARED_DRIVE_FIELDS, pageToken, pageSize: 100 })
-          .auth(options.token)
-          .request((err, resp2) => {
-            if (err || resp2.statusCode !== 200) return reject(handleErrorResponse(err, resp2))
-            return resolve(resp2)
-          }))
-
-        if (!resp) return resp
+        const response = await client.get('drives', { searchParams: { fields: SHARED_DRIVE_FIELDS, pageToken, pageSize: 100 }, responseType: 'json' }).json()
 
-        const { body } = resp
-        const nextPageToken = body && body.nextPageToken
+        const { nextPageToken } = response
         if (nextPageToken) {
-          const nextBody = await fetchSharedDrives(nextPageToken)
-          if (!nextBody) return body
-          return { ...nextBody, drives: [...body.drives, ...nextBody.drives] }
+          const nextResponse = await fetchSharedDrives(nextPageToken)
+          if (!nextResponse) return response
+          return { ...nextResponse, drives: [...response.drives, ...nextResponse.drives] }
         }
-        return body
-      } catch (err) {
-        logger.error(err, 'provider.drive.sharedDrive.error')
-        throw err
-      }
-    }
-
-    async function fetchFiles () {
-      // Shared with me items in root don't have any parents
-      const q = isVirtualSharedDirRoot
-        ? `sharedWithMe and trashed=false`
-        : `('${directory}' in parents) and trashed=false`
-
-      const where = {
-        fields: DRIVE_FILES_FIELDS,
-        pageToken: query.cursor,
-        q,
-        // pageSize: 10, // can be used for testing pagination if you don't have many files
-        orderBy: 'folder,name',
-        includeItemsFromAllDrives: true,
-        supportsAllDrives: true,
-      }
 
-      try {
-        const resp = await new Promise((resolve, reject) => client
-          .query()
-          .get('files')
-          .qs(where)
-          .auth(options.token)
-          .request((err, resp2) => {
-            if (err || resp2.statusCode !== 200) return reject(handleErrorResponse(err, resp2))
-            return resolve(resp2)
-          }))
-
-        return resp && resp.body
-      } catch (err) {
-        logger.error(err, 'provider.drive.list.error')
-        throw err
+        return response
       }
-    }
-
-    const [sharedDrives, filesResponse] = await Promise.all([fetchSharedDrives(), fetchFiles()])
-    // console.log({ directory, sharedDrives, filesResponse })
-
-    return adaptData(
-      filesResponse,
-      sharedDrives,
-      directory,
-      query,
-      isRoot && !query.cursor, // we can only show it on the first page request, or else we will have duplicates of it
-    )
-  }
 
-  async _stats ({ id, token }) {
-    const getStats = async (statsOfId) => new Promise((resolve, reject) => {
-      this.client
-        .query()
-        .get(`files/${encodeURIComponent(statsOfId)}`)
-        .qs({ fields: DRIVE_FILE_FIELDS, supportsAllDrives: true })
-        .auth(token)
-        .request((err, resp) => {
-          if (err || resp.statusCode !== 200) return reject(this._error.bind(this)(err, resp))
-          return resolve(resp.body)
-        })
-    })
+      async function fetchFiles () {
+        // Shared with me items in root don't have any parents
+        const q = isVirtualSharedDirRoot
+          ? `sharedWithMe and trashed=false`
+          : `('${directory}' in parents) and trashed=false`
+
+        const searchParams = {
+          fields: DRIVE_FILES_FIELDS,
+          pageToken: query.cursor,
+          q,
+          // pageSize: 10, // can be used for testing pagination if you don't have many files
+          orderBy: 'folder,name',
+          includeItemsFromAllDrives: true,
+          supportsAllDrives: true,
+        }
 
-    let stats = await getStats(id)
+        return client.get('files', { searchParams, responseType: 'json' }).json()
+      }
 
-    // If it is a shortcut, we need to get stats again on the target
-    if (adapter.isShortcut(stats.mimeType)) {
-      stats = await getStats(stats.shortcutDetails.targetId)
-    }
-    return stats
-  }
+      const [sharedDrives, filesResponse] = await Promise.all([fetchSharedDrives(), fetchFiles()])
+      // console.log({ directory, sharedDrives, filesResponse })
 
-  _exportGsuiteFile (id, token, mimeType) {
-    logger.info(`calling google file export for ${id} to ${mimeType}`, 'provider.drive.export')
-    return this.client
-      .query()
-      .get(`files/${encodeURIComponent(id)}/export`)
-      .qs({ supportsAllDrives: true, mimeType })
-      .auth(token)
-      .request()
+      return adaptData(
+        filesResponse,
+        sharedDrives,
+        directory,
+        query,
+        isRoot && !query.cursor, // we can only show it on the first page request, or else we will have duplicates of it
+      )
+    })
   }
 
   async download ({ id: idIn, token }) {
-    try {
-      const { mimeType, id } = await this._stats({ id: idIn, token })
-
-      const req = adapter.isGsuiteFile(mimeType)
-        ? this._exportGsuiteFile(id, token, adapter.getGsuiteExportType(mimeType))
-        : this.client
-          .query()
-          .get(`files/${encodeURIComponent(id)}`)
-          .qs({ alt: 'media', supportsAllDrives: true })
-          .auth(token)
-          .request()
-
-      return await requestStream(req, async (res) => {
-        try {
-          const jsonResp = await waitForFailedResponse(res)
-          return this._error(null, { ...res, body: jsonResp })
-        } catch (err2) {
-          return this._error(err2, res)
-        }
-      })
-    } catch (err) {
-      logger.error(err, 'provider.drive.download.error')
-      throw err
-    }
+    return this.#withErrorHandling('provider.drive.download.error', async () => {
+      const client = getClient({ token })
+
+      const { mimeType, id } = await getStats({ id: idIn, token })
+
+      let stream
+
+      if (isGsuiteFile(mimeType)) {
+        const mimeType2 = getGsuiteExportType(mimeType)
+        logger.info(`calling google file export for ${id} to ${mimeType2}`, 'provider.drive.export')
+        stream = client.stream.get(`files/${encodeURIComponent(id)}/export`, { searchParams: { supportsAllDrives: true, mimeType: mimeType2 }, responseType: 'json' })
+      } else {
+        stream = client.stream.get(`files/${encodeURIComponent(id)}`, { searchParams: { alt: 'media', supportsAllDrives: true }, responseType: 'json' })
+      }
+
+      await prepareStream(stream)
+      return { stream }
+    })
   }
 
   // eslint-disable-next-line class-methods-use-this
@@ -250,46 +133,39 @@ class Drive extends Provider {
   }
 
   async size ({ id, token }) {
-    try {
-      const { mimeType, size } = await this._stats({ id, token })
+    return this.#withErrorHandling('provider.drive.size.error', async () => {
+      const { mimeType, size } = await getStats({ id, token })
 
-      if (adapter.isGsuiteFile(mimeType)) {
+      if (isGsuiteFile(mimeType)) {
         // GSuite file sizes cannot be predetermined (but are max 10MB)
         // e.g. Transfer-Encoding: chunked
         return undefined
       }
 
       return parseInt(size, 10)
-    } catch (err) {
-      logger.error(err, 'provider.drive.size.error')
-      throw err
-    }
+    })
   }
 
-  _logout ({ token }, done) {
-    this.client
-      .get('https://accounts.google.com/o/oauth2/revoke')
-      .qs({ token })
-      .request((err, resp) => {
-        if (err || resp.statusCode !== 200) {
-          logger.error(err, 'provider.drive.logout.error')
-          done(this._error(err, resp))
-          return
-        }
-        done(null, { revoked: true })
+  logout ({ token }) {
+    return this.#withErrorHandling('provider.drive.logout.error', async () => {
+      await got.post('https://accounts.google.com/o/oauth2/revoke', {
+        searchParams: { token },
+        responseType: 'json',
       })
+
+      return { revoked: true }
+    })
   }
 
-  _error (err, resp) {
-    if (resp) {
-      const fallbackMessage = `request to ${this.authProvider} returned ${resp.statusCode}`
-      const errMsg = (resp.body && resp.body.error) ? resp.body.error.message : fallbackMessage
-      return resp.statusCode === 401 ? new ProviderAuthError() : new ProviderApiError(errMsg, resp.statusCode)
-    }
-    return err
+  async #withErrorHandling (tag, fn) {
+    return withProviderErrorHandling({
+      fn,
+      tag,
+      providerName: this.authProvider,
+      isAuthError: (response) => response.statusCode === 401,
+      getJsonErrorMessage: (body) => body?.error?.message,
+    })
   }
 }
 
-Drive.prototype.logout = promisify(Drive.prototype._logout)
-
 module.exports = Drive

+ 34 - 13
packages/@uppy/companion/src/server/provider/dropbox/adapter.js

@@ -1,50 +1,71 @@
 const mime = require('mime-types')
 const querystring = require('node:querystring')
 
-exports.isFolder = (item) => {
+const isFolder = (item) => {
   return item['.tag'] === 'folder'
 }
 
-exports.getItemSize = (item) => {
+const getItemSize = (item) => {
   return item.size
 }
 
-exports.getItemIcon = (item) => {
+const getItemIcon = (item) => {
   return item['.tag']
 }
 
-exports.getItemSubList = (item) => {
+const getItemSubList = (item) => {
   return item.entries
 }
 
-exports.getItemName = (item) => {
+const getItemName = (item) => {
   return item.name || ''
 }
 
-exports.getMimeType = (item) => {
-  return mime.lookup(exports.getItemName(item)) || null
+const getMimeType = (item) => {
+  return mime.lookup(getItemName(item)) || null
 }
 
-exports.getItemId = (item) => {
+const getItemId = (item) => {
   return item.id
 }
 
-exports.getItemRequestPath = (item) => {
+const getItemRequestPath = (item) => {
   return encodeURIComponent(item.path_lower)
 }
 
-exports.getItemModifiedDate = (item) => {
+const getItemModifiedDate = (item) => {
   return item.server_modified
 }
 
-exports.getItemThumbnailUrl = (item) => {
-  return `/dropbox/thumbnail/${exports.getItemRequestPath(item)}`
+const getItemThumbnailUrl = (item) => {
+  return `/dropbox/thumbnail/${getItemRequestPath(item)}`
 }
 
-exports.getNextPagePath = (data) => {
+const getNextPagePath = (data) => {
   if (!data.has_more) {
     return null
   }
   const query = { cursor: data.cursor }
   return `?${querystring.stringify(query)}`
 }
+
+module.exports = (res, email, buildURL) => {
+  const items = getItemSubList(res).map((item) => ({
+    isFolder: isFolder(item),
+    icon: getItemIcon(item),
+    name: getItemName(item),
+    mimeType: getMimeType(item),
+    id: getItemId(item),
+    thumbnail: buildURL(getItemThumbnailUrl(item), true),
+    requestPath: getItemRequestPath(item),
+    modifiedDate: getItemModifiedDate(item),
+    size: getItemSize(item),
+  }))
+  items.sort((a, b) => a.name.localeCompare(b.name, 'en-US', { numeric: true }))
+
+  return {
+    username: email,
+    items,
+    nextPagePath: getNextPagePath(res),
+  }
+}

+ 64 - 141
packages/@uppy/companion/src/server/provider/dropbox/index.js

@@ -1,12 +1,9 @@
-const request = require('request')
-const purest = require('purest')({ request })
-const { promisify } = require('node:util')
+const got = require('got').default
 
 const Provider = require('../Provider')
-const logger = require('../../logger')
-const adapter = require('./adapter')
-const { ProviderApiError, ProviderAuthError } = require('../error')
-const { requestStream } = require('../../helpers/utils')
+const adaptData = require('./adapter')
+const { withProviderErrorHandling } = require('../providerErrors')
+const { prepareStream } = require('../../helpers/utils')
 
 // From https://www.dropbox.com/developers/reference/json-encoding:
 //
@@ -20,25 +17,23 @@ function httpHeaderSafeJson (v) {
     })
 }
 
-function adaptData (res, email, buildURL) {
-  const items = adapter.getItemSubList(res).map((item) => ({
-    isFolder: adapter.isFolder(item),
-    icon: adapter.getItemIcon(item),
-    name: adapter.getItemName(item),
-    mimeType: adapter.getMimeType(item),
-    id: adapter.getItemId(item),
-    thumbnail: buildURL(adapter.getItemThumbnailUrl(item), true),
-    requestPath: adapter.getItemRequestPath(item),
-    modifiedDate: adapter.getItemModifiedDate(item),
-    size: adapter.getItemSize(item),
-  }))
-  items.sort((a, b) => a.name.localeCompare(b.name, 'en-US', { numeric: true }))
-
-  return {
-    username: email,
-    items,
-    nextPagePath: adapter.getNextPagePath(res),
+const getClient = ({ token }) => got.extend({
+  prefixUrl: 'https://api.dropboxapi.com/2',
+  headers: {
+    authorization: `Bearer ${token}`,
+  },
+})
+
+async function list ({ directory, query, token }) {
+  if (query.cursor) {
+    return getClient({ token }).post('files/list_folder/continue', { json: { cursor: query.cursor }, responseType: 'json' }).json()
   }
+
+  return getClient({ token }).post('files/list_folder', { searchParams: query, json: { path: `${directory || ''}`, include_non_downloadable_files: false }, responseType: 'json' }).json()
+}
+
+async function userInfo ({ token }) {
+  return getClient({ token }).post('users/get_current_account', { responseType: 'json' }).json()
 }
 
 /**
@@ -48,10 +43,6 @@ class DropBox extends Provider {
   constructor (options) {
     super(options)
     this.authProvider = DropBox.authProvider
-    this.client = purest({
-      ...options,
-      provider: DropBox.authProvider,
-    })
     // needed for the thumbnails fetched via companion
     this.needsCookieAuth = true
   }
@@ -60,143 +51,75 @@ class DropBox extends Provider {
     return 'dropbox'
   }
 
-  async _userInfo ({ token }) {
-    const client = this.client
-      .post('users/get_current_account')
-      .options({ version: '2' })
-      .auth(token)
-    return promisify(client.request.bind(client))()
-  }
-
   /**
    *
    * @param {object} options
    */
   async list (options) {
-    try {
+    return this.#withErrorHandling('provider.dropbox.list.error', async () => {
       const responses = await Promise.all([
-        this._stats(options),
-        this._userInfo(options),
+        list(options),
+        userInfo(options),
       ])
-      responses.forEach((response) => {
-        if (response.statusCode !== 200) throw this._error(null, response)
-      })
-      const [{ body: stats }, { body: { email } }] = responses
+      // @ts-ignore
+      const [stats, { email }] = responses
       return adaptData(stats, email, options.companion.buildURL)
-    } catch (err) {
-      logger.error(err, 'provider.dropbox.list.error')
-      throw err
-    }
+    })
   }
 
-  async _stats ({ directory, query, token }) {
-    if (query.cursor) {
-      const client = this.client
-        .post('files/list_folder/continue')
-        .options({ version: '2' })
-        .auth(token)
-        .json({
-          cursor: query.cursor,
-        })
-      return promisify(client.request.bind(client))()
-    }
-
-    const client = this.client
-      .post('files/list_folder')
-      .options({ version: '2' })
-      .qs(query)
-      .auth(token)
-      .json({
-        path: `${directory || ''}`,
-        include_non_downloadable_files: false,
+  async download ({ id, token }) {
+    return this.#withErrorHandling('provider.dropbox.download.error', async () => {
+      const stream = getClient({ token }).stream.post('files/download', {
+        prefixUrl: 'https://content.dropboxapi.com/2',
+        headers: {
+          'Dropbox-API-Arg': httpHeaderSafeJson({ path: String(id) }),
+        },
+        body: Buffer.alloc(0), // if not, it will hang waiting for the writable stream
+        responseType: 'json',
       })
 
-    return promisify(client.request.bind(client))()
-  }
-
-  async download ({ id, token }) {
-    try {
-      const req = this.client
-        .post('https://content.dropboxapi.com/2/files/download')
-        .options({
-          version: '2',
-          headers: {
-            'Dropbox-API-Arg': httpHeaderSafeJson({ path: `${id}` }),
-          },
-        })
-        .auth(token)
-        .request()
-
-      return await requestStream(req, async (res) => this._error(null, res))
-    } catch (err) {
-      logger.error(err, 'provider.dropbox.download.error')
-      throw err
-    }
+      await prepareStream(stream)
+      return { stream }
+    })
   }
 
   async thumbnail ({ id, token }) {
-    try {
-      const req = this.client
-        .post('https://content.dropboxapi.com/2/files/get_thumbnail_v2')
-        .options({
-          headers: {
-            'Dropbox-API-Arg': httpHeaderSafeJson({ resource: { '.tag': 'path', path: `${id}` }, size: 'w256h256' }),
-          },
-        })
-        .auth(token)
-        .request()
-
-      return await requestStream(req, (resp) => this._error(null, resp))
-    } catch (err) {
-      logger.error(err, 'provider.dropbox.thumbnail.error')
-      throw err
-    }
+    return this.#withErrorHandling('provider.dropbox.thumbnail.error', async () => {
+      const stream = getClient({ token }).stream.post('files/get_thumbnail_v2', {
+        prefixUrl: 'https://content.dropboxapi.com/2',
+        headers: { 'Dropbox-API-Arg': httpHeaderSafeJson({ resource: { '.tag': 'path', path: `${id}` }, size: 'w256h256' }) },
+        body: Buffer.alloc(0),
+        responseType: 'json',
+      })
+
+      await prepareStream(stream)
+      return { stream }
+    })
   }
 
   async size ({ id, token }) {
-    const client = this.client
-      .post('files/get_metadata')
-      .options({ version: '2' })
-      .auth(token)
-      .json({ path: id })
-
-    try {
-      const resp = await promisify(client.request.bind(client))()
-      if (resp.statusCode !== 200) throw this._error(null, resp)
-      return parseInt(resp.body.size, 10)
-    } catch (err) {
-      logger.error(err, 'provider.dropbox.size.error')
-      throw err
-    }
+    return this.#withErrorHandling('provider.dropbox.size.error', async () => {
+      const { size } = await getClient({ token }).post('files/get_metadata', { json: { path: id }, responseType: 'json' }).json()
+      return parseInt(size, 10)
+    })
   }
 
   async logout ({ token }) {
-    const client = this.client
-      .post('auth/token/revoke')
-      .options({ version: '2' })
-      .auth(token)
-
-    try {
-      const resp = await promisify(client.request.bind(client))()
-      if (resp.statusCode !== 200) throw this._error(null, resp)
+    return this.#withErrorHandling('provider.dropbox.logout.error', async () => {
+      await getClient({ token }).post('auth/token/revoke', { responseType: 'json' })
       return { revoked: true }
-    } catch (err) {
-      logger.error(err, 'provider.dropbox.logout.error')
-      throw err
-    }
+    })
   }
 
-  _error (err, resp) {
-    if (resp) {
-      const fallbackMessage = `request to ${this.authProvider} returned ${resp.statusCode}`
-      const errMsg = (resp.body || {}).error_summary ? resp.body.error_summary : fallbackMessage
-      return resp.statusCode === 401 ? new ProviderAuthError() : new ProviderApiError(errMsg, resp.statusCode)
-    }
-
-    return err
+  async #withErrorHandling (tag, fn) {
+    return withProviderErrorHandling({
+      fn,
+      tag,
+      providerName: this.authProvider,
+      isAuthError: (response) => response.statusCode === 401,
+      getJsonErrorMessage: (body) => body?.error_summary,
+    })
   }
 }
 
-DropBox.version = 2
-
 module.exports = DropBox

+ 37 - 16
packages/@uppy/companion/src/server/provider/facebook/adapter.js

@@ -1,45 +1,50 @@
 const querystring = require('node:querystring')
 
-exports.isFolder = (item) => {
+const isFolder = (item) => {
   return !!item.type
 }
 
-exports.getItemIcon = (item) => {
-  if (exports.isFolder(item)) {
+exports.sortImages = (images) => {
+  // sort in ascending order of dimension
+  return images.slice().sort((a, b) => a.width - b.width)
+}
+
+const getItemIcon = (item) => {
+  if (isFolder(item)) {
     return 'folder'
   }
   return exports.sortImages(item.images)[0].source
 }
 
-exports.getItemSubList = (item) => {
+const getItemSubList = (item) => {
   return item.data
 }
 
-exports.getItemName = (item) => {
+const getItemName = (item) => {
   return item.name || `${item.id} ${item.created_time}`
 }
 
-exports.getMimeType = (item) => {
-  return exports.isFolder(item) ? null : 'image/jpeg'
+const getMimeType = (item) => {
+  return isFolder(item) ? null : 'image/jpeg'
 }
 
-exports.getItemId = (item) => {
+const getItemId = (item) => {
   return `${item.id}`
 }
 
-exports.getItemRequestPath = (item) => {
+const getItemRequestPath = (item) => {
   return `${item.id}`
 }
 
-exports.getItemModifiedDate = (item) => {
+const getItemModifiedDate = (item) => {
   return item.created_time
 }
 
-exports.getItemThumbnailUrl = (item) => {
-  return exports.isFolder(item) ? null : exports.sortImages(item.images)[0].source
+const getItemThumbnailUrl = (item) => {
+  return isFolder(item) ? null : exports.sortImages(item.images)[0].source
 }
 
-exports.getNextPagePath = (data, currentQuery, currentPath) => {
+const getNextPagePath = (data, currentQuery, currentPath) => {
   if (!data.paging || !data.paging.cursors) {
     return null
   }
@@ -48,7 +53,23 @@ exports.getNextPagePath = (data, currentQuery, currentPath) => {
   return `${currentPath || ''}?${querystring.stringify(query)}`
 }
 
-exports.sortImages = (images) => {
-  // sort in ascending order of dimension
-  return images.slice().sort((a, b) => a.width - b.width)
+exports.adaptData = (res, username, directory, currentQuery) => {
+  const data = { username, items: [] }
+  const items = getItemSubList(res)
+  items.forEach((item) => {
+    data.items.push({
+      isFolder: isFolder(item),
+      icon: getItemIcon(item),
+      name: getItemName(item),
+      mimeType: getMimeType(item),
+      size: null,
+      id: getItemId(item),
+      thumbnail: getItemThumbnailUrl(item),
+      requestPath: getItemRequestPath(item),
+      modifiedDate: getItemModifiedDate(item),
+    })
+  })
+
+  data.nextPagePath = getNextPagePath(res, currentQuery, directory)
+  return data
 }

+ 59 - 153
packages/@uppy/companion/src/server/provider/facebook/index.js

@@ -1,13 +1,24 @@
-const request = require('request')
-const purest = require('purest')({ request })
-const { promisify } = require('node:util')
+const got = require('got').default
 
 const Provider = require('../Provider')
 const { getURLMeta } = require('../../helpers/request')
 const logger = require('../../logger')
-const adapter = require('./adapter')
-const { ProviderApiError, ProviderAuthError } = require('../error')
-const { requestStream } = require('../../helpers/utils')
+const { adaptData, sortImages } = require('./adapter')
+const { withProviderErrorHandling } = require('../providerErrors')
+const { prepareStream } = require('../../helpers/utils')
+
+const getClient = ({ token }) => got.extend({
+  prefixUrl: 'https://graph.facebook.com',
+  headers: {
+    authorization: `Bearer ${token}`,
+  },
+})
+
+async function getMediaUrl ({ token, id }) {
+  const body = await getClient({ token }).get(String(id), { searchParams: { fields: 'images' }, responseType: 'json' }).json()
+  const sortedImages = sortImages(body.images)
+  return sortedImages[sortedImages.length - 1].source
+}
 
 /**
  * Adapter for API https://developers.facebook.com/docs/graph-api/using-graph-api/
@@ -16,96 +27,41 @@ class Facebook extends Provider {
   constructor (options) {
     super(options)
     this.authProvider = Facebook.authProvider
-    this.client = purest({
-      ...options,
-      provider: Facebook.authProvider,
-    })
   }
 
   static get authProvider () {
     return 'facebook'
   }
 
-  _list ({ directory, token, query = { cursor: null } }, done) {
-    const qs = {
-      fields: 'name,cover_photo,created_time,type',
-    }
-
-    if (query.cursor) {
-      qs.after = query.cursor
-    }
-
-    let path = 'me/albums'
-    if (directory) {
-      path = `${directory}/photos`
-      qs.fields = 'icon,images,name,width,height,created_time'
-    }
-
-    this.client
-      .get(`https://graph.facebook.com/${path}`)
-      .qs(qs)
-      .auth(token)
-      .request((err, resp, body) => {
-        if (err || resp.statusCode !== 200) {
-          err = this._error(err, resp)
-          logger.error(err, 'provider.facebook.list.error')
-          return done(err)
-        }
-        this._getUsername(token, (err, username) => {
-          if (err) {
-            done(err)
-          } else {
-            done(null, this.adaptData(body, username, directory, query))
-          }
-        })
-      })
-  }
+  async list ({ directory, token, query = { cursor: null } }) {
+    return this.#withErrorHandling('provider.facebook.list.error', async () => {
+      const qs = { fields: 'name,cover_photo,created_time,type' }
 
-  _getUsername (token, done) {
-    this.client
-      .get('me')
-      .qs({ fields: 'email' })
-      .auth(token)
-      .request((err, resp, body) => {
-        if (err || resp.statusCode !== 200) {
-          err = this._error(err, resp)
-          logger.error(err, 'provider.facebook.user.error')
-          return done(err)
-        }
-        done(null, body.email)
-      })
-  }
+      if (query.cursor) qs.after = query.cursor
 
-  _getMediaUrl (body) {
-    const sortedImages = adapter.sortImages(body.images)
-    return sortedImages[sortedImages.length - 1].source
+      let path = 'me/albums'
+      if (directory) {
+        path = `${directory}/photos`
+        qs.fields = 'icon,images,name,width,height,created_time'
+      }
+
+      const client = getClient({ token })
+
+      const [{ email }, list] = await Promise.all([
+        client.get('me', { searchParams: { fields: 'email' }, responseType: 'json' }).json(),
+        client.get(path, { searchParams: qs, responseType: 'json' }).json(),
+      ])
+      return adaptData(list, email, directory, query)
+    })
   }
 
   async download ({ id, token }) {
-    try {
-      const body1 = await new Promise((resolve, reject) => (
-        this.client
-          .get(`https://graph.facebook.com/${id}`)
-          .qs({ fields: 'images' })
-          .auth(token)
-          .request((err, resp, body) => {
-            if (err || resp.statusCode !== 200) {
-              err = this._error(err, resp)
-              logger.error(err, 'provider.facebook.download.error')
-              reject(err)
-              return
-            }
-            resolve(body)
-          })
-      ))
-
-      const url = this._getMediaUrl(body1)
-      const req = request(url)
-      return await requestStream(req, async (res) => this._error(null, res))
-    } catch (err) {
-      logger.error(err, 'provider.facebook.download.url.error')
-      throw err
-    }
+    return this.#withErrorHandling('provider.facebook.download.error', async () => {
+      const url = await getMediaUrl({ token, id })
+      const stream = got.stream.get(url, { responseType: 'json' })
+      await prepareStream(stream)
+      return { stream }
+    })
   }
 
   // eslint-disable-next-line class-methods-use-this
@@ -115,80 +71,30 @@ class Facebook extends Provider {
     throw new Error('call to thumbnail is not implemented')
   }
 
-  _size ({ id, token }, done) {
-    return this.client
-      .get(`https://graph.facebook.com/${id}`)
-      .qs({ fields: 'images' })
-      .auth(token)
-      .request((err, resp, body) => {
-        if (err || resp.statusCode !== 200) {
-          err = this._error(err, resp)
-          logger.error(err, 'provider.facebook.size.error')
-          return done(err)
-        }
-
-        getURLMeta(this._getMediaUrl(body), true)
-          .then(({ size }) => done(null, size))
-          .catch((err2) => {
-            logger.error(err2, 'provider.facebook.size.error')
-            done(err2)
-          })
-      })
-  }
-
-  _logout ({ token }, done) {
-    return this.client
-      .delete('me/permissions')
-      .auth(token)
-      .request((err, resp) => {
-        if (err || resp.statusCode !== 200) {
-          logger.error(err, 'provider.facebook.logout.error')
-          done(this._error(err, resp))
-          return
-        }
-        done(null, { revoked: true })
-      })
+  async size ({ id, token }) {
+    return this.#withErrorHandling('provider.facebook.size.error', async () => {
+      const url = await getMediaUrl({ token, id })
+      const { size } = await getURLMeta(url, true)
+      return size
+    })
   }
 
-  adaptData (res, username, directory, currentQuery) {
-    const data = { username, items: [] }
-    const items = adapter.getItemSubList(res)
-    items.forEach((item) => {
-      data.items.push({
-        isFolder: adapter.isFolder(item),
-        icon: adapter.getItemIcon(item),
-        name: adapter.getItemName(item),
-        mimeType: adapter.getMimeType(item),
-        size: null,
-        id: adapter.getItemId(item),
-        thumbnail: adapter.getItemThumbnailUrl(item),
-        requestPath: adapter.getItemRequestPath(item),
-        modifiedDate: adapter.getItemModifiedDate(item),
-      })
+  async logout ({ token }) {
+    return this.#withErrorHandling('provider.facebook.logout.error', async () => {
+      await getClient({ token }).delete('me/permissions', { responseType: 'json' }).json()
+      return { revoked: true }
     })
-
-    data.nextPagePath = adapter.getNextPagePath(res, currentQuery, directory)
-    return data
   }
 
-  _error (err, resp) {
-    if (resp) {
-      if (resp.body && resp.body.error.code === 190) {
-        // Invalid OAuth 2.0 Access Token
-        return new ProviderAuthError()
-      }
-
-      const fallbackMessage = `request to ${this.authProvider} returned ${resp.statusCode}`
-      const msg = resp.body && resp.body.error ? resp.body.error.message : fallbackMessage
-      return new ProviderApiError(msg, resp.statusCode)
-    }
-
-    return err
+  async #withErrorHandling (tag, fn) {
+    return withProviderErrorHandling({
+      fn,
+      tag,
+      providerName: this.authProvider,
+      isAuthError: (response) => response.statusCode === 190, // Invalid OAuth 2.0 Access Token
+      getJsonErrorMessage: (body) => body?.error?.message,
+    })
   }
 }
 
-Facebook.prototype.list = promisify(Facebook.prototype._list)
-Facebook.prototype.size = promisify(Facebook.prototype._size)
-Facebook.prototype.logout = promisify(Facebook.prototype._logout)
-
 module.exports = Facebook

+ 1 - 27
packages/@uppy/companion/src/server/provider/index.js

@@ -1,7 +1,6 @@
 /**
  * @module provider
  */
-const purestConfig = require('@purest/providers')
 const dropbox = require('./dropbox')
 const box = require('./box')
 const drive = require('./drive')
@@ -18,31 +17,6 @@ const Provider = require('./Provider')
 // eslint-disable-next-line
 const SearchProvider = require('./SearchProvider')
 
-// leave here for now until Purest Providers gets updated with Zoom provider
-purestConfig.zoom = {
-  'https://zoom.us/': {
-    __domain: {
-      auth: {
-        auth: { bearer: '[0]' },
-      },
-    },
-    '[version]/{endpoint}': {
-      __path: {
-        alias: '__default',
-        version: 'v2',
-      },
-    },
-    'oauth/revoke': {
-      __path: {
-        alias: 'logout',
-        auth: {
-          auth: { basic: '[0]' },
-        },
-      },
-    },
-  },
-}
-
 /**
  *
  * @param {{server: object}} options
@@ -80,7 +54,7 @@ module.exports.getProviderMiddleware = (providers, needsProviderCredentials) =>
   const middleware = (req, res, next, providerName) => {
     const ProviderClass = providers[providerName]
     if (ProviderClass && validOptions(req.companion.options)) {
-      req.companion.provider = new ProviderClass({ providerName, config: purestConfig })
+      req.companion.provider = new ProviderClass({ providerName })
 
       if (needsProviderCredentials) {
         req.companion.getProviderCredentials = getCredentialsResolver(providerName, req.companion.options, req)

+ 31 - 10
packages/@uppy/companion/src/server/provider/instagram/graph/adapter.js

@@ -8,15 +8,15 @@ const MEDIA_TYPES = Object.freeze({
 
 const isVideo = (item) => item.media_type === MEDIA_TYPES.video
 
-exports.isFolder = (item) => { // eslint-disable-line no-unused-vars
+const isFolder = (item) => { // eslint-disable-line no-unused-vars
   return false
 }
 
-exports.getItemIcon = (item) => {
+const getItemIcon = (item) => {
   return isVideo(item) ? item.thumbnail_url : item.media_url
 }
 
-exports.getItemSubList = (item) => {
+const getItemSubList = (item) => {
   const newItems = []
   item.data.forEach((subItem) => {
     if (subItem.media_type === MEDIA_TYPES.carousel) {
@@ -28,25 +28,25 @@ exports.getItemSubList = (item) => {
   return newItems
 }
 
-exports.getItemName = (item, index) => {
+const getItemName = (item, index) => {
   const ext = isVideo(item) ? 'mp4' : 'jpeg'
   // adding index, so the name is unique
   return `Instagram ${item.timestamp}${index}.${ext}`
 }
 
-exports.getMimeType = (item) => {
+const getMimeType = (item) => {
   return isVideo(item) ? 'video/mp4' : 'image/jpeg'
 }
 
-exports.getItemId = (item) => item.id
+const getItemId = (item) => item.id
 
-exports.getItemRequestPath = (item) => item.id
+const getItemRequestPath = (item) => item.id
 
-exports.getItemModifiedDate = (item) => item.timestamp
+const getItemModifiedDate = (item) => item.timestamp
 
-exports.getItemThumbnailUrl = (item) => exports.getItemIcon(item)
+const getItemThumbnailUrl = (item) => getItemIcon(item)
 
-exports.getNextPagePath = (data, currentQuery, currentPath) => {
+const getNextPagePath = (data, currentQuery, currentPath) => {
   if (!data.paging || !data.paging.cursors) {
     return null
   }
@@ -54,3 +54,24 @@ exports.getNextPagePath = (data, currentQuery, currentPath) => {
   const query = { ...currentQuery, cursor: data.paging.cursors.after }
   return `${currentPath || ''}?${querystring.stringify(query)}`
 }
+
+module.exports = (res, username, directory, currentQuery) => {
+  const data = { username, items: [] }
+  const items = getItemSubList(res)
+  items.forEach((item, i) => {
+    data.items.push({
+      isFolder: isFolder(item),
+      icon: getItemIcon(item),
+      name: getItemName(item, i),
+      mimeType: getMimeType(item),
+      id: getItemId(item),
+      size: null,
+      thumbnail: getItemThumbnailUrl(item),
+      requestPath: getItemRequestPath(item),
+      modifiedDate: getItemModifiedDate(item),
+    })
+  })
+
+  data.nextPagePath = getNextPagePath(res, currentQuery, directory)
+  return data
+}

+ 49 - 124
packages/@uppy/companion/src/server/provider/instagram/graph/index.js

@@ -1,13 +1,23 @@
-const request = require('request')
-const purest = require('purest')({ request })
-const { promisify } = require('node:util')
+const got = require('got').default
 
 const Provider = require('../../Provider')
 const { getURLMeta } = require('../../../helpers/request')
 const logger = require('../../../logger')
-const adapter = require('./adapter')
-const { ProviderApiError, ProviderAuthError } = require('../../error')
-const { requestStream } = require('../../../helpers/utils')
+const adaptData = require('./adapter')
+const { withProviderErrorHandling } = require('../../providerErrors')
+const { prepareStream } = require('../../../helpers/utils')
+
+const getClient = ({ token }) => got.extend({
+  prefixUrl: 'https://graph.instagram.com',
+  headers: {
+    authorization: `Bearer ${token}`,
+  },
+})
+
+async function getMediaUrl ({ token, id }) {
+  const body = await getClient({ token }).get(String(id), { searchParams: { fields: 'media_url' }, responseType: 'json' }).json()
+  return body.media_url
+}
 
 /**
  * Adapter for API https://developers.facebook.com/docs/instagram-api/overview
@@ -16,12 +26,9 @@ class Instagram extends Provider {
   constructor (options) {
     super(options)
     this.authProvider = Instagram.authProvider
-    this.client = purest({
-      ...options,
-      provider: Instagram.authProvider,
-    })
   }
 
+  // for "grant"
   static getExtraConfig () {
     return {
       protocol: 'https',
@@ -33,143 +40,61 @@ class Instagram extends Provider {
     return 'instagram'
   }
 
-  _list ({ directory, token, query = { cursor: null } }, done) {
-    const qs = {
-      fields: 'id,media_type,thumbnail_url,media_url,timestamp,children{media_type,media_url,thumbnail_url,timestamp}',
-    }
+  async list ({ directory, token, query = { cursor: null } }) {
+    return this.#withErrorHandling('provider.instagram.list.error', async () => {
+      const qs = { fields: 'id,media_type,thumbnail_url,media_url,timestamp,children{media_type,media_url,thumbnail_url,timestamp}' }
 
-    if (query.cursor) {
-      qs.after = query.cursor
-    }
+      if (query.cursor) qs.after = query.cursor
 
-    this.client
-      .get('https://graph.instagram.com/me/media')
-      .qs(qs)
-      .auth(token)
-      .request((err, resp, body) => {
-        if (err || resp.statusCode !== 200) {
-          err = this._error(err, resp)
-          logger.error(err, 'provider.instagram.list.error')
-          return done(err)
-        }
-        this._getUsername(token, (err, username) => {
-          if (err) done(err)
-          else done(null, this.adaptData(body, username, directory, query))
-        })
-      })
-  }
+      const client = getClient({ token })
 
-  _getUsername (token, done) {
-    this.client
-      .get('https://graph.instagram.com/me')
-      .qs({ fields: 'username' })
-      .auth(token)
-      .request((err, resp, body) => {
-        if (err || resp.statusCode !== 200) {
-          err = this._error(err, resp)
-          logger.error(err, 'provider.instagram.user.error')
-          return done(err)
-        }
-        done(null, body.username)
-      })
+      const [{ username }, list] = await Promise.all([
+        client.get('me', { searchParams: { fields: 'username' }, responseType: 'json' }).json(),
+        client.get('me/media', { searchParams: qs, responseType: 'json' }).json(),
+      ])
+      return adaptData(list, username, directory, query)
+    })
   }
 
   async download ({ id, token }) {
-    try {
-      const body1 = await new Promise((resolve, reject) => (
-        this.client
-          .get(`https://graph.instagram.com/${id}`)
-          .qs({ fields: 'media_url' })
-          .auth(token)
-          .request((err, resp, body) => {
-            if (err || resp.statusCode !== 200) {
-              err = this._error(err, resp)
-              logger.error(err, 'provider.instagram.download.error')
-              reject(err)
-              return
-            }
-            resolve(body)
-          })
-      ))
-
-      const req = request(body1.media_url)
-      return await requestStream(req, async (res) => this._error(null, res))
-    } catch (err) {
-      logger.error(err, 'provider.instagram.download.url.error')
-      throw err
-    }
+    return this.#withErrorHandling('provider.instagram.download.error', async () => {
+      const url = await getMediaUrl({ token, id })
+      const stream = got.stream.get(url, { responseType: 'json' })
+      await prepareStream(stream)
+      return { stream }
+    })
   }
 
+  // eslint-disable-next-line class-methods-use-this
   async thumbnail () {
     // not implementing this because a public thumbnail from instagram will be used instead
     logger.error('call to thumbnail is not implemented', 'provider.instagram.thumbnail.error')
     throw new Error('call to thumbnail is not implemented')
   }
 
-  _size ({ id, token }, done) {
-    return this.client
-      .get(`https://graph.instagram.com/${id}`)
-      .qs({ fields: 'media_url' })
-      .auth(token)
-      .request((err, resp, body) => {
-        if (err || resp.statusCode !== 200) {
-          err = this._error(err, resp)
-          logger.error(err, 'provider.instagram.size.error')
-          return done(err)
-        }
-
-        getURLMeta(body.media_url, true)
-          .then(({ size }) => done(null, size))
-          .catch((err2) => {
-            logger.error(err2, 'provider.instagram.size.error')
-            done(err2)
-          })
-      })
+  async size ({ id, token }) {
+    return this.#withErrorHandling('provider.instagram.size.error', async () => {
+      const url = await getMediaUrl({ token, id })
+      const { size } = await getURLMeta(url, true)
+      return size
+    })
   }
 
+  // eslint-disable-next-line class-methods-use-this
   async logout () {
     // access revoke is not supported by Instagram's API
     return { revoked: false, manual_revoke_url: 'https://www.instagram.com/accounts/manage_access/' }
   }
 
-  adaptData (res, username, directory, currentQuery) {
-    const data = { username, items: [] }
-    const items = adapter.getItemSubList(res)
-    items.forEach((item, i) => {
-      data.items.push({
-        isFolder: adapter.isFolder(item),
-        icon: adapter.getItemIcon(item),
-        name: adapter.getItemName(item, i),
-        mimeType: adapter.getMimeType(item),
-        id: adapter.getItemId(item),
-        size: null,
-        thumbnail: adapter.getItemThumbnailUrl(item),
-        requestPath: adapter.getItemRequestPath(item),
-        modifiedDate: adapter.getItemModifiedDate(item),
-      })
+  async #withErrorHandling (tag, fn) {
+    return withProviderErrorHandling({
+      fn,
+      tag,
+      providerName: this.authProvider,
+      isAuthError: (response) => response.statusCode === 190, // Invalid OAuth 2.0 Access Token
+      getJsonErrorMessage: (body) => body?.error?.message,
     })
-
-    data.nextPagePath = adapter.getNextPagePath(res, currentQuery, directory)
-    return data
-  }
-
-  _error (err, resp) {
-    if (resp) {
-      if (resp.body && resp.body.error.code === 190) {
-        // Invalid OAuth 2.0 Access Token
-        return new ProviderAuthError()
-      }
-
-      const fallbackMessage = `request to ${this.authProvider} returned ${resp.statusCode}`
-      const msg = resp.body && resp.body.error ? resp.body.error.message : fallbackMessage
-      return new ProviderApiError(msg, resp.statusCode)
-    }
-
-    return err
   }
 }
 
-Instagram.prototype.list = promisify(Instagram.prototype._list)
-Instagram.prototype.size = promisify(Instagram.prototype._size)
-
 module.exports = Instagram

+ 38 - 16
packages/@uppy/companion/src/server/provider/onedrive/adapter.js

@@ -1,6 +1,6 @@
 const querystring = require('node:querystring')
 
-exports.isFolder = (item) => {
+const isFolder = (item) => {
   if (item.remoteItem) {
     return !!item.remoteItem.folder
   }
@@ -8,50 +8,50 @@ exports.isFolder = (item) => {
   return !!item.folder
 }
 
-exports.getItemSize = (item) => {
+const getItemSize = (item) => {
   return item.size
 }
 
-exports.getItemIcon = (item) => {
-  return exports.isFolder(item) ? 'folder' : exports.getItemThumbnailUrl(item)
+const getItemThumbnailUrl = (item) => {
+  return item.thumbnails[0] ? item.thumbnails[0].medium.url : null
+}
+
+const getItemIcon = (item) => {
+  return isFolder(item) ? 'folder' : getItemThumbnailUrl(item)
 }
 
-exports.getItemSubList = (item) => {
+const getItemSubList = (item) => {
   return item.value
 }
 
-exports.getItemName = (item) => {
+const getItemName = (item) => {
   return item.name || ''
 }
 
-exports.getMimeType = (item) => {
+const getMimeType = (item) => {
   return item.file ? item.file.mimeType : null
 }
 
-exports.getItemId = (item) => {
+const getItemId = (item) => {
   if (item.remoteItem) {
     return item.remoteItem.id
   }
   return item.id
 }
 
-exports.getItemRequestPath = (item) => {
+const getItemRequestPath = (item) => {
   let query = `?driveId=${item.parentReference.driveId}`
   if (item.remoteItem) {
     query = `?driveId=${item.remoteItem.parentReference.driveId}`
   }
-  return exports.getItemId(item) + query
+  return getItemId(item) + query
 }
 
-exports.getItemModifiedDate = (item) => {
+const getItemModifiedDate = (item) => {
   return item.lastModifiedDateTime
 }
 
-exports.getItemThumbnailUrl = (item) => {
-  return item.thumbnails[0] ? item.thumbnails[0].medium.url : null
-}
-
-exports.getNextPagePath = (data) => {
+const getNextPagePath = (data) => {
   if (!data['@odata.nextLink']) {
     return null
   }
@@ -59,3 +59,25 @@ exports.getNextPagePath = (data) => {
   const query = { cursor: querystring.parse(data['@odata.nextLink']).$skiptoken }
   return `?${querystring.stringify(query)}`
 }
+
+module.exports = (res, username) => {
+  const data = { username, items: [] }
+  const items = getItemSubList(res)
+  items.forEach((item) => {
+    data.items.push({
+      isFolder: isFolder(item),
+      icon: getItemIcon(item),
+      name: getItemName(item),
+      mimeType: getMimeType(item),
+      id: getItemId(item),
+      thumbnail: getItemThumbnailUrl(item),
+      requestPath: getItemRequestPath(item),
+      modifiedDate: getItemModifiedDate(item),
+      size: getItemSize(item),
+    })
+  })
+
+  data.nextPagePath = getNextPagePath(res)
+
+  return data
+}

+ 49 - 103
packages/@uppy/companion/src/server/provider/onedrive/index.js

@@ -1,12 +1,19 @@
-const request = require('request')
-const purest = require('purest')({ request })
-const { promisify } = require('node:util')
+const got = require('got').default
 
 const Provider = require('../Provider')
 const logger = require('../../logger')
-const adapter = require('./adapter')
-const { ProviderApiError, ProviderAuthError } = require('../error')
-const { requestStream } = require('../../helpers/utils')
+const adaptData = require('./adapter')
+const { withProviderErrorHandling } = require('../providerErrors')
+const { prepareStream } = require('../../helpers/utils')
+
+const getClient = ({ token }) => got.extend({
+  prefixUrl: 'https://graph.microsoft.com',
+  headers: {
+    authorization: `Bearer ${token}`,
+  },
+})
+
+const getRootPath = (query) => (query.driveId ? `drives/${query.driveId}` : 'me/drive')
 
 /**
  * Adapter for API https://docs.microsoft.com/en-us/onedrive/developer/rest-api/
@@ -15,23 +22,12 @@ class OneDrive extends Provider {
   constructor (options) {
     super(options)
     this.authProvider = OneDrive.authProvider
-    this.client = purest({
-      ...options,
-      provider: OneDrive.authProvider,
-    })
   }
 
   static get authProvider () {
     return 'microsoft'
   }
 
-  _userInfo ({ token }, done) {
-    this.client
-      .get('me')
-      .auth(token)
-      .request(done)
-  }
-
   /**
    * Makes 2 requests in parallel - 1. to get files, 2. to get user email
    * it then waits till both requests are done before proceeding with the callback
@@ -40,112 +36,62 @@ class OneDrive extends Provider {
    * @param {string} options.directory
    * @param {any} options.query
    * @param {string} options.token
-   * @param {Function} done
    */
-  _list ({ directory, query, token }, done) {
-    const path = directory ? `items/${directory}` : 'root'
-    const rootPath = query.driveId ? `/drives/${query.driveId}` : '/me/drive'
-    const qs = { $expand: 'thumbnails' }
-    if (query.cursor) {
-      qs.$skiptoken = query.cursor
-    }
-
-    this.client
-      .get(`${rootPath}/${path}/children`)
-      .qs(qs)
-      .auth(token)
-      .request((err, resp, body) => {
-        if (err || resp.statusCode !== 200) {
-          err = this._error(err, resp)
-          logger.error(err, 'provider.onedrive.list.error')
-          return done(err)
-        }
-        this._userInfo({ token }, (err, infoResp) => {
-          if (err || infoResp.statusCode !== 200) {
-            err = this._error(err, infoResp)
-            logger.error(err, 'provider.onedrive.user.error')
-            return done(err)
-          }
-          done(null, this.adaptData(body, infoResp.body.mail || infoResp.body.userPrincipalName))
-        })
-      })
+  async list ({ directory, query, token }) {
+    return this.#withErrorHandling('provider.onedrive.list.error', async () => {
+      const path = directory ? `items/${directory}` : 'root'
+      const qs = { $expand: 'thumbnails' }
+      if (query.cursor) {
+        qs.$skiptoken = query.cursor
+      }
+
+      const client = getClient({ token })
+
+      const [{ mail, userPrincipalName }, list] = await Promise.all([
+        client.get('me', { responseType: 'json' }).json(),
+        client.get(`${getRootPath(query)}/${path}/children`, { searchParams: qs, responseType: 'json' }).json(),
+      ])
+
+      return adaptData(list, mail || userPrincipalName)
+    })
   }
 
   async download ({ id, token, query }) {
-    try {
-      const rootPath = query.driveId ? `/drives/${query.driveId}` : '/me/drive'
-
-      const req = this.client
-        .get(`${rootPath}/items/${id}/content`)
-        .auth(token)
-        .request()
-
-      return await requestStream(req, async (res) => this._error(null, res))
-    } catch (err) {
-      logger.error(err, 'provider.onedrive.download.error')
-      throw err
-    }
+    return this.#withErrorHandling('provider.onedrive.download.error', async () => {
+      const stream = getClient({ token }).stream.get(`${getRootPath(query)}/items/${id}/content`, { responseType: 'json' })
+      await prepareStream(stream)
+      return { stream }
+    })
   }
 
+  // eslint-disable-next-line class-methods-use-this
   async thumbnail () {
     // not implementing this because a public thumbnail from onedrive will be used instead
     logger.error('call to thumbnail is not implemented', 'provider.onedrive.thumbnail.error')
     throw new Error('call to thumbnail is not implemented')
   }
 
-  _size ({ id, query, token }, done) {
-    const rootPath = query.driveId ? `/drives/${query.driveId}` : '/me/drive'
-    return this.client
-      .get(`${rootPath}/items/${id}`)
-      .auth(token)
-      .request((err, resp, body) => {
-        if (err || resp.statusCode !== 200) {
-          err = this._error(err, resp)
-          logger.error(err, 'provider.onedrive.size.error')
-          return done(err)
-        }
-        done(null, body.size)
-      })
+  async size ({ id, query, token }) {
+    return this.#withErrorHandling('provider.onedrive.size.error', async () => {
+      const { size } = await getClient({ token }).get(`${getRootPath(query)}/items/${id}`, { responseType: 'json' }).json()
+      return size
+    })
   }
 
+  // eslint-disable-next-line class-methods-use-this
   async logout () {
     return { revoked: false, manual_revoke_url: 'https://account.live.com/consent/Manage' }
   }
 
-  adaptData (res, username) {
-    const data = { username, items: [] }
-    const items = adapter.getItemSubList(res)
-    items.forEach((item) => {
-      data.items.push({
-        isFolder: adapter.isFolder(item),
-        icon: adapter.getItemIcon(item),
-        name: adapter.getItemName(item),
-        mimeType: adapter.getMimeType(item),
-        id: adapter.getItemId(item),
-        thumbnail: adapter.getItemThumbnailUrl(item),
-        requestPath: adapter.getItemRequestPath(item),
-        modifiedDate: adapter.getItemModifiedDate(item),
-        size: adapter.getItemSize(item),
-      })
+  async #withErrorHandling (tag, fn) {
+    return withProviderErrorHandling({
+      fn,
+      tag,
+      providerName: this.authProvider,
+      isAuthError: (response) => response.statusCode === 401,
+      getJsonErrorMessage: (body) => body?.error?.message,
     })
-
-    data.nextPagePath = adapter.getNextPagePath(res)
-
-    return data
-  }
-
-  _error (err, resp) {
-    if (resp) {
-      const fallbackMsg = `request to ${this.authProvider} returned ${resp.statusCode}`
-      const errMsg = (resp.body || {}).error ? resp.body.error.message : fallbackMsg
-      return resp.statusCode === 401 ? new ProviderAuthError() : new ProviderApiError(errMsg, resp.statusCode)
-    }
-
-    return err
   }
 }
 
-OneDrive.prototype.list = promisify(OneDrive.prototype._list)
-OneDrive.prototype.size = promisify(OneDrive.prototype._size)
-
 module.exports = OneDrive

+ 40 - 0
packages/@uppy/companion/src/server/provider/providerErrors.js

@@ -0,0 +1,40 @@
+const logger = require('../logger')
+const { ProviderApiError, ProviderAuthError } = require('./error')
+
+function convertProviderError ({ err, providerName, isAuthError = () => false, getJsonErrorMessage }) {
+  const { response } = err
+
+  function getErrorMessage () {
+    if (typeof response.body === 'object') {
+      const message = getJsonErrorMessage(response.body)
+      if (message != null) return message
+    }
+
+    if (typeof response.body === 'string') {
+      return response.body
+    }
+
+    return `request to ${providerName} returned ${response.statusCode}`
+  }
+
+  if (response) {
+    // @ts-ignore
+    if (isAuthError(response)) return new ProviderAuthError()
+
+    return new ProviderApiError(getErrorMessage(), response.statusCode)
+  }
+
+  return err
+}
+
+async function withProviderErrorHandling ({ fn, tag, providerName, isAuthError, getJsonErrorMessage }) {
+  try {
+    return await fn()
+  } catch (err) {
+    const err2 = convertProviderError({ err, providerName, isAuthError, getJsonErrorMessage })
+    logger.error(err2, tag)
+    throw err2
+  }
+}
+
+module.exports = { withProviderErrorHandling }

+ 40 - 11
packages/@uppy/companion/src/server/provider/unsplash/adapter.js

@@ -1,45 +1,46 @@
 const querystring = require('node:querystring')
 
-exports.isFolder = (item) => { // eslint-disable-line no-unused-vars
+const isFolder = (item) => { // eslint-disable-line no-unused-vars
   return false
 }
 
-exports.getItemIcon = (item) => {
+const getItemIcon = (item) => {
   return item.urls.thumb
 }
 
-exports.getItemSubList = (item) => {
+const getItemSubList = (item) => {
   return item.results
 }
 
-exports.getItemName = (item) => {
+const getItemName = (item) => {
   const description = item.description || item.alt_description
   if (description) {
     return `${description.replace(/^([\S\s]{27})[\S\s]{3,}/, '$1...')}.jpg`
   }
+  return undefined
 }
 
-exports.getMimeType = (item) => { // eslint-disable-line no-unused-vars
+const getMimeType = (item) => { // eslint-disable-line no-unused-vars
   return 'image/jpeg'
 }
 
-exports.getItemId = (item) => {
+const getItemId = (item) => {
   return `${item.id}`
 }
 
-exports.getItemRequestPath = (item) => {
+const getItemRequestPath = (item) => {
   return `${item.id}`
 }
 
-exports.getItemModifiedDate = (item) => {
+const getItemModifiedDate = (item) => {
   return item.created_at
 }
 
-exports.getItemThumbnailUrl = (item) => {
+const getItemThumbnailUrl = (item) => {
   return item.urls.thumb
 }
 
-exports.getNextPageQuery = (currentQuery) => {
+const getNextPageQuery = (currentQuery) => {
   const newCursor = Number.parseInt(currentQuery.cursor || 1, 10) + 1
   const query = {
     ...currentQuery,
@@ -50,6 +51,34 @@ exports.getNextPageQuery = (currentQuery) => {
   return querystring.stringify(query)
 }
 
-exports.getAuthor = (item) => {
+const getAuthor = (item) => {
   return { name: item.user.name, url: item.user.links.html }
 }
+
+module.exports = (body, currentQuery) => {
+  const { total_pages: pagesCount } = body
+  const { cursor, q } = currentQuery
+  const currentPage = Number(cursor || 1)
+  const hasNextPage = currentPage < pagesCount
+  const subList = getItemSubList(body) || []
+
+  return {
+    searchedFor: q,
+    username: null,
+    items: subList.map((item) => ({
+      isFolder: isFolder(item),
+      icon: getItemIcon(item),
+      name: getItemName(item),
+      mimeType: getMimeType(item),
+      id: getItemId(item),
+      thumbnail: getItemThumbnailUrl(item),
+      requestPath: getItemRequestPath(item),
+      modifiedDate: getItemModifiedDate(item),
+      author: getAuthor(item),
+      size: null,
+    })),
+    nextPageQuery: hasNextPage
+      ? getNextPageQuery(currentQuery)
+      : null,
+  }
+}

+ 39 - 122
packages/@uppy/companion/src/server/provider/unsplash/index.js

@@ -1,156 +1,73 @@
-const request = require('request')
-const { promisify } = require('node:util')
+const got = require('got').default
 
 const SearchProvider = require('../SearchProvider')
 const { getURLMeta } = require('../../helpers/request')
-const logger = require('../../logger')
-const adapter = require('./adapter')
-const { ProviderApiError } = require('../error')
-const { requestStream } = require('../../helpers/utils')
+const adaptData = require('./adapter')
+const { withProviderErrorHandling } = require('../providerErrors')
+const { prepareStream } = require('../../helpers/utils')
 
 const BASE_URL = 'https://api.unsplash.com'
 
-function adaptData (body, currentQuery) {
-  const pagesCount = body.total_pages
-  const currentPage = Number(currentQuery.cursor || 1)
-  const hasNextPage = currentPage < pagesCount
-  const subList = adapter.getItemSubList(body) || []
+const getClient = ({ token }) => got.extend({
+  prefixUrl: BASE_URL,
+  headers: {
+    authorization: `Client-ID ${token}`,
+  },
+})
 
-  return {
-    searchedFor: currentQuery.q,
-    username: null,
-    items: subList.map((item) => ({
-      isFolder: adapter.isFolder(item),
-      icon: adapter.getItemIcon(item),
-      name: adapter.getItemName(item),
-      mimeType: adapter.getMimeType(item),
-      id: adapter.getItemId(item),
-      thumbnail: adapter.getItemThumbnailUrl(item),
-      requestPath: adapter.getItemRequestPath(item),
-      modifiedDate: adapter.getItemModifiedDate(item),
-      author: adapter.getAuthor(item),
-      size: null,
-    })),
-    nextPageQuery: hasNextPage
-      ? adapter.getNextPageQuery(currentQuery)
-      : null,
-  }
-}
+const getPhotoMeta = async (client, id) => client.get(`photos/${id}`, { responseType: 'json' }).json()
 
 /**
  * Adapter for API https://api.unsplash.com
  */
 class Unsplash extends SearchProvider {
-  _list ({ token, query = { cursor: null, q: null } }, done) {
-    const reqOpts = {
-      url: `${BASE_URL}/search/photos`,
-      method: 'GET',
-      json: true,
-      qs: {
-        per_page: 40,
-        query: query.q,
-      },
-      headers: {
-        Authorization: `Client-ID ${token}`,
-      },
-    }
-
-    if (query.cursor) {
-      reqOpts.qs.page = query.cursor
-    }
+  async list ({ token, query = { cursor: null, q: null } }) {
+    return this.#withErrorHandling('provider.unsplash.list.error', async () => {
+      const qs = { per_page: 40, query: query.q }
+      if (query.cursor) qs.page = query.cursor
 
-    request(reqOpts, (err, resp, body) => {
-      if (err || resp.statusCode !== 200) {
-        const error = this.error(err, resp)
-        logger.error(error, 'provider.unsplash.list.error')
-        return done(error)
-      }
-      return done(null, adaptData(body, query))
+      const response = await getClient({ token }).get('search/photos', { searchParams: qs, responseType: 'json' }).json()
+      return adaptData(response, query)
     })
   }
 
   async download ({ id, token }) {
-    try {
-      const reqOpts = {
-        method: 'GET',
-        json: true,
-        headers: {
-          Authorization: `Client-ID ${token}`,
-        },
-      }
+    return this.#withErrorHandling('provider.unsplash.download.error', async () => {
+      const client = getClient({ token })
 
-      const body = await new Promise((resolve, reject) => (
-        request({ ...reqOpts, url: `${BASE_URL}/photos/${id}` }, (err, resp, body2) => {
-          if (err || resp.statusCode !== 200) {
-            const err2 = this.error(err, resp)
-            logger.error(err, 'provider.unsplash.download.error')
-            reject(err2)
-            return
-          }
-          resolve(body2)
-        })
-      ))
+      const { links: { download: url, download_location: attributionUrl } } = await getPhotoMeta(client, id)
 
-      const req = request.get(body.links.download)
-      const stream = await requestStream(req, async (res) => this.error(null, res))
+      const stream = got.stream.get(url, { responseType: 'json' })
+      await prepareStream(stream)
 
       // To attribute the author of the image, we call the `download_location`
       // endpoint to increment the download count on Unsplash.
       // https://help.unsplash.com/en/articles/2511258-guideline-triggering-a-download
-      request({ ...reqOpts, url: body.links.download_location }, (err, resp) => {
-        if (err || resp.statusCode !== 200) {
-          const err2 = this.error(err, resp)
-          logger.error(err2, 'provider.unsplash.download.location.error')
-        }
-      })
+      await client.get(attributionUrl, { prefixUrl: '', responseType: 'json' })
 
-      return stream
-    } catch (err) {
-      logger.error(err, 'provider.unsplash.download.url.error')
-      throw err
-    }
+      // finally, stream on!
+      return { stream }
+    })
   }
 
-  _size ({ id, token }, done) {
-    const reqOpts = {
-      url: `${BASE_URL}/photos/${id}`,
-      method: 'GET',
-      json: true,
-      headers: {
-        Authorization: `Client-ID ${token}`,
-      },
-    }
-
-    request(reqOpts, (err, resp, body) => {
-      if (err || resp.statusCode !== 200) {
-        const error = this.error(err, resp)
-        logger.error(error, 'provider.unsplash.size.error')
-        done(error)
-        return
-      }
-
-      getURLMeta(body.links.download, true)
-        .then(({ size }) => done(null, size))
-        .catch((err2) => {
-          logger.error(err2, 'provider.unsplash.size.error')
-          done(err2)
-        })
+  async size ({ id, token }) {
+    return this.#withErrorHandling('provider.unsplash.size.error', async () => {
+      const { links: { download: url } } = await getPhotoMeta(getClient({ token }), id)
+      const { size } = await getURLMeta(url, true)
+      return size
     })
   }
 
   // eslint-disable-next-line class-methods-use-this
-  error (err, resp) {
-    if (resp) {
-      const fallbackMessage = `request to Unsplash returned ${resp.statusCode}`
-      const msg = resp.body && resp.body.errors ? `${resp.body.errors}` : fallbackMessage
-      return new ProviderApiError(msg, resp.statusCode)
-    }
-
-    return err
+  async #withErrorHandling (tag, fn) {
+    // @ts-ignore
+    return withProviderErrorHandling({
+      fn,
+      tag,
+      providerName: 'Unsplash',
+      getJsonErrorMessage: (body) => body?.errors && String(body.errors),
+    })
   }
 }
 
-Unsplash.prototype.list = promisify(Unsplash.prototype._list)
-Unsplash.prototype.size = promisify(Unsplash.prototype._size)
-
 module.exports = Unsplash

+ 98 - 17
packages/@uppy/companion/src/server/provider/zoom/adapter.js

@@ -1,5 +1,7 @@
 const moment = require('moment-timezone')
 
+const DEFAULT_RANGE_MOS = 23
+
 const MIMETYPES = {
   MP4: 'video/mp4',
   M4A: 'audio/mp4',
@@ -26,46 +28,46 @@ const ICONS = {
   TIMELINE: 'file',
 }
 
-exports.getDateName = (start, end) => {
+const getDateName = (start, end) => {
   return `${start.format('YYYY-MM-DD')} - ${end.format('YYYY-MM-DD')}`
 }
 
-exports.getAccountCreationDate = (results) => {
+const getAccountCreationDate = (results) => {
   return moment.utc(results.created_at)
 }
 
-exports.getUserEmail = (results) => {
+const getUserEmail = (results) => {
   return results.email
 }
 
-exports.getDateFolderId = (start, end) => {
+const getDateFolderId = (start, end) => {
   return `${start.format('YYYY-MM-DD')}_${end.format('YYYY-MM-DD')}`
 }
 
-exports.getDateFolderRequestPath = (start, end) => {
+const getDateFolderRequestPath = (start, end) => {
   return `?from=${start.format('YYYY-MM-DD')}&to=${end.format('YYYY-MM-DD')}`
 }
 
-exports.getDateFolderModified = (end) => {
+const getDateFolderModified = (end) => {
   return end.format('YYYY-MM-DD')
 }
 
-exports.getDateNextPagePath = (end) => {
+const getDateNextPagePath = (end) => {
   return `?cursor=${end.format('YYYY-MM-DD')}`
 }
 
-exports.getNextPagePath = (results) => {
+const getNextPagePath = (results) => {
   if (results.next_page_token) {
     return `?cursor=${results.next_page_token}&from=${results.from}&to=${results.to}`
   }
   return null
 }
 // we rely on the file_type attribute to differentiate a recording file from other items
-exports.getIsFolder = (item) => {
+const getIsFolder = (item) => {
   return !item.file_type
 }
 
-exports.getItemName = (item, userResponse) => {
+const getItemName = (item, userResponse) => {
   const start = moment.tz(item.start_time || item.recording_start, userResponse.timezone || 'UTC')
     .format('YYYY-MM-DD, HH:mm')
 
@@ -78,21 +80,21 @@ exports.getItemName = (item, userResponse) => {
   return `${item.topic} (${start})`
 }
 
-exports.getIcon = (item) => {
+const getIcon = (item) => {
   if (item.file_type) {
     return ICONS[item.file_type]
   }
   return ICONS.FOLDER
 }
 
-exports.getMimeType = (item) => {
+const getMimeType = (item) => {
   if (item.file_type) {
     return MIMETYPES[item.file_type]
   }
   return null
 }
 
-exports.getId = (item) => {
+const getId = (item) => {
   if (item.file_type && item.file_type === 'CC') {
     return `${encodeURIComponent(item.meeting_id)}__CC__${encodeURIComponent(item.recording_start)}`
   } if (item.file_type) {
@@ -101,7 +103,7 @@ exports.getId = (item) => {
   return `${encodeURIComponent(item.uuid)}`
 }
 
-exports.getRequestPath = (item) => {
+const getRequestPath = (item) => {
   if (item.file_type && item.file_type === 'CC') {
     return `${encodeURIComponent(item.meeting_id)}?recordingId=CC&recordingStart=${encodeURIComponent(item.recording_start)}`
   } if (item.file_type) {
@@ -112,14 +114,14 @@ exports.getRequestPath = (item) => {
   return `${encodeURIComponent(encodeURIComponent(item.uuid))}`
 }
 
-exports.getStartDate = (item) => {
+const getStartDate = (item) => {
   if (item.file_type === 'CC') {
     return item.recording_start
   }
   return item.start_time
 }
 
-exports.getSize = (item) => {
+const getSize = (item) => {
   if (item.file_type && item.file_type === 'CC') {
     const maxExportFileSize = 1024 * 1024
     return maxExportFileSize
@@ -129,6 +131,85 @@ exports.getSize = (item) => {
   return item.total_size
 }
 
-exports.getItemTopic = (item) => {
+const getItemTopic = (item) => {
   return item.topic
 }
+
+exports.initializeData = (body, initialEnd = null) => {
+  let end = initialEnd || moment.utc().tz(body.timezone || 'UTC')
+  const accountCreation = getAccountCreationDate(body).tz(body.timezone || 'UTC').startOf('day')
+  const defaultLimit = end.clone().subtract(DEFAULT_RANGE_MOS, 'months').date(1).startOf('day')
+  const allResultsShown = accountCreation > defaultLimit
+  const limit = allResultsShown ? accountCreation : defaultLimit
+  // if the limit is mid-month, keep that exact date
+  let start = (end.isSame(limit, 'month') && limit.date() !== 1) ? limit.clone() : end.clone().date(1).startOf('day')
+
+  const data = {
+    items: [],
+    username: getUserEmail(body),
+  }
+
+  while (end.isAfter(limit)) {
+    data.items.push({
+      isFolder: true,
+      icon: 'folder',
+      name: getDateName(start, end),
+      mimeType: null,
+      id: getDateFolderId(start, end),
+      thumbnail: null,
+      requestPath: getDateFolderRequestPath(start, end),
+      modifiedDate: getDateFolderModified(end),
+      size: null,
+    })
+    end = start.clone().subtract(1, 'days').endOf('day')
+    start = (end.isSame(limit, 'month') && limit.date() !== 1) ? limit.clone() : end.clone().date(1).startOf('day')
+  }
+  data.nextPagePath = allResultsShown ? null : getDateNextPagePath(end)
+  return data
+}
+
+exports.adaptData = (userResponse, results, query) => {
+  if (!results) {
+    return { items: [] }
+  }
+
+  // we query the zoom api by date (from 00:00 - 23:59 UTC) which may include
+  // extra results for 00:00 - 23:59 local time that we want to filter out.
+  const utcFrom = moment.tz(query.from, userResponse.timezone || 'UTC').startOf('day').tz('UTC')
+  const utcTo = moment.tz(query.to, userResponse.timezone || 'UTC').endOf('day').tz('UTC')
+
+  const data = {
+    nextPagePath: getNextPagePath(results),
+    items: [],
+    username: getUserEmail(userResponse),
+  }
+
+  let items = []
+  if (results.meetings) {
+    items = results.meetings
+      .map(item => { return { ...item, utcStart: moment.utc(item.start_time) } })
+      .filter(item => moment.utc(item.start_time).isAfter(utcFrom) && moment.utc(item.start_time).isBefore(utcTo))
+  } else {
+    items = results.recording_files
+      .map(item => { return { ...item, topic: results.topic } })
+      .filter(file => file.file_type !== 'TIMELINE')
+  }
+
+  items.forEach(item => {
+    data.items.push({
+      isFolder: getIsFolder(item),
+      icon: getIcon(item),
+      name: getItemName(item, userResponse),
+      mimeType: getMimeType(item),
+      id: getId(item),
+      thumbnail: null,
+      requestPath: getRequestPath(item),
+      modifiedDate: getStartDate(item),
+      size: getSize(item),
+      custom: {
+        topic: getItemTopic(item),
+      },
+    })
+  })
+  return data
+}

+ 113 - 284
packages/@uppy/companion/src/server/provider/zoom/index.js

@@ -1,21 +1,30 @@
-const { promisify } = require('node:util')
-const request = require('request')
+const got = require('got').default
 const moment = require('moment-timezone')
-const purest = require('purest')({ request })
 
 const Provider = require('../Provider')
-const logger = require('../../logger')
-const adapter = require('./adapter')
-const { ProviderApiError, ProviderAuthError } = require('../error')
-const { requestStream } = require('../../helpers/utils')
+const { initializeData, adaptData } = require('./adapter')
+const { withProviderErrorHandling } = require('../providerErrors')
+const { prepareStream, getBasicAuthHeader } = require('../../helpers/utils')
 
 const BASE_URL = 'https://zoom.us/v2'
-const GET_LIST_PATH = '/users/me/recordings'
-const GET_USER_PATH = '/users/me'
 const PAGE_SIZE = 300
-const DEFAULT_RANGE_MOS = 23
 const DEAUTH_EVENT_NAME = 'app_deauthorized'
 
+const getClient = ({ token }) => got.extend({
+  prefixUrl: BASE_URL,
+  headers: {
+    authorization: `Bearer ${token}`,
+  },
+})
+
+async function findFile ({ client, meetingId, fileId, recordingStart }) {
+  const { recording_files: files } = await client.get(`meetings/${encodeURIComponent(meetingId)}/recordings`, { responseType: 'json' }).json()
+
+  return files.find((file) => (
+    fileId === file.id || (file.file_type === fileId && file.recording_start === recordingStart)
+  ))
+}
+
 /**
  * Adapter for API https://marketplace.zoom.us/docs/api-reference/zoom-api
  */
@@ -23,325 +32,145 @@ class Zoom extends Provider {
   constructor (options) {
     super(options)
     this.authProvider = Zoom.authProvider
-    this.client = purest({
-      ...options,
-      provider: Zoom.authProvider,
-    })
   }
 
   static get authProvider () {
     return 'zoom'
   }
 
-  _list (options, done) {
-    /*
-    - returns list of months by default
-    - drill down for specific files in each month
-    */
-    const { token } = options
-    const query = options.query || {}
-    const { cursor, from, to } = query
-    const meetingId = options.directory || ''
-
-    this.client
-      .get(`${BASE_URL}${GET_USER_PATH}`)
-      .auth(token)
-      .request((err, userResponse, userBody) => {
-        if (err || userResponse.statusCode !== 200) {
-          return this._listError(err, userResponse, done)
-        }
+  /*
+  - returns list of months by default
+  - drill down for specific files in each month
+  */
+  async list (options) {
+    return this.#withErrorHandling('provider.zoom.list.error', async () => {
+      const { token } = options
+      const query = options.query || {}
+      const { cursor, from, to } = query
+      const meetingId = options.directory || ''
+
+      const client = getClient({ token })
+      const user = await client.get('users/me', { responseType: 'json' }).json()
+
+      const { timezone } = user
+
+      if (!from && !to && !meetingId) {
+        const end = cursor && moment.utc(cursor).endOf('day').tz(timezone || 'UTC')
+        return initializeData(user, end)
+      }
 
-        if (!from && !to && !meetingId) {
-          const end = cursor && moment.utc(cursor).endOf('day').tz(userBody.timezone || 'UTC')
-          return done(null, this._initializeData(userResponse.body, end))
+      if (from && to) {
+        /*  we need to convert local datetime to UTC date for Zoom query
+        eg: user in PST (UTC-08:00) wants 2020-08-01 (00:00) to 2020-08-31 (23:59)
+        => in UTC, that's 2020-07-31 (16:00) to 2020-08-31 (15:59)
+        */
+        const searchParams = {
+          page_size: PAGE_SIZE,
+          from: moment.tz(from, timezone || 'UTC').startOf('day').tz('UTC').format('YYYY-MM-DD'),
+          to: moment.tz(to, timezone || 'UTC').endOf('day').tz('UTC').format('YYYY-MM-DD'),
         }
+        if (cursor) searchParams.next_page_token = cursor
 
-        if (from && to) {
-          this._meetingsInfo({ token, query }, userResponse, (err, meetingResp) => {
-            if (err || meetingResp.statusCode !== 200) {
-              return this._listError(err, meetingResp, done)
-            }
-            done(null, this._adaptData(userResponse.body, meetingResp.body, query))
-          })
-        } else if (meetingId) {
-          this._recordingInfo({ token }, meetingId, (err, recordingResp) => {
-            if (err || recordingResp.statusCode !== 200) {
-              return this._listError(err, recordingResp, done)
-            }
-            done(null, this._adaptData(userResponse.body, recordingResp.body, query))
-          })
-        }
-      })
-  }
+        const meetingsInfo = await client.get('users/me/recordings', { searchParams, responseType: 'json' }).json()
 
-  _meetingsInfo ({ token, query }, userResponse, done) {
-    const { cursor, from, to } = query
-    /*  we need to convert local datetime to UTC date for Zoom query
-    eg: user in PST (UTC-08:00) wants 2020-08-01 (00:00) to 2020-08-31 (23:59)
-    => in UTC, that's 2020-07-31 (16:00) to 2020-08-31 (15:59)
-    */
-    const queryObj = {
-      page_size: PAGE_SIZE,
-      from: moment.tz(from, userResponse.body.timezone || 'UTC').startOf('day').tz('UTC').format('YYYY-MM-DD'),
-      to: moment.tz(to, userResponse.body.timezone || 'UTC').endOf('day').tz('UTC').format('YYYY-MM-DD'),
-    }
-
-    if (cursor) {
-      queryObj.next_page_token = cursor
-    }
-
-    this.client.get(`${BASE_URL}${GET_LIST_PATH}`)
-      .qs(queryObj)
-      .auth(token)
-      .request(done)
-  }
+        return adaptData(user, meetingsInfo, query)
+      }
 
-  _recordingInfo ({ token }, meetingId, done) {
-    const GET_MEETING_FILES = `/meetings/${encodeURIComponent(meetingId)}/recordings`
-    this.client
-      .get(`${BASE_URL}${GET_MEETING_FILES}`)
-      .auth(token)
-      .request(done)
+      if (meetingId) {
+        const recordingInfo = await client.get(`meetings/${encodeURIComponent(meetingId)}/recordings`, { responseType: 'json' }).json()
+        return adaptData(user, recordingInfo, query)
+      }
+
+      throw new Error('Invalid list() arguments')
+    })
   }
 
-  async download ({ id, token, query }) {
-    try {
+  async download ({ id: meetingId, token, query }) {
+    return this.#withErrorHandling('provider.zoom.download.error', async () => {
       // meeting id + file id required
       // cc files don't have an ID or size
-      const meetingId = id
-      const fileId = query.recordingId
-      const { recordingStart } = query
-      const GET_MEETING_FILES = `/meetings/${encodeURIComponent(meetingId)}/recordings`
-
-      const downloadUrl = await new Promise((resolve, reject) => {
-        this.client
-          .get(`${BASE_URL}${GET_MEETING_FILES}`)
-          .auth(token)
-          .request((err, resp) => {
-            if (err || resp.statusCode !== 200) {
-              const error = this._error(null, resp)
-              reject(error)
-              return
-            }
-            const file = resp
-              .body
-              .recording_files
-              .find(file => fileId === file.id || (file.file_type === fileId && file.recording_start === recordingStart))
-            if (!file || !file.download_url) {
-              const error = this._error(null, resp)
-              reject(error)
-              return
-            }
-            resolve(file.download_url)
-          })
-      })
+      const { recordingStart, recordingId: fileId } = query
+
+      const client = getClient({ token })
 
-      const req = this.client
-        .get(`${downloadUrl}?access_token=${token}`)
-        .request()
+      const foundFile = await findFile({ client, meetingId, fileId, recordingStart })
+      const url = foundFile?.download_url
+      if (!url) throw new Error('Download URL not found')
 
-      return await requestStream(req, async (res) => this._error(null, res))
-    } catch (err) {
-      logger.error(err, 'provider.zoom.download.error')
-      throw err
-    }
+      const stream = client.stream.get(`${url}?access_token=${token}`, { prefixUrl: '', responseType: 'json' })
+      await prepareStream(stream)
+      return { stream }
+    })
   }
 
-  _size ({ id, token, query }, done) {
-    const meetingId = id
-    const fileId = query.recordingId
-    const { recordingStart } = query
-    const GET_MEETING_FILES = `/meetings/${encodeURIComponent(meetingId)}/recordings`
-
-    return this.client
-      .get(`${BASE_URL}${GET_MEETING_FILES}`)
-      .auth(token)
-      .request((err, resp) => {
-        if (err || resp.statusCode !== 200) {
-          return this._downloadError(resp, done)
-        }
-        const file = resp
-          .body
-          .recording_files
-          .find(file => file.id === fileId || (file.file_type === fileId && file.recording_start === recordingStart))
+  async size ({ id: meetingId, token, query }) {
+    return this.#withErrorHandling('provider.zoom.size.error', async () => {
+      const client = getClient({ token })
+      const { recordingStart, recordingId: fileId } = query
 
-        if (!file) {
-          return this._downloadError(resp, done)
-        }
-        done(null, file.file_size) // May be undefined.
-      })
+      const foundFile = await findFile({ client, meetingId, fileId, recordingStart })
+      if (!foundFile) throw new Error('File not found')
+      return foundFile.file_size // Note: May be undefined.
+    })
   }
 
-  _initializeData (body, initialEnd = null) {
-    let end = initialEnd || moment.utc().tz(body.timezone || 'UTC')
-    const accountCreation = adapter.getAccountCreationDate(body).tz(body.timezone || 'UTC').startOf('day')
-    const defaultLimit = end.clone().subtract(DEFAULT_RANGE_MOS, 'months').date(1).startOf('day')
-    const allResultsShown = accountCreation > defaultLimit
-    const limit = allResultsShown ? accountCreation : defaultLimit
-    // if the limit is mid-month, keep that exact date
-    let start = (end.isSame(limit, 'month') && limit.date() !== 1) ? limit.clone() : end.clone().date(1).startOf('day')
-
-    const data = {
-      items: [],
-      username: adapter.getUserEmail(body),
-    }
-
-    while (end.isAfter(limit)) {
-      data.items.push({
-        isFolder: true,
-        icon: 'folder',
-        name: adapter.getDateName(start, end),
-        mimeType: null,
-        id: adapter.getDateFolderId(start, end),
-        thumbnail: null,
-        requestPath: adapter.getDateFolderRequestPath(start, end),
-        modifiedDate: adapter.getDateFolderModified(end),
-        size: null,
-      })
-      end = start.clone().subtract(1, 'days').endOf('day')
-      start = (end.isSame(limit, 'month') && limit.date() !== 1) ? limit.clone() : end.clone().date(1).startOf('day')
-    }
-    data.nextPagePath = allResultsShown ? null : adapter.getDateNextPagePath(end)
-    return data
-  }
+  async logout ({ companion, token }) {
+    return this.#withErrorHandling('provider.zoom.logout.error', async () => {
+      const { key, secret } = await companion.getProviderCredentials()
 
-  _adaptData (userResponse, results, query) {
-    if (!results) {
-      return { items: [] }
-    }
-
-    // we query the zoom api by date (from 00:00 - 23:59 UTC) which may include
-    // extra results for 00:00 - 23:59 local time that we want to filter out.
-    const utcFrom = moment.tz(query.from, userResponse.timezone || 'UTC').startOf('day').tz('UTC')
-    const utcTo = moment.tz(query.to, userResponse.timezone || 'UTC').endOf('day').tz('UTC')
-
-    const data = {
-      nextPagePath: adapter.getNextPagePath(results),
-      items: [],
-      username: adapter.getUserEmail(userResponse),
-    }
-
-    let items = []
-    if (results.meetings) {
-      items = results.meetings
-        .map(item => { return { ...item, utcStart: moment.utc(item.start_time) } })
-        .filter(item => moment.utc(item.start_time).isAfter(utcFrom) && moment.utc(item.start_time).isBefore(utcTo))
-    } else {
-      items = results.recording_files
-        .map(item => { return { ...item, topic: results.topic } })
-        .filter(file => file.file_type !== 'TIMELINE')
-    }
-
-    items.forEach(item => {
-      data.items.push({
-        isFolder: adapter.getIsFolder(item),
-        icon: adapter.getIcon(item),
-        name: adapter.getItemName(item, userResponse),
-        mimeType: adapter.getMimeType(item),
-        id: adapter.getId(item),
-        thumbnail: null,
-        requestPath: adapter.getRequestPath(item),
-        modifiedDate: adapter.getStartDate(item),
-        size: adapter.getSize(item),
-        custom: {
-          topic: adapter.getItemTopic(item),
-        },
-      })
+      const { status } = await got.post('https://zoom.us/oauth/revoke', {
+        searchParams: { token },
+        headers: { Authorization: getBasicAuthHeader(key, secret) },
+        responseType: 'json',
+      }).json()
+
+      return { revoked: status === 'success' }
     })
-    return data
   }
 
-  _logout ({ companion, token }, done) {
-    companion.getProviderCredentials().then(({ key, secret }) => {
-      const encodedAuth = Buffer.from(`${key}:${secret}`, 'binary').toString('base64')
-      return this.client
-        .post('https://zoom.us/oauth/revoke')
-        .options({
-          headers: {
-            Authorization: `Basic ${encodedAuth}`,
-          },
-        })
-        .qs({ token })
-        .request((err, resp, body) => {
-          if (err || resp.statusCode !== 200) {
-            logger.error(err, 'provider.zoom.logout.error')
-            done(this._error(err, resp))
-            return
-          }
-          done(null, { revoked: (body || {}).status === 'success' })
-        })
-    }).catch((err) => done(err))
-  }
+  async deauthorizationCallback ({ companion, body, headers }) {
+    return this.#withErrorHandling('provider.zoom.deauth.error', async () => {
+      if (!body || body.event !== DEAUTH_EVENT_NAME) {
+        return { data: {}, status: 400 }
+      }
 
-  _deauthorizationCallback ({ companion, body, headers }, done) {
-    if (!body || body.event !== DEAUTH_EVENT_NAME) {
-      done(null, { data: {}, status: 400 })
-      return
-    }
+      const { verificationToken, key, secret } = await companion.getProviderCredentials()
 
-    companion.getProviderCredentials().then(({ verificationToken, key, secret }) => {
       const tokenSupplied = headers.authorization
       if (!tokenSupplied || verificationToken !== tokenSupplied) {
-        return done(null, { data: {}, status: 400 })
+        return { data: {}, status: 400 }
       }
 
-      const encodedAuth = Buffer.from(`${key}:${secret}`, 'binary').toString('base64')
-      this.client
-        .post('https://api.zoom.us/oauth/data/compliance')
-        .options({
-          headers: {
-            Authorization: `Basic ${encodedAuth}`,
-          },
-        })
-        .json({
+      await got.post('https://api.zoom.us/oauth/data/compliance', {
+        headers: { Authorization: getBasicAuthHeader(key, secret) },
+        json: {
           client_id: key,
           user_id: body.payload.user_id,
           account_id: body.payload.account_id,
           deauthorization_event_received: body.payload,
           compliance_completed: true,
-        })
-        .request((err, resp) => {
-          if (err || resp.statusCode !== 200) {
-            logger.error(err, 'provider.zoom.deauth.error')
-            done(this._error(err, resp))
-            return
-          }
-          done(null, {})
-        })
-    }).catch((err) => done(err))
+        },
+        responseType: 'json',
+      })
+
+      return {}
+    })
   }
 
-  _error (err, resp) {
+  async #withErrorHandling (tag, fn) {
     const authErrorCodes = [
       124, // expired token
       401,
     ]
-    if (resp) {
-      const fallbackMsg = `request to ${this.authProvider} returned ${resp.statusCode}`
-      const errMsg = (resp.body || {}).message ? resp.body.message : fallbackMsg
-      return authErrorCodes.indexOf(resp.statusCode) > -1
-        ? new ProviderAuthError()
-        : new ProviderApiError(errMsg, resp.statusCode)
-    }
-    return err
-  }
 
-  _downloadError (resp, done) {
-    const error = this._error(null, resp)
-    logger.error(error, 'provider.zoom.download.error')
-    return done(error)
-  }
-
-  _listError (err, resp, done) {
-    const error = this._error(err, resp)
-    logger.error(error, 'provider.zoom.list.error')
-    return done(error)
+    return withProviderErrorHandling({
+      fn,
+      tag,
+      providerName: this.authProvider,
+      isAuthError: (response) => authErrorCodes.includes(response.statusCode),
+      getJsonErrorMessage: (body) => body?.message,
+    })
   }
 }
 
-Zoom.prototype.list = promisify(Zoom.prototype._list)
-Zoom.prototype.size = promisify(Zoom.prototype._size)
-Zoom.prototype.logout = promisify(Zoom.prototype._logout)
-Zoom.prototype.deauthorizationCallback = promisify(Zoom.prototype._deauthorizationCallback)
-
 module.exports = Zoom

+ 0 - 76
packages/@uppy/companion/test/__mocks__/purest.js

@@ -1,76 +0,0 @@
-const fs = require('node:fs')
-const qs = require('node:querystring')
-
-const fixtures = require('../fixtures').providers
-
-function has (object, property) {
-  return Object.prototype.hasOwnProperty.call(object, property)
-}
-
-class MockPurest {
-  constructor (opts) {
-    const methodsToMock = ['query', 'select', 'where', 'auth', 'json']
-    const httpMethodsToMock = ['get', 'put', 'post', 'head', 'delete']
-    methodsToMock.forEach((item) => {
-      this[item] = () => this
-    })
-    httpMethodsToMock.forEach((item) => {
-      this[item] = (url) => {
-        this._requestUrl = url
-        this._method = item
-        return this
-      }
-    })
-    this.opts = opts
-  }
-
-  qs (data) {
-    this._query = qs.stringify(data)
-    return this
-  }
-
-  options (reqOpts) {
-    this._requestOptions = reqOpts
-    return this
-  }
-
-  _getStatusCode () {
-    const { validators } = fixtures[this.opts.providerName]
-    if (validators && validators[this._requestUrl]) {
-      return validators[this._requestUrl](this._requestOptions) ? 200 : 400
-    }
-    return 200
-  }
-
-  request (done) {
-    if (typeof done === 'function') {
-      const { responses } = fixtures[this.opts.providerName]
-      const url = this._query ? `${this._requestUrl}?${this._query}` : this._requestUrl
-      const endpointResponses = responses[url] || responses[this._requestUrl]
-      if (endpointResponses == null || !has(endpointResponses, this._method)) {
-        done(new Error(`No fixture for ${this._method} ${url}`))
-        return this
-      }
-
-      const statusCode = this._getStatusCode()
-
-      const body = statusCode === 200 ? endpointResponses[this._method] : {}
-      done(null, { body, statusCode }, body)
-    }
-
-    return this
-  }
-
-  on (evt, cb) {
-    if (evt === 'response') {
-      const stream = fs.createReadStream('./README.md')
-      stream.statusCode = this._getStatusCode()
-      cb(stream)
-    }
-    return this
-  }
-}
-
-module.exports = () => {
-  return (options) => new MockPurest(options)
-}

+ 63 - 20
packages/@uppy/companion/test/__tests__/companion.js

@@ -1,17 +1,31 @@
-/* global jest:false, test:false, expect:false, describe:false */
+const nock = require('nock')
+const request = require('supertest')
 
-const mockOauthState = require('../mockoauthstate')()
+const mockOauthState = require('../mockoauthstate')
 const { version } = require('../../package.json')
+const { nockGoogleDownloadFile } = require('../fixtures/drive')
 
 jest.mock('tus-js-client')
-jest.mock('purest')
 jest.mock('../../src/server/helpers/oauth-state', () => ({
   ...jest.requireActual('../../src/server/helpers/oauth-state'),
-  ...mockOauthState,
+  ...mockOauthState(),
 }))
 
-const nock = require('nock')
-const request = require('supertest')
+const fakeLocalhost = 'localhost.com'
+
+jest.mock('node:dns', () => {
+  const actual = jest.requireActual('node:dns')
+  return {
+    ...actual,
+    lookup: (hostname, options, callback) => {
+      if (fakeLocalhost === hostname) {
+        return callback(null, '127.0.0.1', 4)
+      }
+      return actual.lookup(hostname, options, callback)
+    },
+  }
+})
+
 const tokenService = require('../../src/server/helpers/jwt')
 const { getServer } = require('../mockserver')
 
@@ -25,8 +39,15 @@ const authData = {
 const token = tokenService.generateEncryptedToken(authData, process.env.COMPANION_SECRET)
 const OAUTH_STATE = 'some-cool-nice-encrytpion'
 
+afterAll(() => {
+  nock.cleanAll()
+  nock.restore()
+})
+
 describe('validate upload data', () => {
   test('invalid upload protocol gets rejected', () => {
+    nockGoogleDownloadFile()
+
     return request(authServer)
       .post('/drive/get/DUMMY-FILE-ID')
       .set('uppy-auth-token', token)
@@ -40,6 +61,8 @@ describe('validate upload data', () => {
   })
 
   test('invalid upload fieldname gets rejected', () => {
+    nockGoogleDownloadFile()
+
     return request(authServer)
       .post('/drive/get/DUMMY-FILE-ID')
       .set('uppy-auth-token', token)
@@ -54,6 +77,8 @@ describe('validate upload data', () => {
   })
 
   test('invalid upload metadata gets rejected', () => {
+    nockGoogleDownloadFile()
+
     return request(authServer)
       .post('/drive/get/DUMMY-FILE-ID')
       .set('uppy-auth-token', token)
@@ -68,6 +93,8 @@ describe('validate upload data', () => {
   })
 
   test('invalid upload headers get rejected', () => {
+    nockGoogleDownloadFile()
+
     return request(authServer)
       .post('/drive/get/DUMMY-FILE-ID')
       .set('uppy-auth-token', token)
@@ -82,6 +109,8 @@ describe('validate upload data', () => {
   })
 
   test('invalid upload HTTP Method gets rejected', () => {
+    nockGoogleDownloadFile()
+
     return request(authServer)
       .post('/drive/get/DUMMY-FILE-ID')
       .set('uppy-auth-token', token)
@@ -96,6 +125,8 @@ describe('validate upload data', () => {
   })
 
   test('valid upload data is allowed - tus', () => {
+    nockGoogleDownloadFile({ times: 2 })
+
     return request(authServer)
       .post('/drive/get/DUMMY-FILE-ID')
       .set('uppy-auth-token', token)
@@ -116,6 +147,8 @@ describe('validate upload data', () => {
   })
 
   test('valid upload data is allowed - s3-multipart', () => {
+    nockGoogleDownloadFile({ times: 2 })
+
     return request(authServer)
       .post('/drive/get/DUMMY-FILE-ID')
       .set('uppy-auth-token', token)
@@ -170,22 +203,20 @@ it('periodically pings', (done) => {
     COMPANION_PERIODIC_PING_INTERVAL: '10',
     COMPANION_PERIODIC_PING_COUNT: '1',
   })
-}, 1000)
+}, 3000)
 
-it('respects allowLocalUrls', async () => {
+async function runUrlMetaTest (url) {
   const server = getServer()
-  const url = 'http://localhost/'
-
-  let res
 
-  res = await request(server)
+  return request(server)
     .post('/url/meta')
     .send({ url })
-    .expect(400)
+}
 
-  expect(res.body).toEqual({ error: 'Invalid request body' })
+async function runUrlGetTest (url) {
+  const server = getServer()
 
-  res = await request(server)
+  return request(server)
     .post('/url/get')
     .send({
       fileId: url,
@@ -195,12 +226,24 @@ it('respects allowLocalUrls', async () => {
       size: null,
       url,
     })
-    .expect(400)
+}
 
+it('respects allowLocalUrls, localhost', async () => {
+  let res = await runUrlMetaTest('http://localhost/')
+  expect(res.statusCode).toBe(400)
+  expect(res.body).toEqual({ error: 'Invalid request body' })
+
+  res = await runUrlGetTest('http://localhost/')
+  expect(res.statusCode).toBe(400)
   expect(res.body).toEqual({ error: 'Invalid request body' })
 }, 1000)
 
-afterAll(() => {
-  nock.cleanAll()
-  nock.restore()
-})
+it('respects allowLocalUrls, valid hostname that resolves to localhost', async () => {
+  let res = await runUrlMetaTest(`http://${fakeLocalhost}/`)
+  expect(res.statusCode).toBe(500)
+  expect(res.body).toEqual({ message: 'failed to fetch URL metadata' })
+
+  res = await runUrlGetTest(`http://${fakeLocalhost}/`)
+  expect(res.statusCode).toBe(500)
+  expect(res.body).toEqual({ message: 'failed to fetch URL metadata' })
+}, 1000)

+ 34 - 38
packages/@uppy/companion/test/__tests__/credentials.js

@@ -1,39 +1,10 @@
-/* global jest:false, test:false, expect:false, describe:false */
-
-// mocking request module used to fetch custom oauth credentials
-jest.mock('request', () => {
-  const { remoteZoomKey, remoteZoomSecret, remoteZoomVerificationToken } = require('../fixtures/zoom').expects
-
-  return {
-    post: (url, options, done) => {
-      if (url === 'http://localhost:2111/zoom-keys') {
-        const { body } = options
-        if (body.provider !== 'zoom') {
-          return done(new Error('wrong provider'))
-        }
-
-        if (body.parameters !== 'ZOOM-CREDENTIALS-PARAMS') {
-          return done(new Error('wrong params'))
-        }
-
-        const respBody = {
-          credentials: {
-            key: remoteZoomKey,
-            secret: remoteZoomSecret,
-            verificationToken: remoteZoomVerificationToken,
-          },
-        }
-        return done(null, { statusCode: 200, body: respBody }, respBody)
-      }
-
-      done(new Error('unsupported request with mock function'))
-    },
-  }
-})
-
 const request = require('supertest')
+const nock = require('nock')
 const tokenService = require('../../src/server/helpers/jwt')
 const { getServer } = require('../mockserver')
+const { nockZoomRevoke } = require('../fixtures/zoom')
+
+const { remoteZoomKey, remoteZoomSecret, remoteZoomVerificationToken } = require('../fixtures/zoom').expects
 
 const authServer = getServer({ COMPANION_ZOOM_KEYS_ENDPOINT: 'http://localhost:2111/zoom-keys' })
 const authData = {
@@ -41,16 +12,42 @@ const authData = {
 }
 const token = tokenService.generateEncryptedToken(authData, process.env.COMPANION_SECRET)
 
+afterAll(() => {
+  nock.cleanAll()
+  nock.restore()
+})
+
 describe('providers requests with remote oauth keys', () => {
-  test('zoom logout with remote oauth keys happy path', () => {
+  // mocking request module used to fetch custom oauth credentials
+  nock('http://localhost:2111')
+    .post('/zoom-keys')
+    .reply((uri, { provider, parameters }) => {
+      if (provider !== 'zoom' || parameters !== 'ZOOM-CREDENTIALS-PARAMS') return [400]
+
+      return [200, {
+        credentials: {
+          key: remoteZoomKey,
+          secret: remoteZoomSecret,
+          verificationToken: remoteZoomVerificationToken,
+        },
+      }]
+    }).persist()
+
+  test('zoom logout with remote oauth keys happy path', async () => {
+    nockZoomRevoke({ key: remoteZoomKey, secret: remoteZoomSecret })
+
     const params = { params: 'ZOOM-CREDENTIALS-PARAMS' }
     const encodedParams = Buffer.from(JSON.stringify(params), 'binary').toString('base64')
-    return request(authServer)
+    const res = await request(authServer)
       .get('/zoom/logout/')
       .set('uppy-auth-token', token)
       .set('uppy-credentials-params', encodedParams)
       .expect(200)
-      .then((res) => expect(res.body.ok).toBe(true))
+
+    expect(res.body).toMatchObject({
+      ok: true,
+      revoked: true,
+    })
   })
 
   test('zoom logout with wrong credentials params', () => {
@@ -60,7 +57,6 @@ describe('providers requests with remote oauth keys', () => {
       .get('/zoom/logout/')
       .set('uppy-auth-token', token)
       .set('uppy-credentials-params', encodedParams)
-      // todo: handle failures differently to return 400 for this case instead
-      .expect(500)
+      .expect(424)
   })
 })

+ 10 - 2
packages/@uppy/companion/test/__tests__/deauthorization.js

@@ -1,11 +1,19 @@
-/* global test:false, describe:false */
-
+const nock = require('nock')
 const request = require('supertest')
 const { getServer } = require('../mockserver')
 
 const authServer = getServer()
 
+afterAll(() => {
+  nock.cleanAll()
+  nock.restore()
+})
+
 describe('handle deauthorization callback', () => {
+  nock('https://api.zoom.us')
+    .post('/oauth/data/compliance')
+    .reply(200)
+
   test('providers without support for callback endpoint', () => {
     return request(authServer)
       .post('/dropbox/deauthorization/callback')

+ 22 - 82
packages/@uppy/companion/test/__tests__/http-agent.js

@@ -1,9 +1,6 @@
-/* global test:false, expect:false, describe:false, */
-
-const request = require('request')
-const http = require('node:http')
-const https = require('node:https')
-const { getProtectedHttpAgent, getRedirectEvaluator, FORBIDDEN_IP_ADDRESS } = require('../../src/server/helpers/request')
+const nock = require('nock')
+const { getRedirectEvaluator, FORBIDDEN_IP_ADDRESS } = require('../../src/server/helpers/request')
+const { getProtectedGot } = require('../../src/server/helpers/request')
 
 describe('test getRedirectEvaluator', () => {
   const httpURL = 'http://uppy.io'
@@ -35,90 +32,33 @@ describe('test getRedirectEvaluator', () => {
   })
 })
 
-describe('test getProtectedHttpAgent', () => {
-  test('setting "https:" as protocol', (done) => {
-    const Agent = getProtectedHttpAgent('https:')
-    expect(Agent).toEqual(https.Agent)
-    done()
-  })
-
-  test('setting "https" as protocol', (done) => {
-    const Agent = getProtectedHttpAgent('https')
-    expect(Agent).toEqual(https.Agent)
-    done()
-  })
-
-  test('setting "http:" as protocol', (done) => {
-    const Agent = getProtectedHttpAgent('http:')
-    expect(Agent).toEqual(http.Agent)
-    done()
-  })
-
-  test('setting "http" as protocol', (done) => {
-    const Agent = getProtectedHttpAgent('http')
-    expect(Agent).toEqual(http.Agent)
-    done()
-  })
+afterAll(() => {
+  nock.cleanAll()
+  nock.restore()
 })
 
 describe('test protected request Agent', () => {
-  test('allows URLs without IP addresses', (done) => {
-    const options = {
-      uri: 'https://transloadit.com',
-      method: 'GET',
-      agentClass: getProtectedHttpAgent('https', true),
-    }
-
-    request(options, (err) => {
-      if (err) {
-        expect(err.message).not.toEqual(FORBIDDEN_IP_ADDRESS)
-        expect(err.message.startsWith(FORBIDDEN_IP_ADDRESS)).toEqual(false)
-        done()
-      } else {
-        done()
-      }
-    })
+  test('allows URLs without IP addresses', async () => {
+    nock('https://transloadit.com').get('/').reply(200)
+    const url = 'https://transloadit.com'
+    await getProtectedGot({ url, blockLocalIPs: true }).get(url)
   })
 
-  test('blocks private http IP address', (done) => {
-    const options = {
-      uri: 'http://172.20.10.4:8090',
-      method: 'GET',
-      agentClass: getProtectedHttpAgent('http', true),
-    }
-
-    request(options, (err) => {
-      expect(err).toBeInstanceOf(Error)
-      expect(err.message).toEqual(FORBIDDEN_IP_ADDRESS)
-      done()
-    })
+  test('blocks private http IP address', async () => {
+    const url = 'http://172.20.10.4:8090'
+    const promise = getProtectedGot({ url, blockLocalIPs: true }).get(url)
+    await expect(promise).rejects.toThrow(new Error(FORBIDDEN_IP_ADDRESS))
   })
 
-  test('blocks private https IP address', (done) => {
-    const options = {
-      uri: 'https://172.20.10.4:8090',
-      method: 'GET',
-      agentClass: getProtectedHttpAgent('https', true),
-    }
-
-    request(options, (err) => {
-      expect(err).toBeInstanceOf(Error)
-      expect(err.message).toEqual(FORBIDDEN_IP_ADDRESS)
-      done()
-    })
+  test('blocks private https IP address', async () => {
+    const url = 'https://172.20.10.4:8090'
+    const promise = getProtectedGot({ url, blockLocalIPs: true }).get(url)
+    await expect(promise).rejects.toThrow(new Error(FORBIDDEN_IP_ADDRESS))
   })
 
-  test('blocks localhost IP address', (done) => {
-    const options = {
-      uri: 'http://127.0.0.1:8090',
-      method: 'GET',
-      agentClass: getProtectedHttpAgent('http', true),
-    }
-
-    request(options, (err) => {
-      expect(err).toBeInstanceOf(Error)
-      expect(err.message).toEqual(FORBIDDEN_IP_ADDRESS)
-      done()
-    })
+  test('blocks localhost IP address', async () => {
+    const url = 'http://127.0.0.1:8090'
+    const promise = getProtectedGot({ url, blockLocalIPs: true }).get(url)
+    await expect(promise).rejects.toThrow(new Error(FORBIDDEN_IP_ADDRESS))
   })
 })

+ 335 - 34
packages/@uppy/companion/test/__tests__/providers.js

@@ -1,21 +1,23 @@
-/* global jest:false, test:false, expect:false, describe:false */
+const request = require('supertest')
+const nock = require('nock')
+
+const mockOauthState = require('../mockoauthstate')
 
 jest.mock('tus-js-client')
-jest.mock('purest')
 jest.mock('../../src/server/helpers/request', () => {
   return {
     getURLMeta: () => Promise.resolve({ size: 758051 }),
   }
 })
-jest.mock('../../src/server/helpers/oauth-state', () => require('../mockoauthstate')())
-
-const request = require('supertest')
-const nock = require('nock')
+jest.mock('../../src/server/helpers/oauth-state', () => mockOauthState())
 
 const fixtures = require('../fixtures')
+const { nockGoogleDownloadFile } = require('../fixtures/drive')
+const { nockZoomRecordings, nockZoomRevoke, expects: { localZoomKey, localZoomSecret } } = require('../fixtures/zoom')
+const defaults = require('../fixtures/constants')
+
 const tokenService = require('../../src/server/helpers/jwt')
 const { getServer } = require('../mockserver')
-const defaults = require('../fixtures/constants')
 
 // todo don't share server between tests. rewrite to not use env variables
 const authServer = getServer({ COMPANION_CLIENT_SOCKET_CONNECT_TIMEOUT: '0' })
@@ -51,26 +53,16 @@ afterAll(() => {
   nock.restore()
 })
 
-describe('set i-am header', () => {
-  test.each(providerNames)('set i-am header in response (%s)', (providerName) => {
-    const providerFixtures = fixtures.providers[providerName].expects
-    return request(authServer)
-      .get(`/${providerName}/list/${providerFixtures.listPath || ''}`)
-      .set('uppy-auth-token', token)
-      .expect(200)
-      .then((res) => expect(res.header['i-am']).toBe('http://localhost:3020'))
-  })
-})
-
 describe('list provider files', () => {
-  test.each(providerNames)('list files for %s', (providerName) => {
+  async function runTest (providerName) {
     const providerFixtures = fixtures.providers[providerName].expects
     return request(authServer)
       .get(`/${providerName}/list/${providerFixtures.listPath || ''}`)
       .set('uppy-auth-token', token)
       .expect(200)
       .then((res) => {
-        expect(res.body.username).toBe(fixtures.defaults.USERNAME)
+        expect(res.header['i-am']).toBe('http://localhost:3020')
+        expect(res.body.username).toBe(defaults.USERNAME)
 
         const items = [...res.body.items]
 
@@ -87,21 +79,225 @@ describe('list provider files', () => {
 
         const item = items[0]
         expect(item.isFolder).toBe(false)
-        expect(item.name).toBe(providerFixtures.itemName || fixtures.defaults.ITEM_NAME)
-        expect(item.mimeType).toBe(providerFixtures.itemMimeType || fixtures.defaults.MIME_TYPE)
-        expect(item.id).toBe(providerFixtures.itemId || fixtures.defaults.ITEM_ID)
-        expect(item.size).toBe(thisOrThat(providerFixtures.itemSize, fixtures.defaults.FILE_SIZE))
-        expect(item.requestPath).toBe(providerFixtures.itemRequestPath || fixtures.defaults.ITEM_ID)
-        expect(item.icon).toBe(providerFixtures.itemIcon || fixtures.defaults.THUMBNAIL_URL)
+        expect(item.name).toBe(providerFixtures.itemName || defaults.ITEM_NAME)
+        expect(item.mimeType).toBe(providerFixtures.itemMimeType || defaults.MIME_TYPE)
+        expect(item.id).toBe(providerFixtures.itemId || defaults.ITEM_ID)
+        expect(item.size).toBe(thisOrThat(providerFixtures.itemSize, defaults.FILE_SIZE))
+        expect(item.requestPath).toBe(providerFixtures.itemRequestPath || defaults.ITEM_ID)
+        expect(item.icon).toBe(providerFixtures.itemIcon || defaults.THUMBNAIL_URL)
       })
+  }
+
+  test('dropbox', async () => {
+    nock('https://api.dropboxapi.com').post('/2/users/get_current_account').reply(200, {
+      name: {
+        given_name: 'Franz',
+        surname: 'Ferdinand',
+        familiar_name: 'Franz',
+        display_name: 'Franz Ferdinand (Personal)',
+        abbreviated_name: 'FF',
+      },
+      email: defaults.USERNAME,
+      email_verified: true,
+      disabled: false,
+      locale: 'en',
+      referral_link: 'https://db.tt/ZITNuhtI',
+      is_paired: true,
+    })
+    nock('https://api.dropboxapi.com').post('/2/files/list_folder').reply(200, {
+      entries: [
+        {
+          '.tag': 'file',
+          name: defaults.ITEM_NAME,
+          id: defaults.ITEM_ID,
+          client_modified: '2015-05-12T15:50:38Z',
+          server_modified: '2015-05-12T15:50:38Z',
+          rev: 'a1c10ce0dd78',
+          size: defaults.FILE_SIZE,
+          path_lower: '/homework/math/prime_numbers.txt',
+          path_display: '/Homework/math/Prime_Numbers.txt',
+          is_downloadable: true,
+          has_explicit_shared_members: false,
+          content_hash: 'e3b0c44298fc1c149afbf41e4649b934ca49',
+          file_lock_info: {
+            is_lockholder: true,
+            lockholder_name: 'Imaginary User',
+            created: '2015-05-12T15:50:38Z',
+          },
+        },
+      ],
+      cursor: 'ZtkX9_EHj3x7PMkVuFIhwKYXEpwpLwyxp9vMKomUhllil9q7eWiAu',
+      has_more: false,
+    })
+
+    await runTest('dropbox')
+  })
+
+  test('box', async () => {
+    nock('https://api.box.com').get('/2.0/users/me').reply(200, {
+      login: defaults.USERNAME,
+    })
+    nock('https://api.box.com').get('/2.0/folders/0/items?fields=id%2Cmodified_at%2Cname%2Cpermissions%2Csize%2Ctype').reply(200, {
+      entries: [
+        {
+          type: 'file',
+          name: defaults.ITEM_NAME,
+          id: defaults.ITEM_ID,
+          modified_at: '2015-05-12T15:50:38Z',
+          size: defaults.FILE_SIZE,
+        },
+      ],
+    })
+
+    await runTest('box')
+  })
+
+  test('drive', async () => {
+    nock('https://www.googleapis.com').get('/drive/v3/drives?fields=*&pageToken=&pageSize=100').reply(200, {
+      kind: 'drive#driveList', drives: [],
+    })
+
+    nock('https://www.googleapis.com').get('/drive/v3/files?fields=kind%2CnextPageToken%2CincompleteSearch%2Cfiles%28kind%2Cid%2CimageMediaMetadata%2Cname%2CmimeType%2CownedByMe%2Cpermissions%28role%2CemailAddress%29%2Csize%2CmodifiedTime%2CiconLink%2CthumbnailLink%2CteamDriveId%2CvideoMediaMetadata%2CshortcutDetails%28targetId%2CtargetMimeType%29%29&q=%28%27root%27+in+parents%29+and+trashed%3Dfalse&orderBy=folder%2Cname&includeItemsFromAllDrives=true&supportsAllDrives=true').reply(200, {
+      kind: 'drive#fileList',
+      nextPageToken: defaults.NEXT_PAGE_TOKEN,
+      files: [
+        {
+          kind: 'drive#file',
+          id: defaults.ITEM_ID,
+          name: defaults.ITEM_NAME,
+          mimeType: defaults.MIME_TYPE,
+          iconLink: 'https://drive-thirdparty.googleusercontent.com/16/type/video/mp4',
+          thumbnailLink: defaults.THUMBNAIL_URL,
+          modifiedTime: '2016-07-10T20:00:08.096Z',
+          ownedByMe: true,
+          permissions: [{ role: 'owner', emailAddress: defaults.USERNAME }],
+          size: '758051',
+        },
+      ],
+    })
+
+    await runTest('drive')
+  })
+
+  test('facebook', async () => {
+    nock('https://graph.facebook.com').get('/me?fields=email').reply(200, {
+      name: 'Fiona Fox',
+      birthday: '01/01/1985',
+      email: defaults.USERNAME,
+    })
+    nock('https://graph.facebook.com').get('/ALBUM-ID/photos?fields=icon%2Cimages%2Cname%2Cwidth%2Cheight%2Ccreated_time').reply(200, {
+      data: [
+        {
+          images: [
+            {
+              height: 1365,
+              source: defaults.THUMBNAIL_URL,
+              width: 2048,
+            },
+          ],
+          width: 720,
+          height: 479,
+          created_time: '2015-07-17T17:26:50+0000',
+          id: defaults.ITEM_ID,
+        },
+      ],
+      paging: {},
+    })
+
+    await runTest('facebook')
+  })
+
+  test('instagram', async () => {
+    nock('https://graph.instagram.com').get('/me?fields=username').reply(200, {
+      id: '17841405793187218',
+      username: defaults.USERNAME,
+    })
+    nock('https://graph.instagram.com').get('/me/media?fields=id%2Cmedia_type%2Cthumbnail_url%2Cmedia_url%2Ctimestamp%2Cchildren%7Bmedia_type%2Cmedia_url%2Cthumbnail_url%2Ctimestamp%7D').reply(200, {
+      data: [
+        {
+          id: defaults.ITEM_ID,
+          media_type: 'IMAGE',
+          timestamp: '2017-08-31T18:10:00+0000',
+          media_url: defaults.THUMBNAIL_URL,
+        },
+      ],
+    })
+
+    await runTest('instagram')
+  })
+
+  test('onedrive', async () => {
+    nock('https://graph.microsoft.com').get('/me').reply(200, {
+      userPrincipalName: defaults.USERNAME,
+      mail: defaults.USERNAME,
+    })
+    nock('https://graph.microsoft.com').get('/me/drive/root/children?%24expand=thumbnails').reply(200, {
+      value: [
+        {
+          createdDateTime: '2020-01-31T15:40:26.197Z',
+          id: defaults.ITEM_ID,
+          lastModifiedDateTime: '2020-01-31T15:40:38.723Z',
+          name: defaults.ITEM_NAME,
+          size: defaults.FILE_SIZE,
+          parentReference: {
+            driveId: 'DUMMY-DRIVE-ID',
+            driveType: 'personal',
+            path: '/drive/root:',
+          },
+          file: {
+            mimeType: defaults.MIME_TYPE,
+          },
+          thumbnails: [{
+            id: '0',
+            large: {
+              height: 452,
+              url: defaults.THUMBNAIL_URL,
+              width: 800,
+            },
+            medium: {
+              height: 100,
+              url: defaults.THUMBNAIL_URL,
+              width: 176,
+            },
+            small: {
+              height: 54,
+              url: defaults.THUMBNAIL_URL,
+              width: 96,
+            },
+          }],
+        },
+      ],
+    })
+
+    await runTest('onedrive')
+  })
+
+  test('zoom', async () => {
+    nock('https://zoom.us').get('/v2/users/me').reply(200, {
+      id: 'DUMMY-USER-ID',
+      first_name: 'John',
+      last_name: 'Doe',
+      email: 'john.doe@transloadit.com',
+      timezone: '',
+      dept: '',
+      created_at: '2020-07-21T09:13:30Z',
+      last_login_time: '2020-10-12T07:55:02Z',
+      group_ids: [],
+      im_group_ids: [],
+      account_id: 'DUMMY-ACCOUNT-ID',
+      language: 'en-US',
+    })
+    nockZoomRecordings()
+
+    await runTest('zoom')
   })
 })
 
-describe('download provider file', () => {
-  test.each(providerNames)('specified file gets downloaded from %s', (providerName) => {
+describe('provider file gets downloaded from', () => {
+  async function runTest (providerName) {
     const providerFixtures = fixtures.providers[providerName].expects
-    return request(authServer)
-      .post(`/${providerName}/get/${providerFixtures.itemRequestPath || fixtures.defaults.ITEM_ID}`)
+    const res = await request(authServer)
+      .post(`/${providerName}/get/${providerFixtures.itemRequestPath || defaults.ITEM_ID}`)
       .set('uppy-auth-token', token)
       .set('Content-Type', 'application/json')
       .send({
@@ -109,7 +305,67 @@ describe('download provider file', () => {
         protocol: 'tus',
       })
       .expect(200)
-      .then((res) => expect(res.body.token).toBeTruthy())
+
+    expect(res.body.token).toBeTruthy()
+  }
+
+  test('dropbox', async () => {
+    nock('https://api.dropboxapi.com').post('/2/files/get_metadata').reply(200, { size: defaults.FILE_SIZE })
+    nock('https://content.dropboxapi.com').post('/2/files/download').reply(200, {})
+    await runTest('dropbox')
+  })
+
+  test('box', async () => {
+    nock('https://api.box.com').get(`/2.0/files/${defaults.ITEM_ID}`).reply(200, { size: defaults.FILE_SIZE })
+    nock('https://api.box.com').get(`/2.0/files/${defaults.ITEM_ID}/content`).reply(200, { size: defaults.FILE_SIZE })
+    await runTest('box')
+  })
+
+  test('drive', async () => {
+    // times(2) because of size request
+    nockGoogleDownloadFile({ times: 2 })
+    await runTest('drive')
+  })
+
+  test('facebook', async () => {
+    // times(2) because of size request
+    nock('https://graph.facebook.com').get(`/${defaults.ITEM_ID}?fields=images`).times(2).reply(200, {
+      images: [
+        {
+          height: 1365,
+          source: defaults.THUMBNAIL_URL,
+          width: 2048,
+        },
+      ],
+      id: defaults.ITEM_ID,
+    })
+    await runTest('facebook')
+  })
+
+  test('instagram', async () => {
+    // times(2) because of size request
+    nock('https://graph.instagram.com').get(`/${defaults.ITEM_ID}?fields=media_url`).times(2).reply(200, {
+      id: defaults.ITEM_ID,
+      media_type: 'IMAGE',
+      media_url: defaults.THUMBNAIL_URL,
+      timestamp: '2017-08-31T18:10:00+0000',
+    })
+    await runTest('instagram')
+  })
+
+  test('onedrive', async () => {
+    nock('https://graph.microsoft.com').get(`/drives/DUMMY-DRIVE-ID/items/${defaults.ITEM_ID}`).reply(200, {
+      size: defaults.FILE_SIZE,
+    })
+    nock('https://graph.microsoft.com').get(`/drives/DUMMY-DRIVE-ID/items/${defaults.ITEM_ID}/content`).reply(200, {})
+    await runTest('onedrive')
+  })
+
+  test('zoom', async () => {
+    // times(2) because of size request
+    nockZoomRecordings({ times: 2 })
+    nock('https://us02web.zoom.us').get('/rec/download/DUMMY-DOWNLOAD-PATH?access_token=token%20value').reply(200, {})
+    await runTest('zoom')
   })
 })
 
@@ -126,11 +382,56 @@ describe('connect to provider', () => {
 })
 
 describe('logout of provider', () => {
-  test.each(providerNames)('logout of %s', (providerName) => {
-    return request(authServer)
+  async function runTest (providerName) {
+    const res = await request(authServer)
       .get(`/${providerName}/logout/`)
       .set('uppy-auth-token', token)
       .expect(200)
-      .then((res) => expect(res.body.ok).toBe(true))
+
+    // only some providers can actually be revoked
+    const expectRevoked = ['box', 'dropbox', 'drive', 'facebook', 'zoom'].includes(providerName)
+
+    expect(res.body).toMatchObject({
+      ok: true,
+      revoked: expectRevoked,
+    })
+  }
+
+  test('dropbox', async () => {
+    nock('https://api.dropboxapi.com').post('/2/auth/token/revoke').reply(200, {})
+    await runTest('dropbox')
+  })
+
+  test('box', async () => {
+    nock('https://api.box.com').post('/oauth2/revoke').reply(200, {})
+    await runTest('box')
+  })
+
+  test('dropbox', async () => {
+    nock('https://api.dropboxapi.com').post('/2/auth/token/revoke').reply(200, {})
+    await runTest('dropbox')
+  })
+
+  test('drive', async () => {
+    nock('https://accounts.google.com').post('/o/oauth2/revoke?token=token+value').reply(200, {})
+    await runTest('drive')
+  })
+
+  test('facebook', async () => {
+    nock('https://graph.facebook.com').delete('/me/permissions').reply(200, {})
+    await runTest('facebook')
+  })
+
+  test('instagram', async () => {
+    await runTest('instagram')
+  })
+
+  test('onedrive', async () => {
+    await runTest('onedrive')
+  })
+
+  test('zoom', async () => {
+    nockZoomRevoke({ key: localZoomKey, secret: localZoomSecret })
+    await runTest('zoom')
   })
 })

+ 0 - 34
packages/@uppy/companion/test/fixtures/box.js

@@ -1,37 +1,3 @@
-const defaults = require('./constants')
-
-module.exports.responses = {
-  'users/me': {
-    get: {
-      login: defaults.USERNAME,
-    },
-  },
-  'folders/0/items': {
-    get: {
-      entries: [
-        {
-          type: 'file',
-          name: defaults.ITEM_NAME,
-          id: defaults.ITEM_ID,
-          modified_at: '2015-05-12T15:50:38Z',
-          size: defaults.FILE_SIZE,
-        },
-      ],
-    },
-  },
-  [`files/${defaults.ITEM_ID}`]: {
-    get: {
-      size: defaults.FILE_SIZE,
-    },
-  },
-  'https://api.box.com/oauth2/revoke': {
-    post: {},
-  },
-  [`files/${defaults.ITEM_ID}/content`]: {
-    get: {},
-  },
-}
-
 module.exports.expects = {
   itemIcon: 'file',
 }

+ 17 - 46
packages/@uppy/companion/test/fixtures/drive.js

@@ -1,49 +1,20 @@
+const nock = require('nock')
 const defaults = require('./constants')
 
-module.exports.responses = {
-  files: {
-    get: {
-      kind: 'drive#fileList',
-      nextPageToken: defaults.NEXT_PAGE_TOKEN,
-      files: [
-        {
-          kind: 'drive#file',
-          id: defaults.ITEM_ID,
-          name: defaults.ITEM_NAME,
-          mimeType: defaults.MIME_TYPE,
-          iconLink: 'https://drive-thirdparty.googleusercontent.com/16/type/video/mp4',
-          thumbnailLink: defaults.THUMBNAIL_URL,
-          modifiedTime: '2016-07-10T20:00:08.096Z',
-          ownedByMe: true,
-          permissions: [{ role: 'owner', emailAddress: defaults.USERNAME }],
-          size: '758051',
-        },
-      ],
-    },
-  },
-  drives: {
-    get: { kind: 'drive#driveList', drives: [] },
-  },
-  [`files/${defaults.ITEM_ID}`]: {
-    get: {
-      kind: 'drive#file',
-      id: defaults.ITEM_ID,
-      name: 'MY DUMMY FILE NAME.mp4',
-      mimeType: 'video/mp4',
-      iconLink: 'https://drive-thirdparty.googleusercontent.com/16/type/video/mp4',
-      thumbnailLink: 'https://DUMMY-THUMBNAIL.com/file.jpg',
-      modifiedTime: '2016-07-10T20:00:08.096Z',
-      ownedByMe: true,
-      permissions: [{ role: 'owner', emailAddress: 'john.doe@transloadit.com' }],
-      size: '758051',
-    },
-  },
-  [`files/${defaults.ITEM_ID}?alt=media&supportsAllDrives=true`]: {
-    get: {},
-  },
-  'https://accounts.google.com/o/oauth2/revoke': {
-    get: {},
-  },
-}
-
 module.exports.expects = {}
+
+module.exports.nockGoogleDownloadFile = ({ times = 1 } = {}) => {
+  nock('https://www.googleapis.com').get(`/drive/v3/files/${defaults.ITEM_ID}?fields=kind%2Cid%2CimageMediaMetadata%2Cname%2CmimeType%2CownedByMe%2Cpermissions%28role%2CemailAddress%29%2Csize%2CmodifiedTime%2CiconLink%2CthumbnailLink%2CteamDriveId%2CvideoMediaMetadata%2CshortcutDetails%28targetId%2CtargetMimeType%29&supportsAllDrives=true`).times(times).reply(200, {
+    kind: 'drive#file',
+    id: defaults.ITEM_ID,
+    name: 'MY DUMMY FILE NAME.mp4',
+    mimeType: 'video/mp4',
+    iconLink: 'https://drive-thirdparty.googleusercontent.com/16/type/video/mp4',
+    thumbnailLink: 'https://DUMMY-THUMBNAIL.com/file.jpg',
+    modifiedTime: '2016-07-10T20:00:08.096Z',
+    ownedByMe: true,
+    permissions: [{ role: 'owner', emailAddress: 'john.doe@transloadit.com' }],
+    size: '758051',
+  })
+  nock('https://www.googleapis.com').get(`/drive/v3/files/${defaults.ITEM_ID}?alt=media&supportsAllDrives=true`).reply(200, {})
+}

+ 0 - 60
packages/@uppy/companion/test/fixtures/dropbox.js

@@ -1,63 +1,3 @@
-const defaults = require('./constants')
-
-module.exports.responses = {
-  'users/get_current_account': {
-    post: {
-      name: {
-        given_name: 'Franz',
-        surname: 'Ferdinand',
-        familiar_name: 'Franz',
-        display_name: 'Franz Ferdinand (Personal)',
-        abbreviated_name: 'FF',
-      },
-      email: defaults.USERNAME,
-      email_verified: true,
-      disabled: false,
-      locale: 'en',
-      referral_link: 'https://db.tt/ZITNuhtI',
-      is_paired: true,
-    },
-  },
-  'files/list_folder': {
-    post: {
-      entries: [
-        {
-          '.tag': 'file',
-          name: defaults.ITEM_NAME,
-          id: defaults.ITEM_ID,
-          client_modified: '2015-05-12T15:50:38Z',
-          server_modified: '2015-05-12T15:50:38Z',
-          rev: 'a1c10ce0dd78',
-          size: defaults.FILE_SIZE,
-          path_lower: '/homework/math/prime_numbers.txt',
-          path_display: '/Homework/math/Prime_Numbers.txt',
-          is_downloadable: true,
-          has_explicit_shared_members: false,
-          content_hash: 'e3b0c44298fc1c149afbf41e4649b934ca49',
-          file_lock_info: {
-            is_lockholder: true,
-            lockholder_name: 'Imaginary User',
-            created: '2015-05-12T15:50:38Z',
-          },
-        },
-      ],
-      cursor: 'ZtkX9_EHj3x7PMkVuFIhwKYXEpwpLwyxp9vMKomUhllil9q7eWiAu',
-      has_more: false,
-    },
-  },
-  'files/get_metadata': {
-    post: {
-      size: defaults.FILE_SIZE,
-    },
-  },
-  'auth/token/revoke': {
-    post: {},
-  },
-  'https://content.dropboxapi.com/2/files/download': {
-    post: {},
-  },
-}
-
 module.exports.expects = {
   itemIcon: 'file',
   itemRequestPath: '%2Fhomework%2Fmath%2Fprime_numbers.txt',

+ 0 - 45
packages/@uppy/companion/test/fixtures/facebook.js

@@ -1,50 +1,5 @@
 const defaults = require('./constants')
 
-module.exports.responses = {
-  me: {
-    get: {
-      name: 'Fiona Fox',
-      birthday: '01/01/1985',
-      email: defaults.USERNAME,
-    },
-  },
-  'https://graph.facebook.com/ALBUM-ID/photos': {
-    get: {
-      data: [
-        {
-          images: [
-            {
-              height: 1365,
-              source: defaults.THUMBNAIL_URL,
-              width: 2048,
-            },
-          ],
-          width: 720,
-          height: 479,
-          created_time: '2015-07-17T17:26:50+0000',
-          id: defaults.ITEM_ID,
-        },
-      ],
-      paging: {},
-    },
-  },
-  'me/permissions': {
-    delete: {},
-  },
-  [`https://graph.facebook.com/${defaults.ITEM_ID}?fields=images`]: {
-    get: {
-      images: [
-        {
-          height: 1365,
-          source: defaults.THUMBNAIL_URL,
-          width: 2048,
-        },
-      ],
-      id: defaults.ITEM_ID,
-    },
-  },
-}
-
 module.exports.expects = {
   listPath: 'ALBUM-ID',
   itemName: `${defaults.ITEM_ID} 2015-07-17T17:26:50+0000`,

+ 15 - 7
packages/@uppy/companion/test/fixtures/index.js

@@ -1,11 +1,19 @@
+const box = require('./box')
+const drive = require('./drive')
+const dropbox =  require('./dropbox')
+const instagram = require('./instagram')
+const onedrive = require('./onedrive')
+const facebook = require('./facebook')
+const zoom = require('./zoom')
+
 module.exports.providers = {
-  box: require('./box'),
-  drive: require('./drive'),
-  dropbox: require('./dropbox'),
-  instagram: require('./instagram'),
-  onedrive: require('./onedrive'),
-  facebook: require('./facebook'),
-  zoom: require('./zoom'),
+  box,
+  drive,
+  dropbox,
+  instagram,
+  onedrive,
+  facebook,
+  zoom,
 }
 
 module.exports.defaults = require('./constants')

+ 0 - 34
packages/@uppy/companion/test/fixtures/instagram.js

@@ -1,37 +1,3 @@
-const defaults = require('./constants')
-
-module.exports.responses = {
-  'https://graph.instagram.com/me': {
-    get: {
-      id: '17841405793187218',
-      username: defaults.USERNAME,
-    },
-  },
-  'https://graph.instagram.com/me/media': {
-    get: {
-      data: [
-        {
-          id: defaults.ITEM_ID,
-          media_type: 'IMAGE',
-          timestamp: '2017-08-31T18:10:00+0000',
-          media_url: defaults.THUMBNAIL_URL,
-        },
-      ],
-    },
-  },
-  [`https://graph.instagram.com/${defaults.ITEM_ID}`]: {
-    get: {
-      id: defaults.ITEM_ID,
-      media_type: 'IMAGE',
-      media_url: defaults.THUMBNAIL_URL,
-      timestamp: '2017-08-31T18:10:00+0000',
-    },
-  },
-  [defaults.THUMBNAIL_URL]: {
-    get: {},
-  },
-}
-
 module.exports.expects = {
   itemName: 'Instagram 2017-08-31T18:10:00+00000.jpeg',
   itemMimeType: 'image/jpeg',

+ 0 - 56
packages/@uppy/companion/test/fixtures/onedrive.js

@@ -1,61 +1,5 @@
 const defaults = require('./constants')
 
-module.exports.responses = {
-  me: {
-    get: {
-      userPrincipalName: defaults.USERNAME,
-      mail: defaults.USERNAME,
-    },
-  },
-  '/me/drive/root/children': {
-    get: {
-      value: [
-        {
-          createdDateTime: '2020-01-31T15:40:26.197Z',
-          id: defaults.ITEM_ID,
-          lastModifiedDateTime: '2020-01-31T15:40:38.723Z',
-          name: defaults.ITEM_NAME,
-          size: defaults.FILE_SIZE,
-          parentReference: {
-            driveId: 'DUMMY-DRIVE-ID',
-            driveType: 'personal',
-            path: '/drive/root:',
-          },
-          file: {
-            mimeType: defaults.MIME_TYPE,
-          },
-          thumbnails: [{
-            id: '0',
-            large: {
-              height: 452,
-              url: defaults.THUMBNAIL_URL,
-              width: 800,
-            },
-            medium: {
-              height: 100,
-              url: defaults.THUMBNAIL_URL,
-              width: 176,
-            },
-            small: {
-              height: 54,
-              url: defaults.THUMBNAIL_URL,
-              width: 96,
-            },
-          }],
-        },
-      ],
-    },
-  },
-  [`/drives/DUMMY-DRIVE-ID/items/${defaults.ITEM_ID}`]: {
-    get: {
-      size: defaults.FILE_SIZE,
-    },
-  },
-  [`/drives/DUMMY-DRIVE-ID/items/${defaults.ITEM_ID}/content`]: {
-    get: {},
-  },
-}
-
 module.exports.expects = {
   itemRequestPath: `${defaults.ITEM_ID}?driveId=DUMMY-DRIVE-ID`,
 }

+ 45 - 65
packages/@uppy/companion/test/fixtures/zoom.js

@@ -1,60 +1,6 @@
-module.exports.responses = {
-  'https://zoom.us/v2/users/me': {
-    get: {
-      id: 'DUMMY-USER-ID',
-      first_name: 'John',
-      last_name: 'Doe',
-      email: 'john.doe@transloadit.com',
-      timezone: '',
-      dept: '',
-      created_at: '2020-07-21T09:13:30Z',
-      last_login_time: '2020-10-12T07:55:02Z',
-      group_ids: [],
-      im_group_ids: [],
-      account_id: 'DUMMY-ACCOUNT-ID',
-      language: 'en-US',
-    },
-  },
-  'https://zoom.us/v2/meetings/DUMMY-UUID%3D%3D/recordings': {
-    get: {
-      uuid: 'DUMMY-UUID==',
-      id: 12345678900,
-      account_id: 'DUMMY-ACCOUNT-ID',
-      host_id: 'DUMMY-HOST-ID',
-      topic: 'DUMMY TOPIC',
-      type: 2,
-      start_time: '2020-05-29T13:19:40Z',
-      timezone: 'Europe/Amsterdam',
-      duration: 0,
-      total_size: 723389,
-      recording_count: 4,
-      recording_files:
-        [
-          {
-            id: 'DUMMY-FILE-ID',
-            meeting_id: 'DUMMY-UUID==',
-            recording_start: '2020-05-29T13:23:57Z',
-            recording_end: '2020-05-29T13:24:02Z',
-            file_type: 'MP4',
-            file_size: 758051,
-            play_url: 'https://us02web.zoom.us/rec/play/DUMMY-DOWNLOAD-PATH',
-            download_url: 'https://us02web.zoom.us/rec/download/DUMMY-DOWNLOAD-PATH',
-            status: 'completed',
-            recording_type: 'shared_screen_with_speaker_view',
-          },
-        ],
-    },
-  },
-  'https://us02web.zoom.us/rec/play/DUMMY-DOWNLOAD-PATH': {
-    get: {},
-  },
-  'https://api.zoom.us/oauth/data/compliance': {
-    post: {},
-  },
-  'https://zoom.us/oauth/revoke': {
-    post: {},
-  },
-}
+const nock = require('nock')
+
+const { getBasicAuthHeader } = require('../../src/server/helpers/utils')
 
 module.exports.expects = {
   listPath: 'DUMMY-UUID%3D%3D',
@@ -62,18 +8,52 @@ module.exports.expects = {
   itemId: 'DUMMY-UUID%3D%3D__DUMMY-FILE-ID',
   itemRequestPath: 'DUMMY-UUID%3D%3D?recordingId=DUMMY-FILE-ID',
   itemIcon: 'video',
+  localZoomKey: 'zoom_key',
+  localZoomSecret: 'zoom_secret',
+  localZoomVerificationToken: 'zoom_verfication_token',
   remoteZoomKey: 'REMOTE-ZOOM-KEY',
   remoteZoomSecret: 'REMOTE-ZOOM-SECRET',
   remoteZoomVerificationToken: 'REMOTE-ZOOM-VERIFICATION-TOKEN',
 }
 
-module.exports.validators = {
-  'https://zoom.us/oauth/revoke': ({ headers }) => {
-    if (process.env.COMPANION_ZOOM_KEYS_ENDPOINT) {
-      const auth = `${module.exports.expects.remoteZoomKey}:${module.exports.expects.remoteZoomSecret}`
-      return headers.Authorization === `Basic ${Buffer.from(auth, 'binary').toString('base64')}`
-    }
+module.exports.nockZoomRecordings = ({ times = 1 } = {}) => {
+  nock('https://zoom.us').get('/v2/meetings/DUMMY-UUID%3D%3D/recordings').times(times).reply(200, {
+    uuid: 'DUMMY-UUID==',
+    id: 12345678900,
+    account_id: 'DUMMY-ACCOUNT-ID',
+    host_id: 'DUMMY-HOST-ID',
+    topic: 'DUMMY TOPIC',
+    type: 2,
+    start_time: '2020-05-29T13:19:40Z',
+    timezone: 'Europe/Amsterdam',
+    duration: 0,
+    total_size: 723389,
+    recording_count: 4,
+    recording_files:
+      [
+        {
+          id: 'DUMMY-FILE-ID',
+          meeting_id: 'DUMMY-UUID==',
+          recording_start: '2020-05-29T13:23:57Z',
+          recording_end: '2020-05-29T13:24:02Z',
+          file_type: 'MP4',
+          file_size: 758051,
+          play_url: 'https://us02web.zoom.us/rec/play/DUMMY-DOWNLOAD-PATH',
+          download_url: 'https://us02web.zoom.us/rec/download/DUMMY-DOWNLOAD-PATH',
+          status: 'completed',
+          recording_type: 'shared_screen_with_speaker_view',
+        },
+      ],
+  })
+}
+
+module.exports.nockZoomRevoke = ({ key, secret }) => {
+  // eslint-disable-next-line func-names
+  nock('https://zoom.us').post('/oauth/revoke?token=token+value').reply(function () {
+    const { headers } = this.req
 
-    return true
-  },
+    const expected = getBasicAuthHeader(key, secret)
+    const success = headers.authorization === expected
+    return success ? [200, { status: 'success' }] : [400]
+  })
 }

+ 6 - 4
packages/@uppy/companion/test/mockserver.js

@@ -1,6 +1,8 @@
 const express = require('express')
 const session = require('express-session')
 
+const { expects: { localZoomKey, localZoomSecret, localZoomVerificationToken } } = require('./fixtures/zoom')
+
 const defaultEnv = {
   NODE_ENV: 'test',
   COMPANION_PORT: 3020,
@@ -10,7 +12,7 @@ const defaultEnv = {
   COMPANION_HIDE_WELCOME: 'false',
 
   COMPANION_STREAMING_UPLOAD: 'true',
-  COMPANION_ALLOW_LOCAL_URLS : 'false',
+  COMPANION_ALLOW_LOCAL_URLS: 'false',
 
   COMPANION_PROTOCOL: 'http',
   COMPANION_DATADIR: './test/output',
@@ -28,9 +30,9 @@ const defaultEnv = {
   COMPANION_INSTAGRAM_KEY: 'instagram_key',
   COMPANION_INSTAGRAM_SECRET: 'instagram_secret',
 
-  COMPANION_ZOOM_KEY: 'zoom_key',
-  COMPANION_ZOOM_SECRET: 'zoom_secret',
-  COMPANION_ZOOM_VERIFICATION_TOKEN: 'zoom_verfication_token',
+  COMPANION_ZOOM_KEY: localZoomKey,
+  COMPANION_ZOOM_SECRET: localZoomSecret,
+  COMPANION_ZOOM_VERIFICATION_TOKEN: localZoomVerificationToken,
 
   COMPANION_PATH: '',
 

+ 1 - 1
private/dev/Dashboard.js

@@ -87,7 +87,7 @@ export default () => {
     // .use(Unsplash, { target: Dashboard, companionUrl: COMPANION_URL, companionAllowedHosts })
     .use(RemoteSources, {
       companionUrl: COMPANION_URL,
-      sources: ['Box', 'Dropbox', 'Facebook', 'GoogleDrive', 'Instagram', 'OneDrive', 'Unsplash', 'Url'],
+      sources: ['Box', 'Dropbox', 'Facebook', 'GoogleDrive', 'Instagram', 'OneDrive', 'Unsplash', 'Zoom', 'Url'],
       companionAllowedHosts,
     })
     .use(Webcam, {

+ 186 - 57
yarn.lock

@@ -6310,22 +6310,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@purest/config@npm:^1.0.0":
-  version: 1.0.1
-  resolution: "@purest/config@npm:1.0.1"
-  dependencies:
-    extend: ^3.0.0
-  checksum: 6157935ffd11fa942c462473480ca3f47ba099d3ef53066c143a050d801efe247994c3beced14da9071ed0064761ba45870927e3c8d824daff106c52f9dedd77
-  languageName: node
-  linkType: hard
-
-"@purest/providers@npm:1.0.1":
-  version: 1.0.1
-  resolution: "@purest/providers@npm:1.0.1"
-  checksum: ea5585f0948d438feb0fe6998e981990934ced66bcf1ac640583ccc9ec38000fa813cb671160ebe5b48e3f447eff7807ad4f8b9e44d742f83ef360cde848dc48
-  languageName: node
-  linkType: hard
-
 "@react-native-community/cli-debugger-ui@npm:^4.13.1":
   version: 4.13.1
   resolution: "@react-native-community/cli-debugger-ui@npm:4.13.1"
@@ -6522,24 +6506,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@request/api@npm:^0.6.0":
-  version: 0.6.0
-  resolution: "@request/api@npm:0.6.0"
-  dependencies:
-    "@request/interface": ^0.1.0
-    deep-copy: ^1.1.2
-    extend: ^3.0.0
-  checksum: 0fbc4d368c71eca08997922c8a6c958b78b3879006d42eb3696a72b373967ed1ce9f8f037c4072faa9c2e641b98320f4864fb9db1d37a69cd4e4af96fe5c44de
-  languageName: node
-  linkType: hard
-
-"@request/interface@npm:^0.1.0":
-  version: 0.1.0
-  resolution: "@request/interface@npm:0.1.0"
-  checksum: a167275a54bafb3f228dba63cd3bbbb31871e2868d53838881248f21c42a41bd88fb6aca26855edee5ab3bb1eccb4e13799eb2365917e491aa0a54146c5c3d60
-  languageName: node
-  linkType: hard
-
 "@rollup/plugin-commonjs@npm:^16.0.0":
   version: 16.0.0
   resolution: "@rollup/plugin-commonjs@npm:16.0.0"
@@ -6711,6 +6677,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@sindresorhus/is@npm:^4.0.0":
+  version: 4.6.0
+  resolution: "@sindresorhus/is@npm:4.6.0"
+  checksum: 83839f13da2c29d55c97abc3bc2c55b250d33a0447554997a85c539e058e57b8da092da396e252b11ec24a0279a0bed1f537fa26302209327060643e327f81d2
+  languageName: node
+  linkType: hard
+
 "@sinonjs/commons@npm:^1.7.0":
   version: 1.8.3
   resolution: "@sinonjs/commons@npm:1.8.3"
@@ -8295,6 +8268,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@szmarczak/http-timer@npm:^4.0.5":
+  version: 4.0.6
+  resolution: "@szmarczak/http-timer@npm:4.0.6"
+  dependencies:
+    defer-to-connect: ^2.0.0
+  checksum: c29df3bcec6fc3bdec2b17981d89d9c9fc9bd7d0c9bcfe92821dc533f4440bc890ccde79971838b4ceed1921d456973c4180d7175ee1d0023ad0562240a58d95
+  languageName: node
+  linkType: hard
+
 "@testing-library/dom@npm:^8.3.0":
   version: 8.11.3
   resolution: "@testing-library/dom@npm:8.11.3"
@@ -8444,6 +8426,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/cacheable-request@npm:^6.0.1":
+  version: 6.0.2
+  resolution: "@types/cacheable-request@npm:6.0.2"
+  dependencies:
+    "@types/http-cache-semantics": "*"
+    "@types/keyv": "*"
+    "@types/node": "*"
+    "@types/responselike": "*"
+  checksum: 667d25808dbf46fe104d6f029e0281ff56058d50c7c1b9182774b3e38bb9c1124f56e4c367ba54f92dbde2d1cc573f26eb0e9748710b2822bc0fd1e5498859c6
+  languageName: node
+  linkType: hard
+
 "@types/caseless@npm:*":
   version: 0.12.2
   resolution: "@types/caseless@npm:0.12.2"
@@ -8693,6 +8687,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/http-cache-semantics@npm:*":
+  version: 4.0.1
+  resolution: "@types/http-cache-semantics@npm:4.0.1"
+  checksum: 1048aacf627829f0d5f00184e16548205cd9f964bf0841c29b36bc504509230c40bc57c39778703a1c965a6f5b416ae2cbf4c1d4589c889d2838dd9dbfccf6e9
+  languageName: node
+  linkType: hard
+
 "@types/http-proxy@npm:^1.17.5":
   version: 1.17.7
   resolution: "@types/http-proxy@npm:1.17.7"
@@ -8795,6 +8796,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/json-buffer@npm:~3.0.0":
+  version: 3.0.0
+  resolution: "@types/json-buffer@npm:3.0.0"
+  checksum: 6b0a371dd603f0eec9d00874574bae195382570e832560dadf2193ee0d1062b8e0694bbae9798bc758632361c227b1e3b19e3bd914043b498640470a2da38b77
+  languageName: node
+  linkType: hard
+
 "@types/json-schema@npm:*, @types/json-schema@npm:^7.0.4, @types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9":
   version: 7.0.9
   resolution: "@types/json-schema@npm:7.0.9"
@@ -8818,6 +8826,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/keyv@npm:*":
+  version: 3.1.4
+  resolution: "@types/keyv@npm:3.1.4"
+  dependencies:
+    "@types/node": "*"
+  checksum: e009a2bfb50e90ca9b7c6e8f648f8464067271fd99116f881073fa6fa76dc8d0133181dd65e6614d5fb1220d671d67b0124aef7d97dc02d7e342ab143a47779d
+  languageName: node
+  linkType: hard
+
 "@types/keyv@npm:^3.1.1":
   version: 3.1.3
   resolution: "@types/keyv@npm:3.1.3"
@@ -9072,7 +9089,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@types/responselike@npm:^1.0.0":
+"@types/responselike@npm:*, @types/responselike@npm:^1.0.0":
   version: 1.0.0
   resolution: "@types/responselike@npm:1.0.0"
   dependencies:
@@ -10145,7 +10162,6 @@ __metadata:
   version: 0.0.0-use.local
   resolution: "@uppy/companion@workspace:packages/@uppy/companion"
   dependencies:
-    "@purest/providers": 1.0.1
     "@types/compression": 1.7.0
     "@types/connect-redis": 0.0.18
     "@types/cookie-parser": 1.4.2
@@ -10176,6 +10192,8 @@ __metadata:
     express-prom-bundle: 6.3.0
     express-request-id: 1.4.1
     express-session: 1.17.1
+    form-data: ^3.0.0
+    got: 11
     grant: 4.7.0
     helmet: ^4.6.0
     into-stream: ^6.0.0
@@ -10191,9 +10209,7 @@ __metadata:
     nock: ^13.1.3
     node-schedule: 1.3.2
     prom-client: 12.0.0
-    purest: 3.1.0
     redis: 4.2.0
-    request: 2.88.2
     semver: 6.3.0
     serialize-error: ^2.1.0
     serialize-javascript: ^6.0.0
@@ -14402,6 +14418,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"cacheable-lookup@npm:^5.0.3":
+  version: 5.0.4
+  resolution: "cacheable-lookup@npm:5.0.4"
+  checksum: 763e02cf9196bc9afccacd8c418d942fc2677f22261969a4c2c2e760fa44a2351a81557bd908291c3921fe9beb10b976ba8fa50c5ca837c5a0dd945f16468f2d
+  languageName: node
+  linkType: hard
+
 "cacheable-request@npm:^6.0.0":
   version: 6.1.0
   resolution: "cacheable-request@npm:6.1.0"
@@ -14417,6 +14440,21 @@ __metadata:
   languageName: node
   linkType: hard
 
+"cacheable-request@npm:^7.0.2":
+  version: 7.0.2
+  resolution: "cacheable-request@npm:7.0.2"
+  dependencies:
+    clone-response: ^1.0.2
+    get-stream: ^5.1.0
+    http-cache-semantics: ^4.0.0
+    keyv: ^4.0.0
+    lowercase-keys: ^2.0.0
+    normalize-url: ^6.0.1
+    responselike: ^2.0.0
+  checksum: 6152813982945a5c9989cb457a6c499f12edcc7ade323d2fbfd759abc860bdbd1306e08096916bb413c3c47e812f8e4c0a0cc1e112c8ce94381a960f115bc77f
+  languageName: node
+  linkType: hard
+
 "cached-path-relative@npm:^1.0.0, cached-path-relative@npm:^1.0.2":
   version: 1.0.2
   resolution: "cached-path-relative@npm:1.0.2"
@@ -15503,6 +15541,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"compress-brotli@npm:^1.3.8":
+  version: 1.3.8
+  resolution: "compress-brotli@npm:1.3.8"
+  dependencies:
+    "@types/json-buffer": ~3.0.0
+    json-buffer: ~3.0.1
+  checksum: de7589d692d40eb362f6c91070b5e51bc10b05a89eabb4a7c76c1aa21b625756f8c101c6999e4df0c4dc6199c5ca2e1353573bfdcca5615810f27485394162a5
+  languageName: node
+  linkType: hard
+
 "compressible@npm:~2.0.16":
   version: 2.0.18
   resolution: "compressible@npm:2.0.18"
@@ -16798,6 +16846,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"decompress-response@npm:^6.0.0":
+  version: 6.0.0
+  resolution: "decompress-response@npm:6.0.0"
+  dependencies:
+    mimic-response: ^3.1.0
+  checksum: d377cf47e02d805e283866c3f50d3d21578b779731e8c5072d6ce8c13cc31493db1c2f6784da9d1d5250822120cefa44f1deab112d5981015f2e17444b763812
+  languageName: node
+  linkType: hard
+
 "dedent@npm:^0.7.0":
   version: 0.7.0
   resolution: "dedent@npm:0.7.0"
@@ -16805,13 +16862,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"deep-copy@npm:^1.1.2":
-  version: 1.4.2
-  resolution: "deep-copy@npm:1.4.2"
-  checksum: 24ba7db4a9d44800c68659dae0068b681c43be0512c75e700710c3d18776e4c41f7dbd83eed6370bcfc1594c60f84d8798f4058f965ab457ea0232db821f0a4e
-  languageName: node
-  linkType: hard
-
 "deep-diff@npm:^0.3.5":
   version: 0.3.8
   resolution: "deep-diff@npm:0.3.8"
@@ -16917,6 +16967,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"defer-to-connect@npm:^2.0.0":
+  version: 2.0.1
+  resolution: "defer-to-connect@npm:2.0.1"
+  checksum: 8a9b50d2f25446c0bfefb55a48e90afd58f85b21bcf78e9207cd7b804354f6409032a1705c2491686e202e64fc05f147aa5aa45f9aa82627563f045937f5791b
+  languageName: node
+  linkType: hard
+
 "define-lazy-prop@npm:^2.0.0":
   version: 2.0.0
   resolution: "define-lazy-prop@npm:2.0.0"
@@ -22025,6 +22082,25 @@ __metadata:
   languageName: node
   linkType: hard
 
+"got@npm:11":
+  version: 11.8.5
+  resolution: "got@npm:11.8.5"
+  dependencies:
+    "@sindresorhus/is": ^4.0.0
+    "@szmarczak/http-timer": ^4.0.5
+    "@types/cacheable-request": ^6.0.1
+    "@types/responselike": ^1.0.0
+    cacheable-lookup: ^5.0.3
+    cacheable-request: ^7.0.2
+    decompress-response: ^6.0.0
+    http2-wrapper: ^1.0.0-beta.5.2
+    lowercase-keys: ^2.0.0
+    p-cancelable: ^2.0.0
+    responselike: ^2.0.0
+  checksum: 2de8a1bbda4e9b6b2b72b2d2100bc055a59adc1740529e631f61feb44a8b9a1f9f8590941ed9da9df0090b6d6d0ed8ffee94cd9ac086ec3409b392b33440f7d2
+  languageName: node
+  linkType: hard
+
 "got@npm:^9.6.0":
   version: 9.6.0
   resolution: "got@npm:9.6.0"
@@ -23191,6 +23267,16 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"http2-wrapper@npm:^1.0.0-beta.5.2":
+  version: 1.0.3
+  resolution: "http2-wrapper@npm:1.0.3"
+  dependencies:
+    quick-lru: ^5.1.1
+    resolve-alpn: ^1.0.0
+  checksum: 74160b862ec699e3f859739101ff592d52ce1cb207b7950295bf7962e4aa1597ef709b4292c673bece9c9b300efad0559fc86c71b1409c7a1e02b7229456003e
+  languageName: node
+  linkType: hard
+
 "https-browserify@npm:^1.0.0":
   version: 1.0.0
   resolution: "https-browserify@npm:1.0.0"
@@ -25863,6 +25949,13 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"json-buffer@npm:3.0.1, json-buffer@npm:~3.0.1":
+  version: 3.0.1
+  resolution: "json-buffer@npm:3.0.1"
+  checksum: 9026b03edc2847eefa2e37646c579300a1f3a4586cfb62bf857832b60c852042d0d6ae55d1afb8926163fa54c2b01d83ae24705f34990348bdac6273a29d4581
+  languageName: node
+  linkType: hard
+
 "json-parse-better-errors@npm:^1.0.1, json-parse-better-errors@npm:^1.0.2":
   version: 1.0.2
   resolution: "json-parse-better-errors@npm:1.0.2"
@@ -26284,6 +26377,16 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"keyv@npm:^4.0.0":
+  version: 4.3.3
+  resolution: "keyv@npm:4.3.3"
+  dependencies:
+    compress-brotli: ^1.3.8
+    json-buffer: 3.0.1
+  checksum: bcc946eeec3407fb3b42d831ce985357162113c5f07a8c45c12ede39704ba2d99be4c3dded76d2d2d2a2366627e42440bdde24393216164156928399949c12a1
+  languageName: node
+  linkType: hard
+
 "kind-of@npm:^1.1.0":
   version: 1.1.0
   resolution: "kind-of@npm:1.1.0"
@@ -28795,6 +28898,13 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"mimic-response@npm:^3.1.0":
+  version: 3.1.0
+  resolution: "mimic-response@npm:3.1.0"
+  checksum: 25739fee32c17f433626bf19f016df9036b75b3d84a3046c7d156e72ec963dd29d7fc8a302f55a3d6c5a4ff24259676b15d915aad6480815a969ff2ec0836867
+  languageName: node
+  linkType: hard
+
 "min-document@npm:^2.19.0":
   version: 2.19.0
   resolution: "min-document@npm:2.19.0"
@@ -30722,6 +30832,13 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"p-cancelable@npm:^2.0.0":
+  version: 2.1.1
+  resolution: "p-cancelable@npm:2.1.1"
+  checksum: 3dba12b4fb4a1e3e34524535c7858fc82381bbbd0f247cc32dedc4018592a3950ce66b106d0880b4ec4c2d8d6576f98ca885dc1d7d0f274d1370be20e9523ddf
+  languageName: node
+  linkType: hard
+
 "p-defer@npm:^1.0.0":
   version: 1.0.0
   resolution: "p-defer@npm:1.0.0"
@@ -33387,17 +33504,6 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
-"purest@npm:3.1.0":
-  version: 3.1.0
-  resolution: "purest@npm:3.1.0"
-  dependencies:
-    "@purest/config": ^1.0.0
-    "@request/api": ^0.6.0
-    extend: ^3.0.0
-  checksum: 76d723fe820aa236b46ee2a9d928bfaa37fe2a9b4da0495049a6972535a7ac04eb90ee4e01c3051c0d4f49b57c895a3e0bf4f9560caa2aefb5ec10a3314f4db9
-  languageName: node
-  linkType: hard
-
 "q@npm:1.4.1":
   version: 1.4.1
   resolution: "q@npm:1.4.1"
@@ -33524,6 +33630,13 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"quick-lru@npm:^5.1.1":
+  version: 5.1.1
+  resolution: "quick-lru@npm:5.1.1"
+  checksum: a516faa25574be7947969883e6068dbe4aa19e8ef8e8e0fd96cddd6d36485e9106d85c0041a27153286b0770b381328f4072aa40d3b18a19f5f7d2b78b94b5ed
+  languageName: node
+  linkType: hard
+
 "quotation@npm:^2.0.0":
   version: 2.0.2
   resolution: "quotation@npm:2.0.2"
@@ -34910,7 +35023,7 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
-"request@npm:2.88.2, request@npm:^2.74.0, request@npm:^2.83.0, request@npm:^2.87.0, request@npm:^2.88.0, request@npm:^2.88.2":
+"request@npm:^2.74.0, request@npm:^2.83.0, request@npm:^2.87.0, request@npm:^2.88.0, request@npm:^2.88.2":
   version: 2.88.2
   resolution: "request@npm:2.88.2"
   dependencies:
@@ -34987,6 +35100,13 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"resolve-alpn@npm:^1.0.0":
+  version: 1.2.1
+  resolution: "resolve-alpn@npm:1.2.1"
+  checksum: f558071fcb2c60b04054c99aebd572a2af97ef64128d59bef7ab73bd50d896a222a056de40ffc545b633d99b304c259ea9d0c06830d5c867c34f0bfa60b8eae0
+  languageName: node
+  linkType: hard
+
 "resolve-cwd@npm:^3.0.0":
   version: 3.0.0
   resolution: "resolve-cwd@npm:3.0.0"
@@ -35177,6 +35297,15 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"responselike@npm:^2.0.0":
+  version: 2.0.1
+  resolution: "responselike@npm:2.0.1"
+  dependencies:
+    lowercase-keys: ^2.0.0
+  checksum: b122535466e9c97b55e69c7f18e2be0ce3823c5d47ee8de0d9c0b114aa55741c6db8bfbfce3766a94d1272e61bfb1ebf0a15e9310ac5629fbb7446a861b4fd3a
+  languageName: node
+  linkType: hard
+
 "restore-cursor@npm:^2.0.0":
   version: 2.0.0
   resolution: "restore-cursor@npm:2.0.0"