index.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. const express = require('express')
  2. const qs = require('querystring')
  3. const helmet = require('helmet')
  4. const morgan = require('morgan')
  5. const bodyParser = require('body-parser')
  6. const { URL } = require('url')
  7. const merge = require('lodash.merge')
  8. const session = require('express-session')
  9. const addRequestId = require('express-request-id')()
  10. const logger = require('../server/logger')
  11. const redis = require('../server/redis')
  12. const companion = require('../companion')
  13. const helper = require('./helper')
  14. const middlewares = require('../server/middlewares')
  15. const { getURLBuilder } = require('../server/helpers/utils')
  16. /**
  17. * Configures an Express app for running Companion standalone
  18. *
  19. * @returns {object}
  20. */
  21. module.exports = function server (inputCompanionOptions = {}) {
  22. let corsOrigins
  23. if (process.env.COMPANION_CLIENT_ORIGINS) {
  24. corsOrigins = process.env.COMPANION_CLIENT_ORIGINS
  25. .split(',')
  26. .map((url) => (helper.hasProtocol(url) ? url : `${process.env.COMPANION_PROTOCOL || 'http'}://${url}`))
  27. } else if (process.env.COMPANION_CLIENT_ORIGINS_REGEX) {
  28. corsOrigins = new RegExp(process.env.COMPANION_CLIENT_ORIGINS_REGEX)
  29. }
  30. const moreCompanionOptions = { ...inputCompanionOptions, corsOrigins }
  31. const companionOptions = helper.getCompanionOptions(moreCompanionOptions)
  32. const app = express()
  33. const router = express.Router()
  34. if (companionOptions.server.path) {
  35. app.use(companionOptions.server.path, router)
  36. } else {
  37. app.use(router)
  38. }
  39. // Query string keys whose values should not end up in logging output.
  40. const sensitiveKeys = new Set(['access_token', 'uppyAuthToken'])
  41. /**
  42. * Obscure the contents of query string keys listed in `sensitiveKeys`.
  43. *
  44. * Returns a copy of the object with unknown types removed and sensitive values replaced by ***.
  45. *
  46. * The input type is more broad that it needs to be, this way typescript can help us guarantee that we're dealing with all
  47. * possible inputs :)
  48. *
  49. * @param {Record<string, any>} rawQuery
  50. * @returns {{
  51. * query: Record<string, any>,
  52. * censored: boolean
  53. * }}
  54. */
  55. function censorQuery (rawQuery) {
  56. /** @type {Record<string, any>} */
  57. const query = {}
  58. let censored = false
  59. Object.keys(rawQuery).forEach((key) => {
  60. if (typeof rawQuery[key] !== 'string') {
  61. return
  62. }
  63. if (sensitiveKeys.has(key)) {
  64. // replace logged access token
  65. query[key] = '********'
  66. censored = true
  67. } else {
  68. query[key] = rawQuery[key]
  69. }
  70. })
  71. return { query, censored }
  72. }
  73. router.use(addRequestId)
  74. // log server requests.
  75. router.use(morgan('combined'))
  76. morgan.token('url', (req) => {
  77. const { query, censored } = censorQuery(req.query)
  78. return censored ? `${req.path}?${qs.stringify(query)}` : req.originalUrl || req.url
  79. })
  80. morgan.token('referrer', (req) => {
  81. const ref = req.headers.referer || req.headers.referrer
  82. if (typeof ref === 'string') {
  83. let parsed
  84. try {
  85. parsed = new URL(ref)
  86. } catch (_) {
  87. return ref
  88. }
  89. const rawQuery = qs.parse(parsed.search.replace('?', ''))
  90. const { query, censored } = censorQuery(rawQuery)
  91. return censored ? `${parsed.href.split('?')[0]}?${qs.stringify(query)}` : parsed.href
  92. }
  93. })
  94. // for server metrics tracking.
  95. // make app metrics available at '/metrics'.
  96. // TODO for the next major version: use instead companion option "metrics": true and remove this code
  97. // eslint-disable-next-line max-len
  98. // See discussion: https://github.com/transloadit/uppy/pull/2854/files/64be97205e4012818abfcc8b0b8b7fe09de91729#diff-68f5e3eb307c1c9d1fd02224fd7888e2f74718744e1b6e35d929fcab1cc50ed1
  99. if (process.env.COMPANION_HIDE_METRICS !== 'true') {
  100. router.use(middlewares.metrics({ path: companionOptions.server.path }))
  101. // backward compatibility
  102. // TODO remove in next major semver
  103. if (companionOptions.server.path) {
  104. const buildUrl = getURLBuilder(companionOptions)
  105. app.get('/metrics', (req, res) => {
  106. process.emitWarning('/metrics is deprecated when specifying a path to companion')
  107. const metricsUrl = buildUrl('/metrics', true)
  108. res.redirect(metricsUrl)
  109. })
  110. }
  111. }
  112. router.use(bodyParser.json())
  113. router.use(bodyParser.urlencoded({ extended: false }))
  114. // Use helmet to secure Express headers
  115. router.use(helmet.frameguard())
  116. router.use(helmet.xssFilter())
  117. router.use(helmet.noSniff())
  118. router.use(helmet.ieNoOpen())
  119. app.disable('x-powered-by')
  120. const sessionOptions = {
  121. secret: companionOptions.secret,
  122. resave: true,
  123. saveUninitialized: true,
  124. }
  125. if (companionOptions.redisUrl) {
  126. const RedisStore = require('connect-redis')(session)
  127. const redisClient = redis.client(
  128. merge({ url: companionOptions.redisUrl }, companionOptions.redisOptions),
  129. )
  130. sessionOptions.store = new RedisStore({ client: redisClient })
  131. }
  132. if (process.env.COMPANION_COOKIE_DOMAIN) {
  133. sessionOptions.cookie = {
  134. domain: process.env.COMPANION_COOKIE_DOMAIN,
  135. maxAge: 24 * 60 * 60 * 1000, // 1 day
  136. }
  137. }
  138. // Session is used for grant redirects, so that we don't need to expose secret tokens in URLs
  139. // See https://github.com/transloadit/uppy/pull/1668
  140. // https://github.com/transloadit/uppy/issues/3538#issuecomment-1069232909
  141. // https://github.com/simov/grant#callback-session
  142. router.use(session(sessionOptions))
  143. // Routes
  144. if (process.env.COMPANION_HIDE_WELCOME !== 'true') {
  145. router.get('/', (req, res) => {
  146. res.setHeader('Content-Type', 'text/plain')
  147. res.send(helper.buildHelpfulStartupMessage(companionOptions))
  148. })
  149. }
  150. // initialize companion
  151. const companionApp = companion.app(companionOptions)
  152. // add companion to server middleware
  153. router.use(companionApp)
  154. // WARNING: This route is added in order to validate your app with OneDrive.
  155. // Only set COMPANION_ONEDRIVE_DOMAIN_VALIDATION if you are sure that you are setting the
  156. // correct value for COMPANION_ONEDRIVE_KEY (i.e application ID). If there's a slightest possiblilty
  157. // that you might have mixed the values for COMPANION_ONEDRIVE_KEY and COMPANION_ONEDRIVE_SECRET,
  158. // please DO NOT set any value for COMPANION_ONEDRIVE_DOMAIN_VALIDATION
  159. if (process.env.COMPANION_ONEDRIVE_DOMAIN_VALIDATION === 'true' && process.env.COMPANION_ONEDRIVE_KEY) {
  160. router.get('/.well-known/microsoft-identity-association.json', (req, res) => {
  161. const content = JSON.stringify({
  162. associatedApplications: [
  163. { applicationId: process.env.COMPANION_ONEDRIVE_KEY },
  164. ],
  165. })
  166. res.header('Content-Length', `${Buffer.byteLength(content, 'utf8')}`)
  167. // use writeHead to prevent 'charset' from being appended
  168. // eslint-disable-next-line max-len
  169. // https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-configure-publisher-domain#to-select-a-verified-domain
  170. res.writeHead(200, { 'Content-Type': 'application/json' })
  171. res.write(content)
  172. res.end()
  173. })
  174. }
  175. app.use((req, res) => {
  176. return res.status(404).json({ message: 'Not Found' })
  177. })
  178. // @ts-ignore
  179. app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
  180. if (app.get('env') === 'production') {
  181. // if the error is a URIError from the requested URL we only log the error message
  182. // to avoid uneccessary error alerts
  183. if (err.status === 400 && err instanceof URIError) {
  184. logger.error(err.message, 'root.error', req.id)
  185. } else {
  186. logger.error(err, 'root.error', req.id)
  187. }
  188. res.status(err.status || 500).json({ message: 'Something went wrong', requestId: req.id })
  189. } else {
  190. logger.error(err, 'root.error', req.id)
  191. res.status(err.status || 500).json({ message: err.message, error: err, requestId: req.id })
  192. }
  193. })
  194. return { app, companionOptions }
  195. }