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

* rewrite to async

* rewrite box and dropbox to got

(not yet working due to jest esm issues)

* downgrade got

* update developer notes

* rewrite

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

* add zoom to dev dashboard

* rewrites

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

* work around ts error

* remove forgotten change
This commit is contained in:
Mikael Finstad 2022-08-16 13:44:04 +02:00 committed by GitHub
parent c042f0220b
commit 35812ca378
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 1925 additions and 2350 deletions

View file

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

View file

@ -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 providers 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 providers 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 thats 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 were using `3.x`, so the API is different, but here is the example for reference: <https://github.com/simov/purest/tree/2.x/examples/storage>
## WebSockets
Companion uses WebSockets to transfer `progress` events to the client during file transfers. Its only set up to transfer progress during Tus uploads to the target server.

View file

@ -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",

View file

@ -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 } }
}
/**

View file

@ -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

View file

@ -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)
})
})
}
/**

View file

@ -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')
}

View file

@ -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}`
}

View file

@ -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')
}

View file

@ -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')
}

View file

@ -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
}

View file

@ -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

View file

@ -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
}
}
/**

View file

@ -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),
}
}

View file

@ -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

View file

@ -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),
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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)

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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 }

View file

@ -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,
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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)
}

View file

@ -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)

View file

@ -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)
})
})

View file

@ -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')

View file

@ -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))
})
})

View file

@ -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')
})
})

View file

@ -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',
}

View file

@ -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, {})
}

View file

@ -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',

View file

@ -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`,

View file

@ -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')

View file

@ -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',

View file

@ -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`,
}

View file

@ -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]
})
}

View file

@ -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: '',

View file

@ -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, {

243
yarn.lock
View file

@ -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"