index.js 6.6 KB

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