@uppy/companion
Version:
OAuth helper and remote fetcher for Uppy's (https://uppy.io) extensible file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Dropbox and Google Drive, S3 and more :dog:
322 lines (283 loc) • 9.24 kB
JavaScript
import { randomUUID } from 'node:crypto'
import cookieParser from 'cookie-parser'
import express from 'express'
import interceptor from 'express-interceptor'
import grant from 'grant'
import merge from 'lodash/merge.js'
import packageJson from '../package.json' with { type: 'json' }
import {
defaultOptions,
getMaskableSecrets,
validateConfig,
} from './config/companion.js'
import grantConfigFn from './config/grant.js'
import googlePicker from './server/controllers/googlePicker.js'
import * as controllers from './server/controllers/index.js'
import s3 from './server/controllers/s3.js'
import searchController from './server/controllers/search.js'
import url from './server/controllers/url.js'
import createEmitter from './server/emitter/index.js'
import { getURLBuilder } from './server/helpers/utils.js'
import * as jobs from './server/jobs.js'
import logger from './server/logger.js'
import * as middlewares from './server/middlewares.js'
import { getCredentialsOverrideMiddleware } from './server/provider/credentials.js'
import {
ProviderApiError,
ProviderAuthError,
ProviderUserError,
} from './server/provider/error.js'
import * as providerManager from './server/provider/index.js'
import { isOAuthProvider } from './server/provider/Provider.js'
import * as redis from './server/redis.js'
import socket from './server/socket.js'
export { socket }
const grantConfig = grantConfigFn()
export function setLoggerProcessName({ loggerProcessName }) {
if (loggerProcessName != null) logger.setProcessName(loggerProcessName)
}
// intercepts grantJS' default response error when something goes
// wrong during oauth process.
const interceptGrantErrorResponse = interceptor((req, res) => {
return {
isInterceptable: () => {
// match grant.js' callback url
return /^\/connect\/\w+\/callback/.test(req.path)
},
intercept: (body, send) => {
const unwantedBody =
'error=Grant%3A%20missing%20session%20or%20misconfigured%20provider'
if (body === unwantedBody) {
logger.error(
`grant.js responded with error: ${body}`,
'grant.oauth.error',
req.id,
)
res.set('Content-Type', 'text/plain')
const reqHint = req.id ? `Request ID: ${req.id}` : ''
send(
[
'Companion was unable to complete the OAuth process :(',
'Error: User session is missing or the Provider was misconfigured',
reqHint,
].join('\n'),
)
} else {
send(body)
}
},
}
})
// make the errors available publicly for custom providers
export const errors = {
ProviderApiError,
ProviderUserError,
ProviderAuthError,
}
/**
* Entry point into initializing the Companion app.
*
* @param {object} optionsArg
* @returns {{ app: import('express').Express, emitter: any }}}
*/
export function app(optionsArg = {}) {
setLoggerProcessName(optionsArg)
validateConfig(optionsArg)
const options = merge({}, defaultOptions, optionsArg)
const providers = providerManager.getDefaultProviders()
const { customProviders } = options
if (customProviders) {
providerManager.addCustomProviders(customProviders, providers, grantConfig)
}
const getOauthProvider = (providerName) =>
providers[providerName]?.oauthProvider
providerManager.addProviderOptions(options, grantConfig, getOauthProvider)
// mask provider secrets from log messages
logger.setMaskables(getMaskableSecrets(options))
// create singleton redis client if corresponding options are set
const redisClient = redis.client(options)
const emitter = createEmitter(redisClient, options.redisPubSubScope)
const app = express()
if (options.metrics) {
app.use(middlewares.metrics({ path: options.server.path }))
}
app.use(cookieParser()) // server tokens are added to cookies
app.use(interceptGrantErrorResponse)
// override provider credentials at request time
// Making `POST` request to the `/connect/:provider/:override?` route requires a form body parser middleware:
// See https://github.com/simov/grant#dynamic-http
app.use(
'/connect/:oauthProvider/:override?',
express.urlencoded({ extended: false }),
getCredentialsOverrideMiddleware(providers, options),
)
app.use(grant.default.express(grantConfig))
app.use((req, res, next) => {
if (options.sendSelfEndpoint) {
const { protocol } = options.server
res.header('i-am', `${protocol}://${options.sendSelfEndpoint}`)
}
next()
})
app.use(middlewares.cors(options))
// add uppy options to the request object so it can be accessed by subsequent handlers.
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,
)
app.get(
'/:providerName/redirect',
middlewares.hasSessionAndProvider,
middlewares.hasOAuthProvider,
controllers.redirect,
)
app.get(
'/:providerName/callback',
middlewares.hasSessionAndProvider,
middlewares.hasOAuthProvider,
controllers.callback,
)
app.post(
'/:providerName/refresh-token',
middlewares.hasSessionAndProvider,
middlewares.hasOAuthProvider,
middlewares.verifyToken,
controllers.refreshToken,
)
app.post(
'/:providerName/deauthorization/callback',
express.json(),
middlewares.hasSessionAndProvider,
middlewares.hasBody,
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.post(
'/:providerName/simple-auth',
express.json(),
middlewares.hasSessionAndProvider,
middlewares.hasBody,
middlewares.hasSimpleAuthProvider,
controllers.simpleAuth,
)
app.get(
'/:providerName/list/:id?',
middlewares.hasSessionAndProvider,
middlewares.verifyToken,
controllers.list,
)
app.get(
'/:providerName/search',
middlewares.hasSessionAndProvider,
middlewares.verifyToken,
searchController,
)
// backwards compat:
app.get(
'/search/:providerName/list',
middlewares.hasSessionAndProvider,
middlewares.verifyToken,
controllers.list,
)
app.post(
'/:providerName/get/:id',
express.json(),
middlewares.hasSessionAndProvider,
middlewares.verifyToken,
controllers.get,
)
// backwards compat:
app.post(
'/search/:providerName/get/:id',
express.json(),
middlewares.hasSessionAndProvider,
middlewares.verifyToken,
controllers.get,
)
app.get(
'/:providerName/thumbnail/:id',
middlewares.hasSessionAndProvider,
middlewares.hasOAuthProvider,
middlewares.cookieAuthToken,
middlewares.verifyToken,
controllers.thumbnail,
)
// Used for testing dynamic credentials only, normally this would run on a separate server.
if (options.testDynamicOauthCredentials) {
app.post('/:providerName/test-dynamic-oauth-credentials', (req, res) => {
if (req.query.secret !== options.testDynamicOauthCredentialsSecret)
throw new Error('Invalid secret')
const { providerName } = req.params
// for simplicity, we just return the normal credentials for the provider, but in a real-world scenario,
// we would query based on parameters
const { key, secret } = options.providerOptions[providerName] ?? {
__proto__: null,
}
function getTransloaditGateway() {
const oauthProvider = getOauthProvider(providerName)
if (!isOAuthProvider(oauthProvider)) return undefined
return getURLBuilder(options)('', true)
}
const response = {
credentials: {
key,
secret,
transloadit_gateway: getTransloaditGateway(),
origins: ['http://localhost:5173'],
},
}
logger.info(
`Returning dynamic OAuth2 credentials for ${providerName}`,
JSON.stringify(response),
)
res.send(response)
})
}
app.param(
'providerName',
providerManager.getProviderMiddleware(providers, grantConfig),
)
if (app.get('env') !== 'test') {
jobs.startCleanUpJob(options.filePath)
}
const processId = randomUUID()
jobs.startPeriodicPingJob({
urls: options.periodicPingUrls,
interval: options.periodicPingInterval,
count: options.periodicPingCount,
staticPayload: options.periodicPingStaticPayload,
version: packageJson.version,
processId,
})
return { app, emitter }
}