diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index cf21e0dd7..f25844283 100644 --- a/.github/CONTRIBUTING.md +++ b/.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 @@ -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: diff --git a/packages/@uppy/companion/ARCHITECTURE.md b/packages/@uppy/companion/ARCHITECTURE.md index 234d06cf6..71b46386c 100644 --- a/packages/@uppy/companion/ARCHITECTURE.md +++ b/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: - ## 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. diff --git a/packages/@uppy/companion/package.json b/packages/@uppy/companion/package.json index 678656eaf..79ac7a639 100644 --- a/packages/@uppy/companion/package.json +++ b/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", diff --git a/packages/@uppy/companion/src/server/Uploader.js b/packages/@uppy/companion/src/server/Uploader.js index 317a2ab1f..0208efd88 100644 --- a/packages/@uppy/companion/src/server/Uploader.js +++ b/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 - } + try { + const httpMethod = (this.options.httpMethod || '').toLowerCase() === 'put' ? 'put' : 'post' + const runRequest = got[httpMethod] - resolve({ response: response2, body: body2 }) - }) - }) + const response = await runRequest(url, reqOptions) - // remove browser forbidden headers - delete response.headers['set-cookie'] - delete response.headers['set-cookie2'] + 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) + } - const respObj = { - responseText: body.toString(), - status: response.statusCode, - statusText: response.statusMessage, - headers: response.headers, + 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') } - - 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) - } - - return { url: null, extraData: { response: respObj, bytesUploaded } } } /** diff --git a/packages/@uppy/companion/src/server/controllers/thumbnail.js b/packages/@uppy/companion/src/server/controllers/thumbnail.js index de2f40682..be4b4d507 100644 --- a/packages/@uppy/companion/src/server/controllers/thumbnail.js +++ b/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 diff --git a/packages/@uppy/companion/src/server/controllers/url.js b/packages/@uppy/companion/src/server/controllers/url.js index 3a16b7da4..09485cff0 100644 --- a/packages/@uppy/companion/src/server/controllers/url.js +++ b/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) - }) - }) } /** diff --git a/packages/@uppy/companion/src/server/helpers/request.js b/packages/@uppy/companion/src/server/helpers/request.js index 4cc03f461..6a91120d2 100644 --- a/packages/@uppy/companion/src/server/helpers/request.js +++ b/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), - } + async function requestWithMethod (method) { + const protectedGot = getProtectedGot({ url, blockLocalIPs }) + const stream = protectedGot.stream(url, { method, throwHttpErrors: false }) - 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) + 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) - req.abort() + // 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, - }) - }) - }) + 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') } diff --git a/packages/@uppy/companion/src/server/helpers/utils.js b/packages/@uppy/companion/src/server/helpers/utils.js index 572a9625f..13f9894ce 100644 --- a/packages/@uppy/companion/src/server/helpers/utils.js +++ b/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) - } - - return { stream: resp } -} - module.exports.defaultGetKey = (req, filename) => `${crypto.randomUUID()}-${filename}` + +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.getBasicAuthHeader = (key, secret) => { + const base64 = Buffer.from(`${key}:${secret}`, 'binary').toString('base64') + return `Basic ${base64}` +} diff --git a/packages/@uppy/companion/src/server/jobs.js b/packages/@uppy/companion/src/server/jobs.js index 06ebd0c5f..c64b152dc 100644 --- a/packages/@uppy/companion/src/server/jobs.js +++ b/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') } diff --git a/packages/@uppy/companion/src/server/provider/Provider.js b/packages/@uppy/companion/src/server/provider/Provider.js index 373b1d403..62d35fa44 100644 --- a/packages/@uppy/companion/src/server/provider/Provider.js +++ b/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') } diff --git a/packages/@uppy/companion/src/server/provider/box/adapter.js b/packages/@uppy/companion/src/server/provider/box/adapter.js index 34c85a87a..d25092e9c 100644 --- a/packages/@uppy/companion/src/server/provider/box/adapter.js +++ b/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 +} diff --git a/packages/@uppy/companion/src/server/provider/box/index.js b/packages/@uppy/companion/src/server/provider/box/index.js index 6db1280e9..23e8d7edf 100644 --- a/packages/@uppy/companion/src/server/provider/box/index.js +++ b/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` + 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 }), + ]) - 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)) - }) - }) + 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 diff --git a/packages/@uppy/companion/src/server/provider/credentials.js b/packages/@uppy/companion/src/server/provider/credentials.js index 08a1eebd1..20c24902f 100644 --- a/packages/@uppy/companion/src/server/provider/credentials.js +++ b/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) - } +async function fetchKeys (url, providerName, credentialRequestParams) { + try { + const { credentials } = await got.post(url, { + json: { provider: providerName, parameters: credentialRequestParams }, + }).json() - 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) - }) - }) + if (!credentials) throw new Error('Received no remote credentials') + return credentials + } catch (err) { + logger.error(err, 'credentials.fetch.fail') + throw err + } } /** diff --git a/packages/@uppy/companion/src/server/provider/drive/adapter.js b/packages/@uppy/companion/src/server/provider/drive/adapter.js index 01b5fc725..9a9c92d47 100644 --- a/packages/@uppy/companion/src/server/provider/drive/adapter.js +++ b/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,52 +84,6 @@ exports.getItemName = (item) => { return item.name ? item.name : '/' } -function getMimeType (mimeType) { - if (exports.isGsuiteFile(mimeType)) { - return exports.getGsuiteExportType(mimeType) - } - return mimeType -} - -exports.getMimeType = (item) => { - if (exports.isShortcut(item.mimeType)) { - return getMimeType(item.shortcutDetails.targetMimeType) - } - return getMimeType(item.mimeType) -} - -exports.getItemId = (item) => { - return item.id -} - -exports.getItemRequestPath = (item) => { - return item.id -} - -exports.getItemModifiedDate = (item) => { - return item.modifiedTime -} - -exports.getItemThumbnailUrl = (item) => { - return item.thumbnailLink -} - -exports.isSharedDrive = (item) => { - return item.kind === 'drive#drive' -} - -exports.getNextPagePath = (data, currentQuery, currentPath) => { - if (!data.nextPageToken) { - return null - } - const query = { ...currentQuery, cursor: data.nextPageToken } - return `${currentPath}?${querystring.stringify(query)}` -} - -exports.isGsuiteFile = (mimeType) => { - return mimeType && mimeType.startsWith('application/vnd.google') -} - exports.getGsuiteExportType = (mimeType) => { const typeMaps = { 'application/vnd.google-apps.document': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', @@ -134,16 +96,106 @@ exports.getGsuiteExportType = (mimeType) => { return typeMaps[mimeType] || 'application/pdf' } -exports.getImageHeight = (item) => item.imageMediaMetadata && item.imageMediaMetadata.height +function getMimeType2 (mimeType) { + if (exports.isGsuiteFile(mimeType)) { + return exports.getGsuiteExportType(mimeType) + } + return mimeType +} -exports.getImageWidth = (item) => item.imageMediaMetadata && item.imageMediaMetadata.width +const getMimeType = (item) => { + if (exports.isShortcut(item.mimeType)) { + return getMimeType2(item.shortcutDetails.targetMimeType) + } + return getMimeType2(item.mimeType) +} -exports.getImageRotation = (item) => item.imageMediaMetadata && item.imageMediaMetadata.rotation +const getItemId = (item) => { + return item.id +} -exports.getImageDate = (item) => item.imageMediaMetadata && item.imageMediaMetadata.date +const getItemRequestPath = (item) => { + return item.id +} -exports.getVideoHeight = (item) => item.videoMediaMetadata && item.videoMediaMetadata.height +const getItemModifiedDate = (item) => { + return item.modifiedTime +} -exports.getVideoWidth = (item) => item.videoMediaMetadata && item.videoMediaMetadata.width +const getItemThumbnailUrl = (item) => { + return item.thumbnailLink +} -exports.getVideoDurationMillis = (item) => item.videoMediaMetadata && item.videoMediaMetadata.durationMillis +const getNextPagePath = (data, currentQuery, currentPath) => { + if (!data.nextPageToken) { + return null + } + const query = { ...currentQuery, cursor: data.nextPageToken } + return `${currentPath}?${querystring.stringify(query)}` +} + +const getImageHeight = (item) => item.imageMediaMetadata && item.imageMediaMetadata.height + +const getImageWidth = (item) => item.imageMediaMetadata && item.imageMediaMetadata.width + +const getImageRotation = (item) => item.imageMediaMetadata && item.imageMediaMetadata.rotation + +const getImageDate = (item) => item.imageMediaMetadata && item.imageMediaMetadata.date + +const getVideoHeight = (item) => item.videoMediaMetadata && item.videoMediaMetadata.height + +const getVideoWidth = (item) => item.videoMediaMetadata && item.videoMediaMetadata.width + +const getVideoDurationMillis = (item) => item.videoMediaMetadata && item.videoMediaMetadata.durationMillis + +// Hopefully this name will not be used by Google +exports.VIRTUAL_SHARED_DIR = 'shared-with-me' + +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), + }, + }) + + 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, + }) + + const adaptedItems = [ + ...(virtualItem ? [virtualItem] : []), // shared folder first + ...([...sharedDrives, ...items].map(adaptItem)), + ] + + return { + username: getUsername(listFilesResp), + items: adaptedItems, + nextPagePath: getNextPagePath(listFilesResp, query, directory), + } +} diff --git a/packages/@uppy/companion/src/server/provider/drive/index.js b/packages/@uppy/companion/src/server/provider/drive/index.js index 293bbf1ff..e3e930f17 100644 --- a/packages/@uppy/companion/src/server/provider/drive/index.js +++ b/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' +const getClient = ({ token }) => got.extend({ + prefixUrl: 'https://www.googleapis.com/drive/v3', + headers: { + authorization: `Bearer ${token}`, + }, +}) -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()) -} +async function getStats ({ id, token }) { + const client = getClient({ 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 getStatsInner = async (statsOfId) => ( + client.get(`files/${encodeURIComponent(statsOfId)}`, { searchParams: { fields: DRIVE_FILE_FIELDS, supportsAllDrives: true }, responseType: 'json' }).json() + ) - const items = adapter.getItemSubList(listFilesResp) - const sharedDrives = sharedDrivesResp ? sharedDrivesResp.drives || [] : [] + const stats = await getStatsInner(id) - // “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), - } + // 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) - })) + const response = await client.get('drives', { searchParams: { fields: SHARED_DRIVE_FIELDS, pageToken, pageSize: 100 }, responseType: 'json' }).json() - if (!resp) return resp - - 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, + return response } - 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) - })) + 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` - return resp && resp.body - } catch (err) { - logger.error(err, 'provider.drive.list.error') - throw err + 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, + } + + return client.get('files', { searchParams, responseType: 'json' }).json() } - } - const [sharedDrives, filesResponse] = await Promise.all([fetchSharedDrives(), fetchFiles()]) - // console.log({ directory, sharedDrives, filesResponse }) + 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) - }) + 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 + ) }) - - let stats = await getStats(id) - - // 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 - } - - _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() } async download ({ id: idIn, token }) { - try { - const { mimeType, id } = await this._stats({ id: idIn, token }) + return this.#withErrorHandling('provider.drive.download.error', async () => { + const client = getClient({ 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() + const { mimeType, id } = await getStats({ id: idIn, token }) - 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 - } + 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 diff --git a/packages/@uppy/companion/src/server/provider/dropbox/adapter.js b/packages/@uppy/companion/src/server/provider/dropbox/adapter.js index b42ac2a17..4f37e4251 100644 --- a/packages/@uppy/companion/src/server/provider/dropbox/adapter.js +++ b/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), + } +} diff --git a/packages/@uppy/companion/src/server/provider/dropbox/index.js b/packages/@uppy/companion/src/server/provider/dropbox/index.js index 6bced57ff..1b6c3ca71 100644 --- a/packages/@uppy/companion/src/server/provider/dropbox/index.js +++ b/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 })) +const getClient = ({ token }) => got.extend({ + prefixUrl: 'https://api.dropboxapi.com/2', + headers: { + authorization: `Bearer ${token}`, + }, +}) - return { - username: email, - items, - nextPagePath: adapter.getNextPagePath(res), +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, - }) - - 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 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 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 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', + }) - return await requestStream(req, (resp) => this._error(null, resp)) - } catch (err) { - logger.error(err, 'provider.dropbox.thumbnail.error') - throw err - } + 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 diff --git a/packages/@uppy/companion/src/server/provider/facebook/adapter.js b/packages/@uppy/companion/src/server/provider/facebook/adapter.js index 5fa6bf00b..de4501a01 100644 --- a/packages/@uppy/companion/src/server/provider/facebook/adapter.js +++ b/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 } diff --git a/packages/@uppy/companion/src/server/provider/facebook/index.js b/packages/@uppy/companion/src/server/provider/facebook/index.js index e2396f5d9..08344b3f7 100644 --- a/packages/@uppy/companion/src/server/provider/facebook/index.js +++ b/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', - } + async list ({ directory, token, query = { cursor: null } }) { + return this.#withErrorHandling('provider.facebook.list.error', async () => { + const qs = { fields: 'name,cover_photo,created_time,type' } - if (query.cursor) { - qs.after = query.cursor - } + 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' - } + 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)) - } - }) - }) - } + const client = getClient({ token }) - _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) - }) - } - - _getMediaUrl (body) { - const sortedImages = adapter.sortImages(body.images) - return sortedImages[sortedImages.length - 1].source + 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 }) - }) - } - - 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 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 }) - - 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() - } + async logout ({ token }) { + return this.#withErrorHandling('provider.facebook.logout.error', async () => { + await getClient({ token }).delete('me/permissions', { responseType: 'json' }).json() + return { revoked: true } + }) + } - 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 diff --git a/packages/@uppy/companion/src/server/provider/index.js b/packages/@uppy/companion/src/server/provider/index.js index 943952da6..acb62df29 100644 --- a/packages/@uppy/companion/src/server/provider/index.js +++ b/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) diff --git a/packages/@uppy/companion/src/server/provider/instagram/graph/adapter.js b/packages/@uppy/companion/src/server/provider/instagram/graph/adapter.js index ec74f516b..b56379bfd 100644 --- a/packages/@uppy/companion/src/server/provider/instagram/graph/adapter.js +++ b/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 +} diff --git a/packages/@uppy/companion/src/server/provider/instagram/graph/index.js b/packages/@uppy/companion/src/server/provider/instagram/graph/index.js index ef31053fb..bcc79caf0 100644 --- a/packages/@uppy/companion/src/server/provider/instagram/graph/index.js +++ b/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 diff --git a/packages/@uppy/companion/src/server/provider/onedrive/adapter.js b/packages/@uppy/companion/src/server/provider/onedrive/adapter.js index b21a6e50c..1cb5be24f 100644 --- a/packages/@uppy/companion/src/server/provider/onedrive/adapter.js +++ b/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 } -exports.getItemSubList = (item) => { +const getItemIcon = (item) => { + return isFolder(item) ? 'folder' : getItemThumbnailUrl(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 +} diff --git a/packages/@uppy/companion/src/server/provider/onedrive/index.js b/packages/@uppy/companion/src/server/provider/onedrive/index.js index fe3a5b36e..95e1dfc56 100644 --- a/packages/@uppy/companion/src/server/provider/onedrive/index.js +++ b/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 - } + 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 + } - 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)) - }) - }) + 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 diff --git a/packages/@uppy/companion/src/server/provider/providerErrors.js b/packages/@uppy/companion/src/server/provider/providerErrors.js new file mode 100644 index 000000000..6234689a5 --- /dev/null +++ b/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 } diff --git a/packages/@uppy/companion/src/server/provider/unsplash/adapter.js b/packages/@uppy/companion/src/server/provider/unsplash/adapter.js index 4883f4d8a..08fd9aa44 100644 --- a/packages/@uppy/companion/src/server/provider/unsplash/adapter.js +++ b/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, + } +} diff --git a/packages/@uppy/companion/src/server/provider/unsplash/index.js b/packages/@uppy/companion/src/server/provider/unsplash/index.js index 08d81f0cf..d240e6376 100644 --- a/packages/@uppy/companion/src/server/provider/unsplash/index.js +++ b/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}`, - }, - } + 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 - if (query.cursor) { - reqOpts.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 diff --git a/packages/@uppy/companion/src/server/provider/zoom/adapter.js b/packages/@uppy/companion/src/server/provider/zoom/adapter.js index 006b2f367..4ad188bb4 100644 --- a/packages/@uppy/companion/src/server/provider/zoom/adapter.js +++ b/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 +} diff --git a/packages/@uppy/companion/src/server/provider/zoom/index.js b/packages/@uppy/companion/src/server/provider/zoom/index.js index cfc3d224a..9be59a399 100644 --- a/packages/@uppy/companion/src/server/provider/zoom/index.js +++ b/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 || '' + /* + - 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 || '' - 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) - } + const client = getClient({ token }) + const user = await client.get('users/me', { responseType: 'json' }).json() - if (!from && !to && !meetingId) { - const end = cursor && moment.utc(cursor).endOf('day').tz(userBody.timezone || 'UTC') - return done(null, this._initializeData(userResponse.body, end)) - } + const { timezone } = user - 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)) - }) - } - }) - } - - _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) - } - - _recordingInfo ({ token }, meetingId, done) { - const GET_MEETING_FILES = `/meetings/${encodeURIComponent(meetingId)}/recordings` - this.client - .get(`${BASE_URL}${GET_MEETING_FILES}`) - .auth(token) - .request(done) - } - - async download ({ id, token, query }) { - try { - // 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 req = this.client - .get(`${downloadUrl}?access_token=${token}`) - .request() - - return await requestStream(req, async (res) => this._error(null, res)) - } catch (err) { - logger.error(err, 'provider.zoom.download.error') - throw err - } - } - - _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)) - - if (!file) { - return this._downloadError(resp, done) - } - done(null, file.file_size) // 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 - } - - _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), - }, - }) - }) - 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)) - } - - _deauthorizationCallback ({ companion, body, headers }, done) { - if (!body || body.event !== DEAUTH_EVENT_NAME) { - done(null, { data: {}, status: 400 }) - return - } - - companion.getProviderCredentials().then(({ verificationToken, key, secret }) => { - const tokenSupplied = headers.authorization - if (!tokenSupplied || verificationToken !== tokenSupplied) { - return done(null, { data: {}, status: 400 }) + if (!from && !to && !meetingId) { + const end = cursor && moment.utc(cursor).endOf('day').tz(timezone || 'UTC') + return initializeData(user, end) } - 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({ + 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 + + const meetingsInfo = await client.get('users/me/recordings', { searchParams, responseType: 'json' }).json() + + return adaptData(user, meetingsInfo, query) + } + + 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: 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 { recordingStart, recordingId: fileId } = query + + const client = getClient({ token }) + + const foundFile = await findFile({ client, meetingId, fileId, recordingStart }) + const url = foundFile?.download_url + if (!url) throw new Error('Download URL not found') + + const stream = client.stream.get(`${url}?access_token=${token}`, { prefixUrl: '', responseType: 'json' }) + await prepareStream(stream) + return { stream } + }) + } + + async size ({ id: meetingId, token, query }) { + return this.#withErrorHandling('provider.zoom.size.error', async () => { + const client = getClient({ token }) + const { recordingStart, recordingId: fileId } = query + + const foundFile = await findFile({ client, meetingId, fileId, recordingStart }) + if (!foundFile) throw new Error('File not found') + return foundFile.file_size // Note: May be undefined. + }) + } + + async logout ({ companion, token }) { + return this.#withErrorHandling('provider.zoom.logout.error', async () => { + const { key, secret } = await companion.getProviderCredentials() + + const { status } = await got.post('https://zoom.us/oauth/revoke', { + searchParams: { token }, + headers: { Authorization: getBasicAuthHeader(key, secret) }, + responseType: 'json', + }).json() + + return { revoked: status === 'success' } + }) + } + + async deauthorizationCallback ({ companion, body, headers }) { + return this.#withErrorHandling('provider.zoom.deauth.error', async () => { + if (!body || body.event !== DEAUTH_EVENT_NAME) { + return { data: {}, status: 400 } + } + + const { verificationToken, key, secret } = await companion.getProviderCredentials() + + const tokenSupplied = headers.authorization + if (!tokenSupplied || verificationToken !== tokenSupplied) { + return { data: {}, status: 400 } + } + + 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 diff --git a/packages/@uppy/companion/test/__mocks__/purest.js b/packages/@uppy/companion/test/__mocks__/purest.js deleted file mode 100644 index 30b4d7ec7..000000000 --- a/packages/@uppy/companion/test/__mocks__/purest.js +++ /dev/null @@ -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) -} diff --git a/packages/@uppy/companion/test/__tests__/companion.js b/packages/@uppy/companion/test/__tests__/companion.js index c71c624d3..6ccce932f 100644 --- a/packages/@uppy/companion/test/__tests__/companion.js +++ b/packages/@uppy/companion/test/__tests__/companion.js @@ -1,17 +1,31 @@ -/* global jest:false, test:false, expect:false, describe:false */ - -const mockOauthState = require('../mockoauthstate')() -const { version } = require('../../package.json') - -jest.mock('tus-js-client') -jest.mock('purest') -jest.mock('../../src/server/helpers/oauth-state', () => ({ - ...jest.requireActual('../../src/server/helpers/oauth-state'), - ...mockOauthState, -})) - const nock = require('nock') const request = require('supertest') + +const mockOauthState = require('../mockoauthstate') +const { version } = require('../../package.json') +const { nockGoogleDownloadFile } = require('../fixtures/drive') + +jest.mock('tus-js-client') +jest.mock('../../src/server/helpers/oauth-state', () => ({ + ...jest.requireActual('../../src/server/helpers/oauth-state'), + ...mockOauthState(), +})) + +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) diff --git a/packages/@uppy/companion/test/__tests__/credentials.js b/packages/@uppy/companion/test/__tests__/credentials.js index 75712c419..0830e4dc3 100644 --- a/packages/@uppy/companion/test/__tests__/credentials.js +++ b/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) }) }) diff --git a/packages/@uppy/companion/test/__tests__/deauthorization.js b/packages/@uppy/companion/test/__tests__/deauthorization.js index 7b6249a3b..2588be0b7 100644 --- a/packages/@uppy/companion/test/__tests__/deauthorization.js +++ b/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') diff --git a/packages/@uppy/companion/test/__tests__/http-agent.js b/packages/@uppy/companion/test/__tests__/http-agent.js index bddf42b49..d1018a867 100644 --- a/packages/@uppy/companion/test/__tests__/http-agent.js +++ b/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)) }) }) diff --git a/packages/@uppy/companion/test/__tests__/providers.js b/packages/@uppy/companion/test/__tests__/providers.js index c48ad99a8..ab43b1853 100644 --- a/packages/@uppy/companion/test/__tests__/providers.js +++ b/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') }) }) diff --git a/packages/@uppy/companion/test/fixtures/box.js b/packages/@uppy/companion/test/fixtures/box.js index 70d764052..23df9e235 100644 --- a/packages/@uppy/companion/test/fixtures/box.js +++ b/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', } diff --git a/packages/@uppy/companion/test/fixtures/drive.js b/packages/@uppy/companion/test/fixtures/drive.js index 4d00b6aa7..ce23ee696 100644 --- a/packages/@uppy/companion/test/fixtures/drive.js +++ b/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, {}) +} diff --git a/packages/@uppy/companion/test/fixtures/dropbox.js b/packages/@uppy/companion/test/fixtures/dropbox.js index d4ceea432..3092e79fa 100644 --- a/packages/@uppy/companion/test/fixtures/dropbox.js +++ b/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', diff --git a/packages/@uppy/companion/test/fixtures/facebook.js b/packages/@uppy/companion/test/fixtures/facebook.js index ff2ebf159..367888a38 100644 --- a/packages/@uppy/companion/test/fixtures/facebook.js +++ b/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`, diff --git a/packages/@uppy/companion/test/fixtures/index.js b/packages/@uppy/companion/test/fixtures/index.js index dba326fbf..48bcd2ee3 100644 --- a/packages/@uppy/companion/test/fixtures/index.js +++ b/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') diff --git a/packages/@uppy/companion/test/fixtures/instagram.js b/packages/@uppy/companion/test/fixtures/instagram.js index e0ccf1fb2..518b3b773 100644 --- a/packages/@uppy/companion/test/fixtures/instagram.js +++ b/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', diff --git a/packages/@uppy/companion/test/fixtures/onedrive.js b/packages/@uppy/companion/test/fixtures/onedrive.js index b1eee075b..f5e10224c 100644 --- a/packages/@uppy/companion/test/fixtures/onedrive.js +++ b/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`, } diff --git a/packages/@uppy/companion/test/fixtures/zoom.js b/packages/@uppy/companion/test/fixtures/zoom.js index 66767cd88..df80eecf6 100644 --- a/packages/@uppy/companion/test/fixtures/zoom.js +++ b/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')}` - } - - return true - }, +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 + + const expected = getBasicAuthHeader(key, secret) + const success = headers.authorization === expected + return success ? [200, { status: 'success' }] : [400] + }) } diff --git a/packages/@uppy/companion/test/mockserver.js b/packages/@uppy/companion/test/mockserver.js index 87758162b..b464330b7 100644 --- a/packages/@uppy/companion/test/mockserver.js +++ b/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: '', diff --git a/private/dev/Dashboard.js b/private/dev/Dashboard.js index ba4d4041d..c4c13ab29 100644 --- a/private/dev/Dashboard.js +++ b/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, { diff --git a/yarn.lock b/yarn.lock index c9d56e588..b3c0ba2cf 100644 --- a/yarn.lock +++ b/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"