@uppy/companion
Version:
OAuth helper and remote fetcher for Uppy's (https://uppy.io) extensible file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Dropbox and Google Drive, S3 and more :dog:
252 lines (218 loc) • 7.58 kB
JavaScript
import got from 'got'
import moment from 'moment-timezone'
import pMap from 'p-map'
import { getBasicAuthHeader, prepareStream } from '../../helpers/utils.js'
import Provider from '../Provider.js'
import { withProviderErrorHandling } from '../providerErrors.js'
import adaptData from './adapter.js'
const BASE_URL = 'https://zoom.us/v2'
const PAGE_SIZE = 300
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
*/
export default class Zoom extends Provider {
static get oauthProvider() {
return 'zoom'
}
async list(options) {
return this.#withErrorHandling('provider.zoom.list.error', async () => {
const {
providerUserSession: { accessToken: token },
} = options
const query = options.query || {}
const meetingId = options.directory || ''
const requestedYear = query.year ? parseInt(query.year, 10) : null
const client = getClient({ token })
const user = await client.get('users/me', { responseType: 'json' }).json()
const { timezone } = user
const userTz = timezone || 'UTC'
if (meetingId) {
const recordingInfo = await client
.get(`meetings/${encodeURIComponent(meetingId)}/recordings`, {
responseType: 'json',
})
.json()
return adaptData(user, recordingInfo)
}
if (requestedYear) {
const now = moment.tz(userTz)
const numMonths =
now.get('year') === requestedYear ? now.get('month') + 1 : 12
const monthsToCheck = Array.from({ length: numMonths }, (_, i) => i) // in moment, months are 0-indexed
// Run each month in parallel:
const allMeetingsInYear = (
await pMap(
monthsToCheck,
async (month) => {
const startDate = moment
.tz({ year: requestedYear, month, day: 1 }, userTz)
.startOf('month')
const endDate = startDate.clone().endOf('month')
const searchParams = {
page_size: PAGE_SIZE,
from: startDate.clone().tz('UTC').format('YYYY-MM-DD'),
to: endDate.clone().tz('UTC').format('YYYY-MM-DD'),
}
const paginatedMeetings = []
do {
const currentChunkMeetingsInfo = await client
.get('users/me/recordings', {
searchParams,
responseType: 'json',
})
.json()
paginatedMeetings.push(
...(currentChunkMeetingsInfo.meetings ?? []),
)
searchParams.next_page_token =
currentChunkMeetingsInfo.next_page_token
} while (searchParams.next_page_token)
return paginatedMeetings
},
{ concurrency: 3 },
)
).flat() // this is effectively a flatMap
// concurrency 3 seems like a sensible number...
const finalResult = { meetings: allMeetingsInYear }
return adaptData(user, finalResult)
}
const accountCreationDate = moment.utc(user.created_at)
const startYear = accountCreationDate.year()
const currentYear = moment.tz(userTz).year()
const years = []
for (let year = currentYear; year >= startYear; year--) {
years.push({
isFolder: true,
icon: 'folder',
name: `${year}`,
mimeType: null,
id: `${year}`,
thumbnail: null,
requestPath: `?year=${year}`,
modifiedDate: `${year}-12-31`, // Representative date
size: null,
})
}
return {
username: user.email,
items: years,
nextPagePath: null,
}
})
}
async download({
id: meetingId,
providerUserSession: { accessToken: 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',
})
const { size } = await prepareStream(stream)
return { stream, size }
})
}
async size({
id: meetingId,
providerUserSession: { accessToken: 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, providerUserSession: { accessToken: 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,
},
responseType: 'json',
})
return {}
})
}
async #withErrorHandling(tag, fn) {
const authErrorCodes = [
124, // expired token
401,
]
return withProviderErrorHandling({
fn,
tag,
providerName: Zoom.oauthProvider,
isAuthError: (response) => authErrorCodes.includes(response.statusCode),
getJsonErrorMessage: (body) => body?.message,
})
}
}