companion.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. const fs = require('fs')
  2. const express = require('express')
  3. const ms = require('ms')
  4. // @ts-ignore
  5. const Grant = require('grant').express()
  6. const merge = require('lodash.merge')
  7. const cookieParser = require('cookie-parser')
  8. const interceptor = require('express-interceptor')
  9. const { isURL } = require('validator')
  10. const uuid = require('uuid')
  11. const grantConfig = require('./config/grant')()
  12. const providerManager = require('./server/provider')
  13. const controllers = require('./server/controllers')
  14. const s3 = require('./server/controllers/s3')
  15. const getS3Client = require('./server/s3-client')
  16. const url = require('./server/controllers/url')
  17. const emitter = require('./server/emitter')
  18. const redis = require('./server/redis')
  19. const { getURLBuilder } = require('./server/helpers/utils')
  20. const jobs = require('./server/jobs')
  21. const logger = require('./server/logger')
  22. const middlewares = require('./server/middlewares')
  23. const { ProviderApiError, ProviderAuthError } = require('./server/provider/error')
  24. const { getCredentialsOverrideMiddleware } = require('./server/provider/credentials')
  25. // @ts-ignore
  26. const { version } = require('../package.json')
  27. const defaultOptions = {
  28. server: {
  29. protocol: 'http',
  30. path: '',
  31. },
  32. providerOptions: {
  33. s3: {
  34. acl: 'public-read',
  35. endpoint: 'https://{service}.{region}.amazonaws.com',
  36. conditions: [],
  37. useAccelerateEndpoint: false,
  38. getKey: (req, filename) => filename,
  39. expires: ms('5 minutes') / 1000,
  40. },
  41. },
  42. debug: true,
  43. logClientVersion: true,
  44. periodicPingUrls: [],
  45. streamingUpload: false,
  46. }
  47. // make the errors available publicly for custom providers
  48. module.exports.errors = { ProviderApiError, ProviderAuthError }
  49. module.exports.socket = require('./server/socket')
  50. /**
  51. * Entry point into initializing the Companion app.
  52. *
  53. * @param {object} options
  54. * @returns {import('express').Express}
  55. */
  56. module.exports.app = (options = {}) => {
  57. validateConfig(options)
  58. options = merge({}, defaultOptions, options)
  59. const providers = providerManager.getDefaultProviders()
  60. const searchProviders = providerManager.getSearchProviders()
  61. providerManager.addProviderOptions(options, grantConfig)
  62. const { customProviders } = options
  63. if (customProviders) {
  64. providerManager.addCustomProviders(customProviders, providers, grantConfig)
  65. }
  66. // mask provider secrets from log messages
  67. maskLogger(options)
  68. // create singleton redis client
  69. if (options.redisUrl) {
  70. redis.client(merge({ url: options.redisUrl }, options.redisOptions || {}))
  71. }
  72. emitter(options.multipleInstances && options.redisUrl, options.redisPubSubScope)
  73. const app = express()
  74. if (options.metrics) {
  75. app.use(middlewares.metrics({ path: options.server.path }))
  76. // backward compatibility
  77. // TODO remove in next major semver
  78. if (options.server.path) {
  79. const buildUrl = getURLBuilder(options)
  80. app.get('/metrics', (req, res) => {
  81. process.emitWarning('/metrics is deprecated when specifying a path to companion')
  82. const metricsUrl = buildUrl('/metrics', true)
  83. res.redirect(metricsUrl)
  84. })
  85. }
  86. }
  87. app.use(cookieParser()) // server tokens are added to cookies
  88. app.use(interceptGrantErrorResponse)
  89. // override provider credentials at request time
  90. app.use('/connect/:authProvider/:override?', getCredentialsOverrideMiddleware(providers, options))
  91. app.use(Grant(grantConfig))
  92. app.use((req, res, next) => {
  93. if (options.sendSelfEndpoint) {
  94. const { protocol } = options.server
  95. res.header('i-am', `${protocol}://${options.sendSelfEndpoint}`)
  96. }
  97. next()
  98. })
  99. app.use(middlewares.cors(options))
  100. // add uppy options to the request object so it can be accessed by subsequent handlers.
  101. app.use('*', getOptionsMiddleware(options))
  102. app.use('/s3', s3(options.providerOptions.s3))
  103. app.use('/url', url())
  104. app.post('/:providerName/preauth', middlewares.hasSessionAndProvider, controllers.preauth)
  105. app.get('/:providerName/connect', middlewares.hasSessionAndProvider, controllers.connect)
  106. app.get('/:providerName/redirect', middlewares.hasSessionAndProvider, controllers.redirect)
  107. app.get('/:providerName/callback', middlewares.hasSessionAndProvider, controllers.callback)
  108. app.post('/:providerName/deauthorization/callback', middlewares.hasSessionAndProvider, controllers.deauthorizationCallback)
  109. app.get('/:providerName/logout', middlewares.hasSessionAndProvider, middlewares.gentleVerifyToken, controllers.logout)
  110. app.get('/:providerName/send-token', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.sendToken)
  111. app.get('/:providerName/list/:id?', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.list)
  112. app.post('/:providerName/get/:id', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.get)
  113. app.get('/:providerName/thumbnail/:id', middlewares.hasSessionAndProvider, middlewares.cookieAuthToken, middlewares.verifyToken, controllers.thumbnail)
  114. // @ts-ignore Type instantiation is excessively deep and possibly infinite.
  115. app.get('/search/:searchProviderName/list', middlewares.hasSearchQuery, middlewares.loadSearchProviderToken, controllers.list)
  116. app.post('/search/:searchProviderName/get/:id', middlewares.loadSearchProviderToken, controllers.get)
  117. app.param('providerName', providerManager.getProviderMiddleware(providers, true))
  118. app.param('searchProviderName', providerManager.getProviderMiddleware(searchProviders))
  119. if (app.get('env') !== 'test') {
  120. jobs.startCleanUpJob(options.filePath)
  121. }
  122. const processId = uuid.v4()
  123. jobs.startPeriodicPingJob({
  124. urls: options.periodicPingUrls,
  125. interval: options.periodicPingInterval,
  126. count: options.periodicPingCount,
  127. staticPayload: options.periodicPingStaticPayload,
  128. version,
  129. processId,
  130. })
  131. return app
  132. }
  133. // intercepts grantJS' default response error when something goes
  134. // wrong during oauth process.
  135. const interceptGrantErrorResponse = interceptor((req, res) => {
  136. return {
  137. isInterceptable: () => {
  138. // match grant.js' callback url
  139. return /^\/connect\/\w+\/callback/.test(req.path)
  140. },
  141. intercept: (body, send) => {
  142. const unwantedBody = 'error=Grant%3A%20missing%20session%20or%20misconfigured%20provider'
  143. if (body === unwantedBody) {
  144. logger.error(`grant.js responded with error: ${body}`, 'grant.oauth.error', req.id)
  145. res.set('Content-Type', 'text/plain')
  146. const reqHint = req.id ? `Request ID: ${req.id}` : ''
  147. send([
  148. 'Companion was unable to complete the OAuth process :(',
  149. 'Error: User session is missing or the Provider was misconfigured',
  150. reqHint,
  151. ].join('\n'))
  152. } else {
  153. send(body)
  154. }
  155. },
  156. }
  157. })
  158. /**
  159. *
  160. * @param {object} options
  161. */
  162. const getOptionsMiddleware = (options) => {
  163. /**
  164. * @param {object} req
  165. * @param {object} res
  166. * @param {Function} next
  167. */
  168. const middleware = (req, res, next) => {
  169. const versionFromQuery = req.query.uppyVersions ? decodeURIComponent(req.query.uppyVersions) : null
  170. req.companion = {
  171. options,
  172. s3Client: getS3Client(options),
  173. authToken: req.header('uppy-auth-token') || req.query.uppyAuthToken,
  174. clientVersion: req.header('uppy-versions') || versionFromQuery || '1.0.0',
  175. buildURL: getURLBuilder(options),
  176. }
  177. if (options.logClientVersion) {
  178. logger.info(`uppy client version ${req.companion.clientVersion}`, 'companion.client.version')
  179. }
  180. next()
  181. }
  182. return middleware
  183. }
  184. /**
  185. * Informs the logger about all provider secrets that should be masked
  186. * if they are found in a log message
  187. *
  188. * @param {object} companionOptions
  189. */
  190. const maskLogger = (companionOptions) => {
  191. const secrets = []
  192. const { providerOptions, customProviders } = companionOptions
  193. Object.keys(providerOptions).forEach((provider) => {
  194. if (providerOptions[provider].secret) {
  195. secrets.push(providerOptions[provider].secret)
  196. }
  197. })
  198. if (customProviders) {
  199. Object.keys(customProviders).forEach((provider) => {
  200. if (customProviders[provider].config && customProviders[provider].config.secret) {
  201. secrets.push(customProviders[provider].config.secret)
  202. }
  203. })
  204. }
  205. logger.setMaskables(secrets)
  206. }
  207. /**
  208. * validates that the mandatory companion options are set.
  209. * If it is invalid, it will console an error of unset options and exits the process.
  210. * If it is valid, nothing happens.
  211. *
  212. * @param {object} companionOptions
  213. */
  214. const validateConfig = (companionOptions) => {
  215. const mandatoryOptions = ['secret', 'filePath', 'server.host']
  216. /** @type {string[]} */
  217. const unspecified = []
  218. mandatoryOptions.forEach((i) => {
  219. const value = i.split('.').reduce((prev, curr) => (prev ? prev[curr] : undefined), companionOptions)
  220. if (!value) unspecified.push(`"${i}"`)
  221. })
  222. // vaidate that all required config is specified
  223. if (unspecified.length) {
  224. const messagePrefix = 'Please specify the following options to use companion:'
  225. throw new Error(`${messagePrefix}\n${unspecified.join(',\n')}`)
  226. }
  227. // validate that specified filePath is writeable/readable.
  228. try {
  229. // @ts-ignore
  230. fs.accessSync(`${companionOptions.filePath}`, fs.R_OK | fs.W_OK) // eslint-disable-line no-bitwise
  231. } catch (err) {
  232. throw new Error(
  233. `No access to "${companionOptions.filePath}". Please ensure the directory exists and with read/write permissions.`,
  234. )
  235. }
  236. const { providerOptions, periodicPingUrls } = companionOptions
  237. if (providerOptions) {
  238. const deprecatedOptions = { microsoft: 'onedrive', google: 'drive' }
  239. Object.keys(deprecatedOptions).forEach((deprected) => {
  240. if (providerOptions[deprected]) {
  241. throw new Error(`The Provider option "${deprected}" is no longer supported. Please use the option "${deprecatedOptions[deprected]}" instead.`)
  242. }
  243. })
  244. }
  245. if (companionOptions.uploadUrls == null || companionOptions.uploadUrls.length === 0) {
  246. logger.warn('Running without uploadUrls specified is a security risk if running in production', 'startup.uploadUrls')
  247. }
  248. if (periodicPingUrls != null && (
  249. !Array.isArray(periodicPingUrls)
  250. || periodicPingUrls.some((url2) => !isURL(url2, { protocols: ['http', 'https'], require_protocol: true, require_tld: false }))
  251. )) {
  252. throw new TypeError('Invalid periodicPingUrls')
  253. }
  254. }