index.js 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. const got = require('got').default
  2. const Provider = require('../Provider')
  3. const logger = require('../../logger')
  4. const { VIRTUAL_SHARED_DIR, adaptData, isShortcut, isGsuiteFile, getGsuiteExportType } = require('./adapter')
  5. const { withProviderErrorHandling } = require('../providerErrors')
  6. const { prepareStream } = require('../../helpers/utils')
  7. const DRIVE_FILE_FIELDS = 'kind,id,imageMediaMetadata,name,mimeType,ownedByMe,size,modifiedTime,iconLink,thumbnailLink,teamDriveId,videoMediaMetadata,shortcutDetails(targetId,targetMimeType)'
  8. const DRIVE_FILES_FIELDS = `kind,nextPageToken,incompleteSearch,files(${DRIVE_FILE_FIELDS})`
  9. // using wildcard to get all 'drive' fields because specifying fields seems no to work for the /drives endpoint
  10. const SHARED_DRIVE_FIELDS = '*'
  11. const getClient = ({ token }) => got.extend({
  12. prefixUrl: 'https://www.googleapis.com/drive/v3',
  13. headers: {
  14. authorization: `Bearer ${token}`,
  15. },
  16. })
  17. const getOauthClient = () => got.extend({
  18. prefixUrl: 'https://oauth2.googleapis.com',
  19. })
  20. async function getStats ({ id, token }) {
  21. const client = getClient({ token })
  22. const getStatsInner = async (statsOfId) => (
  23. client.get(`files/${encodeURIComponent(statsOfId)}`, { searchParams: { fields: DRIVE_FILE_FIELDS, supportsAllDrives: true }, responseType: 'json' }).json()
  24. )
  25. const stats = await getStatsInner(id)
  26. // If it is a shortcut, we need to get stats again on the target
  27. if (isShortcut(stats.mimeType)) return getStatsInner(stats.shortcutDetails.targetId)
  28. return stats
  29. }
  30. /**
  31. * Adapter for API https://developers.google.com/drive/api/v3/
  32. */
  33. class Drive extends Provider {
  34. constructor (options) {
  35. super(options)
  36. this.authProvider = Drive.authProvider
  37. }
  38. static get authProvider () {
  39. return 'google'
  40. }
  41. async list (options) {
  42. return this.#withErrorHandling('provider.drive.list.error', async () => {
  43. const directory = options.directory || 'root'
  44. const query = options.query || {}
  45. const { token } = options
  46. const isRoot = directory === 'root'
  47. const isVirtualSharedDirRoot = directory === VIRTUAL_SHARED_DIR
  48. const client = getClient({ token })
  49. async function fetchSharedDrives (pageToken = null) {
  50. const shouldListSharedDrives = isRoot && !query.cursor
  51. if (!shouldListSharedDrives) return undefined
  52. const response = await client.get('drives', { searchParams: { fields: SHARED_DRIVE_FIELDS, pageToken, pageSize: 100 }, responseType: 'json' }).json()
  53. const { nextPageToken } = response
  54. if (nextPageToken) {
  55. const nextResponse = await fetchSharedDrives(nextPageToken)
  56. if (!nextResponse) return response
  57. return { ...nextResponse, drives: [...response.drives, ...nextResponse.drives] }
  58. }
  59. return response
  60. }
  61. async function fetchFiles () {
  62. // Shared with me items in root don't have any parents
  63. const q = isVirtualSharedDirRoot
  64. ? `sharedWithMe and trashed=false`
  65. : `('${directory}' in parents) and trashed=false`
  66. const searchParams = {
  67. fields: DRIVE_FILES_FIELDS,
  68. pageToken: query.cursor,
  69. q,
  70. // We can only do a page size of 1000 because we do not request permissions in DRIVE_FILES_FIELDS.
  71. // Otherwise we are limited to 100. Instead we get the user info from `this.user()`
  72. pageSize: 1000,
  73. orderBy: 'folder,name',
  74. includeItemsFromAllDrives: true,
  75. supportsAllDrives: true,
  76. }
  77. return client.get('files', { searchParams, responseType: 'json' }).json()
  78. }
  79. async function fetchAbout () {
  80. const searchParams = { fields: 'user' }
  81. return client.get('about', { searchParams, responseType: 'json' }).json()
  82. }
  83. const [sharedDrives, filesResponse, about] = await Promise.all([fetchSharedDrives(), fetchFiles(), fetchAbout()])
  84. return adaptData(
  85. filesResponse,
  86. sharedDrives,
  87. directory,
  88. query,
  89. isRoot && !query.cursor, // we can only show it on the first page request, or else we will have duplicates of it
  90. about,
  91. )
  92. })
  93. }
  94. async download ({ id: idIn, token }) {
  95. return this.#withErrorHandling('provider.drive.download.error', async () => {
  96. const client = getClient({ token })
  97. const { mimeType, id } = await getStats({ id: idIn, token })
  98. let stream
  99. if (isGsuiteFile(mimeType)) {
  100. const mimeType2 = getGsuiteExportType(mimeType)
  101. logger.info(`calling google file export for ${id} to ${mimeType2}`, 'provider.drive.export')
  102. stream = client.stream.get(`files/${encodeURIComponent(id)}/export`, { searchParams: { supportsAllDrives: true, mimeType: mimeType2 }, responseType: 'json' })
  103. } else {
  104. stream = client.stream.get(`files/${encodeURIComponent(id)}`, { searchParams: { alt: 'media', supportsAllDrives: true }, responseType: 'json' })
  105. }
  106. await prepareStream(stream)
  107. return { stream }
  108. })
  109. }
  110. // eslint-disable-next-line class-methods-use-this
  111. async thumbnail () {
  112. // not implementing this because a public thumbnail from googledrive will be used instead
  113. logger.error('call to thumbnail is not implemented', 'provider.drive.thumbnail.error')
  114. throw new Error('call to thumbnail is not implemented')
  115. }
  116. async size ({ id, token }) {
  117. return this.#withErrorHandling('provider.drive.size.error', async () => {
  118. const { mimeType, size } = await getStats({ id, token })
  119. if (isGsuiteFile(mimeType)) {
  120. // GSuite file sizes cannot be predetermined (but are max 10MB)
  121. // e.g. Transfer-Encoding: chunked
  122. return undefined
  123. }
  124. return parseInt(size, 10)
  125. })
  126. }
  127. logout ({ token }) {
  128. return this.#withErrorHandling('provider.drive.logout.error', async () => {
  129. await got.post('https://accounts.google.com/o/oauth2/revoke', {
  130. searchParams: { token },
  131. responseType: 'json',
  132. })
  133. return { revoked: true }
  134. })
  135. }
  136. async refreshToken ({ clientId, clientSecret, refreshToken }) {
  137. return this.#withErrorHandling('provider.drive.token.refresh.error', async () => {
  138. const { access_token: accessToken } = await getOauthClient().post('token', { responseType: 'json', form: { refresh_token: refreshToken, grant_type: 'refresh_token', client_id: clientId, client_secret: clientSecret } }).json()
  139. return { accessToken }
  140. })
  141. }
  142. async #withErrorHandling (tag, fn) {
  143. return withProviderErrorHandling({
  144. fn,
  145. tag,
  146. providerName: this.authProvider,
  147. isAuthError: (response) => (
  148. response.statusCode === 401
  149. || (response.statusCode === 400 && response.body?.error === 'invalid_grant') // Refresh token has expired or been revoked
  150. ),
  151. getJsonErrorMessage: (body) => body?.error?.message,
  152. })
  153. }
  154. }
  155. module.exports = Drive