diff --git a/.eslintrc.js b/.eslintrc.js
index c47834e6ea..151b4c4f90 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -222,6 +222,7 @@ module.exports = {
'packages/@uppy/form/src/**/*.js',
'packages/@uppy/golden-retriever/src/**/*.js',
'packages/@uppy/google-drive/src/**/*.js',
+ 'packages/@uppy/google-photos/src/**/*.js',
'packages/@uppy/image-editor/src/**/*.js',
'packages/@uppy/informer/src/**/*.js',
'packages/@uppy/instagram/src/**/*.js',
diff --git a/docs/companion.md b/docs/companion.md
index 15ca91cacd..8bd6462a39 100644
--- a/docs/companion.md
+++ b/docs/companion.md
@@ -22,8 +22,9 @@ OAuth.
## When should I use it?
If you want to let users download files from [Box][], [Dropbox][], [Facebook][],
-[Google Drive][googledrive], [Instagram][], [OneDrive][], [Unsplash][], [Import
-from URL][url], or [Zoom][] — you need Companion.
+[Google Drive][googledrive], [Google Photos][googlephotos], [Instagram][],
+[OneDrive][], [Unsplash][], [Import from URL][url], or [Zoom][] — you need
+Companion.
Companion supports the same [uploaders](/docs/guides/choosing-uploader) as Uppy:
[Tus](/docs/tus), [AWS S3](/docs/aws-s3), and [regular multipart](/docs/tus).
@@ -435,15 +436,16 @@ the secret, nothing else.
:::
-| Service | Key | Environment variables |
-| ------------ | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Box | `box` | `COMPANION_BOX_KEY`, `COMPANION_BOX_SECRET`, `COMPANION_BOX_SECRET_FILE` |
-| Dropbox | `dropbox` | `COMPANION_DROPBOX_KEY`, `COMPANION_DROPBOX_SECRET`, `COMPANION_DROPBOX_SECRET_FILE` |
-| Facebook | `facebook` | `COMPANION_FACEBOOK_KEY`, `COMPANION_FACEBOOK_SECRET`, `COMPANION_FACEBOOK_SECRET_FILE` |
-| Google Drive | `drive` | `COMPANION_GOOGLE_KEY`, `COMPANION_GOOGLE_SECRET`, `COMPANION_GOOGLE_SECRET_FILE` |
-| Instagram | `instagram` | `COMPANION_INSTAGRAM_KEY`, `COMPANION_INSTAGRAM_SECRET`, `COMPANION_INSTAGRAM_SECRET_FILE` |
-| OneDrive | `onedrive` | `COMPANION_ONEDRIVE_KEY`, `COMPANION_ONEDRIVE_SECRET`, `COMPANION_ONEDRIVE_SECRET_FILE`, `COMPANION_ONEDRIVE_DOMAIN_VALIDATION` (Settings this variable to `true` enables a route that can be used to validate your app with OneDrive) |
-| Zoom | `zoom` | `COMPANION_ZOOM_KEY`, `COMPANION_ZOOM_SECRET`, `COMPANION_ZOOM_SECRET_FILE`, `COMPANION_ZOOM_VERIFICATION_TOKEN` |
+| Service | Key | Environment variables |
+| ------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Box | `box` | `COMPANION_BOX_KEY`, `COMPANION_BOX_SECRET`, `COMPANION_BOX_SECRET_FILE` |
+| Dropbox | `dropbox` | `COMPANION_DROPBOX_KEY`, `COMPANION_DROPBOX_SECRET`, `COMPANION_DROPBOX_SECRET_FILE` |
+| Facebook | `facebook` | `COMPANION_FACEBOOK_KEY`, `COMPANION_FACEBOOK_SECRET`, `COMPANION_FACEBOOK_SECRET_FILE` |
+| Google Drive | `drive` | `COMPANION_GOOGLE_KEY`, `COMPANION_GOOGLE_SECRET`, `COMPANION_GOOGLE_SECRET_FILE` |
+| Google Photos | `googlephotos` | `COMPANION_GOOGLE_KEY`, `COMPANION_GOOGLE_SECRET`, `COMPANION_GOOGLE_SECRET_FILE` |
+| Instagram | `instagram` | `COMPANION_INSTAGRAM_KEY`, `COMPANION_INSTAGRAM_SECRET`, `COMPANION_INSTAGRAM_SECRET_FILE` |
+| OneDrive | `onedrive` | `COMPANION_ONEDRIVE_KEY`, `COMPANION_ONEDRIVE_SECRET`, `COMPANION_ONEDRIVE_SECRET_FILE`, `COMPANION_ONEDRIVE_DOMAIN_VALIDATION` (Settings this variable to `true` enables a route that can be used to validate your app with OneDrive) |
+| Zoom | `zoom` | `COMPANION_ZOOM_KEY`, `COMPANION_ZOOM_SECRET`, `COMPANION_ZOOM_SECRET_FILE`, `COMPANION_ZOOM_VERIFICATION_TOKEN` |
#### `s3`
@@ -918,6 +920,7 @@ when files are changed.
[dropbox]: /docs/dropbox
[facebook]: /docs/facebook
[googledrive]: /docs/google-drive
+[googlephotos]: /docs/google-photos
[instagram]: /docs/instagram
[onedrive]: /docs/onedrive
[unsplash]: /docs/unsplash
diff --git a/docs/guides/migration-guides.md b/docs/guides/migration-guides.md
index 4413da8fea..c0697070f9 100644
--- a/docs/guides/migration-guides.md
+++ b/docs/guides/migration-guides.md
@@ -626,14 +626,15 @@ to:
-| Provider | New Redirect URI |
-| ------------ | ------------------------------------------------- |
-| Dropbox | `https://$COMPANION_HOST_NAME/dropbox/redirect` |
-| Google Drive | `https://$COMPANION_HOST_NAME/drive/redirect` |
-| OneDrive | `https://$COMPANION_HOST_NAME/onedrive/redirect` |
-| Box | `https://$YOUR_COMPANION_HOST_NAME/box/redirect` |
-| Facebook | `https://$COMPANION_HOST_NAME/facebook/redirect` |
-| Instagram | `https://$COMPANION_HOST_NAME/instagram/redirect` |
+| Provider | New Redirect URI |
+| ------------- | ---------------------------------------------------- |
+| Dropbox | `https://$COMPANION_HOST_NAME/dropbox/redirect` |
+| Google Drive | `https://$COMPANION_HOST_NAME/drive/redirect` |
+| Google Photos | `https://$COMPANION_HOST_NAME/googlephotos/redirect` |
+| OneDrive | `https://$COMPANION_HOST_NAME/onedrive/redirect` |
+| Box | `https://$YOUR_COMPANION_HOST_NAME/box/redirect` |
+| Facebook | `https://$COMPANION_HOST_NAME/facebook/redirect` |
+| Instagram | `https://$COMPANION_HOST_NAME/instagram/redirect` |
diff --git a/docs/sources/companion-plugins/google-drive.mdx b/docs/sources/companion-plugins/google-drive.mdx
index 3685c7ddc9..b7332e5c56 100644
--- a/docs/sources/companion-plugins/google-drive.mdx
+++ b/docs/sources/companion-plugins/google-drive.mdx
@@ -10,7 +10,7 @@ import UppyCdnExample from '/src/components/UppyCdnExample';
# Google Drive
The `@uppy/google-drive` plugin lets users import files from their
-[Google Drive](https://www.drive.google.com) account.
+[Google Drive](https://drive.google.com) account.
:::tip
@@ -22,7 +22,7 @@ The `@uppy/google-drive` plugin lets users import files from their
## When should I use this?
When you want to let users import files from their
-[Google Drive](https://www.drive.google.com) account.
+[Google Drive](https://drive.google.com) account.
A [Companion](/docs/companion) instance is required for the Google Drive plugin
to work. Companion handles authentication with Google Drive, downloads the
@@ -112,12 +112,12 @@ https://api2.transloadit.com/companion/drive/redirect
Google will give you an OAuth client ID and client secret.
-Configure the Google Drive key and secret in Companion. With the standalone
-Companion server, specify environment variables:
+Configure the Google key and secret in Companion. With the standalone Companion
+server, specify environment variables:
```shell
-export COMPANION_GOOGLE_KEY="Google Drive OAuth client ID"
-export COMPANION_GOOGLE_SECRET="Google Drive OAuth client secret"
+export COMPANION_GOOGLE_KEY="Google OAuth client ID"
+export COMPANION_GOOGLE_SECRET="Google OAuth client secret"
```
When using the Companion Node.js API, configure these options:
@@ -126,8 +126,8 @@ When using the Companion Node.js API, configure these options:
companion.app({
providerOptions: {
drive: {
- key: 'Google Drive OAuth client ID',
- secret: 'Google Drive OAuth client secret',
+ key: 'Google OAuth client ID',
+ secret: 'Google OAuth client secret',
},
},
});
diff --git a/docs/sources/companion-plugins/google-photos.mdx b/docs/sources/companion-plugins/google-photos.mdx
new file mode 100644
index 0000000000..23d92928a3
--- /dev/null
+++ b/docs/sources/companion-plugins/google-photos.mdx
@@ -0,0 +1,185 @@
+---
+slug: /google-photos
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+import UppyCdnExample from '/src/components/UppyCdnExample';
+
+# Google Photos
+
+The `@uppy/google-photos` plugin lets users import files from their
+[Google Photos](https://photos.google.com) account.
+
+:::tip
+
+[Try out the live example](/examples) or take it for a spin in
+[StackBlitz](https://stackblitz.com/edit/vitejs-vite-zaqyaf?file=main.js).
+
+:::
+
+## When should I use this?
+
+When you want to let users import files from their
+[Google Photos](https://photos.google.com) account.
+
+A [Companion](/docs/companion) instance is required for the Google Photos plugin
+to work. Companion handles authentication with Google Photos, downloads the
+photos/videos, and uploads them to the destination. This saves the user
+bandwidth, especially helpful if they are on a mobile connection.
+
+You can self-host Companion or get a hosted version with any
+[Transloadit plan](https://transloadit.com/pricing/).
+
+
+
+
+```shell
+npm install @uppy/google-photos
+```
+
+
+
+
+
+```shell
+yarn add @uppy/google-photos
+```
+
+
+
+
+
+ {`
+ import { Uppy, GooglePhotos } from "{{UPPY_JS_URL}}"
+ const uppy = new Uppy()
+ uppy.use(GooglePhotos, {
+ // Options
+ })
+ `}
+
+
+
+
+## Use
+
+Using Google Photos requires setup in both Uppy and Companion.
+
+### Use in Uppy
+
+```js {10-13} showLineNumbers
+import Uppy from '@uppy/core';
+import Dashboard from '@uppy/dashboard';
+import GooglePhotos from '@uppy/google-photos';
+
+import '@uppy/core/dist/style.min.css';
+import '@uppy/dashboard/dist/style.min.css';
+
+new Uppy()
+ .use(Dashboard, { inline: true, target: '#dashboard' })
+ .use(GooglePhotos, {
+ target: Dashboard,
+ companionUrl: 'https://your-companion.com',
+ });
+```
+
+### Use in Companion
+
+To sign up for API keys, go to the
+[Google Developer Console](https://console.developers.google.com/).
+
+Create a project for your app if you don’t have one yet.
+
+- On the project’s dashboard,
+ [enable the Google Photos API](https://developers.google.com/photos).
+- [Set up OAuth authorization](https://developers.google.com/photos/library/guides/authorization).
+
+The app page has a `"Redirect URIs"` field. Here, add:
+
+```
+https://$YOUR_COMPANION_HOST_NAME/googlephotos/redirect
+```
+
+If you are using Transloadit hosted Companion:
+
+```
+https://api2.transloadit.com/companion/googlephotos/redirect
+```
+
+Google will give you an OAuth client ID and client secret.
+
+Configure the Google key and secret in Companion. With the standalone Companion
+server, specify environment variables:
+
+```shell
+export COMPANION_GOOGLE_KEY="Google OAuth client ID"
+export COMPANION_GOOGLE_SECRET="Google OAuth client secret"
+```
+
+When using the Companion Node.js API, configure these options:
+
+```js
+companion.app({
+ providerOptions: {
+ googlephotos: {
+ key: 'Google OAuth client ID',
+ secret: 'Google OAuth client secret',
+ },
+ },
+});
+```
+
+## API
+
+### Options
+
+#### `id`
+
+A unique identifier for this plugin (`string`, default: `'GooglePhotos'`).
+
+#### `title`
+
+Title / name shown in the UI, such as Dashboard tabs (`string`, default:
+`'GooglePhotos'`).
+
+#### `target`
+
+DOM element, CSS selector, or plugin to place the drag and drop area into
+(`string` or `Element`, default: `null`).
+
+#### `companionUrl`
+
+URL to a [Companion](/docs/companion) instance (`string`, default: `null`).
+
+#### `companionHeaders`
+
+Custom headers that should be sent along to [Companion](/docs/companion) on
+every request (`Object`, default: `{}`).
+
+#### `companionAllowedHosts`
+
+The valid and authorised URL(s) from which OAuth responses should be accepted
+(`string` or `RegExp` or `Array`, default: `companionUrl`).
+
+This value can be a `string`, a `RegExp` pattern, or an `Array` of both. This is
+useful when you have your [Companion](/docs/companion) running on several hosts.
+Otherwise, the default value should do fine.
+
+#### `companionCookiesRule`
+
+This option correlates to the
+[RequestCredentials value](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials)
+(`string`, default: `'same-origin'`).
+
+This tells the plugin whether to send cookies to [Companion](/docs/companion).
+
+#### `locale`
+
+```js
+export default {
+ strings: {
+ pluginNameGooglePhotos: 'GooglePhotos',
+ },
+};
+```
diff --git a/docs/sources/companion-plugins/instagram.mdx b/docs/sources/companion-plugins/instagram.mdx
index 358f184940..dbda298e72 100644
--- a/docs/sources/companion-plugins/instagram.mdx
+++ b/docs/sources/companion-plugins/instagram.mdx
@@ -115,7 +115,7 @@ When using the Companion Node.js API, configure these options:
```js
companion.app({
providerOptions: {
- drive: {
+ instagram: {
key: 'Instagram OAuth client ID',
secret: 'Instagram OAuth client secret',
},
diff --git a/docs/uploader/transloadit.mdx b/docs/uploader/transloadit.mdx
index 43502a7e9d..a772555798 100644
--- a/docs/uploader/transloadit.mdx
+++ b/docs/uploader/transloadit.mdx
@@ -100,8 +100,8 @@ uppy.on('transloadit:complete', (assembly) => {});
:::note
-All [Transloadit plans](https://transloadit.com/pricing/) come with a hosted version
-of Companion.
+All [Transloadit plans](https://transloadit.com/pricing/) come with a hosted
+version of Companion.
:::
diff --git a/docs/user-interfaces/dashboard.mdx b/docs/user-interfaces/dashboard.mdx
index 79ed5c39c5..340efbd4e4 100644
--- a/docs/user-interfaces/dashboard.mdx
+++ b/docs/user-interfaces/dashboard.mdx
@@ -714,6 +714,8 @@ all Uppy plugins.
[Facebook](https://facebook.com).
- [`@uppy/google-drive`](/docs/google-drive) — import from
[Google Drive](https://drive.google.com).
+- [`@uppy/google-photos`](/docs/google-photos) — import from
+ [Google Photos](https://photos.google.com).
- [`@uppy/instagram`](/docs/instagram) — import from
[Instagram](https://instagram.com).
- [`@uppy/onedrive`](/docs/onedrive) — import from
diff --git a/e2e/cypress/integration/dashboard-transloadit.spec.ts b/e2e/cypress/integration/dashboard-transloadit.spec.ts
index d541e35b0b..68c781b549 100644
--- a/e2e/cypress/integration/dashboard-transloadit.spec.ts
+++ b/e2e/cypress/integration/dashboard-transloadit.spec.ts
@@ -258,7 +258,7 @@ describe('Dashboard with Transloadit', () => {
client_ip: null,
client_referer: null,
transloadit_client:
- 'uppy-core:3.2.0,uppy-transloadit:3.1.3,uppy-tus:3.1.0,uppy-dropbox:3.1.1,uppy-box:2.1.1,uppy-facebook:3.1.1,uppy-google-drive:3.1.1,uppy-instagram:3.1.1,uppy-onedrive:3.1.1,uppy-zoom:2.1.1,uppy-url:3.3.1',
+ 'uppy-core:3.2.0,uppy-transloadit:3.1.3,uppy-tus:3.1.0,uppy-dropbox:3.1.1,uppy-box:2.1.1,uppy-facebook:3.1.1,uppy-google-drive:3.1.1,uppy-google-photos:0.0.1,uppy-instagram:3.1.1,uppy-onedrive:3.1.1,uppy-zoom:2.1.1,uppy-url:3.3.1',
start_date: new Date().toISOString(),
upload_meta_data_extracted: false,
warnings: [],
diff --git a/e2e/package.json b/e2e/package.json
index 89a004d932..b9ec22401c 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -25,6 +25,7 @@
"@uppy/form": "workspace:^",
"@uppy/golden-retriever": "workspace:^",
"@uppy/google-drive": "workspace:^",
+ "@uppy/google-photos": "workspace:^",
"@uppy/image-editor": "workspace:^",
"@uppy/informer": "workspace:^",
"@uppy/instagram": "workspace:^",
diff --git a/examples/angular-example/package.json b/examples/angular-example/package.json
index a7eb2a3872..6e6d7947ea 100644
--- a/examples/angular-example/package.json
+++ b/examples/angular-example/package.json
@@ -23,6 +23,7 @@
"@uppy/core": "workspace:*",
"@uppy/drag-drop": "workspace:*",
"@uppy/google-drive": "workspace:*",
+ "@uppy/google-photos": "workspace:*",
"@uppy/progress-bar": "workspace:*",
"@uppy/tus": "workspace:*",
"@uppy/webcam": "workspace:*",
diff --git a/packages/@uppy/box/src/Box.tsx b/packages/@uppy/box/src/Box.tsx
index d6739c853c..68c9d72bd4 100644
--- a/packages/@uppy/box/src/Box.tsx
+++ b/packages/@uppy/box/src/Box.tsx
@@ -84,6 +84,7 @@ export default class Box extends UIPlugin<
this.view = new ProviderViews(this, {
provider: this.provider,
loadAllFiles: true,
+ virtualList: true,
})
const { target } = this.opts
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 0a2a3b0bde..a401e24545 100644
--- a/packages/@uppy/companion/src/server/controllers/url.js
+++ b/packages/@uppy/companion/src/server/controllers/url.js
@@ -27,8 +27,8 @@ const downloadURL = async (url, blockLocalIPs, traceId) => {
try {
const protectedGot = getProtectedGot({ blockLocalIPs })
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
@@ -79,9 +79,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 4a7d1a9b9c..023ed423b3 100644
--- a/packages/@uppy/companion/src/server/helpers/oauth-state.js
+++ b/packages/@uppy/companion/src/server/helpers/oauth-state.js
@@ -7,7 +7,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))
}
@@ -19,7 +19,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 5f637c43bf..510ac48f2b 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 627d87fcb1..cedc77176e 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/google/drive/adapter.js
similarity index 100%
rename from packages/@uppy/companion/src/server/provider/drive/adapter.js
rename to packages/@uppy/companion/src/server/provider/google/drive/adapter.js
diff --git a/packages/@uppy/companion/src/server/provider/drive/index.js b/packages/@uppy/companion/src/server/provider/google/drive/index.js
similarity index 75%
rename from packages/@uppy/companion/src/server/provider/drive/index.js
rename to packages/@uppy/companion/src/server/provider/google/drive/index.js
index e17e067c5e..c752f295dc 100644
--- a/packages/@uppy/companion/src/server/provider/drive/index.js
+++ b/packages/@uppy/companion/src/server/provider/google/drive/index.js
@@ -1,12 +1,13 @@
const got = require('got').default
-const Provider = require('../Provider')
-const logger = require('../../logger')
+const { logout, refreshToken } = require('../index')
+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 { prepareStream } = require('../../../helpers/utils')
+const { MAX_AGE_REFRESH_TOKEN } = require('../../../helpers/jwt')
+const { ProviderAuthError } = require('../../error')
+const { withGoogleErrorHandling } = require('../../providerErrors')
+const Provider = require('../../Provider')
// For testing refresh token:
@@ -29,10 +30,6 @@ const getClient = ({ token }) => got.extend({
},
})
-const getOauthClient = () => got.extend({
- prefixUrl: 'https://oauth2.googleapis.com',
-})
-
async function getStats ({ id, token }) {
const client = getClient({ token })
@@ -52,15 +49,16 @@ async function getStats ({ id, token }) {
*/
class Drive extends Provider {
static get authProvider () {
- return 'google'
+ return 'googledrive'
}
static get authStateExpiry () {
return MAX_AGE_REFRESH_TOKEN
}
+ // eslint-disable-next-line class-methods-use-this
async list (options) {
- return this.#withErrorHandling('provider.drive.list.error', async () => {
+ return withGoogleErrorHandling(Drive.authProvider, 'provider.drive.list.error', async () => {
const directory = options.directory || 'root'
const query = options.query || {}
const { token } = options
@@ -126,6 +124,7 @@ class Drive extends Provider {
})
}
+ // eslint-disable-next-line class-methods-use-this
async download ({ id: idIn, token }) {
if (mockAccessTokenExpiredError != null) {
logger.warn(`Access token: ${token}`)
@@ -136,7 +135,7 @@ class Drive extends Provider {
}
}
- return this.#withErrorHandling('provider.drive.download.error', async () => {
+ return withGoogleErrorHandling(Drive.authProvider, 'provider.drive.download.error', async () => {
const client = getClient({ token })
const { mimeType, id, exportLinks } = await getStats({ id: idIn, token })
@@ -172,14 +171,8 @@ class Drive extends Provider {
}
// 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 () => {
+ return withGoogleErrorHandling(Drive.authProvider, 'provider.drive.size.error', async () => {
const { mimeType, size } = await getStats({ id, token })
if (isGsuiteFile(mimeType)) {
@@ -192,37 +185,15 @@ class Drive extends Provider {
})
}
- logout ({ token }) {
- return this.#withErrorHandling('provider.drive.logout.error', async () => {
- 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 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 logout(...args) {
+ return logout(...args)
}
// eslint-disable-next-line class-methods-use-this
- async #withErrorHandling (tag, fn) {
- return withProviderErrorHandling({
- fn,
- tag,
- providerName: Drive.authProvider,
- isAuthError: (response) => (
- response.statusCode === 401
- || (response.statusCode === 400 && response.body?.error === 'invalid_grant') // Refresh token has expired or been revoked
- ),
- getJsonErrorMessage: (body) => body?.error?.message,
- })
- }
}
+Drive.prototype.logout = logout
+Drive.prototype.refreshToken = refreshToken
+
module.exports = Drive
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..6b213ee201
--- /dev/null
+++ b/packages/@uppy/companion/src/server/provider/google/googlephotos/index.js
@@ -0,0 +1,172 @@
+const got = require('got').default
+
+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 = ({ token }) => got.extend({
+ headers: {
+ authorization: `Bearer ${token}`,
+ },
+})
+
+const getPhotosClient = ({ token }) => getBaseClient({ token }).extend({
+ prefixUrl: 'https://photoslibrary.googleapis.com/v1',
+})
+
+const getOauthClient = ({ token }) => 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 authProvider () {
+ return 'googlephotos'
+ }
+
+ static get authStateExpiry () {
+ return MAX_AGE_REFRESH_TOKEN
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ async list (options) {
+ return withGoogleErrorHandling(GooglePhotos.authProvider, 'provider.photos.list.error', async () => {
+ const { directory, query } = options
+ const { token } = options
+
+ const isRoot = !directory
+
+ const client = 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 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.authProvider, 'provider.photos.download.error', async () => {
+ const client = getPhotosClient({ token })
+
+ const { baseUrl } = await client.get(`mediaItems/${encodeURIComponent(id)}`, { responseType: 'json' }).json()
+
+ const url = `${baseUrl}=d`;
+ const stream = got.stream.get(url, { responseType: 'json' })
+ const { size } = await prepareStream(stream)
+
+ return { stream, size }
+ })
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ async logout(...args) {
+ return logout(...args)
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ async refreshToken(...args) {
+ return refreshToken(...args)
+ }
+}
+
+module.exports = GooglePhotos
diff --git a/packages/@uppy/companion/src/server/provider/google/index.js b/packages/@uppy/companion/src/server/provider/google/index.js
new file mode 100644
index 0000000000..e946c7cd04
--- /dev/null
+++ b/packages/@uppy/companion/src/server/provider/google/index.js
@@ -0,0 +1,36 @@
+const got = require('got').default
+
+
+const { withGoogleErrorHandling } = require('../providerErrors')
+
+
+/**
+ * Reusable google stuff
+ */
+
+const getOauthClient = () => got.extend({
+ prefixUrl: 'https://oauth2.googleapis.com',
+})
+
+async function refreshToken({ clientId, clientSecret, refreshToken: theRefreshToken }) {
+ return withGoogleErrorHandling('google', 'provider.google.token.refresh.error', async () => {
+ const { access_token: accessToken } = await getOauthClient().post('token', { responseType: 'json', form: { refresh_token: theRefreshToken, grant_type: 'refresh_token', client_id: clientId, client_secret: clientSecret } }).json()
+ return { accessToken }
+ })
+}
+
+async function logout({ token }) {
+ return withGoogleErrorHandling('google', 'provider.google.logout.error', async () => {
+ await got.post('https://accounts.google.com/o/oauth2/revoke', {
+ searchParams: { token },
+ responseType: 'json',
+ })
+
+ return { revoked: true }
+ })
+}
+
+module.exports = {
+ refreshToken,
+ logout,
+}
diff --git a/packages/@uppy/companion/src/server/provider/index.js b/packages/@uppy/companion/src/server/provider/index.js
index fb7d4b1b59..fe2316cdec 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/server/provider/providerErrors.js b/packages/@uppy/companion/src/server/provider/providerErrors.js
index 715de19592..0f4f9f30f3 100644
--- a/packages/@uppy/companion/src/server/provider/providerErrors.js
+++ b/packages/@uppy/companion/src/server/provider/providerErrors.js
@@ -68,4 +68,17 @@ async function withProviderErrorHandling({
}
}
-module.exports = { withProviderErrorHandling }
+async function withGoogleErrorHandling (providerName, tag, fn) {
+ return withProviderErrorHandling({
+ fn,
+ tag,
+ providerName,
+ 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 = { withProviderErrorHandling, withGoogleErrorHandling }
diff --git a/packages/@uppy/companion/src/standalone/helper.js b/packages/@uppy/companion/src/standalone/helper.js
index 1a77119e16..e8d7d7c0b9 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 084e2ec9f1..1f21129e7b 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 bd1c31ae8c..75f5b9a42d 100644
--- a/packages/@uppy/companion/test/__tests__/providers.js
+++ b/packages/@uppy/companion/test/__tests__/providers.js
@@ -26,7 +26,8 @@ const providers = require('../../src/server/provider').getDefaultProviders()
const providerNames = Object.keys(providers)
const AUTH_PROVIDERS = {
- drive: 'google',
+ drive: 'googledrive',
+ googlephotos: 'googlephotos',
onedrive: 'microsoft',
}
const authData = {}
@@ -55,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: {
@@ -130,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 () => {
@@ -149,7 +147,8 @@ describe('list provider files', () => {
],
})
- await runTest('box')
+ const { username, items, providerFixture } = await runTest('box')
+ expect1({ username, items, providerFixture })
})
test('drive', async () => {
@@ -178,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 () => {
@@ -206,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 () => {
@@ -225,7 +278,8 @@ describe('list provider files', () => {
],
})
- await runTest('instagram')
+ const { username, items, providerFixture } = await runTest('instagram')
+ expect1({ username, items, providerFixture })
})
test('onedrive', async () => {
@@ -271,7 +325,8 @@ describe('list provider files', () => {
],
})
- await runTest('onedrive')
+ const { username, items, providerFixture } = await runTest('onedrive')
+ expect1({ username, items, providerFixture })
})
test('zoom', async () => {
@@ -291,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({
@@ -324,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, {
@@ -393,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,
@@ -421,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/core/src/locale.ts b/packages/@uppy/core/src/locale.ts
index 90a9974499..39f3fee3c3 100644
--- a/packages/@uppy/core/src/locale.ts
+++ b/packages/@uppy/core/src/locale.ts
@@ -62,5 +62,6 @@ export default {
},
additionalRestrictionsFailed:
'%{count} additional restrictions were not fulfilled',
+ unnamed: 'Unnamed',
},
}
diff --git a/packages/@uppy/dashboard/src/utils/copyToClipboard.ts b/packages/@uppy/dashboard/src/utils/copyToClipboard.ts
index 6720cec9b7..ba4f276b8e 100644
--- a/packages/@uppy/dashboard/src/utils/copyToClipboard.ts
+++ b/packages/@uppy/dashboard/src/utils/copyToClipboard.ts
@@ -34,7 +34,7 @@ export default function copyToClipboard(
document.body.appendChild(textArea)
textArea.select()
- const magicCopyFailed = (cause?: unknown) => {
+ const magicCopyFailed = () => {
document.body.removeChild(textArea)
// eslint-disable-next-line no-alert
window.prompt(fallbackString, textToCopy)
@@ -44,13 +44,13 @@ export default function copyToClipboard(
try {
const successful = document.execCommand('copy')
if (!successful) {
- return magicCopyFailed('copy command unavailable')
+ return magicCopyFailed()
}
document.body.removeChild(textArea)
return resolve()
} catch (err) {
document.body.removeChild(textArea)
- return magicCopyFailed(err)
+ return magicCopyFailed()
}
})
}
diff --git a/packages/@uppy/dropbox/src/Dropbox.tsx b/packages/@uppy/dropbox/src/Dropbox.tsx
index 6b53078519..c3ebc86b8e 100644
--- a/packages/@uppy/dropbox/src/Dropbox.tsx
+++ b/packages/@uppy/dropbox/src/Dropbox.tsx
@@ -85,6 +85,7 @@ export default class Dropbox extends UIPlugin<
this.view = new ProviderViews(this, {
provider: this.provider,
loadAllFiles: true,
+ virtualList: true,
})
const { target } = this.opts
diff --git a/packages/@uppy/google-drive/src/GoogleDrive.tsx b/packages/@uppy/google-drive/src/GoogleDrive.tsx
index 441c05fa20..7127cf8f81 100644
--- a/packages/@uppy/google-drive/src/GoogleDrive.tsx
+++ b/packages/@uppy/google-drive/src/GoogleDrive.tsx
@@ -104,6 +104,7 @@ export default class GoogleDrive<
this.view = new DriveProviderViews(this, {
provider: this.provider,
loadAllFiles: true,
+ virtualList: true,
})
const { target } = this.opts
diff --git a/packages/@uppy/google-photos/.npmignore b/packages/@uppy/google-photos/.npmignore
new file mode 100644
index 0000000000..6c816673f0
--- /dev/null
+++ b/packages/@uppy/google-photos/.npmignore
@@ -0,0 +1 @@
+tsconfig.*
diff --git a/packages/@uppy/google-photos/CHANGELOG.md b/packages/@uppy/google-photos/CHANGELOG.md
new file mode 100644
index 0000000000..ba99657215
--- /dev/null
+++ b/packages/@uppy/google-photos/CHANGELOG.md
@@ -0,0 +1 @@
+# @uppy/google-photos
diff --git a/packages/@uppy/google-photos/LICENSE b/packages/@uppy/google-photos/LICENSE
new file mode 100644
index 0000000000..6f25c43720
--- /dev/null
+++ b/packages/@uppy/google-photos/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/README.md b/packages/@uppy/google-photos/README.md
new file mode 100644
index 0000000000..4cc652ee12
--- /dev/null
+++ b/packages/@uppy/google-photos/README.md
@@ -0,0 +1,51 @@
+# @uppy/google-photos
+
+
+
+[![npm version](https://img.shields.io/npm/v/@uppy/google-photos.svg?style=flat-square)](https://www.npmjs.com/package/@uppy/google-photos)
+![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 plugin for Uppy lets users import photos from their Google
+Photos account.
+
+A Companion instance is required for the GooglePhotos plugin to work. Companion
+handles authentication with Google, downloads photos from Google Photos and
+uploads them to the destination. This saves the user bandwidth, especially
+helpful if they are on a mobile connection.
+
+Uppy is being developed by the folks at [Transloadit](https://transloadit.com),
+a versatile file encoding service.
+
+## Example
+
+```js
+import Uppy from '@uppy/core'
+import GooglePhotos from '@uppy/google-photos'
+
+const uppy = new Uppy()
+uppy.use(GooglePhotos, {
+ // Options
+})
+```
+
+## Installation
+
+```bash
+$ npm install @uppy/google-photos
+```
+
+Alternatively, you can also use this plugin in a pre-built bundle from
+Transloadit’s CDN: Edgly. In that case `Uppy` will attach itself to the global
+`window.Uppy` object. See the
+[main Uppy documentation](https://uppy.io/docs/#Installation) for instructions.
+
+## Documentation
+
+Documentation for this plugin can be found on the
+[Uppy website](https://uppy.io/docs/google-photos).
+
+## License
+
+The [MIT License](./LICENSE).
diff --git a/packages/@uppy/google-photos/package.json b/packages/@uppy/google-photos/package.json
new file mode 100644
index 0000000000..93ea7cec95
--- /dev/null
+++ b/packages/@uppy/google-photos/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@uppy/google-photos",
+ "description": "The Google Photos plugin for Uppy lets users import photos from their Google Photos account",
+ "version": "0.0.1",
+ "license": "MIT",
+ "main": "lib/index.js",
+ "types": "types/index.d.ts",
+ "type": "module",
+ "keywords": [
+ "file uploader",
+ "google photos",
+ "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/src/GooglePhotos.tsx b/packages/@uppy/google-photos/src/GooglePhotos.tsx
new file mode 100644
index 0000000000..208b80a2d8
--- /dev/null
+++ b/packages/@uppy/google-photos/src/GooglePhotos.tsx
@@ -0,0 +1,135 @@
+import { UIPlugin, Uppy } from '@uppy/core'
+import { ProviderViews } from '@uppy/provider-views'
+import {
+ Provider,
+ tokenStorage,
+ getAllowedHosts,
+ type CompanionPluginOptions,
+} from '@uppy/companion-client'
+import { h, type ComponentChild } from 'preact'
+
+import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile'
+import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.ts'
+
+// 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 GooglePhotosOptions = CompanionPluginOptions
+
+export default class GooglePhotos<
+ M extends Meta,
+ B extends Body,
+> extends UIPlugin {
+ static VERSION = packageJson.version
+
+ icon: () => h.JSX.Element
+
+ provider: Provider
+
+ view: ProviderViews
+
+ storage: typeof tokenStorage
+
+ files: UppyFile[]
+
+ constructor(uppy: Uppy, opts: GooglePhotosOptions) {
+ super(uppy, opts)
+ this.type = 'acquirer'
+ this.storage = this.opts.storage || tokenStorage
+ this.files = []
+ this.id = this.opts.id || 'GooglePhotos'
+ this.icon = () => (
+
+ )
+
+ this.opts.companionAllowedHosts = getAllowedHosts(
+ this.opts.companionAllowedHosts,
+ this.opts.companionUrl,
+ )
+ this.provider = new Provider(uppy, {
+ companionUrl: this.opts.companionUrl,
+ companionHeaders: this.opts.companionHeaders,
+ companionKeysParams: this.opts.companionKeysParams,
+ companionCookiesRule: this.opts.companionCookiesRule,
+ provider: 'googlephotos',
+ pluginId: this.id,
+ supportsRefreshToken: true,
+ })
+
+ this.defaultLocale = locale
+
+ this.i18nInit()
+ this.title = this.i18n('pluginNameGooglePhotos')
+
+ this.onFirstRender = this.onFirstRender.bind(this)
+ this.render = this.render.bind(this)
+ }
+
+ install(): void {
+ this.view = new ProviderViews(this, {
+ provider: this.provider,
+ loadAllFiles: true,
+ })
+
+ const { target } = this.opts
+ if (target) {
+ this.mount(target, this)
+ }
+ }
+
+ uninstall(): void {
+ this.view.tearDown()
+ this.unmount()
+ }
+
+ async onFirstRender(): Promise {
+ await Promise.all([
+ this.provider.fetchPreAuthToken(),
+ this.view.getFolder(),
+ ])
+ }
+
+ render(state: unknown): ComponentChild {
+ if (
+ this.getPluginState().files.length &&
+ !this.getPluginState().folders.length
+ ) {
+ return this.view.render(state, {
+ viewType: 'grid',
+ showFilter: false,
+ showTitles: false,
+ })
+ }
+ return this.view.render(state)
+ }
+}
diff --git a/packages/@uppy/google-photos/src/index.ts b/packages/@uppy/google-photos/src/index.ts
new file mode 100644
index 0000000000..7535efaaca
--- /dev/null
+++ b/packages/@uppy/google-photos/src/index.ts
@@ -0,0 +1 @@
+export { default } from './GooglePhotos.tsx'
diff --git a/packages/@uppy/google-photos/src/locale.ts b/packages/@uppy/google-photos/src/locale.ts
new file mode 100644
index 0000000000..e12b583018
--- /dev/null
+++ b/packages/@uppy/google-photos/src/locale.ts
@@ -0,0 +1,5 @@
+export default {
+ strings: {
+ pluginNameGooglePhotos: 'Google Photos',
+ },
+}
diff --git a/packages/@uppy/google-photos/tsconfig.build.json b/packages/@uppy/google-photos/tsconfig.build.json
new file mode 100644
index 0000000000..99aaf378de
--- /dev/null
+++ b/packages/@uppy/google-photos/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/tsconfig.json b/packages/@uppy/google-photos/tsconfig.json
new file mode 100644
index 0000000000..e5220fb5ab
--- /dev/null
+++ b/packages/@uppy/google-photos/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/types/index.d.ts b/packages/@uppy/google-photos/types/index.d.ts
new file mode 100644
index 0000000000..e37c2591cd
--- /dev/null
+++ b/packages/@uppy/google-photos/types/index.d.ts
@@ -0,0 +1,17 @@
+import type { PluginTarget, UIPlugin, UIPluginOptions } from '@uppy/core'
+import type {
+ PublicProviderOptions,
+ TokenStorage,
+} from '@uppy/companion-client'
+
+export interface GooglePhotosOptions
+ extends UIPluginOptions,
+ PublicProviderOptions {
+ target?: PluginTarget
+ title?: string
+ storage?: TokenStorage
+}
+
+declare class GooglePhotos extends UIPlugin {}
+
+export default GooglePhotos
diff --git a/packages/@uppy/google-photos/types/index.test-d.ts b/packages/@uppy/google-photos/types/index.test-d.ts
new file mode 100644
index 0000000000..0847992597
--- /dev/null
+++ b/packages/@uppy/google-photos/types/index.test-d.ts
@@ -0,0 +1,12 @@
+import Uppy, { UIPlugin, type UIPluginOptions } from '@uppy/core'
+import GooglePhotos from '..'
+
+class SomePlugin extends UIPlugin {}
+
+const uppy = new Uppy()
+uppy.use(GooglePhotos, { companionUrl: '' })
+uppy.use(GooglePhotos, { target: SomePlugin, companionUrl: '' })
+uppy.use(GooglePhotos, {
+ target: document.querySelector('#gphotos') || (undefined as never),
+ companionUrl: '',
+})
diff --git a/packages/@uppy/onedrive/src/OneDrive.tsx b/packages/@uppy/onedrive/src/OneDrive.tsx
index 1b329e6999..fe15f04f5d 100644
--- a/packages/@uppy/onedrive/src/OneDrive.tsx
+++ b/packages/@uppy/onedrive/src/OneDrive.tsx
@@ -97,6 +97,7 @@ export default class OneDrive extends UIPlugin<
this.view = new ProviderViews(this, {
provider: this.provider,
loadAllFiles: true,
+ virtualList: true,
})
const { target } = this.opts
diff --git a/packages/@uppy/provider-views/src/Browser.tsx b/packages/@uppy/provider-views/src/Browser.tsx
index f820491da1..c1ccf61b63 100644
--- a/packages/@uppy/provider-views/src/Browser.tsx
+++ b/packages/@uppy/provider-views/src/Browser.tsx
@@ -74,7 +74,8 @@ function ListItem(props: ListItemProps) {
id: f.id,
title: f.name,
author: f.author,
- getItemIcon: () => f.icon,
+ getItemIcon: () =>
+ viewType === 'grid' && f.thumbnail ? f.thumbnail : f.icon,
isChecked: isChecked(f),
toggleCheckbox: (event: Event) => toggleCheckbox(event, f),
isCheckboxDisabled: false,
@@ -115,7 +116,7 @@ type BrowserProps = {
cancel: () => void
done: () => void
noResultsLabel: string
- loadAllFiles?: boolean
+ virtualList?: boolean
}
function Browser(props: BrowserProps) {
@@ -146,7 +147,7 @@ function Browser(props: BrowserProps) {
cancel,
done,
noResultsLabel,
- loadAllFiles,
+ virtualList,
} = props
const selected = currentSelection.length
@@ -202,7 +203,7 @@ function Browser(props: BrowserProps) {
return {noResultsLabel}
}
- if (loadAllFiles) {
+ if (virtualList) {
return (
diff --git a/packages/@uppy/provider-views/src/Item/components/ListLi.tsx b/packages/@uppy/provider-views/src/Item/components/ListLi.tsx
index c07d68309a..38e95eeb16 100644
--- a/packages/@uppy/provider-views/src/Item/components/ListLi.tsx
+++ b/packages/@uppy/provider-views/src/Item/components/ListLi.tsx
@@ -95,7 +95,9 @@ export default function ListItem(
{itemIconEl}
- {showTitles && {title}}
+ {showTitles && title ?
+ {title}
+ : i18n('unnamed')}
}
diff --git a/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx b/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx
index f89ac115b1..be2b5cd08e 100644
--- a/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx
+++ b/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx
@@ -58,6 +58,7 @@ const defaultOptions = {
showFilter: true,
showBreadcrumbs: true,
loadAllFiles: false,
+ virtualList: false,
}
export interface ProviderViewOptions
@@ -68,6 +69,7 @@ export interface ProviderViewOptions
loading: boolean | string
onAuth: (authFormData: unknown) => Promise
}) => h.JSX.Element
+ virtualList?: boolean
}
type Opts = DefinePluginOpts<
@@ -583,6 +585,7 @@ export default class ProviderView extends View<
getNextFolder: this.getNextFolder,
getFolder: this.getFolder,
loadAllFiles: this.opts.loadAllFiles,
+ virtualList: this.opts.virtualList,
// For SearchFilterInput component
showSearchFilter: targetViewOptions.showFilter,
diff --git a/packages/@uppy/provider-views/src/View.ts b/packages/@uppy/provider-views/src/View.ts
index 98ea88e1bb..fca17e0e4f 100644
--- a/packages/@uppy/provider-views/src/View.ts
+++ b/packages/@uppy/provider-views/src/View.ts
@@ -4,8 +4,6 @@ import type {
} from '@uppy/core/lib/Uppy'
import type { Body, Meta, TagFile } from '@uppy/utils/lib/UppyFile'
import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
-import getFileType from '@uppy/utils/lib/getFileType'
-import isPreviewSupported from '@uppy/utils/lib/isPreviewSupported'
import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal'
type PluginType = 'Provider' | 'SearchProvider'
@@ -148,10 +146,7 @@ export default class View<
},
}
- const fileType = getFileType(tagFile)
-
- // TODO Should we just always use the thumbnail URL if it exists?
- if (fileType && isPreviewSupported(fileType)) {
+ if (file.thumbnail) {
tagFile.preview = file.thumbnail
}
diff --git a/packages/@uppy/react/types/index.test-d.tsx b/packages/@uppy/react/types/index.test-d.tsx
index d408d306a9..154796050d 100644
--- a/packages/@uppy/react/types/index.test-d.tsx
+++ b/packages/@uppy/react/types/index.test-d.tsx
@@ -8,6 +8,7 @@ const { useUppy } = components
const uppy = new Uppy()
{
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
function TestComponent() {
return (
@@ -27,6 +28,7 @@ const uppy = new Uppy()
}
{
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
const el = (
(useUppy(() => uppy))
expectType(useUppy(() => new Uppy()))
diff --git a/packages/@uppy/remote-sources/package.json b/packages/@uppy/remote-sources/package.json
index 7c7799c422..8d72ceb9d5 100644
--- a/packages/@uppy/remote-sources/package.json
+++ b/packages/@uppy/remote-sources/package.json
@@ -10,6 +10,7 @@
"file uploader",
"instagram",
"google-drive",
+ "google-photos",
"facebook",
"dropbox",
"onedrive",
@@ -32,6 +33,7 @@
"@uppy/dropbox": "workspace:^",
"@uppy/facebook": "workspace:^",
"@uppy/google-drive": "workspace:^",
+ "@uppy/google-photos": "workspace:^",
"@uppy/instagram": "workspace:^",
"@uppy/onedrive": "workspace:^",
"@uppy/unsplash": "workspace:^",
diff --git a/packages/@uppy/remote-sources/src/index.test.ts b/packages/@uppy/remote-sources/src/index.test.ts
index b99ae2af89..261bc89a67 100644
--- a/packages/@uppy/remote-sources/src/index.test.ts
+++ b/packages/@uppy/remote-sources/src/index.test.ts
@@ -47,7 +47,7 @@ describe('RemoteSources', () => {
sources: ['Webcam'],
})
}).toThrow(
- 'Invalid plugin: "Webcam" is not one of: Box, Dropbox, Facebook, GoogleDrive, Instagram, OneDrive, Unsplash, Url, or Zoom.',
+ 'Invalid plugin: "Webcam" is not one of: Box, Dropbox, Facebook, GoogleDrive, GooglePhotos, Instagram, OneDrive, Unsplash, Url, or Zoom.',
)
})
})
diff --git a/packages/@uppy/remote-sources/src/index.ts b/packages/@uppy/remote-sources/src/index.ts
index 84e9f44b05..9cc9c78108 100644
--- a/packages/@uppy/remote-sources/src/index.ts
+++ b/packages/@uppy/remote-sources/src/index.ts
@@ -6,6 +6,7 @@ import {
} from '@uppy/core'
import Dropbox from '@uppy/dropbox'
import GoogleDrive from '@uppy/google-drive'
+import GooglePhotos from '@uppy/google-photos'
import Instagram from '@uppy/instagram'
import Facebook from '@uppy/facebook'
import OneDrive from '@uppy/onedrive'
@@ -27,6 +28,7 @@ const availablePlugins = {
Dropbox,
Facebook,
GoogleDrive,
+ GooglePhotos,
Instagram,
OneDrive,
Unsplash,
diff --git a/packages/@uppy/remote-sources/tsconfig.build.json b/packages/@uppy/remote-sources/tsconfig.build.json
index 97b71eb43c..5399803aac 100644
--- a/packages/@uppy/remote-sources/tsconfig.build.json
+++ b/packages/@uppy/remote-sources/tsconfig.build.json
@@ -14,6 +14,8 @@
"@uppy/facebook/lib/*": ["../facebook/src/*"],
"@uppy/google-drive": ["../google-drive/src/index.js"],
"@uppy/google-drive/lib/*": ["../google-drive/src/*"],
+ "@uppy/google-photos": ["../google-photos/src/index.js"],
+ "@uppy/google-photos/lib/*": ["../google-photos/src/*"],
"@uppy/instagram": ["../instagram/src/index.js"],
"@uppy/instagram/lib/*": ["../instagram/src/*"],
"@uppy/onedrive": ["../onedrive/src/index.js"],
@@ -49,6 +51,9 @@
{
"path": "../google-drive/tsconfig.build.json"
},
+ {
+ "path": "../google-photos/tsconfig.build.json"
+ },
{
"path": "../instagram/tsconfig.build.json"
},
diff --git a/packages/@uppy/remote-sources/tsconfig.json b/packages/@uppy/remote-sources/tsconfig.json
index 8a51722f0b..73052cf39c 100644
--- a/packages/@uppy/remote-sources/tsconfig.json
+++ b/packages/@uppy/remote-sources/tsconfig.json
@@ -14,6 +14,8 @@
"@uppy/facebook/lib/*": ["../facebook/src/*"],
"@uppy/google-drive": ["../google-drive/src/index.js"],
"@uppy/google-drive/lib/*": ["../google-drive/src/*"],
+ "@uppy/google-photos": ["../google-photos/src/index.js"],
+ "@uppy/google-photos/lib/*": ["../google-photos/src/*"],
"@uppy/instagram": ["../instagram/src/index.js"],
"@uppy/instagram/lib/*": ["../instagram/src/*"],
"@uppy/onedrive": ["../onedrive/src/index.js"],
@@ -45,6 +47,9 @@
{
"path": "../google-drive/tsconfig.build.json",
},
+ {
+ "path": "../google-photos/tsconfig.build.json",
+ },
{
"path": "../instagram/tsconfig.build.json",
},
diff --git a/packages/@uppy/transloadit/src/index.ts b/packages/@uppy/transloadit/src/index.ts
index 7a8ddb37e7..7fcfbb19ca 100644
--- a/packages/@uppy/transloadit/src/index.ts
+++ b/packages/@uppy/transloadit/src/index.ts
@@ -379,6 +379,7 @@ export default class Transloadit<
addPluginVersion('Box', 'uppy-box')
addPluginVersion('Facebook', 'uppy-facebook')
addPluginVersion('GoogleDrive', 'uppy-google-drive')
+ addPluginVersion('GooglePhotos', 'uppy-google-photos')
addPluginVersion('Instagram', 'uppy-instagram')
addPluginVersion('OneDrive', 'uppy-onedrive')
addPluginVersion('Zoom', 'uppy-zoom')
diff --git a/packages/uppy/index.mjs b/packages/uppy/index.mjs
index 17a0a3077f..d25dbb5e56 100644
--- a/packages/uppy/index.mjs
+++ b/packages/uppy/index.mjs
@@ -38,6 +38,7 @@ export { default as Box } from '@uppy/box'
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 Instagram } from '@uppy/instagram'
export { default as OneDrive } from '@uppy/onedrive'
export { default as RemoteSources } from '@uppy/remote-sources'
diff --git a/packages/uppy/package.json b/packages/uppy/package.json
index 3a7b3c5053..969846e08c 100644
--- a/packages/uppy/package.json
+++ b/packages/uppy/package.json
@@ -47,6 +47,7 @@
"@uppy/form": "workspace:^",
"@uppy/golden-retriever": "workspace:^",
"@uppy/google-drive": "workspace:^",
+ "@uppy/google-photos": "workspace:^",
"@uppy/image-editor": "workspace:^",
"@uppy/informer": "workspace:^",
"@uppy/instagram": "workspace:^",
diff --git a/packages/uppy/types/index.d.ts b/packages/uppy/types/index.d.ts
index 76500aab0a..f9c370a318 100644
--- a/packages/uppy/types/index.d.ts
+++ b/packages/uppy/types/index.d.ts
@@ -22,6 +22,7 @@ export { default as StatusBar } from '@uppy/status-bar'
export { default as Dropbox } from '@uppy/dropbox'
export { default as Box } from '@uppy/box'
export { default as GoogleDrive } from '@uppy/google-drive'
+export { default as GooglePhotos } from '@uppy/google-photos'
export { default as Instagram } from '@uppy/instagram'
export { default as Url } from '@uppy/url'
export { default as Webcam } from '@uppy/webcam'
diff --git a/private/dev/Dashboard.js b/private/dev/Dashboard.js
index 6b20714a98..d750b783af 100644
--- a/private/dev/Dashboard.js
+++ b/private/dev/Dashboard.js
@@ -113,7 +113,7 @@ export default () => {
// .use(Unsplash, { target: Dashboard, companionUrl: COMPANION_URL, companionAllowedHosts })
.use(RemoteSources, {
companionUrl: COMPANION_URL,
- sources: ['Box', 'Dropbox', 'Facebook', 'Instagram', 'OneDrive', 'Unsplash', 'Zoom', 'Url'],
+ sources: ['GooglePhotos', 'Box', 'Dropbox', 'Facebook', 'Instagram', 'OneDrive', 'Unsplash', 'Zoom', 'Url'],
companionAllowedHosts,
})
.use(Webcam, {
diff --git a/yarn.lock b/yarn.lock
index 40eebd7fef..8338adb9a4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8735,6 +8735,7 @@ __metadata:
"@uppy/core": "workspace:*"
"@uppy/drag-drop": "workspace:*"
"@uppy/google-drive": "workspace:*"
+ "@uppy/google-photos": "workspace:*"
"@uppy/progress-bar": "workspace:*"
"@uppy/tus": "workspace:*"
"@uppy/webcam": "workspace:*"
@@ -9400,6 +9401,19 @@ __metadata:
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"
+ dependencies:
+ "@uppy/companion-client": "workspace:^"
+ "@uppy/provider-views": "workspace:^"
+ "@uppy/utils": "workspace:^"
+ preact: ^10.5.13
+ peerDependencies:
+ "@uppy/core": "workspace:^"
+ languageName: unknown
+ linkType: soft
+
"@uppy/image-editor@workspace:*, @uppy/image-editor@workspace:^, @uppy/image-editor@workspace:packages/@uppy/image-editor":
version: 0.0.0-use.local
resolution: "@uppy/image-editor@workspace:packages/@uppy/image-editor"
@@ -9546,6 +9560,7 @@ __metadata:
"@uppy/dropbox": "workspace:^"
"@uppy/facebook": "workspace:^"
"@uppy/google-drive": "workspace:^"
+ "@uppy/google-photos": "workspace:^"
"@uppy/instagram": "workspace:^"
"@uppy/onedrive": "workspace:^"
"@uppy/unsplash": "workspace:^"
@@ -14234,6 +14249,7 @@ __metadata:
"@uppy/form": "workspace:^"
"@uppy/golden-retriever": "workspace:^"
"@uppy/google-drive": "workspace:^"
+ "@uppy/google-photos": "workspace:^"
"@uppy/image-editor": "workspace:^"
"@uppy/informer": "workspace:^"
"@uppy/instagram": "workspace:^"
@@ -31100,6 +31116,7 @@ __metadata:
"@uppy/form": "workspace:^"
"@uppy/golden-retriever": "workspace:^"
"@uppy/google-drive": "workspace:^"
+ "@uppy/google-photos": "workspace:^"
"@uppy/image-editor": "workspace:^"
"@uppy/informer": "workspace:^"
"@uppy/instagram": "workspace:^"