Bladeren bron

@uppy/google-photos: add plugin (#5265)

Backport of #5061
Antoine du Hamel 10 maanden geleden
bovenliggende
commit
12cf278cf6

+ 27 - 12
packages/@uppy/companion/src/config/grant.js

@@ -1,19 +1,34 @@
+const google = {
+  transport: 'session',
+
+  // access_type: offline is needed in order to get refresh tokens.
+  // prompt: 'consent' is needed because sometimes a user will get stuck in an authenticated state where we will
+  // receive no refresh tokens from them. This seems to be happen when running on different subdomains.
+  // therefore to be safe that we always get refresh tokens, we set this.
+  // https://stackoverflow.com/questions/10827920/not-receiving-google-oauth-refresh-token/65108513#65108513
+  custom_params: { access_type : 'offline', prompt: 'consent' },
+
+  // copied from https://github.com/simov/grant/blob/master/config/oauth.json
+  "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth",
+  "access_url": "https://oauth2.googleapis.com/token",
+  "oauth": 2,
+  "scope_delimiter": " "
+}
+
 // oauth configuration for provider services that are used.
 module.exports = () => {
   return {
-    // for drive
-    google: {
-      transport: 'session',
-      scope: [
-        'https://www.googleapis.com/auth/drive.readonly',
-      ],
+    // we need separate auth providers because scopes are different,
+    // and because it would be a too big rewrite to allow reuse of the same provider.
+    googledrive: {
+      ...google,
       callback: '/drive/callback',
-      // access_type: offline is needed in order to get refresh tokens.
-      // prompt: 'consent' is needed because sometimes a user will get stuck in an authenticated state where we will
-      // receive no refresh tokens from them. This seems to be happen when running on different subdomains.
-      // therefore to be safe that we always get refresh tokens, we set this.
-      // https://stackoverflow.com/questions/10827920/not-receiving-google-oauth-refresh-token/65108513#65108513
-      custom_params: { access_type : 'offline', prompt: 'consent' },
+      scope: ['https://www.googleapis.com/auth/drive.readonly'],
+    },
+    googlephotos: {
+      ...google,
+      callback: '/googlephotos/callback',
+      scope: ['https://www.googleapis.com/auth/photoslibrary.readonly', 'https://www.googleapis.com/auth/userinfo.email'], // if name is needed, then add https://www.googleapis.com/auth/userinfo.profile too
     },
     dropbox: {
       transport: 'session',

+ 1 - 4
packages/@uppy/companion/src/server/controllers/get.js

@@ -11,10 +11,7 @@ async function get (req, res) {
     return provider.size({ id, token: accessToken, query: req.query })
   }
 
-  async function download () {
-    const { stream } = await provider.download({ id, token: accessToken, providerUserSession, query: req.query })
-    return stream
-  }
+  const download = () => provider.download({ id, token: accessToken, providerUserSession, query: req.query })
 
   try {
     await startDownUpload({ req, res, getSize, download })

+ 3 - 5
packages/@uppy/companion/src/server/controllers/url.js

@@ -25,8 +25,8 @@ const downloadURL = async (url, allowLocalIPs, traceId) => {
   try {
     const protectedGot = await getProtectedGot({ allowLocalIPs })
     const stream = protectedGot.stream.get(url, { responseType: 'json' })
-    await prepareStream(stream)
-    return stream
+    const { size } = await prepareStream(stream)
+    return { stream, size }
   } catch (err) {
     logger.error(err, 'controller.url.download.error', traceId)
     throw err
@@ -77,9 +77,7 @@ const get = async (req, res) => {
     return size
   }
 
-  async function download () {
-    return downloadURL(req.body.url, allowLocalUrls, req.id)
-  }
+  const download = () => downloadURL(req.body.url, allowLocalUrls, req.id)
 
   try {
     await startDownUpload({ req, res, getSize, download })

+ 2 - 2
packages/@uppy/companion/src/server/helpers/oauth-state.js

@@ -6,7 +6,7 @@ module.exports.encodeState = (state, secret) => {
   return encrypt(encodedState, secret)
 }
 
-const decodeState = (state, secret) => {
+module.exports.decodeState = (state, secret) => {
   const encodedState = decrypt(state, secret)
   return JSON.parse(atob(encodedState))
 }
@@ -18,7 +18,7 @@ module.exports.generateState = () => {
 }
 
 module.exports.getFromState = (state, name, secret) => {
-  return decodeState(state, secret)[name]
+  return module.exports.decodeState(state, secret)[name]
 }
 
 module.exports.getGrantDynamicFromRequest = (req) => {

+ 12 - 4
packages/@uppy/companion/src/server/helpers/upload.js

@@ -4,15 +4,23 @@ const { respondWithError } = require('../provider/error')
 
 async function startDownUpload({ req, res, getSize, download }) {
   try {
-    const size = await getSize()
+    logger.debug('Starting download stream.', null, req.id)
+    const { stream, size: maybeSize } = await download()
+
+    let size
+    // if the provider already knows the size, we can use that
+    if (typeof maybeSize === 'number' && !Number.isNaN(maybeSize) && maybeSize > 0) {
+      size = maybeSize
+    }
+    // if not we need to get the size
+    if (size == null) {
+      size = await getSize()
+    }
     const { clientSocketConnectTimeout } = req.companion.options
 
     logger.debug('Instantiating uploader.', null, req.id)
     const uploader = new Uploader(Uploader.reqToOptions(req, size))
 
-    logger.debug('Starting download stream.', null, req.id)
-    const stream = await download()
-
       // "Forking" off the upload operation to background, so we can return the http request:
       ; (async () => {
         // wait till the client has connected to the socket, before starting

+ 5 - 2
packages/@uppy/companion/src/server/helpers/utils.js

@@ -165,11 +165,14 @@ module.exports.StreamHttpJsonError = StreamHttpJsonError
 
 module.exports.prepareStream = async (stream) => new Promise((resolve, reject) => {
   stream
-    .on('response', () => {
+    .on('response', (response) => {
+      const contentLengthStr = response.headers['content-length']
+      const contentLength = parseInt(contentLengthStr, 10);
+      const size = !Number.isNaN(contentLength) && contentLength >= 0 ? contentLength : undefined;
       // Don't allow any more data to flow yet.
       // https://github.com/request/request/issues/1990#issuecomment-184712275
       stream.pause()
-      resolve()
+      resolve({ size })
     })
     .on('error', (err) => {
       // In this case the error object is not a normal GOT HTTPError where json is already parsed,

+ 0 - 190
packages/@uppy/companion/src/server/provider/drive/adapter.js

@@ -1,190 +0,0 @@
-const querystring = require('node:querystring')
-
-const getUsername = (data) => {
-  return data.user.emailAddress
-}
-
-exports.isGsuiteFile = (mimeType) => {
-  return mimeType && mimeType.startsWith('application/vnd.google')
-}
-
-const isSharedDrive = (item) => {
-  return item.kind === 'drive#drive'
-}
-
-const isFolder = (item) => {
-  return item.mimeType === 'application/vnd.google-apps.folder' || isSharedDrive(item)
-}
-
-exports.isShortcut = (mimeType) => {
-  return mimeType === 'application/vnd.google-apps.shortcut'
-}
-
-const getItemSize = (item) => {
-  return parseInt(item.size, 10)
-}
-
-const getItemIcon = (item) => {
-  if (isSharedDrive(item)) {
-    const size = '=w16-h16-n'
-    const sizeParamRegex = /=[-whncsp0-9]*$/
-    return item.backgroundImageLink.match(sizeParamRegex)
-      ? item.backgroundImageLink.replace(sizeParamRegex, size)
-      : `${item.backgroundImageLink}${size}`
-  }
-
-  if (item.thumbnailLink && !item.mimeType.startsWith('application/vnd.google')) {
-    const smallerThumbnailLink = item.thumbnailLink.replace('s220', 's40')
-    return smallerThumbnailLink
-  }
-
-  return item.iconLink
-}
-
-const getItemSubList = (item) => {
-  const allowedGSuiteTypes = [
-    'application/vnd.google-apps.document',
-    'application/vnd.google-apps.drawing',
-    'application/vnd.google-apps.script',
-    'application/vnd.google-apps.spreadsheet',
-    'application/vnd.google-apps.presentation',
-    'application/vnd.google-apps.shortcut',
-  ]
-
-  return item.files.filter((i) => {
-    return isFolder(i) || !exports.isGsuiteFile(i.mimeType) || allowedGSuiteTypes.includes(i.mimeType)
-  })
-}
-
-const getItemName = (item) => {
-  const extensionMaps = {
-    'application/vnd.google-apps.document': '.docx',
-    'application/vnd.google-apps.drawing': '.png',
-    'application/vnd.google-apps.script': '.json',
-    'application/vnd.google-apps.spreadsheet': '.xlsx',
-    'application/vnd.google-apps.presentation': '.ppt',
-  }
-
-  const extension = extensionMaps[item.mimeType]
-  if (extension && item.name && !item.name.endsWith(extension)) {
-    return item.name + extension
-  }
-
-  return item.name ? item.name : '/'
-}
-
-exports.getGsuiteExportType = (mimeType) => {
-  const typeMaps = {
-    'application/vnd.google-apps.document': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
-    'application/vnd.google-apps.drawing': 'image/png',
-    'application/vnd.google-apps.script': 'application/vnd.google-apps.script+json',
-    'application/vnd.google-apps.spreadsheet': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
-    'application/vnd.google-apps.presentation': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
-  }
-
-  return typeMaps[mimeType] || 'application/pdf'
-}
-
-function getMimeType2 (mimeType) {
-  if (exports.isGsuiteFile(mimeType)) {
-    return exports.getGsuiteExportType(mimeType)
-  }
-  return mimeType
-}
-
-const getMimeType = (item) => {
-  if (exports.isShortcut(item.mimeType)) {
-    return getMimeType2(item.shortcutDetails.targetMimeType)
-  }
-  return getMimeType2(item.mimeType)
-}
-
-const getItemId = (item) => {
-  return item.id
-}
-
-const getItemRequestPath = (item) => {
-  return item.id
-}
-
-const getItemModifiedDate = (item) => {
-  return item.modifiedTime
-}
-
-const getItemThumbnailUrl = (item) => {
-  return item.thumbnailLink
-}
-
-const getNextPagePath = (data, currentQuery, currentPath) => {
-  if (!data.nextPageToken) {
-    return null
-  }
-  const query = { ...currentQuery, cursor: data.nextPageToken }
-  return `${currentPath}?${querystring.stringify(query)}`
-}
-
-const getImageHeight = (item) => item.imageMediaMetadata && item.imageMediaMetadata.height
-
-const getImageWidth = (item) => item.imageMediaMetadata && item.imageMediaMetadata.width
-
-const getImageRotation = (item) => item.imageMediaMetadata && item.imageMediaMetadata.rotation
-
-const getImageDate = (item) => item.imageMediaMetadata && item.imageMediaMetadata.date
-
-const getVideoHeight = (item) => item.videoMediaMetadata && item.videoMediaMetadata.height
-
-const getVideoWidth = (item) => item.videoMediaMetadata && item.videoMediaMetadata.width
-
-const getVideoDurationMillis = (item) => item.videoMediaMetadata && item.videoMediaMetadata.durationMillis
-
-// Hopefully this name will not be used by Google
-exports.VIRTUAL_SHARED_DIR = 'shared-with-me'
-
-exports.adaptData = (listFilesResp, sharedDrivesResp, directory, query, showSharedWithMe, about) => {
-  const adaptItem = (item) => ({
-    isFolder: isFolder(item),
-    icon: getItemIcon(item),
-    name: getItemName(item),
-    mimeType: getMimeType(item),
-    id: getItemId(item),
-    thumbnail: getItemThumbnailUrl(item),
-    requestPath: getItemRequestPath(item),
-    modifiedDate: getItemModifiedDate(item),
-    size: getItemSize(item),
-    custom: {
-      isSharedDrive: isSharedDrive(item),
-      imageHeight: getImageHeight(item),
-      imageWidth: getImageWidth(item),
-      imageRotation: getImageRotation(item),
-      imageDateTime: getImageDate(item),
-      videoHeight: getVideoHeight(item),
-      videoWidth: getVideoWidth(item),
-      videoDurationMillis: getVideoDurationMillis(item),
-    },
-  })
-
-  const items = getItemSubList(listFilesResp)
-  const sharedDrives = sharedDrivesResp ? sharedDrivesResp.drives || [] : []
-
-  // “Shared with me” is a list of shared documents,
-  // not the same as sharedDrives
-  const virtualItem = showSharedWithMe && ({
-    isFolder: true,
-    icon: 'folder',
-    name: 'Shared with me',
-    mimeType: 'application/vnd.google-apps.folder',
-    id: exports.VIRTUAL_SHARED_DIR,
-    requestPath: exports.VIRTUAL_SHARED_DIR,
-  })
-
-  const adaptedItems = [
-    ...(virtualItem ? [virtualItem] : []), // shared folder first
-    ...([...sharedDrives, ...items].map(adaptItem)),
-  ]
-
-  return {
-    username: getUsername(about),
-    items: adaptedItems,
-    nextPagePath: getNextPagePath(listFilesResp, query, directory),
-  }
-}

+ 0 - 227
packages/@uppy/companion/src/server/provider/drive/index.js

@@ -1,227 +0,0 @@
-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 oauthProvider () {
-    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.oauthProvider,
-      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

+ 4 - 12
packages/@uppy/companion/src/server/provider/google/drive/index.js

@@ -9,7 +9,6 @@ const { ProviderAuthError } = require('../../error')
 const { withGoogleErrorHandling } = require('../../providerErrors')
 const Provider = require('../../Provider')
 
-
 // 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
@@ -48,7 +47,7 @@ async function getStats ({ id, token }) {
  * Adapter for API https://developers.google.com/drive/api/v3/
  */
 class Drive extends Provider {
-  static get authProvider () {
+  static get oauthProvider () {
     return 'googledrive'
   }
 
@@ -58,7 +57,7 @@ class Drive extends Provider {
 
   // eslint-disable-next-line class-methods-use-this
   async list (options) {
-    return withGoogleErrorHandling(Drive.authProvider, 'provider.drive.list.error', async () => {
+    return withGoogleErrorHandling(Drive.oauthProvider, 'provider.drive.list.error', async () => {
       const directory = options.directory || 'root'
       const query = options.query || {}
       const { token } = options
@@ -135,7 +134,7 @@ class Drive extends Provider {
       }
     }
 
-    return withGoogleErrorHandling(Drive.authProvider, 'provider.drive.download.error', async () => {
+    return withGoogleErrorHandling(Drive.oauthProvider, 'provider.drive.download.error', async () => {
       const client = await getClient({ token })
 
       const { mimeType, id, exportLinks } = await getStats({ id: idIn, token })
@@ -172,7 +171,7 @@ class Drive extends Provider {
 
   // eslint-disable-next-line class-methods-use-this
   async size ({ id, token }) {
-    return withGoogleErrorHandling(Drive.authProvider, 'provider.drive.size.error', async () => {
+    return withGoogleErrorHandling(Drive.oauthProvider, 'provider.drive.size.error', async () => {
       const { mimeType, size } = await getStats({ id, token })
 
       if (isGsuiteFile(mimeType)) {
@@ -184,13 +183,6 @@ class Drive extends Provider {
       return parseInt(size, 10)
     })
   }
-
-  // eslint-disable-next-line class-methods-use-this
-  async logout(...args) {
-    return logout(...args)
-  }
-
-  // eslint-disable-next-line class-methods-use-this
 }
 
 Drive.prototype.logout = logout

+ 165 - 0
packages/@uppy/companion/src/server/provider/google/googlephotos/index.js

@@ -0,0 +1,165 @@
+const got = require('../../../got')
+
+const { logout, refreshToken } = require('../index')
+const { withGoogleErrorHandling } = require('../../providerErrors')
+const { prepareStream } = require('../../../helpers/utils')
+const { MAX_AGE_REFRESH_TOKEN } = require('../../../helpers/jwt')
+const logger = require('../../../logger')
+const Provider = require('../../Provider')
+
+
+const getBaseClient = async ({ token }) => (await got).extend({
+  headers: {
+    authorization: `Bearer ${token}`,
+  },
+})
+
+const getPhotosClient = async ({ token }) => (await getBaseClient({ token })).extend({
+  prefixUrl: 'https://photoslibrary.googleapis.com/v1',
+})
+
+const getOauthClient = async ({ token }) => (await getBaseClient({ token })).extend({
+  prefixUrl: 'https://www.googleapis.com/oauth2/v1',
+})
+
+async function paginate(fn, getter, limit = 5) {
+  const items = []
+  let pageToken
+
+  for (let i = 0; (i === 0 || pageToken != null); i++) {
+    if (i >= limit) {
+      logger.warn(`Hit pagination limit of ${limit}`)
+      break;
+    }
+    const response = await fn(pageToken);
+    items.push(...getter(response));
+    pageToken = response.nextPageToken
+  }
+  return items
+}
+
+/**
+ * Provider for Google Photos API
+ */
+class GooglePhotos extends Provider {
+  static get oauthProvider () {
+    return 'googlephotos'
+  }
+
+  static get authStateExpiry () {
+    return MAX_AGE_REFRESH_TOKEN
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  async list (options) {
+    return withGoogleErrorHandling(GooglePhotos.oauthProvider, 'provider.photos.list.error', async () => {
+      const { directory, query } = options
+      const { token } = options
+
+      const isRoot = !directory
+
+      const client = await getPhotosClient({ token })
+
+
+      async function fetchAlbums () {
+        if (!isRoot) return [] // albums are only in the root
+
+        return paginate(
+          (pageToken) => client.get('albums', { searchParams: { pageToken, pageSize: 50 }, responseType: 'json' }).json(),
+          (response) => response.albums,
+        )
+      }
+
+      async function fetchSharedAlbums () {
+        if (!isRoot) return [] // albums are only in the root
+
+        return paginate(
+          (pageToken) => client.get('sharedAlbums', { searchParams: { pageToken, pageSize: 50 }, responseType: 'json' }).json(),
+          (response) => response.sharedAlbums ?? [], // seems to be undefined if no shared albums
+        )
+      }
+
+      async function fetchMediaItems () {
+        if (isRoot) return { mediaItems: [] } // no images in root (album list only)
+        const resp = await client.post('mediaItems:search', { json: { pageToken: query?.cursor, albumId: directory, pageSize: 50 }, responseType: 'json' }).json();
+        return resp
+      }
+
+      const [sharedAlbums, albums, { mediaItems, nextPageToken }] = await Promise.all([
+        fetchSharedAlbums(), fetchAlbums(), fetchMediaItems()
+      ])
+
+      const newSp = new URLSearchParams(Object.entries(query));
+      if (nextPageToken) newSp.set('cursor', nextPageToken);
+
+      const iconSize = 64
+      const thumbSize = 300
+      const getIcon = (baseUrl) => `${baseUrl}=w${iconSize}-h${iconSize}-c`
+      const getThumbnail = (baseUrl) => `${baseUrl}=w${thumbSize}-h${thumbSize}-c`
+      const adaptedItems = [
+        ...albums.map((album) => ({
+          isFolder: true,
+          icon: 'https://drive-thirdparty.googleusercontent.com/32/type/application/vnd.google-apps.folder',
+          mimeType: 'application/vnd.google-apps.folder',
+          thumbnail: getThumbnail(album.coverPhotoBaseUrl),
+          name: album.title,
+          id: album.id,
+          requestPath: album.id,
+        })),
+        ...sharedAlbums.map((sharedAlbum) => ({
+          isFolder: true,
+          icon: 'https://drive-thirdparty.googleusercontent.com/32/type/application/vnd.google-apps.folder',
+          mimeType: 'application/vnd.google-apps.folder',
+          thumbnail: getThumbnail(sharedAlbum.coverPhotoBaseUrl),
+          name: sharedAlbum.title,
+          id: sharedAlbum.id,
+          requestPath: sharedAlbum.id,
+        })),
+        ...mediaItems.map((mediaItem) => ({
+          isFolder: false,
+          icon: getIcon(mediaItem.baseUrl),
+          thumbnail: getThumbnail(mediaItem.baseUrl),
+          name: mediaItem.filename,
+          id: mediaItem.id,
+          mimeType: mediaItem.mimeType,
+          modifiedDate: mediaItem.creationTime,
+          requestPath: mediaItem.id,
+          custom: {
+            imageWidth: mediaItem.photo ? mediaItem.width : undefined,
+            imageHeight: mediaItem.photo ? mediaItem.height : undefined,
+            videoWidth: mediaItem.video ? mediaItem.width : undefined,
+            videoHeight: mediaItem.video ? mediaItem.height : undefined,
+          },
+        })),
+      ];
+
+      const { email: username } = await (await getOauthClient({ token })).get('userinfo').json()
+
+      return {
+        username,
+        items: adaptedItems,
+        nextPagePath: newSp.size > 0 ? `${directory ?? ''}?${newSp.toString()}` : null,
+      }
+    })
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  async download ({ id, token }) {
+    return withGoogleErrorHandling(GooglePhotos.oauthProvider, 'provider.photos.download.error', async () => {
+      const client = await getPhotosClient({ token })
+
+      const { baseUrl } = await client.get(`mediaItems/${encodeURIComponent(id)}`, { responseType: 'json' }).json()
+
+      const url = `${baseUrl}=d`;
+      const stream = (await got).stream.get(url, { responseType: 'json' })
+      const { size } = await prepareStream(stream)
+
+      return { stream, size }
+    })
+  }
+}
+
+GooglePhotos.prototype.logout = logout
+GooglePhotos.prototype.refreshToken = refreshToken
+
+module.exports = GooglePhotos

+ 3 - 2
packages/@uppy/companion/src/server/provider/index.js

@@ -3,7 +3,8 @@
  */
 const dropbox = require('./dropbox')
 const box = require('./box')
-const drive = require('./drive')
+const drive = require('./google/drive')
+const googlephotos = require('./google/googlephotos')
 const instagram = require('./instagram/graph')
 const facebook = require('./facebook')
 const onedrive = require('./onedrive')
@@ -66,7 +67,7 @@ module.exports.getProviderMiddleware = (providers, grantConfig) => {
  * @returns {Record<string, typeof Provider>}
  */
 module.exports.getDefaultProviders = () => {
-  const providers = { dropbox, box, drive, facebook, onedrive, zoom, instagram, unsplash }
+  const providers = { dropbox, box, drive, googlephotos, facebook, onedrive, zoom, instagram, unsplash }
 
   return providers
 }

+ 5 - 0
packages/@uppy/companion/src/standalone/helper.js

@@ -81,6 +81,11 @@ const getConfigFromEnv = () => {
         secret: getSecret('COMPANION_GOOGLE_SECRET'),
         credentialsURL: process.env.COMPANION_GOOGLE_KEYS_ENDPOINT,
       },
+      googlephotos: {
+        key: process.env.COMPANION_GOOGLE_KEY,
+        secret: getSecret('COMPANION_GOOGLE_SECRET'),
+        credentialsURL: process.env.COMPANION_GOOGLE_KEYS_ENDPOINT,
+      },
       dropbox: {
         key: process.env.COMPANION_DROPBOX_KEY,
         secret: getSecret('COMPANION_DROPBOX_SECRET'),

+ 16 - 12
packages/@uppy/companion/test/__tests__/companion.js

@@ -19,10 +19,10 @@ jest.mock('node:dns', () => {
   return {
     ...actual,
     lookup: (hostname, options, callback) => {
-      if (fakeLocalhost === hostname) {
+      if (fakeLocalhost === hostname || hostname === 'localhost') {
         return callback(null, '127.0.0.1', 4)
       }
-      return actual.lookup(hostname, options, callback)
+      return callback(new Error(`Unexpected call to hostname ${hostname}`))
     },
   }
 })
@@ -52,7 +52,7 @@ describe('validate upload data', () => {
       mimeType: 'video/mp4',
       id: defaults.ITEM_ID,
     }
-    nock('https://www.googleapis.com').get(`/drive/v3/files/${defaults.ITEM_ID}`).query(() => true).times(2).reply(200, meta)
+    nock('https://www.googleapis.com').get(`/drive/v3/files/${defaults.ITEM_ID}`).query(() => true).reply(200, meta)
 
     nock('https://www.googleapis.com').get(`/drive/v3/files/${defaults.ITEM_ID}?alt=media&supportsAllDrives=true`).reply(401, {
       "error": {
@@ -155,7 +155,7 @@ describe('validate upload data', () => {
   })
 
   test('valid upload data is allowed - tus', () => {
-    nockGoogleDownloadFile({ times: 2 })
+    nockGoogleDownloadFile()
 
     return request(authServer)
       .post('/drive/get/DUMMY-FILE-ID')
@@ -177,7 +177,7 @@ describe('validate upload data', () => {
   })
 
   test('valid upload data is allowed - s3-multipart', () => {
-    nockGoogleDownloadFile({ times: 2 })
+    nockGoogleDownloadFile()
 
     return request(authServer)
       .post('/drive/get/DUMMY-FILE-ID')
@@ -268,12 +268,16 @@ it('respects allowLocalUrls, localhost', async () => {
   expect(res.body).toEqual({ error: 'Invalid request body' })
 })
 
-it('respects allowLocalUrls, valid hostname that resolves to localhost', async () => {
-  let res = await runUrlMetaTest(`http://${fakeLocalhost}/`)
-  expect(res.statusCode).toBe(500)
-  expect(res.body).toEqual({ message: 'failed to fetch URL metadata' })
+describe('respects allowLocalUrls, valid hostname that resolves to localhost', () => {
+  test('meta', async () => {
+    const res = await runUrlMetaTest(`http://${fakeLocalhost}/`)
+    expect(res.statusCode).toBe(500)
+    expect(res.body).toEqual({ message: 'failed to fetch URL metadata' })
+  })
 
-  res = await runUrlGetTest(`http://${fakeLocalhost}/`)
-  expect(res.statusCode).toBe(500)
-  expect(res.body).toEqual({ message: 'failed to fetch URL' })
+  test('get', async () => {
+    const res = await runUrlGetTest(`http://${fakeLocalhost}/`)
+    expect(res.statusCode).toBe(500)
+    expect(res.body).toEqual({ message: 'failed to fetch URL' })
+  })
 })

+ 39 - 7
packages/@uppy/companion/test/__tests__/provider-manager.js

@@ -23,8 +23,11 @@ describe('Test Provider options', () => {
     expect(grantConfig.box.key).toBe('box_key')
     expect(grantConfig.box.secret).toBe('box_secret')
 
-    expect(grantConfig.google.key).toBe('google_key')
-    expect(grantConfig.google.secret).toBe('google_secret')
+    expect(grantConfig.googledrive.key).toBe('google_key')
+    expect(grantConfig.googledrive.secret).toBe('google_secret')
+
+    expect(grantConfig.googlephotos.key).toBe('google_key')
+    expect(grantConfig.googledrive.secret).toBe('google_secret')
 
     expect(grantConfig.instagram.key).toBe('instagram_key')
     expect(grantConfig.instagram.secret).toBe('instagram_secret')
@@ -69,7 +72,12 @@ describe('Test Provider options', () => {
       callback: '/box/callback',
     })
 
-    expect(grantConfig.google).toEqual({
+    expect(grantConfig.googledrive).toEqual({
+      access_url: "https://oauth2.googleapis.com/token",
+      authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
+      oauth: 2,
+      scope_delimiter: " ",
+
       key: 'google_key',
       secret: 'google_secret',
       transport: 'session',
@@ -83,6 +91,25 @@ describe('Test Provider options', () => {
         prompt: 'consent',
       },
     })
+
+    expect(grantConfig.googlephotos).toEqual({
+      access_url: "https://oauth2.googleapis.com/token",
+      authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
+      oauth: 2,
+      scope_delimiter: " ",
+
+      key: 'google_key',
+      secret: 'google_secret',
+      transport: 'session',
+      redirect_uri: 'http://localhost:3020/googlephotos/redirect',
+      scope: ['https://www.googleapis.com/auth/photoslibrary.readonly', 'https://www.googleapis.com/auth/userinfo.email'],
+      callback: '/googlephotos/callback',
+      custom_params: {
+        access_type: 'offline',
+        prompt: 'consent',
+      },
+    })
+
     expect(grantConfig.zoom).toEqual({
       key: 'zoom_key',
       secret: 'zoom_secret',
@@ -108,7 +135,8 @@ describe('Test Provider options', () => {
 
     expect(grantConfig.dropbox.secret).toBe('xobpord')
     expect(grantConfig.box.secret).toBe('xwbepqd')
-    expect(grantConfig.google.secret).toBe('elgoog')
+    expect(grantConfig.googledrive.secret).toBe('elgoog')
+    expect(grantConfig.googlephotos.secret).toBe('elgoog')
     expect(grantConfig.instagram.secret).toBe('margatsni')
     expect(grantConfig.zoom.secret).toBe('u8Z5ceq')
     expect(companionOptions.providerOptions.zoom.verificationToken).toBe('o0u8Z5c')
@@ -125,8 +153,11 @@ describe('Test Provider options', () => {
     expect(grantConfig.box.key).toBeUndefined()
     expect(grantConfig.box.secret).toBeUndefined()
 
-    expect(grantConfig.google.key).toBeUndefined()
-    expect(grantConfig.google.secret).toBeUndefined()
+    expect(grantConfig.googledrive.key).toBeUndefined()
+    expect(grantConfig.googledrive.secret).toBeUndefined()
+
+    expect(grantConfig.googlephotos.key).toBeUndefined()
+    expect(grantConfig.googlephotos.secret).toBeUndefined()
 
     expect(grantConfig.instagram.key).toBeUndefined()
     expect(grantConfig.instagram.secret).toBeUndefined()
@@ -141,7 +172,8 @@ describe('Test Provider options', () => {
 
     expect(grantConfig.dropbox.redirect_uri).toBe('http://domain.com/dropbox/redirect')
     expect(grantConfig.box.redirect_uri).toBe('http://domain.com/box/redirect')
-    expect(grantConfig.google.redirect_uri).toBe('http://domain.com/drive/redirect')
+    expect(grantConfig.googledrive.redirect_uri).toBe('http://domain.com/drive/redirect')
+    expect(grantConfig.googlephotos.redirect_uri).toBe('http://domain.com/googlephotos/redirect')
     expect(grantConfig.instagram.redirect_uri).toBe('http://domain.com/instagram/redirect')
     expect(grantConfig.zoom.redirect_uri).toBe('http://domain.com/zoom/redirect')
   })

+ 105 - 36
packages/@uppy/companion/test/__tests__/providers.js

@@ -56,39 +56,35 @@ afterAll(() => {
 
 describe('list provider files', () => {
   async function runTest (providerName) {
-    const providerFixtures = fixtures.providers[providerName].expects
+    const providerFixture = fixtures.providers[providerName]?.expects ?? {}
     return request(authServer)
-      .get(`/${providerName}/list/${providerFixtures.listPath || ''}`)
+      .get(`/${providerName}/list/${providerFixture.listPath || ''}`)
       .set('uppy-auth-token', token)
       .expect(200)
       .then((res) => {
         expect(res.header['i-am']).toBe('http://localhost:3020')
-        expect(res.body.username).toBe(defaults.USERNAME)
-
-        const items = [...res.body.items]
-
-        // Drive has a virtual "shared-with-me" folder as the first item
-        if (providerName === 'drive') {
-          const item0 = items.shift()
-          expect(item0.isFolder).toBe(true)
-          expect(item0.name).toBe('Shared with me')
-          expect(item0.mimeType).toBe('application/vnd.google-apps.folder')
-          expect(item0.id).toBe('shared-with-me')
-          expect(item0.requestPath).toBe('shared-with-me')
-          expect(item0.icon).toBe('folder')
-        }
 
-        const item = items[0]
-        expect(item.isFolder).toBe(false)
-        expect(item.name).toBe(providerFixtures.itemName || defaults.ITEM_NAME)
-        expect(item.mimeType).toBe(providerFixtures.itemMimeType || defaults.MIME_TYPE)
-        expect(item.id).toBe(providerFixtures.itemId || defaults.ITEM_ID)
-        expect(item.size).toBe(thisOrThat(providerFixtures.itemSize, defaults.FILE_SIZE))
-        expect(item.requestPath).toBe(providerFixtures.itemRequestPath || defaults.ITEM_ID)
-        expect(item.icon).toBe(providerFixtures.itemIcon || defaults.THUMBNAIL_URL)
+        return {
+          username: res.body.username,
+          items: res.body.items,
+          providerFixture,
+        }
       })
   }
 
+  function expect1({ username, items, providerFixture }) {
+    expect(username).toBe(defaults.USERNAME)
+
+    const item = items[0]
+    expect(item.isFolder).toBe(false)
+    expect(item.name).toBe(providerFixture.itemName || defaults.ITEM_NAME)
+    expect(item.mimeType).toBe(providerFixture.itemMimeType || defaults.MIME_TYPE)
+    expect(item.id).toBe(providerFixture.itemId || defaults.ITEM_ID)
+    expect(item.size).toBe(thisOrThat(providerFixture.itemSize, defaults.FILE_SIZE))
+    expect(item.requestPath).toBe(providerFixture.itemRequestPath || defaults.ITEM_ID)
+    expect(item.icon).toBe(providerFixture.itemIcon || defaults.THUMBNAIL_URL)
+  }
+
   test('dropbox', async () => {
     nock('https://api.dropboxapi.com').post('/2/users/get_current_account').reply(200, {
       name: {
@@ -131,7 +127,8 @@ describe('list provider files', () => {
       has_more: false,
     })
 
-    await runTest('dropbox')
+    const { username, items, providerFixture } = await runTest('dropbox')
+    expect1({ username, items, providerFixture })
   })
 
   test('box', async () => {
@@ -150,7 +147,8 @@ describe('list provider files', () => {
       ],
     })
 
-    await runTest('box')
+    const { username, items, providerFixture } = await runTest('box')
+    expect1({ username, items, providerFixture })
   })
 
   test('drive', async () => {
@@ -179,7 +177,60 @@ describe('list provider files', () => {
 
     nock('https://www.googleapis.com').get((uri) => uri.includes('about')).reply(200, { user: { emailAddress: 'john.doe@transloadit.com' } })
 
-    await runTest('drive')
+    const { username, items, providerFixture } = await runTest('drive')
+
+    // Drive has a virtual "shared-with-me" folder as the first item
+    const [item0, ...rest] = items
+    expect(item0.isFolder).toBe(true)
+    expect(item0.name).toBe('Shared with me')
+    expect(item0.mimeType).toBe('application/vnd.google-apps.folder')
+    expect(item0.id).toBe('shared-with-me')
+    expect(item0.requestPath).toBe('shared-with-me')
+    expect(item0.icon).toBe('folder')
+
+    expect1({ username, items: rest, providerFixture })
+  })
+
+  test('googlephotos', async () => {
+    nock('https://photoslibrary.googleapis.com').get('/v1/albums?pageSize=50').reply(200, {
+      albums: [
+        {
+          coverPhotoBaseUrl: 'https://test',
+          title: 'album',
+          id: '1',
+        }
+      ]
+    })
+
+    nock('https://photoslibrary.googleapis.com').get('/v1/sharedAlbums?pageSize=50').reply(200, {
+      sharedAlbums: [
+        {
+          coverPhotoBaseUrl: 'https://test2',
+          title: 'shared album',
+          id: '2',
+        }
+      ]
+    })
+
+    nock('https://www.googleapis.com').get('/oauth2/v1/userinfo').reply(200, {
+      email: defaults.USERNAME,
+    })
+
+    const { items } = await runTest('googlephotos')
+
+    expect(items[0].isFolder).toBe(true)
+    expect(items[0].name).toBe('album')
+    expect(items[0].id).toBe('1')
+    expect(items[0].requestPath).toBe('1')
+    expect(items[0].icon).toBe('https://drive-thirdparty.googleusercontent.com/32/type/application/vnd.google-apps.folder')
+    expect(items[0].thumbnail).toBe('https://test=w300-h300-c')
+
+    expect(items[1].isFolder).toBe(true)
+    expect(items[1].name).toBe('shared album')
+    expect(items[1].id).toBe('2')
+    expect(items[1].requestPath).toBe('2')
+    expect(items[1].icon).toBe('https://drive-thirdparty.googleusercontent.com/32/type/application/vnd.google-apps.folder')
+    expect(items[1].thumbnail).toBe('https://test2=w300-h300-c')
   })
 
   test('facebook', async () => {
@@ -207,7 +258,8 @@ describe('list provider files', () => {
       paging: {},
     })
 
-    await runTest('facebook')
+    const { username, items, providerFixture } = await runTest('facebook')
+    expect1({ username, items, providerFixture })
   })
 
   test('instagram', async () => {
@@ -226,7 +278,8 @@ describe('list provider files', () => {
       ],
     })
 
-    await runTest('instagram')
+    const { username, items, providerFixture } = await runTest('instagram')
+    expect1({ username, items, providerFixture })
   })
 
   test('onedrive', async () => {
@@ -272,7 +325,8 @@ describe('list provider files', () => {
       ],
     })
 
-    await runTest('onedrive')
+    const { username, items, providerFixture } = await runTest('onedrive')
+    expect1({ username, items, providerFixture })
   })
 
   test('zoom', async () => {
@@ -292,15 +346,16 @@ describe('list provider files', () => {
     })
     nockZoomRecordings()
 
-    await runTest('zoom')
+    const { username, items, providerFixture } = await runTest('zoom')
+    expect1({ username, items, providerFixture })
   })
 })
 
 describe('provider file gets downloaded from', () => {
   async function runTest (providerName) {
-    const providerFixtures = fixtures.providers[providerName].expects
+    const providerFixture = fixtures.providers[providerName]?.expects ?? {}
     const res = await request(authServer)
-      .post(`/${providerName}/get/${providerFixtures.itemRequestPath || defaults.ITEM_ID}`)
+      .post(`/${providerName}/get/${providerFixture.itemRequestPath || defaults.ITEM_ID}`)
       .set('uppy-auth-token', token)
       .set('Content-Type', 'application/json')
       .send({
@@ -325,11 +380,20 @@ describe('provider file gets downloaded from', () => {
   })
 
   test('drive', async () => {
-    // times(2) because of size request
-    nockGoogleDownloadFile({ times: 2 })
+    nockGoogleDownloadFile()
     await runTest('drive')
   })
 
+  test('googlephotos', async () => {
+    nock('https://photoslibrary.googleapis.com').get(`/v1/mediaItems/${defaults.ITEM_ID}`).reply(200, {
+      baseUrl: 'https://lh3.googleusercontent.com/test',
+    })
+
+    nock('https://lh3.googleusercontent.com').get(`/test=d`).reply(200, ' ', { 'content-length': 1 })
+
+    await runTest('googlephotos')
+  })
+
   test('facebook', async () => {
     // times(2) because of size request
     nock('https://graph.facebook.com').get(`/${defaults.ITEM_ID}?fields=images`).times(2).reply(200, {
@@ -394,7 +458,7 @@ describe('logout of provider', () => {
       .expect(200)
 
     // only some providers can actually be revoked
-    const expectRevoked = ['box', 'dropbox', 'drive', 'facebook', 'zoom'].includes(providerName)
+    const expectRevoked = ['box', 'dropbox', 'drive', 'googlephotos', 'facebook', 'zoom'].includes(providerName)
 
     expect(res.body).toMatchObject({
       ok: true,
@@ -422,6 +486,11 @@ describe('logout of provider', () => {
     await runTest('drive')
   })
 
+  test('googlephotos', async () => {
+    nock('https://accounts.google.com').post('/o/oauth2/revoke?token=token+value').reply(200, {})
+    await runTest('googlephotos')
+  })
+
   test('facebook', async () => {
     nock('https://graph.facebook.com').delete('/me/permissions').reply(200, {})
     await runTest('facebook')

+ 1 - 1
packages/@uppy/companion/test/fixtures/drive.js

@@ -5,7 +5,7 @@ module.exports.expects = {}
 
 module.exports.nockGoogleDriveAboutCall = () => nock('https://www.googleapis.com').get((uri) => uri.includes('about')).reply(200, { user: { emailAddress: 'john.doe@transloadit.com' } })
 
-module.exports.nockGoogleDownloadFile = ({ times = 1 } = {}) => {
+module.exports.nockGoogleDownloadFile = ({ times = 2 } = {}) => {
   nock('https://www.googleapis.com').get(`/drive/v3/files/${defaults.ITEM_ID}?fields=kind%2Cid%2CimageMediaMetadata%2Cname%2CmimeType%2CownedByMe%2Csize%2CmodifiedTime%2CiconLink%2CthumbnailLink%2CteamDriveId%2CvideoMediaMetadata%2CexportLinks%2CshortcutDetails%28targetId%2CtargetMimeType%29&supportsAllDrives=true`).times(times).reply(200, {
     kind: 'drive#file',
     id: defaults.ITEM_ID,

+ 2 - 10
packages/@uppy/google-photos/src/GooglePhotos.tsx

@@ -34,6 +34,8 @@ export default class GooglePhotos<
 
   files: UppyFile<M, B>[]
 
+  rootFolderId: string | null = null
+
   constructor(uppy: Uppy<M, B>, opts: GooglePhotosOptions) {
     super(uppy, opts)
     this.type = 'acquirer'
@@ -91,13 +93,10 @@ export default class GooglePhotos<
     this.i18nInit()
     this.title = this.i18n('pluginNameGooglePhotos')
 
-    this.onFirstRender = this.onFirstRender.bind(this)
     this.render = this.render.bind(this)
   }
 
   install(): void {
-    // eslint-disable-next-line
-    // @ts-ignore TODO: fix this
     this.view = new ProviderViews(this, {
       provider: this.provider,
       loadAllFiles: true,
@@ -114,13 +113,6 @@ export default class GooglePhotos<
     this.unmount()
   }
 
-  async onFirstRender(): Promise<void> {
-    await Promise.all([
-      this.provider.fetchPreAuthToken(),
-      this.view.getFolder(),
-    ])
-  }
-
   render(state: unknown): ComponentChild {
     if (
       this.getPluginState().files.length &&

+ 1 - 0
packages/@uppy/remote-sources/package.json

@@ -9,6 +9,7 @@
     "file uploader",
     "instagram",
     "google-drive",
+    "google-photos",
     "facebook",
     "dropbox",
     "onedrive",