UNPKG

@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:

217 lines (192 loc) 6.7 kB
import { randomUUID } from 'node:crypto' import qs from 'node:querystring' import { URL } from 'node:url' import RedisStore from 'connect-redis' import express from 'express' import session from 'express-session' import helmet from 'helmet' import morgan from 'morgan' import * as companion from '../companion.js' import logger from '../server/logger.js' import * as redis from '../server/redis.js' import { buildHelpfulStartupMessage, generateSecret, getCompanionOptions, } from './helper.js' /** * Configures an Express app for running Companion standalone * * @returns {object} */ export default function server(inputCompanionOptions) { const companionOptions = getCompanionOptions(inputCompanionOptions) companion.setLoggerProcessName(companionOptions) if (!companionOptions.secret) companionOptions.secret = generateSecret('secret') if (!companionOptions.preAuthSecret) companionOptions.preAuthSecret = generateSecret('preAuthSecret') const app = express() const router = express.Router() if (companionOptions.server.path) { app.use(companionOptions.server.path, router) } else { app.use(router) } // 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 {Record<string, any>} rawQuery * @returns {{ * query: Record<string, any>, * censored: boolean * }} */ function censorQuery(rawQuery) { /** @type {Record<string, any>} */ 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 } } router.use((request, response, next) => { const headerName = 'X-Request-Id' const oldValue = request.get(headerName) response.set(headerName, oldValue ?? randomUUID()) next() }) // log server requests. router.use(morgan('combined')) morgan.token('url', (req) => { const { query, censored } = censorQuery(req.query) return censored ? `${req.path}?${qs.stringify(query)}` : req.originalUrl || req.url }) morgan.token('referrer', (req) => { const ref = req.headers.referer || req.headers.referrer if (typeof ref === 'string') { let parsed try { parsed = new URL(ref) } catch (_) { return 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 undefined }) // Use helmet to secure Express headers router.use(helmet.frameguard()) router.use(helmet.xssFilter()) router.use(helmet.noSniff()) router.use(helmet.ieNoOpen()) app.disable('x-powered-by') const sessionOptions = { secret: companionOptions.secret, resave: true, saveUninitialized: true, } const redisClient = redis.client(companionOptions) if (redisClient) { sessionOptions.store = new RedisStore({ client: redisClient, prefix: process.env.COMPANION_REDIS_EXPRESS_SESSION_PREFIX || 'companion-session:', }) } if (process.env.COMPANION_COOKIE_DOMAIN) { sessionOptions.cookie = { domain: process.env.COMPANION_COOKIE_DOMAIN, maxAge: 24 * 60 * 60 * 1000, // 1 day } } // Session is used for grant redirects, so that we don't need to expose secret tokens in URLs // See https://github.com/transloadit/uppy/pull/1668 // https://github.com/transloadit/uppy/issues/3538#issuecomment-1069232909 // https://github.com/simov/grant#callback-session router.use(session(sessionOptions)) // Routes if (process.env.COMPANION_HIDE_WELCOME !== 'true') { router.get('/', (req, res) => { res.setHeader('Content-Type', 'text/plain') res.send(buildHelpfulStartupMessage(companionOptions)) }) } // initialize companion const { app: companionApp } = companion.app(companionOptions) // add companion to server middleware router.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 ) { router.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() }, ) } app.use((req, res) => { return res.status(404).json({ message: 'Not Found' }) }) app.use((err, req, res, next) => { 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.name === 'URIError') { logger.error(err.message, 'root.error', req.id) } else { logger.error(err, 'root.error', req.id) } res .status(500) .json({ message: 'Something went wrong', requestId: req.id }) } else { logger.error(err, 'root.error', req.id) res .status(500) .json({ message: err.message, error: err, requestId: req.id }) } }) return { app, companionOptions } }