mirror of
https://github.com/transloadit/uppy.git
synced 2026-07-17 16:50:22 +00:00
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:
parent
c042f0220b
commit
35812ca378
46 changed files with 1925 additions and 2350 deletions
12
.github/CONTRIBUTING.md
vendored
12
.github/CONTRIBUTING.md
vendored
|
|
@ -100,7 +100,7 @@ The following steps describe the actions that take place when a user Authenticat
|
|||
|
||||
### Instagram integration
|
||||
|
||||
Even though facebook [allows using](https://developers.facebook.com/blog/post/2018/06/08/enforce-https-facebook-login/) http://localhost in dev mode, Instagram doesn’t seem to support that, and seems to need a publically available domain name with HTTPS.
|
||||
Even though facebook [allows using](https://developers.facebook.com/blog/post/2018/06/08/enforce-https-facebook-login/) http://localhost in dev mode, Instagram doesn’t seem to support that, and seems to need a publically available domain name with HTTPS. So we will tunnel requests to localhost using `ngrok`.
|
||||
|
||||
Make sure that you are using a development facebook app at <https://developers.facebook.com/apps>
|
||||
|
||||
|
|
@ -109,6 +109,8 @@ Go to “Instagram Basic Display” and find `Instagram App ID` and `Instagram A
|
|||
COMPANION_INSTAGRAM_KEY="Instagram App ID"
|
||||
COMPANION_INSTAGRAM_SECRET="Instagram App Secret"
|
||||
|
||||
**Note!** `ngrok` seems to be blocked by Instagram now, so you may have to find an alternative.
|
||||
|
||||
Run
|
||||
|
||||
```bash
|
||||
|
|
@ -136,6 +138,14 @@ Tester invites -> Accept
|
|||
|
||||
Now you should be able to test the Instagram integration.
|
||||
|
||||
## Zoom
|
||||
|
||||
See above Instagram instructions for setting up a tunnel, but replace `instagram` with `zoom` in the URL. Note that **you also have to add the OAuth redirect URL to `OAuth allow list`** in the Zoom Oauth app settings or it will not work.
|
||||
|
||||
Add the following scopes: `recording:read`, `user:read`, `user_info:read`
|
||||
|
||||
To test recording a meeting, you need to sign up for a Zoom Pro trial (can be cancelled later), for example using their iOS app.
|
||||
|
||||
## Releases
|
||||
|
||||
Before doing a release, check that the examples on the website work:
|
||||
|
|
|
|||
|
|
@ -60,14 +60,12 @@ These controllers are generalized to work for any provider. The provider specifi
|
|||
|
||||
To add a new provider to Companion, you need to do two things: add the provider config to `config/grant.js`, and then create a new file in `server/providers` that describes how to interface with the provider’s API.
|
||||
|
||||
We are using a library called [purest](https://github.com/simov/purest) to make it easier to interface with third party APIs. Instead of dealing with each single provider’s client library/SDK, we use Purest, a “generic REST API client library” that gives us a consistent, “generic” API to interface with any provider. This makes life a lot easier.
|
||||
We are using a library called [got](https://github.com/sindresorhus/got) to make it easier to interface with third party APIs.
|
||||
|
||||
Since each API works differently, we need to describe how to `download` and `list` files from the provider in a file within `server/providers`. The name of the file should be the same as what endpoint it will use. For example, `server/providers/foobar.js` if the client requests a list of files from `https://our-server/foobar/list`.
|
||||
|
||||
**Note:** As of right now, you only need to implement `YourProvider.prototype.list` and `YourProvider.prototype.download` for each provider, I believe. `stats` seems to be used by Dropbox to get a list of files, so that’s required there, but `upload` is optional unless you all decide to allow uploading to third parties. I got that code from an example.
|
||||
|
||||
This whole approach was inspired by an example from `purest 2.x`. Keep in mind that we’re using `3.x`, so the API is different, but here is the example for reference: <https://github.com/simov/purest/tree/2.x/examples/storage>
|
||||
|
||||
## WebSockets
|
||||
|
||||
Companion uses WebSockets to transfer `progress` events to the client during file transfers. It’s only set up to transfer progress during Tus uploads to the target server.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
34
packages/@uppy/companion/test/fixtures/box.js
vendored
34
packages/@uppy/companion/test/fixtures/box.js
vendored
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
63
packages/@uppy/companion/test/fixtures/drive.js
vendored
63
packages/@uppy/companion/test/fixtures/drive.js
vendored
|
|
@ -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, {})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
22
packages/@uppy/companion/test/fixtures/index.js
vendored
22
packages/@uppy/companion/test/fixtures/index.js
vendored
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
}
|
||||
|
|
|
|||
112
packages/@uppy/companion/test/fixtures/zoom.js
vendored
112
packages/@uppy/companion/test/fixtures/zoom.js
vendored
|
|
@ -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]
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
||||
|
|
|
|||
|
|
@ -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
243
yarn.lock
|
|
@ -6310,22 +6310,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@purest/config@npm:^1.0.0":
|
||||
version: 1.0.1
|
||||
resolution: "@purest/config@npm:1.0.1"
|
||||
dependencies:
|
||||
extend: ^3.0.0
|
||||
checksum: 6157935ffd11fa942c462473480ca3f47ba099d3ef53066c143a050d801efe247994c3beced14da9071ed0064761ba45870927e3c8d824daff106c52f9dedd77
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@purest/providers@npm:1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "@purest/providers@npm:1.0.1"
|
||||
checksum: ea5585f0948d438feb0fe6998e981990934ced66bcf1ac640583ccc9ec38000fa813cb671160ebe5b48e3f447eff7807ad4f8b9e44d742f83ef360cde848dc48
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-native-community/cli-debugger-ui@npm:^4.13.1":
|
||||
version: 4.13.1
|
||||
resolution: "@react-native-community/cli-debugger-ui@npm:4.13.1"
|
||||
|
|
@ -6522,24 +6506,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@request/api@npm:^0.6.0":
|
||||
version: 0.6.0
|
||||
resolution: "@request/api@npm:0.6.0"
|
||||
dependencies:
|
||||
"@request/interface": ^0.1.0
|
||||
deep-copy: ^1.1.2
|
||||
extend: ^3.0.0
|
||||
checksum: 0fbc4d368c71eca08997922c8a6c958b78b3879006d42eb3696a72b373967ed1ce9f8f037c4072faa9c2e641b98320f4864fb9db1d37a69cd4e4af96fe5c44de
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@request/interface@npm:^0.1.0":
|
||||
version: 0.1.0
|
||||
resolution: "@request/interface@npm:0.1.0"
|
||||
checksum: a167275a54bafb3f228dba63cd3bbbb31871e2868d53838881248f21c42a41bd88fb6aca26855edee5ab3bb1eccb4e13799eb2365917e491aa0a54146c5c3d60
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rollup/plugin-commonjs@npm:^16.0.0":
|
||||
version: 16.0.0
|
||||
resolution: "@rollup/plugin-commonjs@npm:16.0.0"
|
||||
|
|
@ -6711,6 +6677,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sindresorhus/is@npm:^4.0.0":
|
||||
version: 4.6.0
|
||||
resolution: "@sindresorhus/is@npm:4.6.0"
|
||||
checksum: 83839f13da2c29d55c97abc3bc2c55b250d33a0447554997a85c539e058e57b8da092da396e252b11ec24a0279a0bed1f537fa26302209327060643e327f81d2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sinonjs/commons@npm:^1.7.0":
|
||||
version: 1.8.3
|
||||
resolution: "@sinonjs/commons@npm:1.8.3"
|
||||
|
|
@ -8295,6 +8268,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@szmarczak/http-timer@npm:^4.0.5":
|
||||
version: 4.0.6
|
||||
resolution: "@szmarczak/http-timer@npm:4.0.6"
|
||||
dependencies:
|
||||
defer-to-connect: ^2.0.0
|
||||
checksum: c29df3bcec6fc3bdec2b17981d89d9c9fc9bd7d0c9bcfe92821dc533f4440bc890ccde79971838b4ceed1921d456973c4180d7175ee1d0023ad0562240a58d95
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@testing-library/dom@npm:^8.3.0":
|
||||
version: 8.11.3
|
||||
resolution: "@testing-library/dom@npm:8.11.3"
|
||||
|
|
@ -8444,6 +8426,18 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/cacheable-request@npm:^6.0.1":
|
||||
version: 6.0.2
|
||||
resolution: "@types/cacheable-request@npm:6.0.2"
|
||||
dependencies:
|
||||
"@types/http-cache-semantics": "*"
|
||||
"@types/keyv": "*"
|
||||
"@types/node": "*"
|
||||
"@types/responselike": "*"
|
||||
checksum: 667d25808dbf46fe104d6f029e0281ff56058d50c7c1b9182774b3e38bb9c1124f56e4c367ba54f92dbde2d1cc573f26eb0e9748710b2822bc0fd1e5498859c6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/caseless@npm:*":
|
||||
version: 0.12.2
|
||||
resolution: "@types/caseless@npm:0.12.2"
|
||||
|
|
@ -8693,6 +8687,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/http-cache-semantics@npm:*":
|
||||
version: 4.0.1
|
||||
resolution: "@types/http-cache-semantics@npm:4.0.1"
|
||||
checksum: 1048aacf627829f0d5f00184e16548205cd9f964bf0841c29b36bc504509230c40bc57c39778703a1c965a6f5b416ae2cbf4c1d4589c889d2838dd9dbfccf6e9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/http-proxy@npm:^1.17.5":
|
||||
version: 1.17.7
|
||||
resolution: "@types/http-proxy@npm:1.17.7"
|
||||
|
|
@ -8795,6 +8796,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/json-buffer@npm:~3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "@types/json-buffer@npm:3.0.0"
|
||||
checksum: 6b0a371dd603f0eec9d00874574bae195382570e832560dadf2193ee0d1062b8e0694bbae9798bc758632361c227b1e3b19e3bd914043b498640470a2da38b77
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.4, @types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9":
|
||||
version: 7.0.9
|
||||
resolution: "@types/json-schema@npm:7.0.9"
|
||||
|
|
@ -8818,6 +8826,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/keyv@npm:*":
|
||||
version: 3.1.4
|
||||
resolution: "@types/keyv@npm:3.1.4"
|
||||
dependencies:
|
||||
"@types/node": "*"
|
||||
checksum: e009a2bfb50e90ca9b7c6e8f648f8464067271fd99116f881073fa6fa76dc8d0133181dd65e6614d5fb1220d671d67b0124aef7d97dc02d7e342ab143a47779d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/keyv@npm:^3.1.1":
|
||||
version: 3.1.3
|
||||
resolution: "@types/keyv@npm:3.1.3"
|
||||
|
|
@ -9072,7 +9089,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/responselike@npm:^1.0.0":
|
||||
"@types/responselike@npm:*, @types/responselike@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@types/responselike@npm:1.0.0"
|
||||
dependencies:
|
||||
|
|
@ -10145,7 +10162,6 @@ __metadata:
|
|||
version: 0.0.0-use.local
|
||||
resolution: "@uppy/companion@workspace:packages/@uppy/companion"
|
||||
dependencies:
|
||||
"@purest/providers": 1.0.1
|
||||
"@types/compression": 1.7.0
|
||||
"@types/connect-redis": 0.0.18
|
||||
"@types/cookie-parser": 1.4.2
|
||||
|
|
@ -10176,6 +10192,8 @@ __metadata:
|
|||
express-prom-bundle: 6.3.0
|
||||
express-request-id: 1.4.1
|
||||
express-session: 1.17.1
|
||||
form-data: ^3.0.0
|
||||
got: 11
|
||||
grant: 4.7.0
|
||||
helmet: ^4.6.0
|
||||
into-stream: ^6.0.0
|
||||
|
|
@ -10191,9 +10209,7 @@ __metadata:
|
|||
nock: ^13.1.3
|
||||
node-schedule: 1.3.2
|
||||
prom-client: 12.0.0
|
||||
purest: 3.1.0
|
||||
redis: 4.2.0
|
||||
request: 2.88.2
|
||||
semver: 6.3.0
|
||||
serialize-error: ^2.1.0
|
||||
serialize-javascript: ^6.0.0
|
||||
|
|
@ -14402,6 +14418,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cacheable-lookup@npm:^5.0.3":
|
||||
version: 5.0.4
|
||||
resolution: "cacheable-lookup@npm:5.0.4"
|
||||
checksum: 763e02cf9196bc9afccacd8c418d942fc2677f22261969a4c2c2e760fa44a2351a81557bd908291c3921fe9beb10b976ba8fa50c5ca837c5a0dd945f16468f2d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cacheable-request@npm:^6.0.0":
|
||||
version: 6.1.0
|
||||
resolution: "cacheable-request@npm:6.1.0"
|
||||
|
|
@ -14417,6 +14440,21 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cacheable-request@npm:^7.0.2":
|
||||
version: 7.0.2
|
||||
resolution: "cacheable-request@npm:7.0.2"
|
||||
dependencies:
|
||||
clone-response: ^1.0.2
|
||||
get-stream: ^5.1.0
|
||||
http-cache-semantics: ^4.0.0
|
||||
keyv: ^4.0.0
|
||||
lowercase-keys: ^2.0.0
|
||||
normalize-url: ^6.0.1
|
||||
responselike: ^2.0.0
|
||||
checksum: 6152813982945a5c9989cb457a6c499f12edcc7ade323d2fbfd759abc860bdbd1306e08096916bb413c3c47e812f8e4c0a0cc1e112c8ce94381a960f115bc77f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cached-path-relative@npm:^1.0.0, cached-path-relative@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "cached-path-relative@npm:1.0.2"
|
||||
|
|
@ -15503,6 +15541,16 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"compress-brotli@npm:^1.3.8":
|
||||
version: 1.3.8
|
||||
resolution: "compress-brotli@npm:1.3.8"
|
||||
dependencies:
|
||||
"@types/json-buffer": ~3.0.0
|
||||
json-buffer: ~3.0.1
|
||||
checksum: de7589d692d40eb362f6c91070b5e51bc10b05a89eabb4a7c76c1aa21b625756f8c101c6999e4df0c4dc6199c5ca2e1353573bfdcca5615810f27485394162a5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"compressible@npm:~2.0.16":
|
||||
version: 2.0.18
|
||||
resolution: "compressible@npm:2.0.18"
|
||||
|
|
@ -16798,6 +16846,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"decompress-response@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "decompress-response@npm:6.0.0"
|
||||
dependencies:
|
||||
mimic-response: ^3.1.0
|
||||
checksum: d377cf47e02d805e283866c3f50d3d21578b779731e8c5072d6ce8c13cc31493db1c2f6784da9d1d5250822120cefa44f1deab112d5981015f2e17444b763812
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dedent@npm:^0.7.0":
|
||||
version: 0.7.0
|
||||
resolution: "dedent@npm:0.7.0"
|
||||
|
|
@ -16805,13 +16862,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"deep-copy@npm:^1.1.2":
|
||||
version: 1.4.2
|
||||
resolution: "deep-copy@npm:1.4.2"
|
||||
checksum: 24ba7db4a9d44800c68659dae0068b681c43be0512c75e700710c3d18776e4c41f7dbd83eed6370bcfc1594c60f84d8798f4058f965ab457ea0232db821f0a4e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"deep-diff@npm:^0.3.5":
|
||||
version: 0.3.8
|
||||
resolution: "deep-diff@npm:0.3.8"
|
||||
|
|
@ -16917,6 +16967,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"defer-to-connect@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "defer-to-connect@npm:2.0.1"
|
||||
checksum: 8a9b50d2f25446c0bfefb55a48e90afd58f85b21bcf78e9207cd7b804354f6409032a1705c2491686e202e64fc05f147aa5aa45f9aa82627563f045937f5791b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"define-lazy-prop@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "define-lazy-prop@npm:2.0.0"
|
||||
|
|
@ -22025,6 +22082,25 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"got@npm:11":
|
||||
version: 11.8.5
|
||||
resolution: "got@npm:11.8.5"
|
||||
dependencies:
|
||||
"@sindresorhus/is": ^4.0.0
|
||||
"@szmarczak/http-timer": ^4.0.5
|
||||
"@types/cacheable-request": ^6.0.1
|
||||
"@types/responselike": ^1.0.0
|
||||
cacheable-lookup: ^5.0.3
|
||||
cacheable-request: ^7.0.2
|
||||
decompress-response: ^6.0.0
|
||||
http2-wrapper: ^1.0.0-beta.5.2
|
||||
lowercase-keys: ^2.0.0
|
||||
p-cancelable: ^2.0.0
|
||||
responselike: ^2.0.0
|
||||
checksum: 2de8a1bbda4e9b6b2b72b2d2100bc055a59adc1740529e631f61feb44a8b9a1f9f8590941ed9da9df0090b6d6d0ed8ffee94cd9ac086ec3409b392b33440f7d2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"got@npm:^9.6.0":
|
||||
version: 9.6.0
|
||||
resolution: "got@npm:9.6.0"
|
||||
|
|
@ -23191,6 +23267,16 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"http2-wrapper@npm:^1.0.0-beta.5.2":
|
||||
version: 1.0.3
|
||||
resolution: "http2-wrapper@npm:1.0.3"
|
||||
dependencies:
|
||||
quick-lru: ^5.1.1
|
||||
resolve-alpn: ^1.0.0
|
||||
checksum: 74160b862ec699e3f859739101ff592d52ce1cb207b7950295bf7962e4aa1597ef709b4292c673bece9c9b300efad0559fc86c71b1409c7a1e02b7229456003e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"https-browserify@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "https-browserify@npm:1.0.0"
|
||||
|
|
@ -25863,6 +25949,13 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"json-buffer@npm:3.0.1, json-buffer@npm:~3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "json-buffer@npm:3.0.1"
|
||||
checksum: 9026b03edc2847eefa2e37646c579300a1f3a4586cfb62bf857832b60c852042d0d6ae55d1afb8926163fa54c2b01d83ae24705f34990348bdac6273a29d4581
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"json-parse-better-errors@npm:^1.0.1, json-parse-better-errors@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "json-parse-better-errors@npm:1.0.2"
|
||||
|
|
@ -26284,6 +26377,16 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"keyv@npm:^4.0.0":
|
||||
version: 4.3.3
|
||||
resolution: "keyv@npm:4.3.3"
|
||||
dependencies:
|
||||
compress-brotli: ^1.3.8
|
||||
json-buffer: 3.0.1
|
||||
checksum: bcc946eeec3407fb3b42d831ce985357162113c5f07a8c45c12ede39704ba2d99be4c3dded76d2d2d2a2366627e42440bdde24393216164156928399949c12a1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"kind-of@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "kind-of@npm:1.1.0"
|
||||
|
|
@ -28795,6 +28898,13 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mimic-response@npm:^3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "mimic-response@npm:3.1.0"
|
||||
checksum: 25739fee32c17f433626bf19f016df9036b75b3d84a3046c7d156e72ec963dd29d7fc8a302f55a3d6c5a4ff24259676b15d915aad6480815a969ff2ec0836867
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"min-document@npm:^2.19.0":
|
||||
version: 2.19.0
|
||||
resolution: "min-document@npm:2.19.0"
|
||||
|
|
@ -30722,6 +30832,13 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"p-cancelable@npm:^2.0.0":
|
||||
version: 2.1.1
|
||||
resolution: "p-cancelable@npm:2.1.1"
|
||||
checksum: 3dba12b4fb4a1e3e34524535c7858fc82381bbbd0f247cc32dedc4018592a3950ce66b106d0880b4ec4c2d8d6576f98ca885dc1d7d0f274d1370be20e9523ddf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"p-defer@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "p-defer@npm:1.0.0"
|
||||
|
|
@ -33387,17 +33504,6 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"purest@npm:3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "purest@npm:3.1.0"
|
||||
dependencies:
|
||||
"@purest/config": ^1.0.0
|
||||
"@request/api": ^0.6.0
|
||||
extend: ^3.0.0
|
||||
checksum: 76d723fe820aa236b46ee2a9d928bfaa37fe2a9b4da0495049a6972535a7ac04eb90ee4e01c3051c0d4f49b57c895a3e0bf4f9560caa2aefb5ec10a3314f4db9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"q@npm:1.4.1":
|
||||
version: 1.4.1
|
||||
resolution: "q@npm:1.4.1"
|
||||
|
|
@ -33524,6 +33630,13 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"quick-lru@npm:^5.1.1":
|
||||
version: 5.1.1
|
||||
resolution: "quick-lru@npm:5.1.1"
|
||||
checksum: a516faa25574be7947969883e6068dbe4aa19e8ef8e8e0fd96cddd6d36485e9106d85c0041a27153286b0770b381328f4072aa40d3b18a19f5f7d2b78b94b5ed
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"quotation@npm:^2.0.0":
|
||||
version: 2.0.2
|
||||
resolution: "quotation@npm:2.0.2"
|
||||
|
|
@ -34910,7 +35023,7 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"request@npm:2.88.2, request@npm:^2.74.0, request@npm:^2.83.0, request@npm:^2.87.0, request@npm:^2.88.0, request@npm:^2.88.2":
|
||||
"request@npm:^2.74.0, request@npm:^2.83.0, request@npm:^2.87.0, request@npm:^2.88.0, request@npm:^2.88.2":
|
||||
version: 2.88.2
|
||||
resolution: "request@npm:2.88.2"
|
||||
dependencies:
|
||||
|
|
@ -34987,6 +35100,13 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"resolve-alpn@npm:^1.0.0":
|
||||
version: 1.2.1
|
||||
resolution: "resolve-alpn@npm:1.2.1"
|
||||
checksum: f558071fcb2c60b04054c99aebd572a2af97ef64128d59bef7ab73bd50d896a222a056de40ffc545b633d99b304c259ea9d0c06830d5c867c34f0bfa60b8eae0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"resolve-cwd@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "resolve-cwd@npm:3.0.0"
|
||||
|
|
@ -35177,6 +35297,15 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"responselike@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "responselike@npm:2.0.1"
|
||||
dependencies:
|
||||
lowercase-keys: ^2.0.0
|
||||
checksum: b122535466e9c97b55e69c7f18e2be0ce3823c5d47ee8de0d9c0b114aa55741c6db8bfbfce3766a94d1272e61bfb1ebf0a15e9310ac5629fbb7446a861b4fd3a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"restore-cursor@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "restore-cursor@npm:2.0.0"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue