index.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  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, mimeType) {
  94. logger.info(`calling google file export for ${id} to ${mimeType}`, 'provider.drive.export')
  95. return this.client
  96. .query()
  97. .get(`files/${id}/export`)
  98. .qs({ supportsAllDrives: true, mimeType })
  99. .auth(token)
  100. .request()
  101. }
  102. _getGsuiteFileMeta (id, token, mimeType, 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 })
  108. .auth(token)
  109. .request(onDone)
  110. }
  111. _getGsuiteExportType (mimeType) {
  112. const typeMaps = {
  113. 'application/vnd.google-apps.document': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  114. 'application/vnd.google-apps.drawing': 'image/png',
  115. 'application/vnd.google-apps.script': 'application/vnd.google-apps.script+json',
  116. 'application/vnd.google-apps.spreadsheet': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  117. 'application/vnd.google-apps.presentation': 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
  118. }
  119. return typeMaps[mimeType] || 'application/pdf'
  120. }
  121. _isGsuiteFile (mimeType) {
  122. return mimeType.startsWith('application/vnd.google')
  123. }
  124. download ({ id, token }, onData) {
  125. this.stats({ id, token }, (err, resp, body) => {
  126. if (err) {
  127. logger.error(err, 'provider.drive.download.stats.error')
  128. onData(err)
  129. return
  130. }
  131. let requestStream
  132. if (this._isGsuiteFile(body.mimeType)) {
  133. requestStream = this._exportGsuiteFile(id, token, this._getGsuiteExportType(body.mimeType))
  134. } else {
  135. requestStream = this.client
  136. .query()
  137. .get(`files/${id}`)
  138. .qs({ alt: 'media', supportsAllDrives: true })
  139. .auth(token)
  140. .request()
  141. }
  142. requestStream
  143. .on('data', (chunk) => onData(null, chunk))
  144. .on('end', () => onData(null, null))
  145. .on('error', (err) => {
  146. logger.error(err, 'provider.drive.download.error')
  147. onData(err)
  148. })
  149. })
  150. }
  151. thumbnail (_, done) {
  152. // not implementing this because a public thumbnail from googledrive will be used instead
  153. const err = new Error('call to thumbnail is not implemented')
  154. logger.error(err, 'provider.drive.thumbnail.error')
  155. return done(err)
  156. }
  157. size ({ id, token }, done) {
  158. return this.stats({ id, token }, (err, resp, body) => {
  159. if (err || resp.statusCode !== 200) {
  160. err = this._error(err, resp)
  161. logger.error(err, 'provider.drive.size.error')
  162. return done(err)
  163. }
  164. if (this._isGsuiteFile(body.mimeType)) {
  165. // Google Docs file sizes can be determined
  166. // while Google sheets file sizes can't be determined
  167. const googleDocMimeType = 'application/vnd.google-apps.document'
  168. if (body.mimeType !== googleDocMimeType) {
  169. const maxExportFileSize = 10 * 1024 * 1024 // 10 MB
  170. done(null, maxExportFileSize)
  171. return
  172. }
  173. this._getGsuiteFileMeta(id, token, this._getGsuiteExportType(body.mimeType), (err, resp) => {
  174. if (err || resp.statusCode !== 200) {
  175. err = this._error(err, resp)
  176. logger.error(err, 'provider.drive.docs.size.error')
  177. return done(err)
  178. }
  179. const size = resp.headers['content-length']
  180. done(null, size ? parseInt(size) : null)
  181. })
  182. } else {
  183. done(null, parseInt(body.size))
  184. }
  185. })
  186. }
  187. logout ({ token }, done) {
  188. return this.client
  189. .get('https://accounts.google.com/o/oauth2/revoke')
  190. .qs({ token })
  191. .request((err, resp) => {
  192. if (err || resp.statusCode !== 200) {
  193. logger.error(err, 'provider.drive.logout.error')
  194. done(this._error(err, resp))
  195. return
  196. }
  197. done(null, { revoked: true })
  198. })
  199. }
  200. adaptData (res, sharedDrivesResp, directory, query) {
  201. const adaptItem = (item) => ({
  202. isFolder: adapter.isFolder(item),
  203. icon: adapter.getItemIcon(item),
  204. name: adapter.getItemName(item),
  205. mimeType: adapter.getMimeType(item),
  206. id: adapter.getItemId(item),
  207. thumbnail: adapter.getItemThumbnailUrl(item),
  208. requestPath: adapter.getItemRequestPath(item),
  209. modifiedDate: adapter.getItemModifiedDate(item),
  210. size: adapter.getItemSize(item),
  211. custom: {
  212. // @todo isTeamDrive is left for backward compatibility. We should remove it in the next
  213. // major release.
  214. isTeamDrive: adapter.isSharedDrive(item),
  215. isSharedDrive: adapter.isSharedDrive(item)
  216. }
  217. })
  218. const items = adapter.getItemSubList(res)
  219. const sharedDrives = sharedDrivesResp ? sharedDrivesResp.drives || [] : []
  220. const adaptedItems = sharedDrives.concat(items).map(adaptItem)
  221. return {
  222. username: adapter.getUsername(res),
  223. items: adaptedItems,
  224. nextPagePath: adapter.getNextPagePath(res, query, directory)
  225. }
  226. }
  227. _error (err, resp) {
  228. if (resp) {
  229. const fallbackMessage = `request to ${this.authProvider} returned ${resp.statusCode}`
  230. const errMsg = (resp.body && resp.body.error) ? resp.body.error.message : fallbackMessage
  231. return resp.statusCode === 401 ? new ProviderAuthError() : new ProviderApiError(errMsg, resp.statusCode)
  232. }
  233. return err
  234. }
  235. }
  236. module.exports = Drive