index.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. const Provider = require('../Provider')
  2. const request = require('request')
  3. const moment = require('moment')
  4. const purest = require('purest')({ request })
  5. const logger = require('../../logger')
  6. const adapter = require('./adapter')
  7. const { ProviderApiError, ProviderAuthError } = require('../error')
  8. const BASE_URL = 'https://zoom.us/v2'
  9. const GET_LIST_PATH = '/users/me/recordings'
  10. const GET_USER_PATH = '/users/me'
  11. const PAGE_SIZE = 300
  12. const DEFAULT_RANGE_MOS = 23
  13. /**
  14. * Adapter for API https://marketplace.zoom.us/docs/api-reference/zoom-api
  15. */
  16. class Zoom extends Provider {
  17. constructor (options) {
  18. super(options)
  19. this.authProvider = options.provider = Zoom.authProvider
  20. this.client = purest(options)
  21. }
  22. static get authProvider () {
  23. return 'zoom'
  24. }
  25. list (options, done) {
  26. /*
  27. - returns list of months by default
  28. - drill down for specific files in each month
  29. */
  30. const token = options.token
  31. const query = options.query || {}
  32. const { cursor, from, to } = query
  33. const meetingId = options.directory || ''
  34. let meetingsPromise = Promise.resolve(undefined)
  35. let recordingsPromise = Promise.resolve(undefined)
  36. const userPromise = new Promise((resolve, reject) => {
  37. this.client
  38. .get(`${BASE_URL}${GET_USER_PATH}`)
  39. .auth(token)
  40. .request((err, resp, body) => {
  41. if (err || resp.statusCode !== 200) {
  42. return this._listError(err, resp, done)
  43. }
  44. resolve(resp)
  45. })
  46. })
  47. if (from && to) {
  48. const queryObj = {
  49. page_size: PAGE_SIZE,
  50. from,
  51. to
  52. }
  53. if (cursor) {
  54. queryObj.next_page_token = cursor
  55. }
  56. meetingsPromise = new Promise((resolve, reject) => {
  57. this.client.get(`${BASE_URL}${GET_LIST_PATH}`)
  58. .qs(queryObj)
  59. .auth(token)
  60. .request((err, resp, body) => {
  61. if (err || resp.statusCode !== 200) {
  62. return this._listError(err, resp, done)
  63. } else {
  64. resolve(resp)
  65. }
  66. })
  67. })
  68. } else if (meetingId) {
  69. const GET_MEETING_FILES = `/meetings/${meetingId}/recordings`
  70. recordingsPromise = new Promise((resolve, reject) => {
  71. this.client
  72. .get(`${BASE_URL}${GET_MEETING_FILES}`)
  73. .auth(token)
  74. .request((err, resp, body) => {
  75. if (err || resp.statusCode !== 200) {
  76. return this._listError(err, resp, done)
  77. } else {
  78. resolve(resp)
  79. }
  80. })
  81. })
  82. }
  83. Promise.all([userPromise, meetingsPromise, recordingsPromise])
  84. .then(
  85. ([userResponse, meetingsResponse, recordingsResponse]) => {
  86. let returnData = null
  87. if (!meetingsResponse && !recordingsResponse) {
  88. const end = cursor && moment(cursor)
  89. returnData = this._initializeData(userResponse.body, end)
  90. } else if (meetingsResponse) {
  91. returnData = this._adaptData(userResponse.body, meetingsResponse.body)
  92. } else if (recordingsResponse) {
  93. returnData = this._adaptData(userResponse.body, recordingsResponse.body)
  94. }
  95. done(null, returnData)
  96. },
  97. (reqErr) => {
  98. done(reqErr)
  99. }
  100. )
  101. }
  102. download ({ id, token, query }, done) {
  103. // meeting id + file id required
  104. // timeline files don't have an ID or size
  105. const meetingId = id
  106. const fileId = query.recordingId
  107. const GET_MEETING_FILES = `/meetings/${meetingId}/recordings`
  108. const downloadUrlPromise = new Promise((resolve) => {
  109. this.client
  110. .get(`${BASE_URL}${GET_MEETING_FILES}`)
  111. .auth(token)
  112. .request((err, resp) => {
  113. if (err || resp.statusCode !== 200) {
  114. return this._downloadError(resp, done)
  115. }
  116. const file = resp
  117. .body
  118. .recording_files
  119. .find(file => fileId === file.id || fileId === file.file_type)
  120. if (!file || !file.download_url) {
  121. return this._downloadError(resp, done)
  122. }
  123. resolve(file.download_url)
  124. })
  125. })
  126. downloadUrlPromise.then((downloadUrl) => {
  127. this.client
  128. .get(`${downloadUrl}?access_token=${token}`)
  129. .request()
  130. .on('response', (resp) => {
  131. if (resp.statusCode !== 200) {
  132. done(this._error(null, resp))
  133. } else {
  134. resp.on('data', (chunk) => done(null, chunk))
  135. }
  136. })
  137. .on('end', () => {
  138. done(null, null)
  139. })
  140. .on('error', (err) => {
  141. logger.error(err, 'provider.zoom.download.error')
  142. done(err)
  143. })
  144. })
  145. }
  146. size ({ id, token, query }, done) {
  147. const meetingId = id
  148. const fileId = query.recordingId
  149. const GET_MEETING_FILES = `/meetings/${meetingId}/recordings`
  150. return this.client
  151. .get(`${BASE_URL}${GET_MEETING_FILES}`)
  152. .auth(token)
  153. .request((err, resp, body) => {
  154. if (err || resp.statusCode !== 200) {
  155. return this._downloadError(resp, done)
  156. }
  157. const file = resp
  158. .body
  159. .recording_files
  160. .find(file => file.id === fileId || file.file_type === fileId)
  161. if (!file) {
  162. return this._downloadError(resp, done)
  163. }
  164. // timeline files don't have file size, but are typically small json files, should be much less than 1MB
  165. const maxExportFileSize = 1024 * 1024
  166. done(null, file.file_size || maxExportFileSize)
  167. })
  168. }
  169. _initializeData (body, initialEnd = null) {
  170. let end = initialEnd || moment()
  171. let start = end.clone().date(1)
  172. const accountCreation = adapter.getAccountCreationDate(body)
  173. const defaultLimit = end.clone().subtract(DEFAULT_RANGE_MOS, 'months')
  174. const limit = accountCreation > defaultLimit ? accountCreation : defaultLimit
  175. const data = {
  176. items: [],
  177. username: adapter.getUserEmail(body)
  178. }
  179. while (start > limit) {
  180. start = end.clone().date(1)
  181. data.items.push({
  182. isFolder: true,
  183. icon: 'folder',
  184. name: adapter.getDateName(start, end),
  185. mimeType: null,
  186. id: adapter.getDateFolderId(start, end),
  187. thumbnail: null,
  188. requestPath: adapter.getDateFolderRequestPath(start, end),
  189. modifiedDate: adapter.getDateFolderModified(end),
  190. size: null
  191. })
  192. end = start.clone().subtract(1, 'days')
  193. }
  194. data.nextPagePath = adapter.getDateNextPagePath(start)
  195. return data
  196. }
  197. _adaptData (userResponse, results) {
  198. if (!results) {
  199. return { items: [] }
  200. }
  201. const data = {
  202. nextPagePath: adapter.getNextPagePath(results),
  203. items: [],
  204. username: adapter.getUserEmail(userResponse)
  205. }
  206. const items = results.meetings || results.recording_files
  207. items.forEach(item => {
  208. data.items.push({
  209. isFolder: adapter.getIsFolder(item),
  210. icon: adapter.getIcon(item),
  211. name: adapter.getItemName(item),
  212. mimeType: adapter.getMimeType(item),
  213. id: adapter.getId(item),
  214. thumbnail: null,
  215. requestPath: adapter.getRequestPath(item),
  216. modifiedDate: adapter.getStartDate(item),
  217. size: adapter.getSize(item)
  218. })
  219. })
  220. return data
  221. }
  222. logout ({ companion, token }, done) {
  223. const key = companion.options.providerOptions.zoom.key
  224. const secret = companion.options.providerOptions.zoom.secret
  225. const encodedAuth = Buffer.from(`${key}:${secret}`, 'binary').toString('base64')
  226. return this.client
  227. .post('https://zoom.us/oauth/revoke')
  228. .options({
  229. headers: {
  230. Authorization: `Basic ${encodedAuth}`
  231. }
  232. })
  233. .qs({ token })
  234. .request((err, resp, body) => {
  235. if (err || resp.statusCode !== 200) {
  236. logger.error(err, 'provider.zoom.logout.error')
  237. done(this._error(err, resp))
  238. return
  239. }
  240. done(null, { revoked: (body || {}).status === 'success' })
  241. })
  242. }
  243. _error (err, resp) {
  244. const authErrorCodes = [
  245. 124, // expired token
  246. 401
  247. ]
  248. if (resp) {
  249. const fallbackMsg = `request to ${this.authProvider} returned ${resp.statusCode}`
  250. const errMsg = (resp.body || {}).message ? resp.body.message : fallbackMsg
  251. return authErrorCodes.indexOf(resp.statusCode) > -1 ? new ProviderAuthError() : new ProviderApiError(errMsg, resp.statusCode)
  252. }
  253. return err
  254. }
  255. _downloadError (resp, done) {
  256. const error = this._error(null, resp)
  257. logger.error(error, 'provider.zoom.download.error')
  258. return done(error)
  259. }
  260. _listError (err, resp, done) {
  261. const error = this._error(err, resp)
  262. logger.error(error, 'provider.zoom.list.error')
  263. return done(error)
  264. }
  265. }
  266. module.exports = Zoom