diff --git a/packages/@uppy/companion/src/standalone/helper.js b/packages/@uppy/companion/src/standalone/helper.js index 3f74b35943..e2a9488322 100644 --- a/packages/@uppy/companion/src/standalone/helper.js +++ b/packages/@uppy/companion/src/standalone/helper.js @@ -13,8 +13,8 @@ const { version } = require('../../package.json') * * @returns {object} */ -exports.getCompanionOptions = () => { - return merge({}, getConfigFromEnv(), getConfigFromFile()) +exports.getCompanionOptions = (options = {}) => { + return merge({}, getConfigFromEnv(), getConfigFromFile(), options) } /** diff --git a/packages/@uppy/companion/src/standalone/index.js b/packages/@uppy/companion/src/standalone/index.js index 386d7ec86f..68224dff49 100644 --- a/packages/@uppy/companion/src/standalone/index.js +++ b/packages/@uppy/companion/src/standalone/index.js @@ -16,209 +16,219 @@ const helper = require('./helper') // @ts-ignore const { version } = require('../../package.json') -const app = express() - -// for server metrics tracking. -const metricsMiddleware = promBundle({ includeMethod: true }) -const promClient = metricsMiddleware.promClient -const collectDefaultMetrics = promClient.collectDefaultMetrics -const promInterval = collectDefaultMetrics({ register: promClient.register, timeout: 5000 }) - -// Add version as a prometheus gauge -const versionGauge = new promClient.Gauge({ name: 'companion_version', help: 'npm version as an integer' }) -// @ts-ignore -const numberVersion = version.replace(/\D/g, '') * 1 -versionGauge.set(numberVersion) - -if (app.get('env') !== 'test') { - clearInterval(promInterval) -} - -// Query string keys whose values should not end up in logging output. -const sensitiveKeys = new Set(['access_token', 'uppyAuthToken']) - /** - * Obscure the contents of query string keys listed in `sensitiveKeys`. - * - * Returns a copy of the object with unknown types removed and sensitive values replaced by ***. - * - * The input type is more broad that it needs to be, this way typescript can help us guarantee that we're dealing with all possible inputs :) + * Configures an Express app for running Companion standalone * - * @param {{ [key: string]: any }} rawQuery - * @returns {{ - * query: { [key: string]: string }, - * censored: boolean - * }} + * @returns {object} */ -function censorQuery (rawQuery) { - /** @type {{ [key: string]: string }} */ - const query = {} - let censored = false - Object.keys(rawQuery).forEach((key) => { - if (typeof rawQuery[key] !== 'string') { - return - } - if (sensitiveKeys.has(key)) { - // replace logged access token - query[key] = '********' - censored = true - } else { - query[key] = rawQuery[key] +function server (moreCompanionOptions = {}) { + const app = express() + + // for server metrics tracking. + const metricsMiddleware = promBundle({ includeMethod: true }) + const promClient = metricsMiddleware.promClient + const collectDefaultMetrics = promClient.collectDefaultMetrics + const promInterval = collectDefaultMetrics({ register: promClient.register, timeout: 5000 }) + + // Add version as a prometheus gauge + const versionGauge = new promClient.Gauge({ name: 'companion_version', help: 'npm version as an integer' }) + // @ts-ignore + const numberVersion = version.replace(/\D/g, '') * 1 + versionGauge.set(numberVersion) + + if (app.get('env') !== 'test') { + clearInterval(promInterval) + } + + // Query string keys whose values should not end up in logging output. + const sensitiveKeys = new Set(['access_token', 'uppyAuthToken']) + + /** + * Obscure the contents of query string keys listed in `sensitiveKeys`. + * + * Returns a copy of the object with unknown types removed and sensitive values replaced by ***. + * + * The input type is more broad that it needs to be, this way typescript can help us guarantee that we're dealing with all possible inputs :) + * + * @param {{ [key: string]: any }} rawQuery + * @returns {{ + * query: { [key: string]: string }, + * censored: boolean + * }} + */ + function censorQuery (rawQuery) { + /** @type {{ [key: string]: string }} */ + const query = {} + let censored = false + Object.keys(rawQuery).forEach((key) => { + if (typeof rawQuery[key] !== 'string') { + return + } + if (sensitiveKeys.has(key)) { + // replace logged access token + query[key] = '********' + censored = true + } else { + query[key] = rawQuery[key] + } + }) + return { query, censored } + } + + app.use(addRequestId) + // log server requests. + app.use(morgan('combined')) + morgan.token('url', (req, res) => { + const { query, censored } = censorQuery(req.query) + return censored ? `${req.path}?${qs.stringify(query)}` : req.originalUrl || req.url + }) + + morgan.token('referrer', (req, res) => { + const ref = req.headers.referer || req.headers.referrer + if (typeof ref === 'string') { + const parsed = new URL(ref) + const rawQuery = qs.parse(parsed.search.replace('?', '')) + const { query, censored } = censorQuery(rawQuery) + return censored ? `${parsed.href.split('?')[0]}?${qs.stringify(query)}` : parsed.href } }) - return { query, censored } -} -app.use(addRequestId) -// log server requests. -app.use(morgan('combined')) -morgan.token('url', (req, res) => { - const { query, censored } = censorQuery(req.query) - return censored ? `${req.path}?${qs.stringify(query)}` : req.originalUrl || req.url -}) - -morgan.token('referrer', (req, res) => { - const ref = req.headers.referer || req.headers.referrer - if (typeof ref === 'string') { - const parsed = new URL(ref) - const rawQuery = qs.parse(parsed.search.replace('?', '')) - const { query, censored } = censorQuery(rawQuery) - return censored ? `${parsed.href.split('?')[0]}?${qs.stringify(query)}` : parsed.href + // make app metrics available at '/metrics'. + app.use(metricsMiddleware) + + app.use(bodyParser.json()) + app.use(bodyParser.urlencoded({ extended: false })) + + // Use helmet to secure Express headers + app.use(helmet.frameguard()) + app.use(helmet.xssFilter()) + app.use(helmet.noSniff()) + app.use(helmet.ieNoOpen()) + app.disable('x-powered-by') + + const companionOptions = helper.getCompanionOptions(moreCompanionOptions) + const sessionOptions = { + secret: companionOptions.secret, + resave: true, + saveUninitialized: true } -}) - -// make app metrics available at '/metrics'. -app.use(metricsMiddleware) - -app.use(bodyParser.json()) -app.use(bodyParser.urlencoded({ extended: false })) - -// Use helmet to secure Express headers -app.use(helmet.frameguard()) -app.use(helmet.xssFilter()) -app.use(helmet.noSniff()) -app.use(helmet.ieNoOpen()) -app.disable('x-powered-by') - -const companionOptions = helper.getCompanionOptions() -const sessionOptions = { - secret: companionOptions.secret, - resave: true, - saveUninitialized: true -} -if (companionOptions.redisUrl) { - const RedisStore = require('connect-redis')(session) - const redisClient = redis.client( - merge({ url: companionOptions.redisUrl }, companionOptions.redisOptions) - ) - sessionOptions.store = new RedisStore({ client: redisClient }) -} + if (companionOptions.redisUrl) { + const RedisStore = require('connect-redis')(session) + const redisClient = redis.client( + merge({ url: companionOptions.redisUrl }, companionOptions.redisOptions) + ) + sessionOptions.store = new RedisStore({ client: redisClient }) + } -if (process.env.COMPANION_COOKIE_DOMAIN) { - sessionOptions.cookie = { - domain: process.env.COMPANION_COOKIE_DOMAIN, - maxAge: 24 * 60 * 60 * 1000 // 1 day + if (process.env.COMPANION_COOKIE_DOMAIN) { + sessionOptions.cookie = { + domain: process.env.COMPANION_COOKIE_DOMAIN, + maxAge: 24 * 60 * 60 * 1000 // 1 day + } } -} -app.use(session(sessionOptions)) + app.use(session(sessionOptions)) + + app.use((req, res, next) => { + const protocol = process.env.COMPANION_PROTOCOL || 'http' + + // if endpoint urls are specified, then we only allow those endpoints + // otherwise, we allow any client url to access companion. + // here we also enforce that only the protocol allowed by companion is used. + if (process.env.COMPANION_CLIENT_ORIGINS) { + const whitelist = process.env.COMPANION_CLIENT_ORIGINS + .split(',') + .map((url) => helper.hasProtocol(url) ? url : `${protocol}://${url}`) + + // @ts-ignore + if (req.headers.origin && whitelist.indexOf(req.headers.origin) > -1) { + res.setHeader('Access-Control-Allow-Origin', req.headers.origin) + // only allow credentials when origin is whitelisted + res.setHeader('Access-Control-Allow-Credentials', 'true') + } + } else { + res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*') + } -app.use((req, res, next) => { - const protocol = process.env.COMPANION_PROTOCOL || 'http' + res.setHeader( + 'Access-Control-Allow-Methods', + 'GET, POST, OPTIONS, PUT, PATCH, DELETE' + ) + res.setHeader( + 'Access-Control-Allow-Headers', + 'Authorization, Origin, Content-Type, Accept' + ) + next() + }) - // if endpoint urls are specified, then we only allow those endpoints - // otherwise, we allow any client url to access companion. - // here we also enforce that only the protocol allowed by companion is used. - if (process.env.COMPANION_CLIENT_ORIGINS) { - const whitelist = process.env.COMPANION_CLIENT_ORIGINS - .split(',') - .map((url) => helper.hasProtocol(url) ? url : `${protocol}://${url}`) + // Routes + app.get('/', (req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.send(helper.buildHelpfulStartupMessage(companionOptions)) + }) - // @ts-ignore - if (req.headers.origin && whitelist.indexOf(req.headers.origin) > -1) { - res.setHeader('Access-Control-Allow-Origin', req.headers.origin) - // only allow credentials when origin is whitelisted - res.setHeader('Access-Control-Allow-Credentials', 'true') - } - } else { - res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*') + let companionApp + try { + // initialize companion + companionApp = companion.app(companionOptions) + } catch (error) { + console.error('\x1b[31m', error.message, '\x1b[0m') + process.exit(1) } - res.setHeader( - 'Access-Control-Allow-Methods', - 'GET, POST, OPTIONS, PUT, PATCH, DELETE' - ) - res.setHeader( - 'Access-Control-Allow-Headers', - 'Authorization, Origin, Content-Type, Accept' - ) - next() -}) - -// Routes -app.get('/', (req, res) => { - res.setHeader('Content-Type', 'text/plain') - res.send(helper.buildHelpfulStartupMessage(companionOptions)) -}) - -let companionApp -try { - // initialize companion - companionApp = companion.app(companionOptions) -} catch (error) { - console.error('\x1b[31m', error.message, '\x1b[0m') - process.exit(1) -} - -// add companion to server middlewear -if (process.env.COMPANION_PATH) { - app.use(process.env.COMPANION_PATH, companionApp) -} else { - app.use(companionApp) -} + // add companion to server middlewear + if (process.env.COMPANION_PATH) { + app.use(process.env.COMPANION_PATH, companionApp) + } else { + app.use(companionApp) + } -// WARNING: This route is added in order to validate your app with OneDrive. -// Only set COMPANION_ONEDRIVE_DOMAIN_VALIDATION if you are sure that you are setting the -// correct value for COMPANION_ONEDRIVE_KEY (i.e application ID). If there's a slightest possiblilty -// that you might have mixed the values for COMPANION_ONEDRIVE_KEY and COMPANION_ONEDRIVE_SECRET, -// please DO NOT set any value for COMPANION_ONEDRIVE_DOMAIN_VALIDATION -if (process.env.COMPANION_ONEDRIVE_DOMAIN_VALIDATION === 'true' && process.env.COMPANION_ONEDRIVE_KEY) { - app.get('/.well-known/microsoft-identity-association.json', (req, res) => { - const content = JSON.stringify({ - associatedApplications: [ - { applicationId: process.env.COMPANION_ONEDRIVE_KEY } - ] + // WARNING: This route is added in order to validate your app with OneDrive. + // Only set COMPANION_ONEDRIVE_DOMAIN_VALIDATION if you are sure that you are setting the + // correct value for COMPANION_ONEDRIVE_KEY (i.e application ID). If there's a slightest possiblilty + // that you might have mixed the values for COMPANION_ONEDRIVE_KEY and COMPANION_ONEDRIVE_SECRET, + // please DO NOT set any value for COMPANION_ONEDRIVE_DOMAIN_VALIDATION + if (process.env.COMPANION_ONEDRIVE_DOMAIN_VALIDATION === 'true' && process.env.COMPANION_ONEDRIVE_KEY) { + app.get('/.well-known/microsoft-identity-association.json', (req, res) => { + const content = JSON.stringify({ + associatedApplications: [ + { applicationId: process.env.COMPANION_ONEDRIVE_KEY } + ] + }) + res.header('Content-Length', `${Buffer.byteLength(content, 'utf8')}`) + // use writeHead to prevent 'charset' from being appended + // https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-configure-publisher-domain#to-select-a-verified-domain + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.write(content) + res.end() }) - res.header('Content-Length', `${Buffer.byteLength(content, 'utf8')}`) - // use writeHead to prevent 'charset' from being appended - // https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-configure-publisher-domain#to-select-a-verified-domain - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.write(content) - res.end() - }) -} + } -app.use((req, res, next) => { - return res.status(404).json({ message: 'Not Found' }) -}) + app.use((req, res, next) => { + return res.status(404).json({ message: 'Not Found' }) + }) -// @ts-ignore -app.use((err, req, res, next) => { - const logStackTrace = true - if (app.get('env') === 'production') { - // if the error is a URIError from the requested URL we only log the error message - // to avoid uneccessary error alerts - if (err.status === 400 && err instanceof URIError) { - logger.error(err.message, 'root.error', req.id) + // @ts-ignore + app.use((err, req, res, next) => { + const logStackTrace = true + if (app.get('env') === 'production') { + // if the error is a URIError from the requested URL we only log the error message + // to avoid uneccessary error alerts + if (err.status === 400 && err instanceof URIError) { + logger.error(err.message, 'root.error', req.id) + } else { + logger.error(err, 'root.error', req.id, logStackTrace) + } + res.status(err.status || 500).json({ message: 'Something went wrong', requestId: req.id }) } else { logger.error(err, 'root.error', req.id, logStackTrace) + res.status(err.status || 500).json({ message: err.message, error: err, requestId: req.id }) } - res.status(err.status || 500).json({ message: 'Something went wrong', requestId: req.id }) - } else { - logger.error(err, 'root.error', req.id, logStackTrace) - res.status(err.status || 500).json({ message: err.message, error: err, requestId: req.id }) - } -}) + }) + + return { app, companionOptions } +} -module.exports = { app, companionOptions } +const { app, companionOptions } = server() +module.exports = { app, companionOptions, server }