123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227 |
- const Provider = require('../Provider')
- const logger = require('../../logger')
- const { VIRTUAL_SHARED_DIR, adaptData, isShortcut, isGsuiteFile, getGsuiteExportType } = require('./adapter')
- const { withProviderErrorHandling } = require('../providerErrors')
- const { prepareStream } = require('../../helpers/utils')
- const { MAX_AGE_REFRESH_TOKEN } = require('../../helpers/jwt')
- const { ProviderAuthError } = require('../error')
- const got = require('../../got')
- // For testing refresh token:
- // first run a download with mockAccessTokenExpiredError = true
- // then when you want to test expiry, set to mockAccessTokenExpiredError to the logged access token
- // This will trigger companion/nodemon to restart, and it will respond with a simulated invalid token response
- const mockAccessTokenExpiredError = undefined
- // const mockAccessTokenExpiredError = true
- // const mockAccessTokenExpiredError = ''
- const DRIVE_FILE_FIELDS = 'kind,id,imageMediaMetadata,name,mimeType,ownedByMe,size,modifiedTime,iconLink,thumbnailLink,teamDriveId,videoMediaMetadata,exportLinks,shortcutDetails(targetId,targetMimeType)'
- const DRIVE_FILES_FIELDS = `kind,nextPageToken,incompleteSearch,files(${DRIVE_FILE_FIELDS})`
- // using wildcard to get all 'drive' fields because specifying fields seems no to work for the /drives endpoint
- const SHARED_DRIVE_FIELDS = '*'
- const getClient = async ({ token }) => (await got).extend({
- prefixUrl: 'https://www.googleapis.com/drive/v3',
- headers: {
- authorization: `Bearer ${token}`,
- },
- })
- const getOauthClient = async () => (await got).extend({
- prefixUrl: 'https://oauth2.googleapis.com',
- })
- async function getStats ({ id, token }) {
- const client = await getClient({ token })
- const getStatsInner = async (statsOfId) => (
- client.get(`files/${encodeURIComponent(statsOfId)}`, { searchParams: { fields: DRIVE_FILE_FIELDS, supportsAllDrives: true }, responseType: 'json' }).json()
- )
- const stats = await getStatsInner(id)
- // If it is a shortcut, we need to get stats again on the target
- if (isShortcut(stats.mimeType)) return getStatsInner(stats.shortcutDetails.targetId)
- return stats
- }
- /**
- * Adapter for API https://developers.google.com/drive/api/v3/
- */
- class Drive extends Provider {
- static get authProvider () {
- return 'google'
- }
- static get authStateExpiry () {
- return MAX_AGE_REFRESH_TOKEN
- }
- async list (options) {
- return this.#withErrorHandling('provider.drive.list.error', async () => {
- const directory = options.directory || 'root'
- const query = options.query || {}
- const { token } = options
- const isRoot = directory === 'root'
- const isVirtualSharedDirRoot = directory === VIRTUAL_SHARED_DIR
- const client = await getClient({ token })
- async function fetchSharedDrives (pageToken = null) {
- const shouldListSharedDrives = isRoot && !query.cursor
- if (!shouldListSharedDrives) return undefined
- const response = await client.get('drives', { searchParams: { fields: SHARED_DRIVE_FIELDS, pageToken, pageSize: 100 }, responseType: 'json' }).json()
- const { nextPageToken } = response
- if (nextPageToken) {
- const nextResponse = await fetchSharedDrives(nextPageToken)
- if (!nextResponse) return response
- return { ...nextResponse, drives: [...response.drives, ...nextResponse.drives] }
- }
- return response
- }
- async function fetchFiles () {
- // Shared with me items in root don't have any parents
- const q = isVirtualSharedDirRoot
- ? `sharedWithMe and trashed=false`
- : `('${directory}' in parents) and trashed=false`
- const searchParams = {
- fields: DRIVE_FILES_FIELDS,
- pageToken: query.cursor,
- q,
- // We can only do a page size of 1000 because we do not request permissions in DRIVE_FILES_FIELDS.
- // Otherwise we are limited to 100. Instead we get the user info from `this.user()`
- pageSize: 1000,
- orderBy: 'folder,name',
- includeItemsFromAllDrives: true,
- supportsAllDrives: true,
- }
- return client.get('files', { searchParams, responseType: 'json' }).json()
- }
- async function fetchAbout () {
- const searchParams = { fields: 'user' }
- return client.get('about', { searchParams, responseType: 'json' }).json()
- }
- const [sharedDrives, filesResponse, about] = await Promise.all([fetchSharedDrives(), fetchFiles(), fetchAbout()])
- return adaptData(
- filesResponse,
- sharedDrives,
- directory,
- query,
- isRoot && !query.cursor, // we can only show it on the first page request, or else we will have duplicates of it
- about,
- )
- })
- }
- async download ({ id: idIn, token }) {
- if (mockAccessTokenExpiredError != null) {
- logger.warn(`Access token: ${token}`)
- if (mockAccessTokenExpiredError === token) {
- logger.warn('Mocking expired access token!')
- throw new ProviderAuthError()
- }
- }
- return this.#withErrorHandling('provider.drive.download.error', async () => {
- const client = await getClient({ token })
- const { mimeType, id, exportLinks } = await getStats({ id: idIn, token })
- let stream
- if (isGsuiteFile(mimeType)) {
- const mimeType2 = getGsuiteExportType(mimeType)
- logger.info(`calling google file export for ${id} to ${mimeType2}`, 'provider.drive.export')
- // GSuite files exported with large converted size results in error using standard export method.
- // Error message: "This file is too large to be exported.".
- // Issue logged in Google APIs: https://github.com/googleapis/google-api-nodejs-client/issues/3446
- // Implemented based on the answer from StackOverflow: https://stackoverflow.com/a/59168288
- const mimeTypeExportLink = exportLinks?.[mimeType2]
- if (mimeTypeExportLink) {
- const gSuiteFilesClient = (await got).extend({
- headers: {
- authorization: `Bearer ${token}`,
- },
- })
- stream = gSuiteFilesClient.stream.get(mimeTypeExportLink, { responseType: 'json' })
- } else {
- stream = client.stream.get(`files/${encodeURIComponent(id)}/export`, { searchParams: { supportsAllDrives: true, mimeType: mimeType2 }, responseType: 'json' })
- }
- } else {
- stream = client.stream.get(`files/${encodeURIComponent(id)}`, { searchParams: { alt: 'media', supportsAllDrives: true }, responseType: 'json' })
- }
- await prepareStream(stream)
- return { stream }
- })
- }
- // eslint-disable-next-line class-methods-use-this
- async thumbnail () {
- // not implementing this because a public thumbnail from googledrive will be used instead
- logger.error('call to thumbnail is not implemented', 'provider.drive.thumbnail.error')
- throw new Error('call to thumbnail is not implemented')
- }
- async size ({ id, token }) {
- return this.#withErrorHandling('provider.drive.size.error', async () => {
- const { mimeType, size } = await getStats({ id, token })
- if (isGsuiteFile(mimeType)) {
- // GSuite file sizes cannot be predetermined (but are max 10MB)
- // e.g. Transfer-Encoding: chunked
- return undefined
- }
- return parseInt(size, 10)
- })
- }
- logout ({ token }) {
- return this.#withErrorHandling('provider.drive.logout.error', async () => {
- await (await got).post('https://accounts.google.com/o/oauth2/revoke', {
- searchParams: { token },
- responseType: 'json',
- })
- return { revoked: true }
- })
- }
- async refreshToken ({ clientId, clientSecret, refreshToken }) {
- return this.#withErrorHandling('provider.drive.token.refresh.error', async () => {
- 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()
- return { accessToken }
- })
- }
- // eslint-disable-next-line class-methods-use-this
- async #withErrorHandling (tag, fn) {
- return withProviderErrorHandling({
- fn,
- tag,
- providerName: Drive.authProvider,
- isAuthError: (response) => (
- response.statusCode === 401
- || (response.statusCode === 400 && response.body?.error === 'invalid_grant') // Refresh token has expired or been revoked
- ),
- getJsonErrorMessage: (body) => body?.error?.message,
- })
- }
- }
- module.exports = Drive
|