diff --git a/packages/@uppy/companion/src/config/grant.js b/packages/@uppy/companion/src/config/grant.js index 39f80b8328..ad4983c24d 100644 --- a/packages/@uppy/companion/src/config/grant.js +++ b/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', diff --git a/packages/@uppy/companion/src/server/controllers/get.js b/packages/@uppy/companion/src/server/controllers/get.js index e3bd4da759..1ba7f916ae 100644 --- a/packages/@uppy/companion/src/server/controllers/get.js +++ b/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 }) diff --git a/packages/@uppy/companion/src/server/controllers/url.js b/packages/@uppy/companion/src/server/controllers/url.js index d54f2d10dd..79cd04ed58 100644 --- a/packages/@uppy/companion/src/server/controllers/url.js +++ b/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 }) diff --git a/packages/@uppy/companion/src/server/helpers/oauth-state.js b/packages/@uppy/companion/src/server/helpers/oauth-state.js index a42438c6e4..14b5e95fd0 100644 --- a/packages/@uppy/companion/src/server/helpers/oauth-state.js +++ b/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) => { diff --git a/packages/@uppy/companion/src/server/helpers/upload.js b/packages/@uppy/companion/src/server/helpers/upload.js index 24de627055..b628634eeb 100644 --- a/packages/@uppy/companion/src/server/helpers/upload.js +++ b/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 diff --git a/packages/@uppy/companion/src/server/helpers/utils.js b/packages/@uppy/companion/src/server/helpers/utils.js index 0f8ededaae..209990d228 100644 --- a/packages/@uppy/companion/src/server/helpers/utils.js +++ b/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, diff --git a/packages/@uppy/companion/src/server/provider/drive/adapter.js b/packages/@uppy/companion/src/server/provider/drive/adapter.js deleted file mode 100644 index 4d005bc7a7..0000000000 --- a/packages/@uppy/companion/src/server/provider/drive/adapter.js +++ /dev/null @@ -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), - } -} diff --git a/packages/@uppy/companion/src/server/provider/drive/index.js b/packages/@uppy/companion/src/server/provider/drive/index.js deleted file mode 100644 index 5c2b4ff327..0000000000 --- a/packages/@uppy/companion/src/server/provider/drive/index.js +++ /dev/null @@ -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 diff --git a/packages/@uppy/companion/src/server/provider/google/drive/index.js b/packages/@uppy/companion/src/server/provider/google/drive/index.js index b63e93a07a..e08a55c60a 100644 --- a/packages/@uppy/companion/src/server/provider/google/drive/index.js +++ b/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 diff --git a/packages/@uppy/companion/src/server/provider/google/googlephotos/index.js b/packages/@uppy/companion/src/server/provider/google/googlephotos/index.js new file mode 100644 index 0000000000..efd6831caa --- /dev/null +++ b/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 diff --git a/packages/@uppy/companion/src/server/provider/index.js b/packages/@uppy/companion/src/server/provider/index.js index c556395b30..4bea2a5231 100644 --- a/packages/@uppy/companion/src/server/provider/index.js +++ b/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} */ 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 } diff --git a/packages/@uppy/companion/src/standalone/helper.js b/packages/@uppy/companion/src/standalone/helper.js index 5f352e82d6..76662d1fcc 100644 --- a/packages/@uppy/companion/src/standalone/helper.js +++ b/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'), diff --git a/packages/@uppy/companion/test/__tests__/companion.js b/packages/@uppy/companion/test/__tests__/companion.js index 5a9ed228b2..71ca178c62 100644 --- a/packages/@uppy/companion/test/__tests__/companion.js +++ b/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' }) + }) }) diff --git a/packages/@uppy/companion/test/__tests__/provider-manager.js b/packages/@uppy/companion/test/__tests__/provider-manager.js index 6d8d5322c4..f5dfe73462 100644 --- a/packages/@uppy/companion/test/__tests__/provider-manager.js +++ b/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') }) diff --git a/packages/@uppy/companion/test/__tests__/providers.js b/packages/@uppy/companion/test/__tests__/providers.js index 6012490549..1fb7341a47 100644 --- a/packages/@uppy/companion/test/__tests__/providers.js +++ b/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') diff --git a/packages/@uppy/companion/test/fixtures/drive.js b/packages/@uppy/companion/test/fixtures/drive.js index 8b136f3a1a..28f3ffdafe 100644 --- a/packages/@uppy/companion/test/fixtures/drive.js +++ b/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, diff --git a/packages/@uppy/google-photos/src/GooglePhotos.tsx b/packages/@uppy/google-photos/src/GooglePhotos.tsx index 4650783084..32b9d99b25 100644 --- a/packages/@uppy/google-photos/src/GooglePhotos.tsx +++ b/packages/@uppy/google-photos/src/GooglePhotos.tsx @@ -34,6 +34,8 @@ export default class GooglePhotos< files: UppyFile[] + rootFolderId: string | null = null + constructor(uppy: Uppy, 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 { - await Promise.all([ - this.provider.fetchPreAuthToken(), - this.view.getFolder(), - ]) - } - render(state: unknown): ComponentChild { if ( this.getPluginState().files.length && diff --git a/packages/@uppy/remote-sources/package.json b/packages/@uppy/remote-sources/package.json index b63014f28a..ff47161353 100644 --- a/packages/@uppy/remote-sources/package.json +++ b/packages/@uppy/remote-sources/package.json @@ -9,6 +9,7 @@ "file uploader", "instagram", "google-drive", + "google-photos", "facebook", "dropbox", "onedrive",