index.js 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. const request = require('request')
  2. const purest = require('purest')({ request })
  3. const { promisify } = require('util')
  4. const Provider = require('../Provider')
  5. const logger = require('../../logger')
  6. const adapter = require('./adapter')
  7. const { ProviderApiError, ProviderAuthError } = require('../error')
  8. const { requestStream } = require('../../helpers/utils')
  9. const BOX_FILES_FIELDS = 'id,modified_at,name,permissions,size,type'
  10. const BOX_THUMBNAIL_SIZE = 256
  11. /**
  12. * Adapter for API https://developer.box.com/reference/
  13. */
  14. class Box extends Provider {
  15. constructor (options) {
  16. super(options)
  17. this.authProvider = Box.authProvider
  18. this.client = purest({
  19. ...options,
  20. provider: Box.authProvider,
  21. })
  22. // needed for the thumbnails fetched via companion
  23. this.needsCookieAuth = true
  24. }
  25. static get authProvider () {
  26. return 'box'
  27. }
  28. _userInfo ({ token }, done) {
  29. this.client
  30. .get('users/me')
  31. .auth(token)
  32. .request(done)
  33. }
  34. /**
  35. * Lists files and folders from Box API
  36. *
  37. * @param {object} options
  38. * @param {string} options.directory
  39. * @param {any} options.query
  40. * @param {string} options.token
  41. * @param {unknown} options.companion
  42. * @param {Function} done
  43. */
  44. _list ({ directory, token, query, companion }, done) {
  45. const rootFolderID = '0'
  46. const path = `folders/${directory || rootFolderID}/items`
  47. this.client
  48. .get(path)
  49. .qs({ fields: BOX_FILES_FIELDS, offset: query.cursor })
  50. .auth(token)
  51. .request((err, resp, body) => {
  52. if (err || resp.statusCode !== 200) {
  53. err = this._error(err, resp)
  54. logger.error(err, 'provider.box.list.error')
  55. return done(err)
  56. }
  57. this._userInfo({ token }, (err, infoResp) => {
  58. if (err || infoResp.statusCode !== 200) {
  59. err = this._error(err, infoResp)
  60. logger.error(err, 'provider.token.user.error')
  61. return done(err)
  62. }
  63. done(null, this.adaptData(body, infoResp.body.login, companion))
  64. })
  65. })
  66. }
  67. async download ({ id, token }) {
  68. try {
  69. const req = this.client
  70. .get(`files/${id}/content`)
  71. .auth(token)
  72. .request()
  73. return await requestStream(req, async (res) => this._error(null, res))
  74. } catch (err) {
  75. logger.error(err, 'provider.box.download.error')
  76. throw err
  77. }
  78. }
  79. async thumbnail ({ id, token }) {
  80. const maxRetryTime = 10
  81. const extension = 'jpg' // set to png to more easily reproduce http 202 retry-after
  82. let remainingRetryTime = maxRetryTime
  83. const tryGetThumbnail = async () => {
  84. const req = this.client
  85. .get(`files/${id}/thumbnail.${extension}`)
  86. .qs({ max_height: BOX_THUMBNAIL_SIZE, max_width: BOX_THUMBNAIL_SIZE })
  87. .auth(token)
  88. .request()
  89. // See also requestStream
  90. const resp = await new Promise((resolve, reject) => (
  91. req
  92. .on('response', (response) => {
  93. // Don't allow any more data to flow yet.
  94. // https://github.com/request/request/issues/1990#issuecomment-184712275
  95. response.pause()
  96. resolve(response)
  97. })
  98. .on('error', reject)
  99. ))
  100. if (resp.statusCode === 200) {
  101. return { stream: resp }
  102. }
  103. req.abort() // Or we will leak memory (the stream is paused and we're not using this response stream anymore)
  104. // From box API docs:
  105. // Sometimes generating a thumbnail can take a few seconds.
  106. // In these situations the API returns a Location-header pointing to a placeholder graphic
  107. // for this file type.
  108. // The placeholder graphic can be used in a user interface until the thumbnail generation has completed.
  109. // The Retry-After-header indicates when to the thumbnail will be ready.
  110. // At that time, retry this endpoint to retrieve the thumbnail.
  111. //
  112. // This can be reproduced more easily by changing extension to png and trying on a newly uploaded image
  113. const retryAfter = parseInt(resp.headers['retry-after'], 10)
  114. if (!Number.isNaN(retryAfter)) {
  115. const retryInSec = Math.min(remainingRetryTime, retryAfter)
  116. if (retryInSec <= 0) throw new ProviderApiError('Timed out waiting for thumbnail', 504)
  117. logger.debug(`Need to retry box thumbnail in ${retryInSec} sec`)
  118. remainingRetryTime -= retryInSec
  119. await new Promise((resolve) => setTimeout(resolve, retryInSec * 1000))
  120. return tryGetThumbnail()
  121. }
  122. // we have an error status code, throw
  123. throw this._error(null, resp)
  124. }
  125. try {
  126. return await tryGetThumbnail()
  127. } catch (err) {
  128. logger.error(err, 'provider.box.thumbnail.error')
  129. throw err
  130. }
  131. }
  132. _size ({ id, token }, done) {
  133. return this.client
  134. .get(`files/${id}`)
  135. .auth(token)
  136. .request((err, resp, body) => {
  137. if (err || resp.statusCode !== 200) {
  138. err = this._error(err, resp)
  139. logger.error(err, 'provider.box.size.error')
  140. return done(err)
  141. }
  142. done(null, parseInt(body.size, 10))
  143. })
  144. }
  145. _logout ({ companion, token }, done) {
  146. const { key, secret } = companion.options.providerOptions.box
  147. return this.client
  148. .post('https://api.box.com/oauth2/revoke')
  149. .options({
  150. formData: {
  151. client_id: key,
  152. client_secret: secret,
  153. token,
  154. },
  155. })
  156. .auth(token)
  157. .request((err, resp) => {
  158. if (err || resp.statusCode !== 200) {
  159. logger.error(err, 'provider.box.logout.error')
  160. done(this._error(err, resp))
  161. return
  162. }
  163. done(null, { revoked: true })
  164. })
  165. }
  166. adaptData (res, username, companion) {
  167. const data = { username, items: [] }
  168. const items = adapter.getItemSubList(res)
  169. items.forEach((item) => {
  170. data.items.push({
  171. isFolder: adapter.isFolder(item),
  172. icon: adapter.getItemIcon(item),
  173. name: adapter.getItemName(item),
  174. mimeType: adapter.getMimeType(item),
  175. id: adapter.getItemId(item),
  176. thumbnail: companion.buildURL(adapter.getItemThumbnailUrl(item), true),
  177. requestPath: adapter.getItemRequestPath(item),
  178. modifiedDate: adapter.getItemModifiedDate(item),
  179. size: adapter.getItemSize(item),
  180. })
  181. })
  182. data.nextPagePath = adapter.getNextPagePath(res)
  183. return data
  184. }
  185. _error (err, resp) {
  186. if (resp) {
  187. const fallbackMessage = `request to ${this.authProvider} returned ${resp.statusCode}`
  188. const errMsg = (resp.body || {}).message ? resp.body.message : fallbackMessage
  189. return resp.statusCode === 401 ? new ProviderAuthError() : new ProviderApiError(errMsg, resp.statusCode)
  190. }
  191. return err
  192. }
  193. }
  194. Box.version = 2
  195. Box.prototype.list = promisify(Box.prototype._list)
  196. Box.prototype.size = promisify(Box.prototype._size)
  197. Box.prototype.logout = promisify(Box.prototype._logout)
  198. module.exports = Box