index.js 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. const express = require('express')
  2. const qs = require('querystring')
  3. const companion = require('../companion')
  4. const helmet = require('helmet')
  5. const morgan = require('morgan')
  6. const bodyParser = require('body-parser')
  7. const redis = require('../server/redis')
  8. const logger = require('../server/logger')
  9. const { URL } = require('url')
  10. const merge = require('lodash.merge')
  11. // @ts-ignore
  12. const promBundle = require('express-prom-bundle')
  13. const session = require('express-session')
  14. const addRequestId = require('express-request-id')()
  15. const helper = require('./helper')
  16. // @ts-ignore
  17. const { version } = require('../../package.json')
  18. /**
  19. * Configures an Express app for running Companion standalone
  20. *
  21. * @returns {object}
  22. */
  23. function server (moreCompanionOptions = {}) {
  24. const app = express()
  25. // for server metrics tracking.
  26. let metricsMiddleware
  27. if (process.env.COMPANION_HIDE_METRICS !== 'true') {
  28. metricsMiddleware = promBundle({ includeMethod: true })
  29. // @ts-ignore Not in the typings, but it does exist
  30. const promClient = metricsMiddleware.promClient
  31. const collectDefaultMetrics = promClient.collectDefaultMetrics
  32. collectDefaultMetrics({ register: promClient.register })
  33. // Add version as a prometheus gauge
  34. const versionGauge = new promClient.Gauge({ name: 'companion_version', help: 'npm version as an integer' })
  35. // @ts-ignore
  36. const numberVersion = version.replace(/\D/g, '') * 1
  37. versionGauge.set(numberVersion)
  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 possible inputs :)
  47. *
  48. * @param {{ [key: string]: any }} rawQuery
  49. * @returns {{
  50. * query: { [key: string]: string },
  51. * censored: boolean
  52. * }}
  53. */
  54. function censorQuery (rawQuery) {
  55. /** @type {{ [key: string]: string }} */
  56. const query = {}
  57. let censored = false
  58. Object.keys(rawQuery).forEach((key) => {
  59. if (typeof rawQuery[key] !== 'string') {
  60. return
  61. }
  62. if (sensitiveKeys.has(key)) {
  63. // replace logged access token
  64. query[key] = '********'
  65. censored = true
  66. } else {
  67. query[key] = rawQuery[key]
  68. }
  69. })
  70. return { query, censored }
  71. }
  72. app.use(addRequestId)
  73. // log server requests.
  74. app.use(morgan('combined'))
  75. morgan.token('url', (req, res) => {
  76. const { query, censored } = censorQuery(req.query)
  77. return censored ? `${req.path}?${qs.stringify(query)}` : req.originalUrl || req.url
  78. })
  79. morgan.token('referrer', (req, res) => {
  80. const ref = req.headers.referer || req.headers.referrer
  81. if (typeof ref === 'string') {
  82. const parsed = new URL(ref)
  83. const rawQuery = qs.parse(parsed.search.replace('?', ''))
  84. const { query, censored } = censorQuery(rawQuery)
  85. return censored ? `${parsed.href.split('?')[0]}?${qs.stringify(query)}` : parsed.href
  86. }
  87. })
  88. // make app metrics available at '/metrics'.
  89. if (process.env.COMPANION_HIDE_METRICS !== 'true') {
  90. app.use(metricsMiddleware)
  91. }
  92. app.use(bodyParser.json())
  93. app.use(bodyParser.urlencoded({ extended: false }))
  94. // Use helmet to secure Express headers
  95. app.use(helmet.frameguard())
  96. app.use(helmet.xssFilter())
  97. app.use(helmet.noSniff())
  98. app.use(helmet.ieNoOpen())
  99. app.disable('x-powered-by')
  100. const companionOptions = helper.getCompanionOptions(moreCompanionOptions)
  101. const sessionOptions = {
  102. secret: companionOptions.secret,
  103. resave: true,
  104. saveUninitialized: true
  105. }
  106. if (companionOptions.redisUrl) {
  107. const RedisStore = require('connect-redis')(session)
  108. const redisClient = redis.client(
  109. merge({ url: companionOptions.redisUrl }, companionOptions.redisOptions)
  110. )
  111. sessionOptions.store = new RedisStore({ client: redisClient })
  112. }
  113. if (process.env.COMPANION_COOKIE_DOMAIN) {
  114. sessionOptions.cookie = {
  115. domain: process.env.COMPANION_COOKIE_DOMAIN,
  116. maxAge: 24 * 60 * 60 * 1000 // 1 day
  117. }
  118. }
  119. app.use(session(sessionOptions))
  120. app.use((req, res, next) => {
  121. const protocol = process.env.COMPANION_PROTOCOL || 'http'
  122. // if endpoint urls are specified, then we only allow those endpoints
  123. // otherwise, we allow any client url to access companion.
  124. // here we also enforce that only the protocol allowed by companion is used.
  125. if (process.env.COMPANION_CLIENT_ORIGINS) {
  126. const whitelist = process.env.COMPANION_CLIENT_ORIGINS
  127. .split(',')
  128. .map((url) => helper.hasProtocol(url) ? url : `${protocol}://${url}`)
  129. // @ts-ignore
  130. if (req.headers.origin && whitelist.indexOf(req.headers.origin) > -1) {
  131. res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
  132. // only allow credentials when origin is whitelisted
  133. res.setHeader('Access-Control-Allow-Credentials', 'true')
  134. }
  135. } else {
  136. res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*')
  137. }
  138. res.setHeader(
  139. 'Access-Control-Allow-Methods',
  140. 'GET, POST, OPTIONS, PUT, PATCH, DELETE'
  141. )
  142. res.setHeader(
  143. 'Access-Control-Allow-Headers',
  144. 'Authorization, Origin, Content-Type, Accept'
  145. )
  146. next()
  147. })
  148. // Routes
  149. if (process.env.COMPANION_HIDE_WELCOME !== 'true') {
  150. app.get('/', (req, res) => {
  151. res.setHeader('Content-Type', 'text/plain')
  152. res.send(helper.buildHelpfulStartupMessage(companionOptions))
  153. })
  154. }
  155. let companionApp
  156. try {
  157. // initialize companion
  158. companionApp = companion.app(companionOptions)
  159. } catch (error) {
  160. console.error('\x1b[31m', error.message, '\x1b[0m')
  161. process.exit(1)
  162. }
  163. // add companion to server middlewear
  164. if (process.env.COMPANION_PATH) {
  165. app.use(process.env.COMPANION_PATH, companionApp)
  166. } else {
  167. app.use(companionApp)
  168. }
  169. // WARNING: This route is added in order to validate your app with OneDrive.
  170. // Only set COMPANION_ONEDRIVE_DOMAIN_VALIDATION if you are sure that you are setting the
  171. // correct value for COMPANION_ONEDRIVE_KEY (i.e application ID). If there's a slightest possiblilty
  172. // that you might have mixed the values for COMPANION_ONEDRIVE_KEY and COMPANION_ONEDRIVE_SECRET,
  173. // please DO NOT set any value for COMPANION_ONEDRIVE_DOMAIN_VALIDATION
  174. if (process.env.COMPANION_ONEDRIVE_DOMAIN_VALIDATION === 'true' && process.env.COMPANION_ONEDRIVE_KEY) {
  175. app.get('/.well-known/microsoft-identity-association.json', (req, res) => {
  176. const content = JSON.stringify({
  177. associatedApplications: [
  178. { applicationId: process.env.COMPANION_ONEDRIVE_KEY }
  179. ]
  180. })
  181. res.header('Content-Length', `${Buffer.byteLength(content, 'utf8')}`)
  182. // use writeHead to prevent 'charset' from being appended
  183. // https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-configure-publisher-domain#to-select-a-verified-domain
  184. res.writeHead(200, { 'Content-Type': 'application/json' })
  185. res.write(content)
  186. res.end()
  187. })
  188. }
  189. app.use((req, res, next) => {
  190. return res.status(404).json({ message: 'Not Found' })
  191. })
  192. // @ts-ignore
  193. app.use((err, req, res, next) => {
  194. const logStackTrace = true
  195. if (app.get('env') === 'production') {
  196. // if the error is a URIError from the requested URL we only log the error message
  197. // to avoid uneccessary error alerts
  198. if (err.status === 400 && err instanceof URIError) {
  199. logger.error(err.message, 'root.error', req.id)
  200. } else {
  201. logger.error(err, 'root.error', req.id, logStackTrace)
  202. }
  203. res.status(err.status || 500).json({ message: 'Something went wrong', requestId: req.id })
  204. } else {
  205. logger.error(err, 'root.error', req.id, logStackTrace)
  206. res.status(err.status || 500).json({ message: err.message, error: err, requestId: req.id })
  207. }
  208. })
  209. return { app, companionOptions }
  210. }
  211. const { app, companionOptions } = server()
  212. module.exports = { app, companionOptions, server }