Selaa lähdekoodia

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

Mikael Finstad 10 kuukautta sitten
vanhempi
commit
e6674a1eee
62 muutettua tiedostoa jossa 1091 lisäystä ja 184 poistoa
  1. 1 0
      .eslintrc.js
  2. 14 11
      docs/companion.md
  3. 9 8
      docs/guides/migration-guides.md
  4. 8 8
      docs/sources/companion-plugins/google-drive.mdx
  5. 185 0
      docs/sources/companion-plugins/google-photos.mdx
  6. 1 1
      docs/sources/companion-plugins/instagram.mdx
  7. 2 2
      docs/uploader/transloadit.mdx
  8. 2 0
      docs/user-interfaces/dashboard.mdx
  9. 1 1
      e2e/cypress/integration/dashboard-transloadit.spec.ts
  10. 1 0
      e2e/package.json
  11. 1 0
      examples/angular-example/package.json
  12. 1 0
      packages/@uppy/box/src/Box.tsx
  13. 27 12
      packages/@uppy/companion/src/config/grant.js
  14. 1 4
      packages/@uppy/companion/src/server/controllers/get.js
  15. 3 5
      packages/@uppy/companion/src/server/controllers/url.js
  16. 2 2
      packages/@uppy/companion/src/server/helpers/oauth-state.js
  17. 12 4
      packages/@uppy/companion/src/server/helpers/upload.js
  18. 5 2
      packages/@uppy/companion/src/server/helpers/utils.js
  19. 0 0
      packages/@uppy/companion/src/server/provider/google/drive/adapter.js
  20. 19 48
      packages/@uppy/companion/src/server/provider/google/drive/index.js
  21. 172 0
      packages/@uppy/companion/src/server/provider/google/googlephotos/index.js
  22. 36 0
      packages/@uppy/companion/src/server/provider/google/index.js
  23. 3 2
      packages/@uppy/companion/src/server/provider/index.js
  24. 14 1
      packages/@uppy/companion/src/server/provider/providerErrors.js
  25. 5 0
      packages/@uppy/companion/src/standalone/helper.js
  26. 16 12
      packages/@uppy/companion/test/__tests__/companion.js
  27. 39 7
      packages/@uppy/companion/test/__tests__/provider-manager.js
  28. 107 37
      packages/@uppy/companion/test/__tests__/providers.js
  29. 1 1
      packages/@uppy/companion/test/fixtures/drive.js
  30. 1 0
      packages/@uppy/core/src/locale.ts
  31. 3 3
      packages/@uppy/dashboard/src/utils/copyToClipboard.ts
  32. 1 0
      packages/@uppy/dropbox/src/Dropbox.tsx
  33. 1 0
      packages/@uppy/google-drive/src/GoogleDrive.tsx
  34. 1 0
      packages/@uppy/google-photos/.npmignore
  35. 1 0
      packages/@uppy/google-photos/CHANGELOG.md
  36. 21 0
      packages/@uppy/google-photos/LICENSE
  37. 51 0
      packages/@uppy/google-photos/README.md
  38. 33 0
      packages/@uppy/google-photos/package.json
  39. 135 0
      packages/@uppy/google-photos/src/GooglePhotos.tsx
  40. 1 0
      packages/@uppy/google-photos/src/index.ts
  41. 5 0
      packages/@uppy/google-photos/src/locale.ts
  42. 35 0
      packages/@uppy/google-photos/tsconfig.build.json
  43. 31 0
      packages/@uppy/google-photos/tsconfig.json
  44. 17 0
      packages/@uppy/google-photos/types/index.d.ts
  45. 12 0
      packages/@uppy/google-photos/types/index.test-d.ts
  46. 1 0
      packages/@uppy/onedrive/src/OneDrive.tsx
  47. 5 4
      packages/@uppy/provider-views/src/Browser.tsx
  48. 3 1
      packages/@uppy/provider-views/src/Item/components/ListLi.tsx
  49. 3 0
      packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx
  50. 1 6
      packages/@uppy/provider-views/src/View.ts
  51. 4 0
      packages/@uppy/react/types/index.test-d.tsx
  52. 2 0
      packages/@uppy/remote-sources/package.json
  53. 1 1
      packages/@uppy/remote-sources/src/index.test.ts
  54. 2 0
      packages/@uppy/remote-sources/src/index.ts
  55. 5 0
      packages/@uppy/remote-sources/tsconfig.build.json
  56. 5 0
      packages/@uppy/remote-sources/tsconfig.json
  57. 1 0
      packages/@uppy/transloadit/src/index.ts
  58. 1 0
      packages/uppy/index.mjs
  59. 1 0
      packages/uppy/package.json
  60. 1 0
      packages/uppy/types/index.d.ts
  61. 1 1
      private/dev/Dashboard.js
  62. 17 0
      yarn.lock

+ 1 - 0
.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',

+ 14 - 11
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

+ 9 - 8
docs/guides/migration-guides.md

@@ -626,14 +626,15 @@ to:
 
 <div class="table-responsive">
 
-| 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`    |
 
 </div>
 

+ 8 - 8
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',
 		},
 	},
 });

+ 185 - 0
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/).
+
+<Tabs>
+  <TabItem value="npm" label="NPM" default>
+
+```shell
+npm install @uppy/google-photos
+```
+
+  </TabItem>
+
+  <TabItem value="yarn" label="Yarn">
+
+```shell
+yarn add @uppy/google-photos
+```
+
+  </TabItem>
+
+  <TabItem value="cdn" label="CDN">
+    <UppyCdnExample>
+      {`
+        import { Uppy, GooglePhotos } from "{{UPPY_JS_URL}}"
+        const uppy = new Uppy()
+        uppy.use(GooglePhotos, {
+          // Options
+        })
+      `}
+    </UppyCdnExample>
+  </TabItem>
+</Tabs>
+
+## 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',
+	},
+};
+```

+ 1 - 1
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',
 		},

+ 2 - 2
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.
 
 :::
 

+ 2 - 0
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

+ 1 - 1
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: [],

+ 1 - 0
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:^",

+ 1 - 0
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:*",

+ 1 - 0
packages/@uppy/box/src/Box.tsx

@@ -84,6 +84,7 @@ export default class Box<M extends Meta, B extends Body> extends UIPlugin<
     this.view = new ProviderViews(this, {
       provider: this.provider,
       loadAllFiles: true,
+      virtualList: true,
     })
 
     const { target } = this.opts

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

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

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

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

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

@@ -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 })

+ 2 - 2
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) => {

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

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

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

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

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


+ 19 - 48
packages/@uppy/companion/src/server/provider/drive/index.js → 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

+ 172 - 0
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

+ 36 - 0
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,
+}

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

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

+ 14 - 1
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 }

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

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

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

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

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

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

+ 107 - 37
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')

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

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

+ 1 - 0
packages/@uppy/core/src/locale.ts

@@ -62,5 +62,6 @@ export default {
     },
     additionalRestrictionsFailed:
       '%{count} additional restrictions were not fulfilled',
+    unnamed: 'Unnamed',
   },
 }

+ 3 - 3
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()
     }
   })
 }

+ 1 - 0
packages/@uppy/dropbox/src/Dropbox.tsx

@@ -85,6 +85,7 @@ export default class Dropbox<M extends Meta, B extends Body> extends UIPlugin<
     this.view = new ProviderViews(this, {
       provider: this.provider,
       loadAllFiles: true,
+      virtualList: true,
     })
 
     const { target } = this.opts

+ 1 - 0
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

+ 1 - 0
packages/@uppy/google-photos/.npmignore

@@ -0,0 +1 @@
+tsconfig.*

+ 1 - 0
packages/@uppy/google-photos/CHANGELOG.md

@@ -0,0 +1 @@
+# @uppy/google-photos

+ 21 - 0
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.

+ 51 - 0
packages/@uppy/google-photos/README.md

@@ -0,0 +1,51 @@
+# @uppy/google-photos
+
+<img src="https://uppy.io/img/logo.svg" width="120" alt="Uppy logo: a smiling puppy above a pink upwards arrow" align="right">
+
+[![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).

+ 33 - 0
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:^"
+  }
+}

+ 135 - 0
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<GooglePhotosOptions, M, B, UnknownProviderPluginState> {
+  static VERSION = packageJson.version
+
+  icon: () => h.JSX.Element
+
+  provider: Provider<M, B>
+
+  view: ProviderViews<M, B>
+
+  storage: typeof tokenStorage
+
+  files: UppyFile<M, B>[]
+
+  constructor(uppy: Uppy<M, B>, opts: GooglePhotosOptions) {
+    super(uppy, opts)
+    this.type = 'acquirer'
+    this.storage = this.opts.storage || tokenStorage
+    this.files = []
+    this.id = this.opts.id || 'GooglePhotos'
+    this.icon = () => (
+      <svg
+        aria-hidden="true"
+        focusable="false"
+        width="32"
+        height="32"
+        viewBox="-7 -7 73 73"
+      >
+        <g fill="none" fill-rule="evenodd">
+          <path d="M-3-3h64v64H-3z" />
+          <g fill-rule="nonzero">
+            <path
+              fill="#FBBC04"
+              d="M14.8 13.4c8.1 0 14.7 6.6 14.7 14.8v1.3H1.3c-.7 0-1.3-.6-1.3-1.3C0 20 6.6 13.4 14.8 13.4z"
+            />
+            <path
+              fill="#EA4335"
+              d="M45.6 14.8c0 8.1-6.6 14.7-14.8 14.7h-1.3V1.3c0-.7.6-1.3 1.3-1.3C39 0 45.6 6.6 45.6 14.8z"
+            />
+            <path
+              fill="#4285F4"
+              d="M44.3 45.6c-8.2 0-14.8-6.6-14.8-14.8v-1.3h28.2c.7 0 1.3.6 1.3 1.3 0 8.2-6.6 14.8-14.8 14.8z"
+            />
+            <path
+              fill="#34A853"
+              d="M13.4 44.3c0-8.2 6.6-14.8 14.8-14.8h1.3v28.2c0 .7-.6 1.3-1.3 1.3-8.2 0-14.8-6.6-14.8-14.8z"
+            />
+          </g>
+        </g>
+      </svg>
+    )
+
+    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<void> {
+    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)
+  }
+}

+ 1 - 0
packages/@uppy/google-photos/src/index.ts

@@ -0,0 +1 @@
+export { default } from './GooglePhotos.tsx'

+ 5 - 0
packages/@uppy/google-photos/src/locale.ts

@@ -0,0 +1,5 @@
+export default {
+  strings: {
+    pluginNameGooglePhotos: 'Google Photos',
+  },
+}

+ 35 - 0
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"
+    }
+  ]
+}

+ 31 - 0
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",
+    },
+  ],
+}

+ 17 - 0
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<GooglePhotosOptions> {}
+
+export default GooglePhotos

+ 12 - 0
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<UIPluginOptions> {}
+
+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: '',
+})

+ 1 - 0
packages/@uppy/onedrive/src/OneDrive.tsx

@@ -97,6 +97,7 @@ export default class OneDrive<M extends Meta, B extends Body> extends UIPlugin<
     this.view = new ProviderViews(this, {
       provider: this.provider,
       loadAllFiles: true,
+      virtualList: true,
     })
 
     const { target } = this.opts

+ 5 - 4
packages/@uppy/provider-views/src/Browser.tsx

@@ -74,7 +74,8 @@ function ListItem<M extends Meta, B extends Body>(props: ListItemProps<M, B>) {
     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<M extends Meta, B extends Body> = {
   cancel: () => void
   done: () => void
   noResultsLabel: string
-  loadAllFiles?: boolean
+  virtualList?: boolean
 }
 
 function Browser<M extends Meta, B extends Body>(props: BrowserProps<M, B>) {
@@ -146,7 +147,7 @@ function Browser<M extends Meta, B extends Body>(props: BrowserProps<M, B>) {
     cancel,
     done,
     noResultsLabel,
-    loadAllFiles,
+    virtualList,
   } = props
 
   const selected = currentSelection.length
@@ -202,7 +203,7 @@ function Browser<M extends Meta, B extends Body>(props: BrowserProps<M, B>) {
           return <div className="uppy-Provider-empty">{noResultsLabel}</div>
         }
 
-        if (loadAllFiles) {
+        if (virtualList) {
           return (
             <div className="uppy-ProviderBrowser-body">
               <ul className="uppy-ProviderBrowser-list">

+ 3 - 1
packages/@uppy/provider-views/src/Item/components/ListLi.tsx

@@ -95,7 +95,9 @@ export default function ListItem<M extends Meta, B extends Body>(
             <div className="uppy-ProviderBrowserItem-iconWrap">
               {itemIconEl}
             </div>
-            {showTitles && <span>{title}</span>}
+            {showTitles && title ?
+              <span>{title}</span>
+            : i18n('unnamed')}
           </button>
 
       }

+ 3 - 0
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<M extends Meta, B extends Body>
@@ -68,6 +69,7 @@ export interface ProviderViewOptions<M extends Meta, B extends Body>
     loading: boolean | string
     onAuth: (authFormData: unknown) => Promise<void>
   }) => h.JSX.Element
+  virtualList?: boolean
 }
 
 type Opts<M extends Meta, B extends Body> = DefinePluginOpts<
@@ -583,6 +585,7 @@ export default class ProviderView<M extends Meta, B extends Body> extends View<
       getNextFolder: this.getNextFolder,
       getFolder: this.getFolder,
       loadAllFiles: this.opts.loadAllFiles,
+      virtualList: this.opts.virtualList,
 
       // For SearchFilterInput component
       showSearchFilter: targetViewOptions.showFilter,

+ 1 - 6
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
     }
 

+ 4 - 0
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 (
       <components.Dashboard uppy={uppy} closeAfterFinish hideCancelButton />
@@ -27,6 +28,7 @@ const uppy = new Uppy()
 }
 
 {
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const el = (
     <components.DragDrop
       width={200}
@@ -47,6 +49,7 @@ const uppy = new Uppy()
 }
 
 {
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const el = (
     <components.DashboardModal
       target="body"
@@ -64,6 +67,7 @@ const uppy = new Uppy()
 }
 
 {
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
   function TestHook() {
     expectType<Uppy>(useUppy(() => uppy))
     expectType<Uppy>(useUppy(() => new Uppy()))

+ 2 - 0
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:^",

+ 1 - 1
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.',
     )
   })
 })

+ 2 - 0
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,

+ 5 - 0
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"
     },

+ 5 - 0
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",
     },

+ 1 - 0
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')

+ 1 - 0
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'

+ 1 - 0
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:^",

+ 1 - 0
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'

+ 1 - 1
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, {

+ 17 - 0
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:^"