index.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. const Provider = require('../Provider')
  2. const request = require('request')
  3. // @ts-ignore
  4. const purest = require('purest')({ request })
  5. const logger = require('../../logger')
  6. const adapter = require('./adapter')
  7. const { ProviderApiError, ProviderAuthError } = require('../error')
  8. const DRIVE_FILE_FIELDS = 'kind,id,name,mimeType,ownedByMe,permissions(role,emailAddress),size,modifiedTime,iconLink,thumbnailLink,teamDriveId'
  9. const DRIVE_FILES_FIELDS = `kind,nextPageToken,incompleteSearch,files(${DRIVE_FILE_FIELDS})`
  10. // using wildcard to get all 'drive' fields because specifying fields seems no to work for the /drives endpoint
  11. const SHARED_DRIVE_FIELDS = '*'
  12. /**
  13. * Adapter for API https://developers.google.com/drive/api/v3/
  14. */
  15. class Drive extends Provider {
  16. constructor (options) {
  17. super(options)
  18. this.authProvider = options.provider = Drive.authProvider
  19. options.alias = 'drive'
  20. options.version = 'v3'
  21. this.client = purest(options)
  22. }
  23. static get authProvider () {
  24. return 'google'
  25. }
  26. list (options, done) {
  27. const directory = options.directory || 'root'
  28. const query = options.query || {}
  29. let sharedDrivesPromise = Promise.resolve(undefined)
  30. const shouldListSharedDrives = directory === 'root' && !query.cursor
  31. if (shouldListSharedDrives) {
  32. sharedDrivesPromise = new Promise((resolve) => {
  33. this.client
  34. .query()
  35. .get('drives')
  36. .qs({ fields: SHARED_DRIVE_FIELDS })
  37. .auth(options.token)
  38. .request((err, resp) => {
  39. if (err) {
  40. logger.error(err, 'provider.drive.sharedDrive.error')
  41. return
  42. }
  43. resolve(resp)
  44. })
  45. })
  46. }
  47. const where = {
  48. fields: DRIVE_FILES_FIELDS,
  49. pageToken: query.cursor,
  50. q: `'${directory}' in parents and trashed=false`,
  51. includeItemsFromAllDrives: true,
  52. supportsAllDrives: true
  53. }
  54. const filesPromise = new Promise((resolve, reject) => {
  55. this.client
  56. .query()
  57. .get('files')
  58. .qs(where)
  59. .auth(options.token)
  60. .request((err, resp) => {
  61. if (err || resp.statusCode !== 200) {
  62. reject(this._error(err, resp))
  63. return
  64. }
  65. resolve(resp)
  66. })
  67. })
  68. Promise.all([sharedDrivesPromise, filesPromise])
  69. .then(
  70. ([sharedDrives, filesResponse]) => {
  71. const returnData = this.adaptData(
  72. filesResponse.body,
  73. sharedDrives && sharedDrives.body,
  74. directory,
  75. query
  76. )
  77. done(null, returnData)
  78. },
  79. (reqErr) => {
  80. logger.error(reqErr, 'provider.drive.list.error')
  81. done(reqErr)
  82. }
  83. )
  84. }
  85. stats ({ id, token }, done) {
  86. return this.client
  87. .query()
  88. .get(`files/${id}`)
  89. .qs({ fields: DRIVE_FILE_FIELDS, supportsAllDrives: true })
  90. .auth(token)
  91. .request(done)
  92. }
  93. _exportGsuiteFile ({ id, token }) {
  94. logger.info(`calling google file export for ${id}`, 'provider.drive.export')
  95. return this.client
  96. .query()
  97. .get(`files/${id}/export`)
  98. .qs({ supportsAllDrives: true, mimeType: 'application/pdf' })
  99. .auth(token)
  100. .request()
  101. }
  102. _getGsuiteFileMeta ({ id, token }, onDone) {
  103. logger.info(`calling Gsuite file meta for ${id}`, 'provider.drive.export.meta')
  104. return this.client
  105. .query()
  106. .head(`files/${id}/export`)
  107. .qs({ supportsAllDrives: true, mimeType: 'application/pdf' })
  108. .auth(token)
  109. .request(onDone)
  110. }
  111. _isGsuiteFile (mimeType) {
  112. return mimeType.startsWith('application/vnd.google')
  113. }
  114. download ({ id, token }, onData) {
  115. this.stats({ id, token }, (err, resp, body) => {
  116. if (err) {
  117. logger.error(err, 'provider.drive.download.stats.error')
  118. onData(err)
  119. return
  120. }
  121. let requestStream
  122. if (this._isGsuiteFile(body.mimeType)) {
  123. requestStream = this._exportGsuiteFile({ id, token })
  124. } else {
  125. requestStream = this.client
  126. .query()
  127. .get(`files/${id}`)
  128. .qs({ alt: 'media', supportsAllDrives: true })
  129. .auth(token)
  130. .request()
  131. }
  132. requestStream
  133. .on('data', (chunk) => onData(null, chunk))
  134. .on('end', () => onData(null, null))
  135. .on('error', (err) => {
  136. logger.error(err, 'provider.drive.download.error')
  137. onData(err)
  138. })
  139. })
  140. }
  141. thumbnail (_, done) {
  142. // not implementing this because a public thumbnail from googledrive will be used instead
  143. const err = new Error('call to thumbnail is not implemented')
  144. logger.error(err, 'provider.drive.thumbnail.error')
  145. return done(err)
  146. }
  147. size ({ id, token }, done) {
  148. return this.stats({ id, token }, (err, resp, body) => {
  149. if (err || resp.statusCode !== 200) {
  150. err = this._error(err, resp)
  151. logger.error(err, 'provider.drive.size.error')
  152. return done(err)
  153. }
  154. if (this._isGsuiteFile(body.mimeType)) {
  155. // Google Docs file sizes can be determined
  156. // while Google sheets file sizes can't be determined
  157. const googleDocMimeType = 'application/vnd.google-apps.document'
  158. if (body.mimeType !== googleDocMimeType) {
  159. const maxExportFileSize = 10 * 1024 * 1024 // 10 MB
  160. done(null, maxExportFileSize)
  161. return
  162. }
  163. this._getGsuiteFileMeta({ id, token }, (err, resp) => {
  164. if (err || resp.statusCode !== 200) {
  165. err = this._error(err, resp)
  166. logger.error(err, 'provider.drive.docs.size.error')
  167. return done(err)
  168. }
  169. const size = resp.headers['content-length']
  170. done(null, size ? parseInt(size) : null)
  171. })
  172. } else {
  173. done(null, parseInt(body.size))
  174. }
  175. })
  176. }
  177. logout ({ token }, done) {
  178. return this.client
  179. .get('https://accounts.google.com/o/oauth2/revoke')
  180. .qs({ token })
  181. .request((err, resp) => {
  182. if (err || resp.statusCode !== 200) {
  183. logger.error(err, 'provider.drive.logout.error')
  184. done(this._error(err, resp))
  185. return
  186. }
  187. done(null, { revoked: true })
  188. })
  189. }
  190. adaptData (res, sharedDrivesResp, directory, query) {
  191. const adaptItem = (item) => ({
  192. isFolder: adapter.isFolder(item),
  193. icon: adapter.getItemIcon(item),
  194. name: adapter.getItemName(item),
  195. mimeType: adapter.getMimeType(item),
  196. id: adapter.getItemId(item),
  197. thumbnail: adapter.getItemThumbnailUrl(item),
  198. requestPath: adapter.getItemRequestPath(item),
  199. modifiedDate: adapter.getItemModifiedDate(item),
  200. size: adapter.getItemSize(item),
  201. custom: {
  202. // @todo isTeamDrive is left for backward compatibility. We should remove it in the next
  203. // major release.
  204. isTeamDrive: adapter.isSharedDrive(item),
  205. isSharedDrive: adapter.isSharedDrive(item)
  206. }
  207. })
  208. const items = adapter.getItemSubList(res)
  209. const sharedDrives = sharedDrivesResp ? sharedDrivesResp.drives || [] : []
  210. const adaptedItems = sharedDrives.concat(items).map(adaptItem)
  211. return {
  212. username: adapter.getUsername(res),
  213. items: adaptedItems,
  214. nextPagePath: adapter.getNextPagePath(res, query, directory)
  215. }
  216. }
  217. _error (err, resp) {
  218. if (resp) {
  219. const fallbackMessage = `request to ${this.authProvider} returned ${resp.statusCode}`
  220. const errMsg = (resp.body && resp.body.error) ? resp.body.error.message : fallbackMessage
  221. return resp.statusCode === 401 ? new ProviderAuthError() : new ProviderApiError(errMsg, resp.statusCode)
  222. }
  223. return err
  224. }
  225. }
  226. module.exports = Drive