uppy/packages/@uppy/companion/src/standalone/index.ts

228 lines
7.5 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import qs from 'node:querystring'
import { URL } from 'node:url'
import RedisStore from 'connect-redis'
import type { NextFunction, Request, Response } from 'express'
import express from 'express'
import type { SessionOptions } from 'express-session'
import session from 'express-session'
import helmet from 'helmet'
import morgan from 'morgan'
import * as companion from '../companion.js'
import type { CompanionInitOptions } from '../schemas/index.js'
import { isRecord, toError } from '../server/helpers/type-guards.js'
import logger from '../server/logger.js'
import * as redis from '../server/redis.js'
import {
buildHelpfulStartupMessage,
generateSecret,
getCompanionOptions,
} from './helper.js'
export default function server(inputCompanionOptions?: CompanionInitOptions) {
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()
const serverPath = companionOptions.server.path
if (serverPath != null) {
app.use(serverPath, 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 rawQuery
*/
function censorQuery(rawQuery: Record<string, unknown>): {
query: Record<string, string>
censored: boolean
} {
const query: Record<string, string> = {}
let censored = false
Object.keys(rawQuery).forEach((key) => {
const value = rawQuery[key]
if (typeof value !== 'string') {
return
}
if (sensitiveKeys.has(key)) {
// replace logged access token
query[key] = '********'
censored = true
} else {
query[key] = value
}
})
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<Request>('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: URL
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 '-'
})
// 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: SessionOptions = {
secret: companionOptions.secret,
resave: true,
saveUninitialized: true,
}
const redisClient = redis.client({
redisUrl: companionOptions.redisUrl,
redisOptions: companionOptions.redisOptions,
})
if (redisClient) {
sessionOptions.store = new RedisStore({
client: redisClient,
prefix:
process.env['COMPANION_REDIS_EXPRESS_SESSION_PREFIX'] ||
'companion-session:',
})
}
const cookieOptions: SessionOptions['cookie'] = {
// AFAIK sessions are only used for the initial oauth2 dance, and not after that.
// Therefore 10 minutes should be plenty of time for the user to complete the oauth2 dance.
// we might want to see if we can make grant work without sessions, so we can remove sessions:
// https://github.com/transloadit/uppy/issues/4394
// https://github.com/transloadit/api2/pull/8345#pullrequestreview-4552806932
maxAge: 10 * 60 * 1000,
}
if (process.env['COMPANION_COOKIE_DOMAIN']) {
cookieOptions.domain = process.env['COMPANION_COOKIE_DOMAIN']
}
sessionOptions.cookie = cookieOptions
// 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, emitter } = 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: unknown, req: Request, res: Response, _next: NextFunction) => {
const error = toError(err)
const status = isRecord(err) ? err['status'] : undefined
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 (status === 400 && error.name === 'URIError') {
logger.error(error.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: error.message, error: err, requestId: req.id })
}
})
return { app, companionOptions, emitter }
}