diff --git a/.env.example b/.env.example index 7df30462fc..a3bbe74b7f 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,9 @@ COMPANION_PREAUTH_SECRET=development2 # NOTE: Only enable this in development. Enabling it in production is a security risk COMPANION_ALLOW_LOCAL_URLS=true +COMPANION_ENABLE_URL_ENDPOINT=true +COMPANION_ENABLE_GOOGLE_PICKER_ENDPOINT=true + # to enable S3 COMPANION_AWS_KEY="YOUR AWS KEY" COMPANION_AWS_SECRET="YOUR AWS SECRET" @@ -89,3 +92,10 @@ VITE_TRANSLOADIT_TEMPLATE=*** VITE_TRANSLOADIT_SERVICE_URL=https://api2.transloadit.com # Fill in if you want requests sent to Transloadit to be signed: # VITE_TRANSLOADIT_SECRET=*** + +# For Google Photos Picker and Google Drive Picker: +VITE_GOOGLE_PICKER_CLIENT_ID=*** + +# For Google Drive Picker +VITE_GOOGLE_PICKER_API_KEY=*** +VITE_GOOGLE_PICKER_APP_ID=*** diff --git a/e2e/package.json b/e2e/package.json index e923ebf3bd..c835aa7c36 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,9 @@ "@uppy/form": "workspace:^", "@uppy/golden-retriever": "workspace:^", "@uppy/google-drive": "workspace:^", + "@uppy/google-drive-picker": "workspace:^", "@uppy/google-photos": "workspace:^", + "@uppy/google-photos-picker": "workspace:^", "@uppy/image-editor": "workspace:^", "@uppy/informer": "workspace:^", "@uppy/instagram": "workspace:^", diff --git a/packages/@uppy/box/src/Box.tsx b/packages/@uppy/box/src/Box.tsx index 74c0e903b6..e004aff38b 100644 --- a/packages/@uppy/box/src/Box.tsx +++ b/packages/@uppy/box/src/Box.tsx @@ -9,7 +9,11 @@ import { ProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js' +import type { + AsyncStore, + UnknownProviderPlugin, + UnknownProviderPluginState, +} from '@uppy/core/lib/Uppy.js' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json @@ -17,12 +21,10 @@ import packageJson from '../package.json' export type BoxOptions = CompanionPluginOptions -export default class Box extends UIPlugin< - BoxOptions, - M, - B, - UnknownProviderPluginState -> { +export default class Box + extends UIPlugin + implements UnknownProviderPlugin +{ static VERSION = packageJson.version icon: () => h.JSX.Element @@ -31,7 +33,7 @@ export default class Box extends UIPlugin< view!: ProviderViews - storage: typeof tokenStorage + storage: AsyncStore files: UppyFile[] diff --git a/packages/@uppy/companion-client/src/CompanionPluginOptions.ts b/packages/@uppy/companion-client/src/CompanionPluginOptions.ts index 751357f7f4..a923952e75 100644 --- a/packages/@uppy/companion-client/src/CompanionPluginOptions.ts +++ b/packages/@uppy/companion-client/src/CompanionPluginOptions.ts @@ -1,8 +1,8 @@ import type { UIPluginOptions } from '@uppy/core' -import type { tokenStorage } from './index.ts' +import type { AsyncStore } from '@uppy/core/lib/Uppy.js' export interface CompanionPluginOptions extends UIPluginOptions { - storage?: typeof tokenStorage + storage?: AsyncStore companionUrl: string companionHeaders?: Record companionKeysParams?: { key: string; credentialsName: string } diff --git a/packages/@uppy/companion-client/src/Provider.ts b/packages/@uppy/companion-client/src/Provider.ts index bd0fb96214..2f6bdc066c 100644 --- a/packages/@uppy/companion-client/src/Provider.ts +++ b/packages/@uppy/companion-client/src/Provider.ts @@ -320,10 +320,7 @@ export default class Provider // Once a refresh token operation has started, we need all other request to wait for this operation (atomically) this.#refreshingTokenPromise = (async () => { try { - this.uppy.log( - `[CompanionClient] Refreshing expired auth token`, - 'info', - ) + this.uppy.log(`[CompanionClient] Refreshing expired auth token`) const response = await super.request<{ uppyAuthToken: string }>({ path: this.refreshTokenUrl(), method: 'POST', diff --git a/packages/@uppy/companion-client/src/RequestClient.ts b/packages/@uppy/companion-client/src/RequestClient.ts index 236ce62527..68a0992941 100644 --- a/packages/@uppy/companion-client/src/RequestClient.ts +++ b/packages/@uppy/companion-client/src/RequestClient.ts @@ -505,7 +505,7 @@ export default class RequestClient { }) const closeSocket = () => { - this.uppy.log(`Closing socket ${file.id}`, 'info') + this.uppy.log(`Closing socket ${file.id}`) clearTimeout(activityTimeout) if (socket) socket.close() socket = undefined @@ -524,7 +524,7 @@ export default class RequestClient { signal: socketAbortController.signal, onFailedAttempt: () => { if (socketAbortController.signal.aborted) return // don't log in this case - this.uppy.log(`Retrying websocket ${file.id}`, 'info') + this.uppy.log(`Retrying websocket ${file.id}`) }, }) })() @@ -547,14 +547,14 @@ export default class RequestClient { if (targetFile.id !== file.id) return socketSend('cancel') socketAbortController?.abort?.() - this.uppy.log(`upload ${file.id} was removed`, 'info') + this.uppy.log(`upload ${file.id} was removed`) resolve() } const onCancelAll = () => { socketSend('cancel') socketAbortController?.abort?.() - this.uppy.log(`upload ${file.id} was canceled`, 'info') + this.uppy.log(`upload ${file.id} was canceled`) resolve() } diff --git a/packages/@uppy/companion-client/src/tokenStorage.ts b/packages/@uppy/companion-client/src/tokenStorage.ts index 24de0685cd..581bdff1ea 100644 --- a/packages/@uppy/companion-client/src/tokenStorage.ts +++ b/packages/@uppy/companion-client/src/tokenStorage.ts @@ -1,20 +1,15 @@ /** * This module serves as an Async wrapper for LocalStorage + * Why? Because the Provider API `storage` option allows an async storage */ -export function setItem(key: string, value: string): Promise { - return new Promise((resolve) => { - localStorage.setItem(key, value) - resolve() - }) +export async function setItem(key: string, value: string): Promise { + localStorage.setItem(key, value) } -export function getItem(key: string): Promise { - return Promise.resolve(localStorage.getItem(key)) +export async function getItem(key: string): Promise { + return localStorage.getItem(key) } -export function removeItem(key: string): Promise { - return new Promise((resolve) => { - localStorage.removeItem(key) - resolve() - }) +export async function removeItem(key: string): Promise { + localStorage.removeItem(key) } diff --git a/packages/@uppy/companion/src/companion.js b/packages/@uppy/companion/src/companion.js index 01f55c2fa5..fd53ebaaf2 100644 --- a/packages/@uppy/companion/src/companion.js +++ b/packages/@uppy/companion/src/companion.js @@ -10,6 +10,7 @@ const providerManager = require('./server/provider') const controllers = require('./server/controllers') const s3 = require('./server/controllers/s3') const url = require('./server/controllers/url') +const googlePicker = require('./server/controllers/googlePicker') const createEmitter = require('./server/emitter') const redis = require('./server/redis') const jobs = require('./server/jobs') @@ -120,6 +121,7 @@ module.exports.app = (optionsArg = {}) => { app.use('*', middlewares.getCompanionMiddleware(options)) app.use('/s3', s3(options.s3)) if (options.enableUrlEndpoint) app.use('/url', url()) + if (options.enableGooglePickerEndpoint) app.use('/google-picker', googlePicker()) app.post('/:providerName/preauth', express.json(), express.urlencoded({ extended: false }), middlewares.hasSessionAndProvider, middlewares.hasBody, middlewares.hasOAuthProvider, controllers.preauth) app.get('/:providerName/connect', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, controllers.connect) diff --git a/packages/@uppy/companion/src/config/companion.js b/packages/@uppy/companion/src/config/companion.js index b847f3df7a..78858d3ec9 100644 --- a/packages/@uppy/companion/src/config/companion.js +++ b/packages/@uppy/companion/src/config/companion.js @@ -17,6 +17,7 @@ const defaultOptions = { expires: 800, // seconds }, enableUrlEndpoint: false, + enableGooglePickerEndpoint: false, allowLocalUrls: false, periodicPingUrls: [], streamingUpload: true, diff --git a/packages/@uppy/companion/src/server/controllers/googlePicker.js b/packages/@uppy/companion/src/server/controllers/googlePicker.js new file mode 100644 index 0000000000..4b3dd051ee --- /dev/null +++ b/packages/@uppy/companion/src/server/controllers/googlePicker.js @@ -0,0 +1,57 @@ +const express = require('express') +const assert = require('node:assert') + +const { startDownUpload } = require('../helpers/upload') +const { validateURL } = require('../helpers/request') +const { getURLMeta } = require('../helpers/request') +const logger = require('../logger') +const { downloadURL } = require('../download') +const { getGoogleFileSize, streamGoogleFile } = require('../provider/google/drive'); + + +const getAuthHeader = (token) => ({ authorization: `Bearer ${token}` }); + +/** + * + * @param {object} req expressJS request object + * @param {object} res expressJS response object + */ +const get = async (req, res) => { + try { + logger.debug('Google Picker file import handler running', null, req.id) + + const allowLocalUrls = false + + const { accessToken, platform, fileId } = req.body + + assert(platform === 'drive' || platform === 'photos'); + + const getSize = async () => { + if (platform === 'drive') { + return getGoogleFileSize({ id: fileId, token: accessToken }) + } + const { size } = await getURLMeta(req.body.url, allowLocalUrls, { headers: getAuthHeader(accessToken) }) + return size + } + + if (platform === 'photos' && !validateURL(req.body.url, allowLocalUrls)) { + res.status(400).json({ error: 'Invalid URL' }) + return + } + + const download = () => { + if (platform === 'drive') { + return streamGoogleFile({ token: accessToken, id: fileId }) + } + return downloadURL(req.body.url, allowLocalUrls, req.id, { headers: getAuthHeader(accessToken) }) + } + + await startDownUpload({ req, res, getSize, download }) + } catch (err) { + logger.error(err, 'controller.googlePicker.error', req.id) + res.status(err.status || 500).json({ message: 'failed to fetch Google Picker URL' }) + } +} + +module.exports = () => express.Router() + .post('/get', express.json(), get) diff --git a/packages/@uppy/companion/src/server/controllers/url.js b/packages/@uppy/companion/src/server/controllers/url.js index 79cd04ed58..838d659ee7 100644 --- a/packages/@uppy/companion/src/server/controllers/url.js +++ b/packages/@uppy/companion/src/server/controllers/url.js @@ -1,9 +1,9 @@ const express = require('express') const { startDownUpload } = require('../helpers/upload') -const { prepareStream } = require('../helpers/utils') +const { downloadURL } = require('../download') const { validateURL } = require('../helpers/request') -const { getURLMeta, getProtectedGot } = require('../helpers/request') +const { getURLMeta } = require('../helpers/request') const logger = require('../logger') /** @@ -12,27 +12,6 @@ const logger = require('../logger') * @param {string | Buffer | Buffer[]} chunk */ -/** - * Downloads the content in the specified url, and passes the data - * to the callback chunk by chunk. - * - * @param {string} url - * @param {boolean} allowLocalIPs - * @param {string} traceId - * @returns {Promise} - */ -const downloadURL = async (url, allowLocalIPs, traceId) => { - try { - const protectedGot = await getProtectedGot({ allowLocalIPs }) - const stream = protectedGot.stream.get(url, { responseType: 'json' }) - const { size } = await prepareStream(stream) - return { stream, size } - } catch (err) { - logger.error(err, 'controller.url.download.error', traceId) - throw err - } -} - /** * Fetches the size and content type of a URL * diff --git a/packages/@uppy/companion/src/server/download.js b/packages/@uppy/companion/src/server/download.js new file mode 100644 index 0000000000..e8a685577d --- /dev/null +++ b/packages/@uppy/companion/src/server/download.js @@ -0,0 +1,28 @@ +const logger = require('./logger') +const { getProtectedGot } = require('./helpers/request') +const { prepareStream } = require('./helpers/utils') + +/** + * Downloads the content in the specified url, and passes the data + * to the callback chunk by chunk. + * + * @param {string} url + * @param {boolean} allowLocalIPs + * @param {string} traceId + * @returns {Promise} + */ +const downloadURL = async (url, allowLocalIPs, traceId, options) => { + try { + const protectedGot = await getProtectedGot({ allowLocalIPs }) + const stream = protectedGot.stream.get(url, { responseType: 'json', ...options }) + const { size } = await prepareStream(stream) + return { stream, size } + } catch (err) { + logger.error(err, 'controller.url.download.error', traceId) + throw err + } +} + +module.exports = { + downloadURL, +} diff --git a/packages/@uppy/companion/src/server/helpers/request.js b/packages/@uppy/companion/src/server/helpers/request.js index c1afed5f02..6ef96a6257 100644 --- a/packages/@uppy/companion/src/server/helpers/request.js +++ b/packages/@uppy/companion/src/server/helpers/request.js @@ -105,10 +105,10 @@ module.exports.getProtectedGot = getProtectedGot * @param {boolean} allowLocalIPs * @returns {Promise<{name: string, type: string, size: number}>} */ -exports.getURLMeta = async (url, allowLocalIPs = false) => { +exports.getURLMeta = async (url, allowLocalIPs = false, options = undefined) => { async function requestWithMethod (method) { const protectedGot = await getProtectedGot({ allowLocalIPs }) - const stream = protectedGot.stream(url, { method, throwHttpErrors: false }) + const stream = protectedGot.stream(url, { method, throwHttpErrors: false, ...options }) return new Promise((resolve, reject) => ( stream 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 e08a55c60a..a4a790f3c5 100644 --- a/packages/@uppy/companion/src/server/provider/google/drive/index.js +++ b/packages/@uppy/companion/src/server/provider/google/drive/index.js @@ -43,6 +43,53 @@ async function getStats ({ id, token }) { return stats } + +async function streamGoogleFile({ token, id: idIn }) { + 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 } +} + +async function getGoogleFileSize({ id, token }) { + 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) +} + /** * Adapter for API https://developers.google.com/drive/api/v3/ */ @@ -124,7 +171,7 @@ class Drive extends Provider { } // eslint-disable-next-line class-methods-use-this - async download ({ id: idIn, token }) { + async download ({ id, token }) { if (mockAccessTokenExpiredError != null) { logger.warn(`Access token: ${token}`) @@ -135,57 +182,23 @@ class Drive extends Provider { } return withGoogleErrorHandling(Drive.oauthProvider, '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 } + return streamGoogleFile({ token, id }) }) } // eslint-disable-next-line class-methods-use-this async size ({ id, token }) { - return withGoogleErrorHandling(Drive.oauthProvider, '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) - }) + return withGoogleErrorHandling(Drive.oauthProvider, 'provider.drive.size.error', async () => ( + getGoogleFileSize({ id, token }) + )) } } Drive.prototype.logout = logout Drive.prototype.refreshToken = refreshToken -module.exports = Drive +module.exports = { + Drive, + streamGoogleFile, + getGoogleFileSize, +} diff --git a/packages/@uppy/companion/src/server/provider/index.js b/packages/@uppy/companion/src/server/provider/index.js index 52c707dac0..55854e8b74 100644 --- a/packages/@uppy/companion/src/server/provider/index.js +++ b/packages/@uppy/companion/src/server/provider/index.js @@ -3,7 +3,7 @@ */ const dropbox = require('./dropbox') const box = require('./box') -const drive = require('./google/drive') +const { Drive } = require('./google/drive') const googlephotos = require('./google/googlephotos') const instagram = require('./instagram/graph') const facebook = require('./facebook') @@ -68,7 +68,7 @@ module.exports.getProviderMiddleware = (providers, grantConfig) => { * @returns {Record} */ module.exports.getDefaultProviders = () => { - const providers = { dropbox, box, drive, googlephotos, facebook, onedrive, zoom, instagram, unsplash } + const providers = { dropbox, box, drive: 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 e6ccd9887b..57494dadb8 100644 --- a/packages/@uppy/companion/src/standalone/helper.js +++ b/packages/@uppy/companion/src/standalone/helper.js @@ -152,6 +152,7 @@ const getConfigFromEnv = () => { validHosts, }, enableUrlEndpoint: process.env.COMPANION_ENABLE_URL_ENDPOINT === 'true', + enableGooglePickerEndpoint: process.env.COMPANION_ENABLE_GOOGLE_PICKER_ENDPOINT === 'true', periodicPingUrls: process.env.COMPANION_PERIODIC_PING_URLS ? process.env.COMPANION_PERIODIC_PING_URLS.split(',') : [], periodicPingInterval: process.env.COMPANION_PERIODIC_PING_INTERVAL ? parseInt(process.env.COMPANION_PERIODIC_PING_INTERVAL, 10) : undefined, diff --git a/packages/@uppy/core/src/Uppy.ts b/packages/@uppy/core/src/Uppy.ts index f8e25e538e..d20de5aaf1 100644 --- a/packages/@uppy/core/src/Uppy.ts +++ b/packages/@uppy/core/src/Uppy.ts @@ -142,8 +142,25 @@ export type UnknownProviderPluginState = { currentFolderId: PartialTreeId username: string | null } + +export interface AsyncStore { + getItem: (key: string) => Promise + setItem: (key: string, value: string) => Promise + removeItem: (key: string) => Promise +} + +/** + * This is a base for a provider that does not necessarily use the Companion-assisted OAuth2 flow + */ +export interface BaseProviderPlugin { + title: string + icon: () => h.JSX.Element + storage: AsyncStore +} + /* - * UnknownProviderPlugin can be any Companion plugin (such as Google Drive). + * UnknownProviderPlugin can be any Companion plugin (such as Google Drive) + * that uses the Companion-assisted OAuth flow. * As the plugins are passed around throughout Uppy we need a generic type for this. * It may seems like duplication, but this type safe. Changing the type of `storage` * will error in the `Provider` class of @uppy/companion-client and vice versa. @@ -154,18 +171,12 @@ export type UnknownProviderPluginState = { export type UnknownProviderPlugin< M extends Meta, B extends Body, -> = UnknownPlugin & { - title: string - rootFolderId: string | null - files: UppyFile[] - icon: () => h.JSX.Element - provider: CompanionClientProvider - storage: { - getItem: (key: string) => Promise - setItem: (key: string, value: string) => Promise - removeItem: (key: string) => Promise +> = UnknownPlugin & + BaseProviderPlugin & { + rootFolderId: string | null + files: UppyFile[] + provider: CompanionClientProvider } -} /* * UnknownSearchProviderPlugin can be any search Companion plugin (such as Unsplash). @@ -185,11 +196,10 @@ export type UnknownSearchProviderPluginState = { export type UnknownSearchProviderPlugin< M extends Meta, B extends Body, -> = UnknownPlugin & { - title: string - icon: () => h.JSX.Element - provider: CompanionClientSearchProvider -} +> = UnknownPlugin & + BaseProviderPlugin & { + provider: CompanionClientSearchProvider + } export interface UploadResult { successful?: UppyFile[] @@ -712,8 +722,7 @@ export class Uppy< const updatedFiles = { ...this.getState().files } if (!updatedFiles[fileID]) { this.log( - 'Was trying to set metadata for a file that has been removed: ', - fileID, + `Was trying to set metadata for a file that has been removed: ${fileID}`, ) return } @@ -1948,7 +1957,7 @@ export class Uppy< * Passes messages to a function, provided in `opts.logger`. * If `opts.logger: Uppy.debugLogger` or `opts.debug: true`, logs to the browser console. */ - log(message: string | Record | Error, type?: string): void { + log(message: unknown, type?: 'error' | 'warning'): void { const { logger } = this.opts switch (type) { case 'error': diff --git a/packages/@uppy/core/src/locale.ts b/packages/@uppy/core/src/locale.ts index 39f3fee3c3..f1d91e4270 100644 --- a/packages/@uppy/core/src/locale.ts +++ b/packages/@uppy/core/src/locale.ts @@ -41,6 +41,9 @@ export default { openFolderNamed: 'Open folder %{name}', cancel: 'Cancel', logOut: 'Log out', + logIn: 'Log in', + pickFiles: 'Pick files', + pickPhotos: 'Pick photos', filter: 'Filter', resetFilter: 'Reset filter', loading: 'Loading...', @@ -63,5 +66,6 @@ export default { additionalRestrictionsFailed: '%{count} additional restrictions were not fulfilled', unnamed: 'Unnamed', + pleaseWait: 'Please wait', }, } diff --git a/packages/@uppy/core/src/useStore.ts b/packages/@uppy/core/src/useStore.ts new file mode 100644 index 0000000000..a01f6f2d85 --- /dev/null +++ b/packages/@uppy/core/src/useStore.ts @@ -0,0 +1,28 @@ +import { useCallback, useEffect, useState } from 'preact/hooks' + +import type { AsyncStore } from './Uppy' + +export default function useStore( + store: AsyncStore, + key: string, +): [string | undefined | null, (v: string | null) => Promise] { + const [value, setValueState] = useState() + useEffect(() => { + ;(async () => { + setValueState(await store.getItem(key)) + })() + }, [key, store]) + + const setValue = useCallback( + async (v: string | null) => { + setValueState(v) + if (v == null) { + return store.removeItem(key) + } + return store.setItem(key, v) + }, + [key, store], + ) + + return [value, setValue] +} diff --git a/packages/@uppy/dropbox/src/Dropbox.tsx b/packages/@uppy/dropbox/src/Dropbox.tsx index 17082dc561..e94c9000c5 100644 --- a/packages/@uppy/dropbox/src/Dropbox.tsx +++ b/packages/@uppy/dropbox/src/Dropbox.tsx @@ -9,7 +9,11 @@ import { ProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js' +import type { + AsyncStore, + UnknownProviderPlugin, + UnknownProviderPluginState, +} from '@uppy/core/lib/Uppy.js' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json @@ -17,12 +21,10 @@ import packageJson from '../package.json' export type DropboxOptions = CompanionPluginOptions -export default class Dropbox extends UIPlugin< - DropboxOptions, - M, - B, - UnknownProviderPluginState -> { +export default class Dropbox + extends UIPlugin + implements UnknownProviderPlugin +{ static VERSION = packageJson.version icon: () => h.JSX.Element @@ -31,7 +33,7 @@ export default class Dropbox extends UIPlugin< view!: ProviderViews - storage: typeof tokenStorage + storage: AsyncStore files: UppyFile[] diff --git a/packages/@uppy/facebook/src/Facebook.tsx b/packages/@uppy/facebook/src/Facebook.tsx index fdf253a576..41f388e825 100644 --- a/packages/@uppy/facebook/src/Facebook.tsx +++ b/packages/@uppy/facebook/src/Facebook.tsx @@ -9,7 +9,11 @@ import { ProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js' +import type { + AsyncStore, + UnknownProviderPlugin, + UnknownProviderPluginState, +} from '@uppy/core/lib/Uppy.js' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json @@ -17,12 +21,10 @@ import packageJson from '../package.json' export type FacebookOptions = CompanionPluginOptions -export default class Facebook extends UIPlugin< - FacebookOptions, - M, - B, - UnknownProviderPluginState -> { +export default class Facebook + extends UIPlugin + implements UnknownProviderPlugin +{ static VERSION = packageJson.version icon: () => h.JSX.Element @@ -31,7 +33,7 @@ export default class Facebook extends UIPlugin< view!: ProviderViews - storage: typeof tokenStorage + storage: AsyncStore files: UppyFile[] diff --git a/packages/@uppy/google-drive-picker/.npmignore b/packages/@uppy/google-drive-picker/.npmignore new file mode 100644 index 0000000000..6c816673f0 --- /dev/null +++ b/packages/@uppy/google-drive-picker/.npmignore @@ -0,0 +1 @@ +tsconfig.* diff --git a/packages/@uppy/google-drive-picker/CHANGELOG.md b/packages/@uppy/google-drive-picker/CHANGELOG.md new file mode 100644 index 0000000000..f663421d73 --- /dev/null +++ b/packages/@uppy/google-drive-picker/CHANGELOG.md @@ -0,0 +1 @@ +# @uppy/google-drive-picker diff --git a/packages/@uppy/google-drive-picker/LICENSE b/packages/@uppy/google-drive-picker/LICENSE new file mode 100644 index 0000000000..6f25c43720 --- /dev/null +++ b/packages/@uppy/google-drive-picker/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Transloadit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/@uppy/google-drive-picker/README.md b/packages/@uppy/google-drive-picker/README.md new file mode 100644 index 0000000000..40d85ae66a --- /dev/null +++ b/packages/@uppy/google-drive-picker/README.md @@ -0,0 +1,18 @@ +# @uppy/google-drive-picker + +Uppy logo: a smiling puppy above a pink upwards arrow + +[![npm version](https://img.shields.io/npm/v/@uppy/google-drive-picker.svg?style=flat-square)](https://www.npmjs.com/package/@uppy/google-drive-picker) +![CI status for Uppy tests](https://github.com/transloadit/uppy/workflows/Tests/badge.svg) +![CI status for Companion tests](https://github.com/transloadit/uppy/workflows/Companion/badge.svg) +![CI status for browser tests](https://github.com/transloadit/uppy/workflows/End-to-end%20tests/badge.svg) + +The Google Drive Picker plugin for Uppy lets users import files from their +Google Drive account using the new Picker API. + +Documentation for this plugin can be found on the +[Uppy website](https://uppy.io/docs/google-drive-picker). + +## License + +The [MIT License](./LICENSE). diff --git a/packages/@uppy/google-drive-picker/package.json b/packages/@uppy/google-drive-picker/package.json new file mode 100644 index 0000000000..fca1a0aea3 --- /dev/null +++ b/packages/@uppy/google-drive-picker/package.json @@ -0,0 +1,33 @@ +{ + "name": "@uppy/google-drive-picker", + "description": "The Google Drive Picker plugin for Uppy lets users import files from their Google Drive account", + "version": "0.1.0", + "license": "MIT", + "main": "lib/index.js", + "type": "module", + "keywords": [ + "file uploader", + "google drive", + "google picker", + "cloud storage", + "uppy", + "uppy-plugin" + ], + "homepage": "https://uppy.io", + "bugs": { + "url": "https://github.com/transloadit/uppy/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/transloadit/uppy.git" + }, + "dependencies": { + "@uppy/companion-client": "workspace:^", + "@uppy/provider-views": "workspace:^", + "@uppy/utils": "workspace:^", + "preact": "^10.5.13" + }, + "peerDependencies": { + "@uppy/core": "workspace:^" + } +} diff --git a/packages/@uppy/google-drive-picker/src/GoogleDrivePicker.tsx b/packages/@uppy/google-drive-picker/src/GoogleDrivePicker.tsx new file mode 100644 index 0000000000..e8bcac0ea6 --- /dev/null +++ b/packages/@uppy/google-drive-picker/src/GoogleDrivePicker.tsx @@ -0,0 +1,115 @@ +import { h } from 'preact' +import { UIPlugin, Uppy } from '@uppy/core' +import { GooglePickerView } from '@uppy/provider-views' +import { GoogleDriveIcon } from '@uppy/provider-views/lib/GooglePicker/icons.js' +import { + RequestClient, + type CompanionPluginOptions, + tokenStorage, +} from '@uppy/companion-client' + +import type { PickedItem } from '@uppy/provider-views/lib/GooglePicker/googlePicker.js' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' +import type { AsyncStore, BaseProviderPlugin } from '@uppy/core/lib/Uppy.js' + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore We don't want TS to generate types for the package.json +import packageJson from '../package.json' +import locale from './locale.ts' + +export type GoogleDrivePickerOptions = CompanionPluginOptions & { + clientId: string + apiKey: string + appId: string +} + +export default class GoogleDrivePicker< + M extends Meta & { width: number; height: number }, + B extends Body, + > + extends UIPlugin + implements BaseProviderPlugin +{ + static VERSION = packageJson.version + + static requestClientId = GoogleDrivePicker.name + + type = 'acquirer' + + icon = GoogleDriveIcon + + storage: AsyncStore + + defaultLocale = locale + + constructor(uppy: Uppy, opts: GoogleDrivePickerOptions) { + super(uppy, opts) + this.id = this.opts.id || 'GoogleDrivePicker' + this.storage = this.opts.storage || tokenStorage + + this.i18nInit() + this.title = this.i18n('pluginNameGoogleDrive') + + const client = new RequestClient(uppy, { + pluginId: this.id, + provider: 'url', + companionUrl: this.opts.companionUrl, + companionHeaders: this.opts.companionHeaders, + companionCookiesRule: this.opts.companionCookiesRule, + }) + + this.uppy.registerRequestClient(GoogleDrivePicker.requestClientId, client) + } + + install(): void { + const { target } = this.opts + if (target) { + this.mount(target, this) + } + } + + uninstall(): void { + this.unmount() + } + + private handleFilesPicked = async ( + files: PickedItem[], + accessToken: string, + ) => { + this.uppy.addFiles( + files.map(({ id, mimeType, name, ...rest }) => { + return { + source: this.id, + name, + type: mimeType, + data: { + size: null, // defer to companion to determine size + }, + isRemote: true, + remote: { + companionUrl: this.opts.companionUrl, + url: `${this.opts.companionUrl}/google-picker/get`, + body: { + fileId: id, + accessToken, + ...rest, + }, + requestClientId: GoogleDrivePicker.requestClientId, + }, + } + }), + ) + } + + render = () => ( + + ) +} diff --git a/packages/@uppy/google-drive-picker/src/index.ts b/packages/@uppy/google-drive-picker/src/index.ts new file mode 100644 index 0000000000..b4f53a6d4e --- /dev/null +++ b/packages/@uppy/google-drive-picker/src/index.ts @@ -0,0 +1 @@ +export { default } from './GoogleDrivePicker.tsx' diff --git a/packages/@uppy/google-drive-picker/src/locale.ts b/packages/@uppy/google-drive-picker/src/locale.ts new file mode 100644 index 0000000000..6dcfedeef9 --- /dev/null +++ b/packages/@uppy/google-drive-picker/src/locale.ts @@ -0,0 +1,3 @@ +export default { + strings: {}, +} diff --git a/packages/@uppy/google-drive-picker/tsconfig.build.json b/packages/@uppy/google-drive-picker/tsconfig.build.json new file mode 100644 index 0000000000..99aaf378de --- /dev/null +++ b/packages/@uppy/google-drive-picker/tsconfig.build.json @@ -0,0 +1,35 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "noImplicitAny": false, + "outDir": "./lib", + "paths": { + "@uppy/companion-client": ["../companion-client/src/index.js"], + "@uppy/companion-client/lib/*": ["../companion-client/src/*"], + "@uppy/provider-views": ["../provider-views/src/index.js"], + "@uppy/provider-views/lib/*": ["../provider-views/src/*"], + "@uppy/utils/lib/*": ["../utils/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"] + }, + "resolveJsonModule": false, + "rootDir": "./src", + "skipLibCheck": true + }, + "include": ["./src/**/*.*"], + "exclude": ["./src/**/*.test.ts"], + "references": [ + { + "path": "../companion-client/tsconfig.build.json" + }, + { + "path": "../provider-views/tsconfig.build.json" + }, + { + "path": "../utils/tsconfig.build.json" + }, + { + "path": "../core/tsconfig.build.json" + } + ] +} diff --git a/packages/@uppy/google-drive-picker/tsconfig.json b/packages/@uppy/google-drive-picker/tsconfig.json new file mode 100644 index 0000000000..e5220fb5ab --- /dev/null +++ b/packages/@uppy/google-drive-picker/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "emitDeclarationOnly": false, + "noEmit": true, + "paths": { + "@uppy/companion-client": ["../companion-client/src/index.js"], + "@uppy/companion-client/lib/*": ["../companion-client/src/*"], + "@uppy/provider-views": ["../provider-views/src/index.js"], + "@uppy/provider-views/lib/*": ["../provider-views/src/*"], + "@uppy/utils/lib/*": ["../utils/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"], + }, + }, + "include": ["./package.json", "./src/**/*.*"], + "references": [ + { + "path": "../companion-client/tsconfig.build.json", + }, + { + "path": "../provider-views/tsconfig.build.json", + }, + { + "path": "../utils/tsconfig.build.json", + }, + { + "path": "../core/tsconfig.build.json", + }, + ], +} diff --git a/packages/@uppy/google-drive/src/GoogleDrive.tsx b/packages/@uppy/google-drive/src/GoogleDrive.tsx index 028d8ee287..8c902bdd98 100644 --- a/packages/@uppy/google-drive/src/GoogleDrive.tsx +++ b/packages/@uppy/google-drive/src/GoogleDrive.tsx @@ -9,7 +9,11 @@ import { ProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js' +import type { + AsyncStore, + UnknownProviderPlugin, + UnknownProviderPluginState, +} from '@uppy/core/lib/Uppy.js' import DriveProviderViews from './DriveProviderViews.ts' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -18,10 +22,10 @@ import packageJson from '../package.json' export type GoogleDriveOptions = CompanionPluginOptions -export default class GoogleDrive< - M extends Meta, - B extends Body, -> extends UIPlugin { +export default class GoogleDrive + extends UIPlugin + implements UnknownProviderPlugin +{ static VERSION = packageJson.version icon: () => h.JSX.Element @@ -30,7 +34,7 @@ export default class GoogleDrive< view!: ProviderViews - storage: typeof tokenStorage + storage: AsyncStore files: UppyFile[] diff --git a/packages/@uppy/google-photos-picker/.npmignore b/packages/@uppy/google-photos-picker/.npmignore new file mode 100644 index 0000000000..6c816673f0 --- /dev/null +++ b/packages/@uppy/google-photos-picker/.npmignore @@ -0,0 +1 @@ +tsconfig.* diff --git a/packages/@uppy/google-photos-picker/CHANGELOG.md b/packages/@uppy/google-photos-picker/CHANGELOG.md new file mode 100644 index 0000000000..99eddaf743 --- /dev/null +++ b/packages/@uppy/google-photos-picker/CHANGELOG.md @@ -0,0 +1 @@ +# @uppy/google-photos-picker diff --git a/packages/@uppy/google-photos-picker/LICENSE b/packages/@uppy/google-photos-picker/LICENSE new file mode 100644 index 0000000000..6f25c43720 --- /dev/null +++ b/packages/@uppy/google-photos-picker/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Transloadit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/@uppy/google-photos-picker/README.md b/packages/@uppy/google-photos-picker/README.md new file mode 100644 index 0000000000..fb360e218c --- /dev/null +++ b/packages/@uppy/google-photos-picker/README.md @@ -0,0 +1,18 @@ +# @uppy/google-photos-picker + +Uppy logo: a smiling puppy above a pink upwards arrow + +[![npm version](https://img.shields.io/npm/v/@uppy/google-photos-picker.svg?style=flat-square)](https://www.npmjs.com/package/@uppy/google-photos-picker) +![CI status for Uppy tests](https://github.com/transloadit/uppy/workflows/Tests/badge.svg) +![CI status for Companion tests](https://github.com/transloadit/uppy/workflows/Companion/badge.svg) +![CI status for browser tests](https://github.com/transloadit/uppy/workflows/End-to-end%20tests/badge.svg) + +The Google Photos Picker plugin for Uppy lets users import photos from their +Google Photos account using the new Picker API. + +Documentation for this plugin can be found on the +[Uppy website](https://uppy.io/docs/google-photos-picker). + +## License + +The [MIT License](./LICENSE). diff --git a/packages/@uppy/google-photos-picker/package.json b/packages/@uppy/google-photos-picker/package.json new file mode 100644 index 0000000000..4c66e196f0 --- /dev/null +++ b/packages/@uppy/google-photos-picker/package.json @@ -0,0 +1,33 @@ +{ + "name": "@uppy/google-photos-picker", + "description": "The Google Photos Picker plugin for Uppy lets users import files from their Google Photos account", + "version": "0.1.0", + "license": "MIT", + "main": "lib/index.js", + "type": "module", + "keywords": [ + "file uploader", + "google photos", + "google picker", + "cloud storage", + "uppy", + "uppy-plugin" + ], + "homepage": "https://uppy.io", + "bugs": { + "url": "https://github.com/transloadit/uppy/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/transloadit/uppy.git" + }, + "dependencies": { + "@uppy/companion-client": "workspace:^", + "@uppy/provider-views": "workspace:^", + "@uppy/utils": "workspace:^", + "preact": "^10.5.13" + }, + "peerDependencies": { + "@uppy/core": "workspace:^" + } +} diff --git a/packages/@uppy/google-photos-picker/src/GooglePhotosPicker.tsx b/packages/@uppy/google-photos-picker/src/GooglePhotosPicker.tsx new file mode 100644 index 0000000000..111a1864cd --- /dev/null +++ b/packages/@uppy/google-photos-picker/src/GooglePhotosPicker.tsx @@ -0,0 +1,111 @@ +import { h } from 'preact' +import { UIPlugin, Uppy } from '@uppy/core' +import { GooglePickerView } from '@uppy/provider-views' +import { GooglePhotosIcon } from '@uppy/provider-views/lib/GooglePicker/icons.js' +import { + RequestClient, + type CompanionPluginOptions, + tokenStorage, +} from '@uppy/companion-client' + +import type { PickedItem } from '@uppy/provider-views/lib/GooglePicker/googlePicker.js' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' +import type { AsyncStore, BaseProviderPlugin } from '@uppy/core/lib/Uppy.js' + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore We don't want TS to generate types for the package.json +import packageJson from '../package.json' +import locale from './locale.ts' + +export type GooglePhotosPickerOptions = CompanionPluginOptions & { + clientId: string +} + +export default class GooglePhotosPicker< + M extends Meta & { width: number; height: number }, + B extends Body, + > + extends UIPlugin + implements BaseProviderPlugin +{ + static VERSION = packageJson.version + + static requestClientId = GooglePhotosPicker.name + + type = 'acquirer' + + icon = GooglePhotosIcon + + storage: AsyncStore + + defaultLocale = locale + + constructor(uppy: Uppy, opts: GooglePhotosPickerOptions) { + super(uppy, opts) + this.id = this.opts.id || 'GooglePhotosPicker' + this.storage = this.opts.storage || tokenStorage + + this.i18nInit() + this.title = this.i18n('pluginNameGooglePhotos') + + const client = new RequestClient(uppy, { + pluginId: this.id, + provider: 'url', + companionUrl: this.opts.companionUrl, + companionHeaders: this.opts.companionHeaders, + companionCookiesRule: this.opts.companionCookiesRule, + }) + + this.uppy.registerRequestClient(GooglePhotosPicker.requestClientId, client) + } + + install(): void { + const { target } = this.opts + if (target) { + this.mount(target, this) + } + } + + uninstall(): void { + this.unmount() + } + + private handleFilesPicked = async ( + files: PickedItem[], + accessToken: string, + ) => { + this.uppy.addFiles( + files.map(({ id, mimeType, name, ...rest }) => { + return { + source: this.id, + name, + type: mimeType, + data: { + size: null, // defer to companion to determine size + }, + isRemote: true, + remote: { + companionUrl: this.opts.companionUrl, + url: `${this.opts.companionUrl}/google-picker/get`, + body: { + fileId: id, + accessToken, + ...rest, + }, + requestClientId: GooglePhotosPicker.requestClientId, + }, + } + }), + ) + } + + render = () => ( + + ) +} diff --git a/packages/@uppy/google-photos-picker/src/index.ts b/packages/@uppy/google-photos-picker/src/index.ts new file mode 100644 index 0000000000..7d64a7cb5e --- /dev/null +++ b/packages/@uppy/google-photos-picker/src/index.ts @@ -0,0 +1 @@ +export { default } from './GooglePhotosPicker.tsx' diff --git a/packages/@uppy/google-photos-picker/src/locale.ts b/packages/@uppy/google-photos-picker/src/locale.ts new file mode 100644 index 0000000000..6dcfedeef9 --- /dev/null +++ b/packages/@uppy/google-photos-picker/src/locale.ts @@ -0,0 +1,3 @@ +export default { + strings: {}, +} diff --git a/packages/@uppy/google-photos-picker/tsconfig.build.json b/packages/@uppy/google-photos-picker/tsconfig.build.json new file mode 100644 index 0000000000..99aaf378de --- /dev/null +++ b/packages/@uppy/google-photos-picker/tsconfig.build.json @@ -0,0 +1,35 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "noImplicitAny": false, + "outDir": "./lib", + "paths": { + "@uppy/companion-client": ["../companion-client/src/index.js"], + "@uppy/companion-client/lib/*": ["../companion-client/src/*"], + "@uppy/provider-views": ["../provider-views/src/index.js"], + "@uppy/provider-views/lib/*": ["../provider-views/src/*"], + "@uppy/utils/lib/*": ["../utils/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"] + }, + "resolveJsonModule": false, + "rootDir": "./src", + "skipLibCheck": true + }, + "include": ["./src/**/*.*"], + "exclude": ["./src/**/*.test.ts"], + "references": [ + { + "path": "../companion-client/tsconfig.build.json" + }, + { + "path": "../provider-views/tsconfig.build.json" + }, + { + "path": "../utils/tsconfig.build.json" + }, + { + "path": "../core/tsconfig.build.json" + } + ] +} diff --git a/packages/@uppy/google-photos-picker/tsconfig.json b/packages/@uppy/google-photos-picker/tsconfig.json new file mode 100644 index 0000000000..e5220fb5ab --- /dev/null +++ b/packages/@uppy/google-photos-picker/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "emitDeclarationOnly": false, + "noEmit": true, + "paths": { + "@uppy/companion-client": ["../companion-client/src/index.js"], + "@uppy/companion-client/lib/*": ["../companion-client/src/*"], + "@uppy/provider-views": ["../provider-views/src/index.js"], + "@uppy/provider-views/lib/*": ["../provider-views/src/*"], + "@uppy/utils/lib/*": ["../utils/src/*"], + "@uppy/core": ["../core/src/index.js"], + "@uppy/core/lib/*": ["../core/src/*"], + }, + }, + "include": ["./package.json", "./src/**/*.*"], + "references": [ + { + "path": "../companion-client/tsconfig.build.json", + }, + { + "path": "../provider-views/tsconfig.build.json", + }, + { + "path": "../utils/tsconfig.build.json", + }, + { + "path": "../core/tsconfig.build.json", + }, + ], +} diff --git a/packages/@uppy/google-photos/src/GooglePhotos.tsx b/packages/@uppy/google-photos/src/GooglePhotos.tsx index eba6bbb3e2..248f7e5ac1 100644 --- a/packages/@uppy/google-photos/src/GooglePhotos.tsx +++ b/packages/@uppy/google-photos/src/GooglePhotos.tsx @@ -9,7 +9,11 @@ import { import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js' +import type { + AsyncStore, + UnknownProviderPlugin, + UnknownProviderPluginState, +} from '@uppy/core/lib/Uppy.js' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json @@ -18,10 +22,10 @@ import locale from './locale.ts' export type GooglePhotosOptions = CompanionPluginOptions -export default class GooglePhotos< - M extends Meta, - B extends Body, -> extends UIPlugin { +export default class GooglePhotos + extends UIPlugin + implements UnknownProviderPlugin +{ static VERSION = packageJson.version icon: () => h.JSX.Element @@ -30,7 +34,7 @@ export default class GooglePhotos< view!: ProviderViews - storage: typeof tokenStorage + storage: AsyncStore files: UppyFile[] diff --git a/packages/@uppy/instagram/src/Instagram.tsx b/packages/@uppy/instagram/src/Instagram.tsx index 4c85265c20..c0cd987d17 100644 --- a/packages/@uppy/instagram/src/Instagram.tsx +++ b/packages/@uppy/instagram/src/Instagram.tsx @@ -9,7 +9,11 @@ import { ProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js' +import type { + AsyncStore, + UnknownProviderPlugin, + UnknownProviderPluginState, +} from '@uppy/core/lib/Uppy.js' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json @@ -17,12 +21,10 @@ import packageJson from '../package.json' export type InstagramOptions = CompanionPluginOptions -export default class Instagram extends UIPlugin< - InstagramOptions, - M, - B, - UnknownProviderPluginState -> { +export default class Instagram + extends UIPlugin + implements UnknownProviderPlugin +{ static VERSION = packageJson.version icon: () => h.JSX.Element @@ -31,7 +33,7 @@ export default class Instagram extends UIPlugin< view!: ProviderViews - storage: typeof tokenStorage + storage: AsyncStore files: UppyFile[] diff --git a/packages/@uppy/onedrive/src/OneDrive.tsx b/packages/@uppy/onedrive/src/OneDrive.tsx index b954058a6f..bdfcfe2763 100644 --- a/packages/@uppy/onedrive/src/OneDrive.tsx +++ b/packages/@uppy/onedrive/src/OneDrive.tsx @@ -8,8 +8,12 @@ import { UIPlugin, Uppy } from '@uppy/core' import { ProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' +import type { AsyncStore } from '@uppy/core/src/Uppy.js' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js' +import type { + UnknownProviderPlugin, + UnknownProviderPluginState, +} from '@uppy/core/lib/Uppy.js' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json @@ -17,12 +21,10 @@ import packageJson from '../package.json' export type OneDriveOptions = CompanionPluginOptions -export default class OneDrive extends UIPlugin< - OneDriveOptions, - M, - B, - UnknownProviderPluginState -> { +export default class OneDrive + extends UIPlugin + implements UnknownProviderPlugin +{ static VERSION = packageJson.version icon: () => h.JSX.Element @@ -31,7 +33,7 @@ export default class OneDrive extends UIPlugin< view!: ProviderViews - storage: typeof tokenStorage + storage: AsyncStore files: UppyFile[] diff --git a/packages/@uppy/provider-views/package.json b/packages/@uppy/provider-views/package.json index 901725579f..2f4485fe32 100644 --- a/packages/@uppy/provider-views/package.json +++ b/packages/@uppy/provider-views/package.json @@ -26,6 +26,9 @@ "preact": "^10.5.13" }, "devDependencies": { + "@types/gapi": "^0.0.47", + "@types/google.accounts": "^0.0.14", + "@types/google.picker": "^0.0.42", "vitest": "^1.6.0" }, "peerDependencies": { diff --git a/packages/@uppy/provider-views/src/GooglePicker/GooglePickerView.tsx b/packages/@uppy/provider-views/src/GooglePicker/GooglePickerView.tsx new file mode 100644 index 0000000000..72fc10dd47 --- /dev/null +++ b/packages/@uppy/provider-views/src/GooglePicker/GooglePickerView.tsx @@ -0,0 +1,234 @@ +import { h } from 'preact' +import { useCallback, useEffect, useRef, useState } from 'preact/hooks' + +import type { Uppy } from '@uppy/core' +import useStore from '@uppy/core/lib/useStore.js' +import type { AsyncStore } from '@uppy/core/lib/Uppy.js' + +import { + authorize, + ensureScriptsInjected, + InvalidTokenError, + logout, + pollPickingSession, + showDrivePicker, + showPhotosPicker, + type PickedItem, + type PickingSession, +} from './googlePicker.js' +import AuthView from '../ProviderView/AuthView.js' +import { GoogleDriveIcon, GooglePhotosIcon } from './icons.js' + +export type GooglePickerViewProps = { + uppy: Uppy + clientId: string + onFilesPicked: (files: PickedItem[], accessToken: string) => void + storage: AsyncStore +} & ( + | { + pickerType: 'drive' + apiKey: string + appId: string + } + | { + pickerType: 'photos' + apiKey?: undefined + appId?: undefined + } +) + +export default function GooglePickerView({ + uppy, + clientId, + onFilesPicked, + pickerType, + apiKey, + appId, + storage, +}: GooglePickerViewProps) { + const [loading, setLoading] = useState(false) + const [accessToken, setAccessTokenStored] = useStore( + storage, + `uppy:google-${pickerType}-picker:accessToken`, + ) + + const pickingSessionRef = useRef() + const accessTokenRef = useRef(accessToken) + const shownPickerRef = useRef(false) + + const setAccessToken = useCallback( + (t: string | null) => { + uppy.log('Access token updated') + setAccessTokenStored(t) + accessTokenRef.current = t + }, + [setAccessTokenStored, uppy], + ) + + // keep access token in sync with the ref + useEffect(() => { + accessTokenRef.current = accessToken + }, [accessToken]) + + const showPicker = useCallback( + async (signal?: AbortSignal) => { + let newAccessToken = accessToken + + const doShowPicker = async (token: string) => { + if (pickerType === 'drive') { + await showDrivePicker({ token, apiKey, appId, onFilesPicked, signal }) + } else { + // photos + const onPickingSessionChange = ( + newPickingSession: PickingSession, + ) => { + pickingSessionRef.current = newPickingSession + } + await showPhotosPicker({ + token, + pickingSession: pickingSessionRef.current, + onPickingSessionChange, + signal, + }) + } + } + + setLoading(true) + try { + try { + await ensureScriptsInjected(pickerType) + + if (newAccessToken == null) { + newAccessToken = await authorize({ clientId, pickerType }) + } + if (newAccessToken == null) throw new Error() + + await doShowPicker(newAccessToken) + shownPickerRef.current = true + setAccessToken(newAccessToken) + } catch (err) { + if (err instanceof InvalidTokenError) { + uppy.log('Token is invalid or expired, reauthenticating') + newAccessToken = await authorize({ + pickerType, + accessToken: newAccessToken, + clientId, + }) + // now try again: + await doShowPicker(newAccessToken) + shownPickerRef.current = true + setAccessToken(newAccessToken) + } else { + throw err + } + } + } catch (err) { + if ( + err instanceof Error && + 'type' in err && + err.type === 'popup_closed' + ) { + // user closed the auth popup, ignore + } else { + setAccessToken(null) + uppy.log(err) + } + } finally { + setLoading(false) + } + }, + [ + accessToken, + apiKey, + appId, + clientId, + onFilesPicked, + pickerType, + setAccessToken, + uppy, + ], + ) + + useEffect(() => { + const abortController = new AbortController() + + pollPickingSession({ + pickingSessionRef, + accessTokenRef, + signal: abortController.signal, + onFilesPicked, + onError: (err) => uppy.log(err), + }) + + return () => abortController.abort() + }, [onFilesPicked, uppy]) + + useEffect(() => { + // when mounting, once we have a token, be nice to the user and automatically show the picker + // accessToken === undefined means not yet loaded from storage, so wait for that first + if (accessToken === undefined || shownPickerRef.current) { + return undefined + } + + const abortController = new AbortController() + + showPicker(abortController.signal) + + return () => { + // only abort the picker if it's not yet shown + if (!shownPickerRef.current) abortController.abort() + } + }, [accessToken, showPicker]) + + const handleLogoutClick = useCallback(async () => { + if (accessToken) { + await logout(accessToken) + setAccessToken(null) + pickingSessionRef.current = undefined + } + }, [accessToken, setAccessToken]) + + if (loading) { + return
{uppy.i18n('pleaseWait')}...
+ } + + if (accessToken == null) { + return ( + + ) + } + + return ( +
+ + +
+ ) +} diff --git a/packages/@uppy/provider-views/src/GooglePicker/googlePicker.ts b/packages/@uppy/provider-views/src/GooglePicker/googlePicker.ts new file mode 100644 index 0000000000..53fcaf9069 --- /dev/null +++ b/packages/@uppy/provider-views/src/GooglePicker/googlePicker.ts @@ -0,0 +1,425 @@ +import { type MutableRef } from 'preact/hooks' + +// https://developers.google.com/photos/picker/reference/rest/v1/mediaItems +export interface MediaItemBase { + id: string + createTime: string +} + +interface MediaFileMetadataBase { + width: number + height: number + cameraMake: string + cameraModel: string +} + +interface MediaFileBase { + baseUrl: string + mimeType: string + filename: string +} + +export interface VideoMediaItem extends MediaItemBase { + type: 'VIDEO' + mediaFile: MediaFileBase & { + mediaFileMetadata: MediaFileMetadataBase & { + videoMetadata: { + fps: number + processingStatus: 'UNSPECIFIED' | 'PROCESSING' | 'READY' | 'FAILED' + } + } + } +} + +export interface PhotoMediaItem extends MediaItemBase { + type: 'PHOTO' + mediaFile: MediaFileBase & { + mediaFileMetadata: MediaFileMetadataBase & { + photoMetadata: { + focalLength: number + apertureFNumber: number + isoEquivalent: number + exposureTime: string + } + } + } +} + +export interface UnspecifiedMediaItem extends MediaItemBase { + type: 'TYPE_UNSPECIFIED' + mediaFile: MediaFileBase +} + +export type MediaItem = VideoMediaItem | PhotoMediaItem | UnspecifiedMediaItem + +// https://developers.google.com/photos/picker/reference/rest/v1/sessions +export interface PickingSession { + id: string + pickerUri: string + pollingConfig: { + pollInterval: string + timeoutIn: string + } + expireTime: string + mediaItemsSet: boolean +} + +export interface PickedItemBase { + id: string + mimeType: string + name: string +} + +export interface PickedDriveItem extends PickedItemBase { + platform: 'drive' +} + +export interface PickedPhotosItem extends PickedItemBase { + platform: 'photos' + url: string +} + +export type PickedItem = PickedPhotosItem | PickedDriveItem + +type PickerType = 'drive' | 'photos' + +const getAuthHeader = (token: string) => ({ + authorization: `Bearer ${token}`, +}) + +const injectedScripts = new Set() +let driveApiLoaded = false + +// https://stackoverflow.com/a/39008859/6519037 +async function injectScript(src: string) { + if (injectedScripts.has(src)) return + + await new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = src + script.addEventListener('load', () => resolve()) + script.addEventListener('error', (e) => reject(e.error)) + document.head.appendChild(script) + }) + injectedScripts.add(src) +} + +export async function ensureScriptsInjected( + pickerType: PickerType, +): Promise { + await Promise.all([ + injectScript('https://accounts.google.com/gsi/client'), // Google Identity Services + (async () => { + await injectScript('https://apis.google.com/js/api.js') + + if (pickerType === 'drive' && !driveApiLoaded) { + await new Promise((resolve) => + gapi.load('client:picker', () => resolve()), + ) + await gapi.client.load( + 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest', + ) + driveApiLoaded = true + } + })(), + ]) +} + +async function isTokenValid( + accessToken: string, + signal: AbortSignal | undefined, +) { + const response = await fetch( + `https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${encodeURIComponent(accessToken)}`, + { signal }, + ) + if (response.ok) { + return true + } + // console.warn('Token is invalid or expired:', response.status, await response.text()); + // Token is invalid or expired + return false +} + +export async function authorize({ + pickerType, + clientId, + accessToken, +}: { + pickerType: PickerType + clientId: string + accessToken?: string | null | undefined +}): Promise { + const response = await new Promise( + (resolve, reject) => { + const scopes = + pickerType === 'drive' ? + ['https://www.googleapis.com/auth/drive.readonly'] + : ['https://www.googleapis.com/auth/photospicker.mediaitems.readonly'] + + const tokenClient = google.accounts.oauth2.initTokenClient({ + client_id: clientId, + // Authorization scopes required by the API; multiple scopes can be included, separated by spaces. + scope: scopes.join(' '), + callback: resolve, + error_callback: reject, + }) + + if (accessToken === null) { + // Prompt the user to select a Google Account and ask for consent to share their data + // when establishing a new session. + tokenClient.requestAccessToken({ prompt: 'consent' }) + } else { + // Skip display of account chooser and consent dialog for an existing session. + tokenClient.requestAccessToken({ prompt: '' }) + } + }, + ) + + if (response.error) { + throw new Error(`OAuth2 error: ${response.error}`) + } + return response.access_token +} + +export async function logout(accessToken: string): Promise { + await new Promise((resolve) => + google.accounts.oauth2.revoke(accessToken, resolve), + ) +} + +export class InvalidTokenError extends Error { + constructor() { + super('Invalid or expired token') + this.name = 'InvalidTokenError' + } +} + +export async function showDrivePicker({ + token, + apiKey, + appId, + onFilesPicked, + signal, +}: { + token: string + apiKey: string + appId: string + onFilesPicked: (files: PickedItem[], accessToken: string) => void + signal: AbortSignal | undefined +}): Promise { + // google drive picker will crash hard if given an invalid token, so we need to check it first + // https://github.com/transloadit/uppy/pull/5443#pullrequestreview-2452439265 + if (!(await isTokenValid(token, signal))) { + throw new InvalidTokenError() + } + + const onPicked = (picked: google.picker.ResponseObject) => { + if (picked.action === google.picker.Action.PICKED) { + // console.log('Picker response', JSON.stringify(picked, null, 2)); + onFilesPicked( + picked['docs'].map((doc) => ({ + platform: 'drive', + id: doc['id'], + name: doc['name'], + mimeType: doc['mimeType'], + })), + token, + ) + } + } + + const picker = new google.picker.PickerBuilder() + .enableFeature(google.picker.Feature.NAV_HIDDEN) + .enableFeature(google.picker.Feature.MULTISELECT_ENABLED) + .setDeveloperKey(apiKey) + .setAppId(appId) + .setOAuthToken(token) + .addView( + new google.picker.DocsView(google.picker.ViewId.DOCS) + .setIncludeFolders(true) + // Note: setEnableDrives doesn't seem to work + // .setEnableDrives(true) + .setSelectFolderEnabled(false), + ) + // NOTE: photos is broken and results in an error being returned from Google + // I think it's the old Picasa photos + // .addView(google.picker.ViewId.PHOTOS) + .setCallback(onPicked) + .build() + + picker.setVisible(true) + signal?.addEventListener('abort', () => picker.dispose()) +} + +export async function showPhotosPicker({ + token, + pickingSession, + onPickingSessionChange, + signal, +}: { + token: string + pickingSession: PickingSession | undefined + onPickingSessionChange: (ps: PickingSession) => void + signal: AbortSignal | undefined +}): Promise { + // https://developers.google.com/photos/picker/guides/get-started-picker + const headers = getAuthHeader(token) + + let newPickingSession = pickingSession + if (newPickingSession == null) { + const createSessionResponse = await fetch( + 'https://photospicker.googleapis.com/v1/sessions', + { method: 'post', headers, signal }, + ) + + if (createSessionResponse.status === 401) { + const resp = await createSessionResponse.json() + if (resp.error?.status === 'UNAUTHENTICATED') { + throw new InvalidTokenError() + } + } + + if (!createSessionResponse.ok) { + throw new Error('Failed to create a session') + } + newPickingSession = (await createSessionResponse.json()) as PickingSession + + onPickingSessionChange(newPickingSession) + } + + const w = window.open(newPickingSession.pickerUri) + signal?.addEventListener('abort', () => w?.close()) +} + +async function resolvePickedPhotos({ + accessToken, + pickingSession, + signal, +}: { + accessToken: string + pickingSession: PickingSession + signal: AbortSignal +}) { + const headers = getAuthHeader(accessToken) + + let pageToken: string | undefined + let mediaItems: MediaItem[] = [] + do { + const pageSize = 100 + const response = await fetch( + `https://photospicker.googleapis.com/v1/mediaItems?${new URLSearchParams({ sessionId: pickingSession.id, pageSize: String(pageSize) }).toString()}`, + { headers, signal }, + ) + if (!response.ok) throw new Error('Failed to get a media items') + const { + mediaItems: batchMediaItems, + nextPageToken, + }: { mediaItems: MediaItem[]; nextPageToken?: string } = + await response.json() + pageToken = nextPageToken + mediaItems.push(...batchMediaItems) + } while (pageToken) + + // todo show alert instead about invalid picked files? + mediaItems = mediaItems.flatMap((i) => + ( + i.type === 'PHOTO' || + (i.type === 'VIDEO' && + i.mediaFile.mediaFileMetadata.videoMetadata.processingStatus === + 'READY') + ) ? + [i] + : [], + ) + + return mediaItems.map( + ({ + id, + // we want the original resolution, so we don't append any parameter to the baseUrl + // https://developers.google.com/photos/library/guides/access-media-items#base-urls + mediaFile: { mimeType, filename, baseUrl }, + }) => ({ + platform: 'photos' as const, + id, + mimeType, + url: baseUrl, + name: filename, + }), + ) +} + +export async function pollPickingSession({ + pickingSessionRef, + accessTokenRef, + signal, + onFilesPicked, + onError, +}: { + pickingSessionRef: MutableRef + accessTokenRef: MutableRef + signal: AbortSignal + onFilesPicked: (files: PickedItem[], accessToken: string) => void + onError: (err: unknown) => void +}): Promise { + // if we have an active session, poll it until it either times out, or the user selects some photos. + // Note that the user can also just close the page, but we get no indication of that from Google when polling, + // so we just have to continue polling in the background, so we can react to it + // in case the user opens the photo selector again. Hence the infinite for loop + for (let interval = 1; ; ) { + try { + if (pickingSessionRef.current != null) { + interval = parseFloat( + pickingSessionRef.current.pollingConfig.pollInterval, + ) + } else { + interval = 1 + } + + await Promise.race([ + new Promise((resolve) => setTimeout(resolve, interval * 1000)), + new Promise((_resolve, reject) => { + signal.addEventListener('abort', reject) + }), + ]) + + signal.throwIfAborted() + + const accessToken = accessTokenRef.current + const pickingSession = pickingSessionRef.current + + if (pickingSession != null && accessToken != null) { + const headers = getAuthHeader(accessToken) + + // https://developers.google.com/photos/picker/reference/rest/v1/sessions + const response = await fetch( + `https://photospicker.googleapis.com/v1/sessions/${encodeURIComponent(pickingSession.id)}`, + { headers, signal }, + ) + if (!response.ok) throw new Error('Failed to get session') + const json: PickingSession = await response.json() + if (json.mediaItemsSet) { + // console.log('User picked!', json) + const resolvedPhotos = await resolvePickedPhotos({ + accessToken, + pickingSession, + signal, + }) + // eslint-disable-next-line no-param-reassign + pickingSessionRef.current = undefined + onFilesPicked(resolvedPhotos, accessToken) + } + if (pickingSession.pollingConfig.timeoutIn === '0s') { + // eslint-disable-next-line no-param-reassign + pickingSessionRef.current = undefined + } + } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + return + } + // just report the error and continue polling + onError(err) + } + } +} diff --git a/packages/@uppy/provider-views/src/GooglePicker/icons.tsx b/packages/@uppy/provider-views/src/GooglePicker/icons.tsx new file mode 100644 index 0000000000..0a83b53679 --- /dev/null +++ b/packages/@uppy/provider-views/src/GooglePicker/icons.tsx @@ -0,0 +1,70 @@ +import { h } from 'preact' + +export const GooglePhotosIcon = () => ( + +) + +export const GoogleDriveIcon = () => ( + +) diff --git a/packages/@uppy/provider-views/src/index.ts b/packages/@uppy/provider-views/src/index.ts index 64c621627e..bdf3178237 100644 --- a/packages/@uppy/provider-views/src/index.ts +++ b/packages/@uppy/provider-views/src/index.ts @@ -4,3 +4,5 @@ export { } from './ProviderView/index.ts' export { default as SearchProviderViews } from './SearchProviderView/index.ts' + +export { default as GooglePickerView } from './GooglePicker/GooglePickerView.tsx' diff --git a/packages/@uppy/provider-views/src/style.scss b/packages/@uppy/provider-views/src/style.scss index d29a8d30dd..0869ab76df 100644 --- a/packages/@uppy/provider-views/src/style.scss +++ b/packages/@uppy/provider-views/src/style.scss @@ -379,3 +379,11 @@ padding-bottom: 10px; } } + +/* https://stackoverflow.com/a/33082658/6519037 */ +.picker-dialog-bg { + z-index: 20000 !important; +} +.picker-dialog { + z-index: 20001 !important; +} diff --git a/packages/@uppy/provider-views/src/utils/getTagFile.ts b/packages/@uppy/provider-views/src/utils/getTagFile.ts index 84bc4c39f5..bb3a8df0c8 100644 --- a/packages/@uppy/provider-views/src/utils/getTagFile.ts +++ b/packages/@uppy/provider-views/src/utils/getTagFile.ts @@ -34,7 +34,6 @@ const getTagFile = ( }, remote: { companionUrl: plugin.opts.companionUrl, - // @ts-expect-error untyped for now url: `${provider.fileUrl(file.requestPath)}`, body: { fileId: file.id, diff --git a/packages/@uppy/provider-views/tsconfig.json b/packages/@uppy/provider-views/tsconfig.json index a76c3b714a..8a0f38c687 100644 --- a/packages/@uppy/provider-views/tsconfig.json +++ b/packages/@uppy/provider-views/tsconfig.json @@ -8,6 +8,7 @@ "@uppy/core": ["../core/src/index.js"], "@uppy/core/lib/*": ["../core/src/*"], }, + "types": ["google.accounts", "google.picker", "gapi"], }, "include": ["./package.json", "./src/**/*.*"], "references": [ diff --git a/packages/@uppy/transloadit/src/index.ts b/packages/@uppy/transloadit/src/index.ts index 91b97a0bdd..6ec52bbc2d 100644 --- a/packages/@uppy/transloadit/src/index.ts +++ b/packages/@uppy/transloadit/src/index.ts @@ -334,6 +334,8 @@ export default class Transloadit< addPluginVersion('Facebook', 'uppy-facebook') addPluginVersion('GoogleDrive', 'uppy-google-drive') addPluginVersion('GooglePhotos', 'uppy-google-photos') + addPluginVersion('GoogleDrivePicker', 'uppy-google-drive-picker') + addPluginVersion('GooglePhotosPicker', 'uppy-google-photos-picker') addPluginVersion('Instagram', 'uppy-instagram') addPluginVersion('OneDrive', 'uppy-onedrive') addPluginVersion('Zoom', 'uppy-zoom') diff --git a/packages/@uppy/unsplash/src/Unsplash.tsx b/packages/@uppy/unsplash/src/Unsplash.tsx index fbbf5dcc5f..a91dfcc1f3 100644 --- a/packages/@uppy/unsplash/src/Unsplash.tsx +++ b/packages/@uppy/unsplash/src/Unsplash.tsx @@ -9,7 +9,11 @@ import { SearchProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownSearchProviderPluginState } from '@uppy/core/lib/Uppy.js' +import type { + AsyncStore, + UnknownSearchProviderPlugin, + UnknownSearchProviderPluginState, +} from '@uppy/core/lib/Uppy.js' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json @@ -17,12 +21,10 @@ import packageJson from '../package.json' export type UnsplashOptions = CompanionPluginOptions -export default class Unsplash extends UIPlugin< - UnsplashOptions, - M, - B, - UnknownSearchProviderPluginState -> { +export default class Unsplash + extends UIPlugin + implements UnknownSearchProviderPlugin +{ static VERSION = packageJson.version icon: () => h.JSX.Element @@ -31,7 +33,7 @@ export default class Unsplash extends UIPlugin< view!: SearchProviderViews - storage: typeof tokenStorage + storage: AsyncStore files: UppyFile[] diff --git a/packages/@uppy/utils/src/CompanionClientProvider.ts b/packages/@uppy/utils/src/CompanionClientProvider.ts index 4ef80ecbfd..98877c909d 100644 --- a/packages/@uppy/utils/src/CompanionClientProvider.ts +++ b/packages/@uppy/utils/src/CompanionClientProvider.ts @@ -26,6 +26,7 @@ export interface CompanionClientProvider { login(options?: RequestOptions): Promise logout(options?: RequestOptions): Promise fetchPreAuthToken(): Promise + fileUrl: (a: string) => string list( directory: string | null, options: RequestOptions, @@ -38,5 +39,6 @@ export interface CompanionClientProvider { export interface CompanionClientSearchProvider { name: string provider: string + fileUrl: (a: string) => string search(text: string, queries?: string): Promise } diff --git a/packages/@uppy/zoom/src/Zoom.tsx b/packages/@uppy/zoom/src/Zoom.tsx index 888e359a01..4f70bcfdd0 100644 --- a/packages/@uppy/zoom/src/Zoom.tsx +++ b/packages/@uppy/zoom/src/Zoom.tsx @@ -9,7 +9,11 @@ import { ProviderViews } from '@uppy/provider-views' import { h, type ComponentChild } from 'preact' import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile' -import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js' +import type { + AsyncStore, + UnknownProviderPlugin, + UnknownProviderPluginState, +} from '@uppy/core/lib/Uppy.js' import locale from './locale.ts' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore We don't want TS to generate types for the package.json @@ -17,12 +21,10 @@ import packageJson from '../package.json' export type ZoomOptions = CompanionPluginOptions -export default class Zoom extends UIPlugin< - ZoomOptions, - M, - B, - UnknownProviderPluginState -> { +export default class Zoom + extends UIPlugin + implements UnknownProviderPlugin +{ static VERSION = packageJson.version icon: () => h.JSX.Element @@ -31,7 +33,7 @@ export default class Zoom extends UIPlugin< view!: ProviderViews - storage: typeof tokenStorage + storage: AsyncStore files: UppyFile[] diff --git a/packages/uppy/package.json b/packages/uppy/package.json index db2688a9be..24d9fe4a20 100644 --- a/packages/uppy/package.json +++ b/packages/uppy/package.json @@ -46,7 +46,9 @@ "@uppy/form": "workspace:^", "@uppy/golden-retriever": "workspace:^", "@uppy/google-drive": "workspace:^", + "@uppy/google-drive-picker": "workspace:^", "@uppy/google-photos": "workspace:^", + "@uppy/google-photos-picker": "workspace:^", "@uppy/image-editor": "workspace:^", "@uppy/informer": "workspace:^", "@uppy/instagram": "workspace:^", diff --git a/packages/uppy/src/bundle.ts b/packages/uppy/src/bundle.ts index a16e05078f..2973473007 100644 --- a/packages/uppy/src/bundle.ts +++ b/packages/uppy/src/bundle.ts @@ -22,7 +22,9 @@ export const views = { ProviderView } // Stores export { default as DefaultStore } from '@uppy/store-default' -// @ts-expect-error untyped +// not yet typed +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore export { default as ReduxStore } from '@uppy/store-redux' // UI plugins @@ -42,6 +44,8 @@ export { default as Dropbox } from '@uppy/dropbox' export { default as Facebook } from '@uppy/facebook' export { default as GoogleDrive } from '@uppy/google-drive' export { default as GooglePhotos } from '@uppy/google-photos' +export { default as GoogleDrivePicker } from '@uppy/google-drive-picker' +export { default as GooglePhotosPicker } from '@uppy/google-photos-picker' export { default as Instagram } from '@uppy/instagram' export { default as OneDrive } from '@uppy/onedrive' export { default as RemoteSources } from '@uppy/remote-sources' @@ -61,7 +65,9 @@ export { default as XHRUpload } from '@uppy/xhr-upload' export { default as Compressor } from '@uppy/compressor' export { default as Form } from '@uppy/form' export { default as GoldenRetriever } from '@uppy/golden-retriever' -// @ts-expect-error untyped +// not yet typed +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore export { default as ReduxDevTools } from '@uppy/redux-dev-tools' export { default as ThumbnailGenerator } from '@uppy/thumbnail-generator' diff --git a/private/dev/Dashboard.js b/private/dev/Dashboard.js index f71abf5e09..9c3d2d8a1c 100644 --- a/private/dev/Dashboard.js +++ b/private/dev/Dashboard.js @@ -16,6 +16,8 @@ import Audio from '@uppy/audio' import Compressor from '@uppy/compressor' import GoogleDrive from '@uppy/google-drive' import english from '@uppy/locales/lib/en_US.js' +import GoogleDrivePicker from '@uppy/google-drive-picker' +import GooglePhotosPicker from '@uppy/google-photos-picker' /* eslint-enable import/no-extraneous-dependencies */ import generateSignatureIfSecret from './generateSignatureIfSecret.js' @@ -30,6 +32,9 @@ const { VITE_TRANSLOADIT_SECRET: TRANSLOADIT_SECRET, VITE_TRANSLOADIT_TEMPLATE: TRANSLOADIT_TEMPLATE, VITE_TRANSLOADIT_SERVICE_URL: TRANSLOADIT_SERVICE_URL, + VITE_GOOGLE_PICKER_API_KEY: GOOGLE_PICKER_API_KEY, + VITE_GOOGLE_PICKER_CLIENT_ID: GOOGLE_PICKER_CLIENT_ID, + VITE_GOOGLE_PICKER_APP_ID: GOOGLE_PICKER_APP_ID, } = import.meta.env const companionAllowedHosts = @@ -125,6 +130,20 @@ export default () => { // .use(Zoom, { target: Dashboard, companionUrl: COMPANION_URL, companionAllowedHosts }) // .use(Url, { target: Dashboard, companionUrl: COMPANION_URL, companionAllowedHosts }) // .use(Unsplash, { target: Dashboard, companionUrl: COMPANION_URL, companionAllowedHosts }) + .use(GoogleDrivePicker, { + target: Dashboard, + companionUrl: COMPANION_URL, + companionAllowedHosts, + clientId: GOOGLE_PICKER_CLIENT_ID, + apiKey: GOOGLE_PICKER_API_KEY, + appId: GOOGLE_PICKER_APP_ID, + }) + .use(GooglePhotosPicker, { + target: Dashboard, + companionUrl: COMPANION_URL, + companionAllowedHosts, + clientId: GOOGLE_PICKER_CLIENT_ID, + }) .use(RemoteSources, { companionUrl: COMPANION_URL, sources: [ diff --git a/tsconfig.json b/tsconfig.json index bcc25fbd62..c64bff1806 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -49,6 +49,12 @@ { "path": "./packages/@uppy/google-photos/tsconfig.build.json", }, + { + "path": "./packages/@uppy/google-drive-picker/tsconfig.build.json", + }, + { + "path": "./packages/@uppy/google-photos-picker/tsconfig.build.json", + }, { "path": "./packages/@uppy/image-editor/tsconfig.build.json", }, diff --git a/yarn.lock b/yarn.lock index 426223c54b..0be0798828 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7492,6 +7492,27 @@ __metadata: languageName: node linkType: hard +"@types/gapi@npm:^0.0.47": + version: 0.0.47 + resolution: "@types/gapi@npm:0.0.47" + checksum: 10/b8104688ef132190cb661b461b912a3f6f07ce589eb90ab4bff4acdfaa9bbb8a6321be1119e865db89bf46dfc00cab2141764839535518cf63a8e2caa19f475e + languageName: node + linkType: hard + +"@types/google.accounts@npm:^0.0.14": + version: 0.0.14 + resolution: "@types/google.accounts@npm:0.0.14" + checksum: 10/0332acd210eaad1904d28a9de2081da796cb8c22e4f61bbe0768729c71d1de1606355abc0615907505b9f4ac28694911b9722a6a4e6ee563c21f747e9e1c32b5 + languageName: node + linkType: hard + +"@types/google.picker@npm:^0.0.42": + version: 0.0.42 + resolution: "@types/google.picker@npm:0.0.42" + checksum: 10/7e428495807c840f30ff3eab63fbfc4b9760ba20cbf977b94915f2222f678bb29c5bf73eff6f285f661b293127ddfbbedd4d8b2075d102c272ab941f63fa7d78 + languageName: node + linkType: hard + "@types/graceful-fs@npm:^4.1.2, @types/graceful-fs@npm:^4.1.3": version: 4.1.9 resolution: "@types/graceful-fs@npm:4.1.9" @@ -8872,6 +8893,19 @@ __metadata: languageName: unknown linkType: soft +"@uppy/google-drive-picker@workspace:^, @uppy/google-drive-picker@workspace:packages/@uppy/google-drive-picker": + version: 0.0.0-use.local + resolution: "@uppy/google-drive-picker@workspace:packages/@uppy/google-drive-picker" + dependencies: + "@uppy/companion-client": "workspace:^" + "@uppy/provider-views": "workspace:^" + "@uppy/utils": "workspace:^" + preact: "npm:^10.5.13" + peerDependencies: + "@uppy/core": "workspace:^" + languageName: unknown + linkType: soft + "@uppy/google-drive@workspace:*, @uppy/google-drive@workspace:^, @uppy/google-drive@workspace:packages/@uppy/google-drive": version: 0.0.0-use.local resolution: "@uppy/google-drive@workspace:packages/@uppy/google-drive" @@ -8885,6 +8919,19 @@ __metadata: languageName: unknown linkType: soft +"@uppy/google-photos-picker@workspace:^, @uppy/google-photos-picker@workspace:packages/@uppy/google-photos-picker": + version: 0.0.0-use.local + resolution: "@uppy/google-photos-picker@workspace:packages/@uppy/google-photos-picker" + dependencies: + "@uppy/companion-client": "workspace:^" + "@uppy/provider-views": "workspace:^" + "@uppy/utils": "workspace:^" + preact: "npm:^10.5.13" + peerDependencies: + "@uppy/core": "workspace:^" + languageName: unknown + linkType: soft + "@uppy/google-photos@workspace:*, @uppy/google-photos@workspace:^, @uppy/google-photos@workspace:packages/@uppy/google-photos": version: 0.0.0-use.local resolution: "@uppy/google-photos@workspace:packages/@uppy/google-photos" @@ -8970,6 +9017,9 @@ __metadata: version: 0.0.0-use.local resolution: "@uppy/provider-views@workspace:packages/@uppy/provider-views" dependencies: + "@types/gapi": "npm:^0.0.47" + "@types/google.accounts": "npm:^0.0.14" + "@types/google.picker": "npm:^0.0.42" "@uppy/utils": "workspace:^" classnames: "npm:^2.2.6" nanoid: "npm:^5.0.0" @@ -13510,7 +13560,9 @@ __metadata: "@uppy/form": "workspace:^" "@uppy/golden-retriever": "workspace:^" "@uppy/google-drive": "workspace:^" + "@uppy/google-drive-picker": "workspace:^" "@uppy/google-photos": "workspace:^" + "@uppy/google-photos-picker": "workspace:^" "@uppy/image-editor": "workspace:^" "@uppy/informer": "workspace:^" "@uppy/instagram": "workspace:^" @@ -29643,7 +29695,9 @@ __metadata: "@uppy/form": "workspace:^" "@uppy/golden-retriever": "workspace:^" "@uppy/google-drive": "workspace:^" + "@uppy/google-drive-picker": "workspace:^" "@uppy/google-photos": "workspace:^" + "@uppy/google-photos-picker": "workspace:^" "@uppy/image-editor": "workspace:^" "@uppy/informer": "workspace:^" "@uppy/instagram": "workspace:^"