Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@uppy/companion: merge Provider/SearchProvider #4330

Merged
merged 7 commits into from
Mar 28, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions packages/@uppy/companion/src/companion.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ module.exports.app = (optionsArg = {}) => {
const options = merge({}, defaultOptions, optionsArg)

const providers = providerManager.getDefaultProviders()
const searchProviders = providerManager.getSearchProviders()

providerManager.addProviderOptions(options, grantConfig)

const { customProviders } = options
Expand Down Expand Up @@ -116,22 +116,25 @@ module.exports.app = (optionsArg = {}) => {
app.use('/s3', s3(options.s3))
app.use('/url', url())

app.post('/:providerName/preauth', middlewares.hasSessionAndProvider, controllers.preauth)
app.get('/:providerName/connect', middlewares.hasSessionAndProvider, controllers.connect)
app.get('/:providerName/redirect', middlewares.hasSessionAndProvider, controllers.redirect)
app.get('/:providerName/callback', middlewares.hasSessionAndProvider, controllers.callback)
app.post('/:providerName/deauthorization/callback', middlewares.hasSessionAndProvider, controllers.deauthorizationCallback)
app.get('/:providerName/logout', middlewares.hasSessionAndProvider, middlewares.gentleVerifyToken, controllers.logout)
app.get('/:providerName/send-token', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.sendToken)
app.post('/:providerName/preauth', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, controllers.preauth)
app.get('/:providerName/connect', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, controllers.connect)
app.get('/:providerName/redirect', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, controllers.redirect)
app.get('/:providerName/callback', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, controllers.callback)
app.post('/:providerName/deauthorization/callback', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, controllers.deauthorizationCallback)
app.get('/:providerName/logout', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, middlewares.gentleVerifyToken, controllers.logout)
app.get('/:providerName/send-token', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, middlewares.verifyToken, controllers.sendToken)

app.get('/:providerName/list/:id?', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.list)
// backwards compat:
app.get('/search/:providerName/list', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.list)

app.post('/:providerName/get/:id', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.get)
app.get('/:providerName/thumbnail/:id', middlewares.hasSessionAndProvider, middlewares.cookieAuthToken, middlewares.verifyToken, controllers.thumbnail)
// @ts-ignore Type instantiation is excessively deep and possibly infinite.
app.get('/search/:searchProviderName/list', middlewares.hasSearchQuery, middlewares.loadSearchProviderToken, controllers.list)
app.post('/search/:searchProviderName/get/:id', middlewares.loadSearchProviderToken, controllers.get)
// backwards compat:
app.post('/search/:providerName/get/:id', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.get)

app.get('/:providerName/thumbnail/:id', middlewares.hasSessionAndProvider, middlewares.hasOAuthProvider, middlewares.cookieAuthToken, middlewares.verifyToken, controllers.thumbnail)

app.param('providerName', providerManager.getProviderMiddleware(providers, true))
app.param('searchProviderName', providerManager.getProviderMiddleware(searchProviders))
app.param('providerName', providerManager.getProviderMiddleware(providers))

if (app.get('env') !== 'test') {
jobs.startCleanUpJob(options.filePath)
Expand Down
47 changes: 30 additions & 17 deletions packages/@uppy/companion/src/server/middlewares.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const tokenService = require('./helpers/jwt')
const logger = require('./logger')
const getS3Client = require('./s3-client')
const { getURLBuilder } = require('./helpers/utils')
const { isOAuthProvider } = require('./provider/Provider')

exports.hasSessionAndProvider = (req, res, next) => {
if (!req.session || !req.body) {
Expand All @@ -22,28 +23,52 @@ exports.hasSessionAndProvider = (req, res, next) => {
return next()
}

exports.hasSearchQuery = (req, res, next) => {
if (typeof req.query.q !== 'string') {
logger.debug('search request has no search query', 'search.query.check', req.id)
const isOAuthProviderReq = (req) => isOAuthProvider(req.companion.providerClass.authProvider)

/**
* Middleware can be used to verify that the current request is to an OAuth provider
* This is because not all requests are supported by non-oauth providers (formerly known as SearchProviders)
*/
exports.hasOAuthProvider = (req, res, next) => {
if (!isOAuthProviderReq(req)) {
logger.debug('Provider does not support OAuth.', null, req.id)
return res.sendStatus(400)
}

return next()
}

exports.verifyToken = (req, res, next) => {
// for non oauth providers, we just load the static key from options
if (!isOAuthProviderReq(req)) {
const { providerOptions } = req.companion.options
const { providerName } = req.params
if (!providerOptions[providerName] || !providerOptions[providerName].key) {
logger.info(`unconfigured credentials for ${providerName}`, 'non.oauth.token.load.unset', req.id)
res.sendStatus(501)
return
}

req.companion.providerToken = providerOptions[providerName].key
next()
return
}

// Ok, OAuth provider, we fetch the token:
const token = req.companion.authToken
if (token == null) {
logger.info('cannot auth token', 'token.verify.unset', req.id)
return res.sendStatus(401)
res.sendStatus(401)
return
}
const { providerName } = req.params
const { err, payload } = tokenService.verifyEncryptedToken(token, req.companion.options.secret)
if (err || !payload[providerName]) {
if (err) {
logger.error(err.message, 'token.verify.error', req.id)
}
return res.sendStatus(401)
res.sendStatus(401)
return
}
req.companion.providerTokens = payload
req.companion.providerToken = payload[providerName]
Expand All @@ -67,18 +92,6 @@ exports.cookieAuthToken = (req, res, next) => {
return next()
}

exports.loadSearchProviderToken = (req, res, next) => {
const { providerOptions } = req.companion.options
const providerName = req.params.searchProviderName
if (!providerOptions[providerName] || !providerOptions[providerName].key) {
logger.info(`unconfigured credentials for ${providerName}`, 'searchtoken.load.unset', req.id)
return res.sendStatus(501)
}

req.companion.providerToken = providerOptions[providerName].key
next()
}

exports.cors = (options = {}) => (req, res, next) => {
// HTTP headers are not case sensitive, and express always handles them in lower case, so that's why we lower case them.
// I believe that HTTP verbs are case sensitive, and should be uppercase.
Expand Down
25 changes: 19 additions & 6 deletions packages/@uppy/companion/src/server/provider/Provider.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const noAuthProvider = ''

mifi marked this conversation as resolved.
Show resolved Hide resolved
/**
* Provider interface defines the specifications of any provider implementation
*/
Expand All @@ -24,7 +26,8 @@ class Provider {
* @param {object} options
* @returns {Promise}
*/
async list (options) { // eslint-disable-line no-unused-vars
// eslint-disable-next-line class-methods-use-this,no-unused-vars
async list (options) {
throw new Error('method not implemented')
}

Expand All @@ -34,7 +37,8 @@ class Provider {
* @param {object} options
* @returns {Promise}
*/
async download (options) { // eslint-disable-line no-unused-vars
// eslint-disable-next-line class-methods-use-this,no-unused-vars
async download (options) {
throw new Error('method not implemented')
}

Expand All @@ -44,7 +48,8 @@ class Provider {
* @param {object} options
* @returns {Promise}
*/
async thumbnail (options) { // eslint-disable-line no-unused-vars
// eslint-disable-next-line class-methods-use-this,no-unused-vars
async thumbnail (options) {
throw new Error('method not implemented')
}

Expand All @@ -54,7 +59,8 @@ class Provider {
* @param {object} options
* @returns {Promise}
*/
async size (options) { // eslint-disable-line no-unused-vars
// eslint-disable-next-line class-methods-use-this,no-unused-vars
async size (options) {
throw new Error('method not implemented')
}

Expand All @@ -64,17 +70,24 @@ class Provider {
* @param {object} options
* @returns {Promise}
*/
async deauthorizationCallback (options) { // eslint-disable-line no-unused-vars
// eslint-disable-next-line class-methods-use-this,no-unused-vars
async deauthorizationCallback (options) {
// @todo consider doing something like throw new NotImplementedError() instead
throw new Error('method not implemented')
}

/**
* Name of the OAuth provider. Return empty string if no OAuth provider is needed.
*
* @returns {string}
*/
static get authProvider () {
return ''
return noAuthProvider
mifi marked this conversation as resolved.
Show resolved Hide resolved
}
}

module.exports = Provider
module.exports.noAuthProvider = noAuthProvider

mifi marked this conversation as resolved.
Show resolved Hide resolved
// OAuth providers are those that have a `static authProvider` set. It means they require OAuth authentication to work
module.exports.isOAuthProvider = (authProvider) => authProvider !== noAuthProvider
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this const noAuthProvider as an empty string? It's not very intention revealing to me that we use an empty string. Can we use null instead?

mifi marked this conversation as resolved.
Show resolved Hide resolved
36 changes: 0 additions & 36 deletions packages/@uppy/companion/src/server/provider/SearchProvider.js

This file was deleted.

40 changes: 18 additions & 22 deletions packages/@uppy/companion/src/server/provider/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ const logger = require('../logger')
const { getCredentialsResolver } = require('./credentials')
// eslint-disable-next-line
const Provider = require('./Provider')
// eslint-disable-next-line
const SearchProvider = require('./SearchProvider')

const { isOAuthProvider } = Provider

/**
*
Expand All @@ -40,10 +40,9 @@ const providerNameToAuthName = (name, options) => { // eslint-disable-line no-un
* adds the desired provider module to the request object,
* based on the providerName parameter specified
*
* @param {Record<string, (typeof Provider) | typeof SearchProvider>} providers
* @param {boolean} [needsProviderCredentials]
* @param {Record<string, typeof Provider>} providers
*/
module.exports.getProviderMiddleware = (providers, needsProviderCredentials) => {
module.exports.getProviderMiddleware = (providers) => {
/**
*
* @param {object} req
Expand All @@ -55,8 +54,9 @@ module.exports.getProviderMiddleware = (providers, needsProviderCredentials) =>
const ProviderClass = providers[providerName]
if (ProviderClass && validOptions(req.companion.options)) {
req.companion.provider = new ProviderClass({ providerName })
req.companion.providerClass = ProviderClass

if (needsProviderCredentials) {
if (isOAuthProvider(ProviderClass.authProvider)) {
req.companion.getProviderCredentials = getCredentialsResolver(providerName, req.companion.options, req)
}
} else {
Expand All @@ -72,18 +72,11 @@ module.exports.getProviderMiddleware = (providers, needsProviderCredentials) =>
* @returns {Record<string, typeof Provider>}
*/
module.exports.getDefaultProviders = () => {
const providers = { dropbox, box, drive, facebook, onedrive, zoom, instagram }
const providers = { dropbox, box, drive, facebook, onedrive, zoom, instagram, unsplash }

return providers
}

/**
* @returns {Record<string, typeof SearchProvider>}
*/
module.exports.getSearchProviders = () => {
return { unsplash }
}

/**
*
* @typedef {{'module': typeof Provider, config: Record<string,unknown>}} CustomProvider
Expand All @@ -97,13 +90,16 @@ module.exports.addCustomProviders = (customProviders, providers, grantConfig) =>
const customProvider = customProviders[providerName]

providers[providerName] = customProvider.module
grantConfig[providerName] = {
...customProvider.config,
// todo: consider setting these options from a universal point also used
// by official providers. It'll prevent these from getting left out if the
// requirement changes.
callback: `/${providerName}/callback`,
transport: 'session',

if (isOAuthProvider(customProvider.module.authProvider)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is addCustomProviders used? I liked that you separated most things in different middlewares, but here we use if statements in existing logic. Are you aware of some alternatives or does this make the most sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the function is being used here:
https://github.com/transloadit/uppy/blob/main/packages/@uppy/companion/src/companion.js#L79

but here we use if statements in existing logic. Are you aware of some alternatives or does this make the most sense?
not sure what you mean by this

grantConfig[providerName] = {
...customProvider.config,
// todo: consider setting these options from a universal point also used
// by official providers. It'll prevent these from getting left out if the
// requirement changes.
callback: `/${providerName}/callback`,
transport: 'session',
}
}
})
}
Expand All @@ -130,7 +126,7 @@ module.exports.addProviderOptions = (companionOptions, grantConfig) => {
const keys = Object.keys(providerOptions).filter((key) => key !== 'server')
keys.forEach((providerName) => {
const authProvider = providerNameToAuthName(providerName, companionOptions)
if (authProvider && grantConfig[authProvider]) {
if (isOAuthProvider(authProvider) && grantConfig[authProvider]) {
// explicitly add providerOptions so users don't override other providerOptions.
grantConfig[authProvider].key = providerOptions[providerName].key
grantConfig[authProvider].secret = providerOptions[providerName].secret
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
const got = require('got').default

const SearchProvider = require('../SearchProvider')
const Provider = require('../Provider')
const { getURLMeta } = require('../../helpers/request')
const adaptData = require('./adapter')
const { withProviderErrorHandling } = require('../providerErrors')
const { prepareStream } = require('../../helpers/utils')
const { ProviderApiError } = require('../error')

const BASE_URL = 'https://api.unsplash.com'

Expand All @@ -20,8 +21,12 @@ const getPhotoMeta = async (client, id) => client.get(`photos/${id}`, { response
/**
* Adapter for API https://api.unsplash.com
*/
class Unsplash extends SearchProvider {
class Unsplash extends Provider {
async list ({ token, query = { cursor: null, q: null } }) {
if (typeof query.q !== 'string') {
throw new ProviderApiError('Search query missing', 400)
}

return this.#withErrorHandling('provider.unsplash.list.error', async () => {
const qs = { per_page: 40, query: query.q }
if (query.cursor) qs.page = query.cursor
Expand Down
5 changes: 4 additions & 1 deletion packages/@uppy/companion/test/__tests__/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const defaults = require('../fixtures/constants')

const tokenService = require('../../src/server/helpers/jwt')
const { getServer } = require('../mockserver')
const { noAuthProvider } = require('../../src/server/provider/Provider')
mifi marked this conversation as resolved.
Show resolved Hide resolved

// todo don't share server between tests. rewrite to not use env variables
const authServer = getServer({ COMPANION_CLIENT_SOCKET_CONNECT_TIMEOUT: '0' })
Expand Down Expand Up @@ -373,7 +374,9 @@ describe('connect to provider', () => {
test.each(providerNames)('connect to %s via grant.js endpoint', (providerName) => {
const authProvider = AUTH_PROVIDERS[providerName] || providerName

return request(authServer)
if (authProvider.authProvider === noAuthProvider) return
mifi marked this conversation as resolved.
Show resolved Hide resolved

request(authServer)
.get(`/${providerName}/connect?foo=bar`)
.set('uppy-auth-token', token)
.expect(302)
Expand Down