index.js 8.4 KB

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