Skip to content

Commit

Permalink
Google Picker (#5443)
Browse files Browse the repository at this point in the history
* initial poc

* improvements

- split into two plugins
- implement photos picker
- auto login
- save access token in local storage
- document
- handle photos/files picked and send to companion
- add new hook useStore for making it easier to use localStorage data in react
- add new hook useUppyState for making it easier to use uppy state from react
- add new hook useUppyPluginState for making it easier to plugin state from react
- fix css error

* implement picker in companion

* type todo

* fix ts error

which occurs in dev when js has been built before build:ts gets called

* reuse docs

* imrpve type safety

* simplify async wrapper

* improve doc

* fix lint

* fix build error

* check if token is valid

* fix broken logging code

* pull logic out from react component

* remove docs

* improve auth ui

* fix bug

* remove unused useUppyState

* try to fix build error
  • Loading branch information
mifi authored Dec 2, 2024
1 parent 44a378a commit afd4bef
Show file tree
Hide file tree
Showing 62 changed files with 1,679 additions and 184 deletions.
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ COMPANION_PREAUTH_SECRET=development2
# NOTE: Only enable this in development. Enabling it in production is a security risk
COMPANION_ALLOW_LOCAL_URLS=true

COMPANION_ENABLE_URL_ENDPOINT=true
COMPANION_ENABLE_GOOGLE_PICKER_ENDPOINT=true

# to enable S3
COMPANION_AWS_KEY="YOUR AWS KEY"
COMPANION_AWS_SECRET="YOUR AWS SECRET"
Expand Down Expand Up @@ -89,3 +92,10 @@ VITE_TRANSLOADIT_TEMPLATE=***
VITE_TRANSLOADIT_SERVICE_URL=https://api2.transloadit.com
# Fill in if you want requests sent to Transloadit to be signed:
# VITE_TRANSLOADIT_SECRET=***

# For Google Photos Picker and Google Drive Picker:
VITE_GOOGLE_PICKER_CLIENT_ID=***

# For Google Drive Picker
VITE_GOOGLE_PICKER_API_KEY=***
VITE_GOOGLE_PICKER_APP_ID=***
2 changes: 2 additions & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
"@uppy/form": "workspace:^",
"@uppy/golden-retriever": "workspace:^",
"@uppy/google-drive": "workspace:^",
"@uppy/google-drive-picker": "workspace:^",
"@uppy/google-photos": "workspace:^",
"@uppy/google-photos-picker": "workspace:^",
"@uppy/image-editor": "workspace:^",
"@uppy/informer": "workspace:^",
"@uppy/instagram": "workspace:^",
Expand Down
18 changes: 10 additions & 8 deletions packages/@uppy/box/src/Box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,22 @@ import { ProviderViews } from '@uppy/provider-views'
import { h, type ComponentChild } from 'preact'

import type { UppyFile, Body, Meta } from '@uppy/utils/lib/UppyFile'
import type { UnknownProviderPluginState } from '@uppy/core/lib/Uppy.js'
import type {
AsyncStore,
UnknownProviderPlugin,
UnknownProviderPluginState,
} from '@uppy/core/lib/Uppy.js'
import locale from './locale.ts'
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore We don't want TS to generate types for the package.json
import packageJson from '../package.json'

export type BoxOptions = CompanionPluginOptions

export default class Box<M extends Meta, B extends Body> extends UIPlugin<
BoxOptions,
M,
B,
UnknownProviderPluginState
> {
export default class Box<M extends Meta, B extends Body>
extends UIPlugin<BoxOptions, M, B, UnknownProviderPluginState>
implements UnknownProviderPlugin<M, B>
{
static VERSION = packageJson.version

icon: () => h.JSX.Element
Expand All @@ -31,7 +33,7 @@ export default class Box<M extends Meta, B extends Body> extends UIPlugin<

view!: ProviderViews<M, B>

storage: typeof tokenStorage
storage: AsyncStore

files: UppyFile<M, B>[]

Expand Down
4 changes: 2 additions & 2 deletions packages/@uppy/companion-client/src/CompanionPluginOptions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { UIPluginOptions } from '@uppy/core'
import type { tokenStorage } from './index.ts'
import type { AsyncStore } from '@uppy/core/lib/Uppy.js'

export interface CompanionPluginOptions extends UIPluginOptions {
storage?: typeof tokenStorage
storage?: AsyncStore
companionUrl: string
companionHeaders?: Record<string, string>
companionKeysParams?: { key: string; credentialsName: string }
Expand Down
5 changes: 1 addition & 4 deletions packages/@uppy/companion-client/src/Provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,10 +320,7 @@ export default class Provider<M extends Meta, B extends Body>
// Once a refresh token operation has started, we need all other request to wait for this operation (atomically)
this.#refreshingTokenPromise = (async () => {
try {
this.uppy.log(
`[CompanionClient] Refreshing expired auth token`,
'info',
)
this.uppy.log(`[CompanionClient] Refreshing expired auth token`)
const response = await super.request<{ uppyAuthToken: string }>({
path: this.refreshTokenUrl(),
method: 'POST',
Expand Down
8 changes: 4 additions & 4 deletions packages/@uppy/companion-client/src/RequestClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ export default class RequestClient<M extends Meta, B extends Body> {
})

const closeSocket = () => {
this.uppy.log(`Closing socket ${file.id}`, 'info')
this.uppy.log(`Closing socket ${file.id}`)
clearTimeout(activityTimeout)
if (socket) socket.close()
socket = undefined
Expand All @@ -524,7 +524,7 @@ export default class RequestClient<M extends Meta, B extends Body> {
signal: socketAbortController.signal,
onFailedAttempt: () => {
if (socketAbortController.signal.aborted) return // don't log in this case
this.uppy.log(`Retrying websocket ${file.id}`, 'info')
this.uppy.log(`Retrying websocket ${file.id}`)
},
})
})()
Expand All @@ -547,14 +547,14 @@ export default class RequestClient<M extends Meta, B extends Body> {
if (targetFile.id !== file.id) return
socketSend('cancel')
socketAbortController?.abort?.()
this.uppy.log(`upload ${file.id} was removed`, 'info')
this.uppy.log(`upload ${file.id} was removed`)
resolve()
}

const onCancelAll = () => {
socketSend('cancel')
socketAbortController?.abort?.()
this.uppy.log(`upload ${file.id} was canceled`, 'info')
this.uppy.log(`upload ${file.id} was canceled`)
resolve()
}

Expand Down
19 changes: 7 additions & 12 deletions packages/@uppy/companion-client/src/tokenStorage.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
/**
* This module serves as an Async wrapper for LocalStorage
* Why? Because the Provider API `storage` option allows an async storage
*/
export function setItem(key: string, value: string): Promise<void> {
return new Promise((resolve) => {
localStorage.setItem(key, value)
resolve()
})
export async function setItem(key: string, value: string): Promise<void> {
localStorage.setItem(key, value)
}

export function getItem(key: string): Promise<string | null> {
return Promise.resolve(localStorage.getItem(key))
export async function getItem(key: string): Promise<string | null> {
return localStorage.getItem(key)
}

export function removeItem(key: string): Promise<void> {
return new Promise((resolve) => {
localStorage.removeItem(key)
resolve()
})
export async function removeItem(key: string): Promise<void> {
localStorage.removeItem(key)
}
2 changes: 2 additions & 0 deletions packages/@uppy/companion/src/companion.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const providerManager = require('./server/provider')
const controllers = require('./server/controllers')
const s3 = require('./server/controllers/s3')
const url = require('./server/controllers/url')
const googlePicker = require('./server/controllers/googlePicker')
const createEmitter = require('./server/emitter')
const redis = require('./server/redis')
const jobs = require('./server/jobs')
Expand Down Expand Up @@ -120,6 +121,7 @@ module.exports.app = (optionsArg = {}) => {
app.use('*', middlewares.getCompanionMiddleware(options))
app.use('/s3', s3(options.s3))
if (options.enableUrlEndpoint) app.use('/url', url())
if (options.enableGooglePickerEndpoint) app.use('/google-picker', googlePicker())

app.post('/:providerName/preauth', express.json(), express.urlencoded({ extended: false }), middlewares.hasSessionAndProvider, middlewares.hasBody, middlewares.hasOAuthProvider, controllers.preauth)
app.get('/:providerName/connect', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, controllers.connect)
Expand Down
1 change: 1 addition & 0 deletions packages/@uppy/companion/src/config/companion.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const defaultOptions = {
expires: 800, // seconds
},
enableUrlEndpoint: false,
enableGooglePickerEndpoint: false,
allowLocalUrls: false,
periodicPingUrls: [],
streamingUpload: true,
Expand Down
57 changes: 57 additions & 0 deletions packages/@uppy/companion/src/server/controllers/googlePicker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const express = require('express')
const assert = require('node:assert')

const { startDownUpload } = require('../helpers/upload')
const { validateURL } = require('../helpers/request')
const { getURLMeta } = require('../helpers/request')
const logger = require('../logger')
const { downloadURL } = require('../download')
const { getGoogleFileSize, streamGoogleFile } = require('../provider/google/drive');


const getAuthHeader = (token) => ({ authorization: `Bearer ${token}` });

/**
*
* @param {object} req expressJS request object
* @param {object} res expressJS response object
*/
const get = async (req, res) => {
try {
logger.debug('Google Picker file import handler running', null, req.id)

const allowLocalUrls = false

const { accessToken, platform, fileId } = req.body

assert(platform === 'drive' || platform === 'photos');

const getSize = async () => {
if (platform === 'drive') {
return getGoogleFileSize({ id: fileId, token: accessToken })
}
const { size } = await getURLMeta(req.body.url, allowLocalUrls, { headers: getAuthHeader(accessToken) })
return size
}

if (platform === 'photos' && !validateURL(req.body.url, allowLocalUrls)) {
res.status(400).json({ error: 'Invalid URL' })
return
}

const download = () => {
if (platform === 'drive') {
return streamGoogleFile({ token: accessToken, id: fileId })
}
return downloadURL(req.body.url, allowLocalUrls, req.id, { headers: getAuthHeader(accessToken) })
}

await startDownUpload({ req, res, getSize, download })
} catch (err) {
logger.error(err, 'controller.googlePicker.error', req.id)
res.status(err.status || 500).json({ message: 'failed to fetch Google Picker URL' })
}
}

module.exports = () => express.Router()
.post('/get', express.json(), get)
25 changes: 2 additions & 23 deletions packages/@uppy/companion/src/server/controllers/url.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
const express = require('express')

const { startDownUpload } = require('../helpers/upload')
const { prepareStream } = require('../helpers/utils')
const { downloadURL } = require('../download')
const { validateURL } = require('../helpers/request')
const { getURLMeta, getProtectedGot } = require('../helpers/request')
const { getURLMeta } = require('../helpers/request')
const logger = require('../logger')

/**
Expand All @@ -12,27 +12,6 @@ const logger = require('../logger')
* @param {string | Buffer | Buffer[]} chunk
*/

/**
* Downloads the content in the specified url, and passes the data
* to the callback chunk by chunk.
*
* @param {string} url
* @param {boolean} allowLocalIPs
* @param {string} traceId
* @returns {Promise}
*/
const downloadURL = async (url, allowLocalIPs, traceId) => {
try {
const protectedGot = await getProtectedGot({ allowLocalIPs })
const stream = protectedGot.stream.get(url, { responseType: 'json' })
const { size } = await prepareStream(stream)
return { stream, size }
} catch (err) {
logger.error(err, 'controller.url.download.error', traceId)
throw err
}
}

/**
* Fetches the size and content type of a URL
*
Expand Down
28 changes: 28 additions & 0 deletions packages/@uppy/companion/src/server/download.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const logger = require('./logger')
const { getProtectedGot } = require('./helpers/request')
const { prepareStream } = require('./helpers/utils')

/**
* Downloads the content in the specified url, and passes the data
* to the callback chunk by chunk.
*
* @param {string} url
* @param {boolean} allowLocalIPs
* @param {string} traceId
* @returns {Promise}
*/
const downloadURL = async (url, allowLocalIPs, traceId, options) => {
try {
const protectedGot = await getProtectedGot({ allowLocalIPs })
const stream = protectedGot.stream.get(url, { responseType: 'json', ...options })
const { size } = await prepareStream(stream)
return { stream, size }
} catch (err) {
logger.error(err, 'controller.url.download.error', traceId)
throw err
}
}

module.exports = {
downloadURL,
}
4 changes: 2 additions & 2 deletions packages/@uppy/companion/src/server/helpers/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,10 @@ module.exports.getProtectedGot = getProtectedGot
* @param {boolean} allowLocalIPs
* @returns {Promise<{name: string, type: string, size: number}>}
*/
exports.getURLMeta = async (url, allowLocalIPs = false) => {
exports.getURLMeta = async (url, allowLocalIPs = false, options = undefined) => {
async function requestWithMethod (method) {
const protectedGot = await getProtectedGot({ allowLocalIPs })
const stream = protectedGot.stream(url, { method, throwHttpErrors: false })
const stream = protectedGot.stream(url, { method, throwHttpErrors: false, ...options })

return new Promise((resolve, reject) => (
stream
Expand Down
Loading

0 comments on commit afd4bef

Please sign in to comment.