mirror of
https://github.com/transloadit/uppy.git
synced 2026-07-17 16:50:22 +00:00
228 lines
7.5 KiB
TypeScript
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 }
|
|
}
|