companion.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. const fs = require('fs')
  2. const express = require('express')
  3. // @ts-ignore
  4. const Grant = require('grant').express()
  5. const grantConfig = require('./config/grant')()
  6. const providerManager = require('./server/provider')
  7. const controllers = require('./server/controllers')
  8. const s3 = require('./server/controllers/s3')
  9. const url = require('./server/controllers/url')
  10. const SocketServer = require('ws').Server
  11. const emitter = require('./server/emitter')
  12. const merge = require('lodash.merge')
  13. const redis = require('./server/redis')
  14. const cookieParser = require('cookie-parser')
  15. const { jsonStringify, getURLBuilder } = require('./server/helpers/utils')
  16. const jobs = require('./server/jobs')
  17. const interceptor = require('express-interceptor')
  18. const logger = require('./server/logger')
  19. const { STORAGE_PREFIX } = require('./server/Uploader')
  20. const middlewares = require('./server/middlewares')
  21. const { shortenToken } = require('./server/Uploader')
  22. const { ProviderApiError, ProviderAuthError } = require('./server/provider/error')
  23. const ms = require('ms')
  24. const defaultOptions = {
  25. server: {
  26. protocol: 'http',
  27. path: ''
  28. },
  29. providerOptions: {
  30. s3: {
  31. acl: 'public-read',
  32. endpoint: 'https://{service}.{region}.amazonaws.com',
  33. conditions: [],
  34. useAccelerateEndpoint: false,
  35. getKey: (req, filename) => filename,
  36. expires: ms('5 minutes') / 1000
  37. }
  38. },
  39. debug: true
  40. }
  41. // make the errors available publicly for custom providers
  42. module.exports.errors = { ProviderApiError, ProviderAuthError }
  43. /**
  44. * Entry point into initializing the Companion app.
  45. *
  46. * @param {object} options
  47. */
  48. module.exports.app = (options = {}) => {
  49. validateConfig(options)
  50. options = merge({}, defaultOptions, options)
  51. const providers = providerManager.getDefaultProviders(options)
  52. providerManager.addProviderOptions(options, grantConfig)
  53. const customProviders = options.customProviders
  54. if (customProviders) {
  55. providerManager.addCustomProviders(customProviders, providers, grantConfig)
  56. }
  57. // mask provider secrets from log messages
  58. maskLogger(options)
  59. // create singleton redis client
  60. if (options.redisUrl) {
  61. redis.client(merge({ url: options.redisUrl }, options.redisOptions || {}))
  62. }
  63. emitter(options.multipleInstances && options.redisUrl)
  64. const app = express()
  65. app.use(cookieParser()) // server tokens are added to cookies
  66. app.use(interceptGrantErrorResponse)
  67. app.use(Grant(grantConfig))
  68. app.use((req, res, next) => {
  69. res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE')
  70. res.header(
  71. 'Access-Control-Allow-Headers',
  72. [
  73. 'uppy-auth-token',
  74. 'uppy-versions',
  75. res.get('Access-Control-Allow-Headers')
  76. ].join(',')
  77. )
  78. const exposedHeaders = [
  79. // exposed so it can be accessed for our custom uppy preflight
  80. 'Access-Control-Allow-Headers'
  81. ]
  82. if (options.sendSelfEndpoint) {
  83. // add it to the exposed headers.
  84. exposedHeaders.push('i-am')
  85. const { protocol } = options.server
  86. res.header('i-am', `${protocol}://${options.sendSelfEndpoint}`)
  87. }
  88. if (res.get('Access-Control-Expose-Headers')) {
  89. // if the header had been previously set, the values should be added too
  90. exposedHeaders.push(res.get('Access-Control-Expose-Headers'))
  91. }
  92. res.header('Access-Control-Expose-Headers', exposedHeaders.join(','))
  93. next()
  94. })
  95. // add uppy options to the request object so it can be accessed by subsequent handlers.
  96. app.use('*', getOptionsMiddleware(options))
  97. app.use('/s3', s3(options.providerOptions.s3))
  98. app.use('/url', url())
  99. app.get('/:providerName/connect', middlewares.hasSessionAndProvider, controllers.connect)
  100. app.get('/:providerName/redirect', middlewares.hasSessionAndProvider, controllers.redirect)
  101. app.get('/:providerName/callback', middlewares.hasSessionAndProvider, controllers.callback)
  102. app.post('/:providerName/deauthorization/callback', middlewares.hasSessionAndProvider, controllers.deauthorizationCallback)
  103. app.get('/:providerName/logout', middlewares.hasSessionAndProvider, middlewares.gentleVerifyToken, controllers.logout)
  104. app.get('/:providerName/send-token', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.sendToken)
  105. app.get('/:providerName/list/:id?', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.list)
  106. app.post('/:providerName/get/:id', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.get)
  107. app.get('/:providerName/thumbnail/:id', middlewares.hasSessionAndProvider, middlewares.cookieAuthToken, middlewares.verifyToken, controllers.thumbnail)
  108. app.param('providerName', providerManager.getProviderMiddleware(providers))
  109. if (app.get('env') !== 'test') {
  110. jobs.startCleanUpJob(options.filePath)
  111. }
  112. return app
  113. }
  114. /**
  115. * the socket is used to send progress events during an upload
  116. *
  117. * @param {object} server
  118. */
  119. module.exports.socket = (server) => {
  120. const wss = new SocketServer({ server })
  121. const redisClient = redis.client()
  122. // A new connection is usually created when an upload begins,
  123. // or when connection fails while an upload is on-going and,
  124. // client attempts to reconnect.
  125. wss.on('connection', (ws, req) => {
  126. // @ts-ignore
  127. const fullPath = req.url
  128. // the token identifies which ongoing upload's progress, the socket
  129. // connection wishes to listen to.
  130. const token = fullPath.replace(/^.*\/api\//, '')
  131. logger.info(`connection received from ${token}`, 'socket.connect')
  132. /**
  133. *
  134. * @param {{action: string, payload: object}} data
  135. */
  136. function sendProgress (data) {
  137. ws.send(jsonStringify(data), (err) => {
  138. if (err) logger.error(err, 'socket.progress.error', shortenToken(token))
  139. })
  140. }
  141. // if the redisClient is available, then we attempt to check the storage
  142. // if we have any already stored progress data on the upload.
  143. if (redisClient) {
  144. redisClient.get(`${STORAGE_PREFIX}:${token}`, (err, data) => {
  145. if (err) logger.error(err, 'socket.redis.error', shortenToken(token))
  146. if (data) {
  147. const dataObj = JSON.parse(data.toString())
  148. if (dataObj.action) sendProgress(dataObj)
  149. }
  150. })
  151. }
  152. emitter().emit(`connection:${token}`)
  153. emitter().on(token, sendProgress)
  154. ws.on('message', (jsonData) => {
  155. const data = JSON.parse(jsonData.toString())
  156. // whitelist triggered actions
  157. if (['pause', 'resume', 'cancel'].includes(data.action)) {
  158. emitter().emit(`${data.action}:${token}`)
  159. }
  160. })
  161. ws.on('close', () => {
  162. emitter().removeListener(token, sendProgress)
  163. })
  164. })
  165. }
  166. // intercepts grantJS' default response error when something goes
  167. // wrong during oauth process.
  168. const interceptGrantErrorResponse = interceptor((req, res) => {
  169. return {
  170. isInterceptable: () => {
  171. // match grant.js' callback url
  172. return /^\/connect\/\w+\/callback/.test(req.path)
  173. },
  174. intercept: (body, send) => {
  175. const unwantedBody = 'error=Grant%3A%20missing%20session%20or%20misconfigured%20provider'
  176. if (body === unwantedBody) {
  177. logger.error(`grant.js responded with error: ${body}`, 'grant.oauth.error', req.id)
  178. res.set('Content-Type', 'text/plain')
  179. const reqHint = req.id ? `Request ID: ${req.id}` : ''
  180. send([
  181. 'Companion was unable to complete the OAuth process :(',
  182. 'Error: User session is missing or the Provider was misconfigured',
  183. reqHint
  184. ].join('\n'))
  185. } else {
  186. send(body)
  187. }
  188. }
  189. }
  190. })
  191. /**
  192. *
  193. * @param {object} options
  194. */
  195. const getOptionsMiddleware = (options) => {
  196. let s3Client = null
  197. if (options.providerOptions.s3) {
  198. const S3 = require('aws-sdk/clients/s3')
  199. const AWS = require('aws-sdk')
  200. const s3ProviderOptions = options.providerOptions.s3
  201. if (s3ProviderOptions.accessKeyId || s3ProviderOptions.secretAccessKey) {
  202. throw new Error('Found `providerOptions.s3.accessKeyId` or `providerOptions.s3.secretAccessKey` configuration, but Companion requires `key` and `secret` option names instead. Please use the `key` property instead of `accessKeyId` and the `secret` property instead of `secretAccessKey`.')
  203. }
  204. const rawClientOptions = s3ProviderOptions.awsClientOptions
  205. if (rawClientOptions && (rawClientOptions.accessKeyId || rawClientOptions.secretAccessKey)) {
  206. throw new Error('Found unsupported `providerOptions.s3.awsClientOptions.accessKeyId` or `providerOptions.s3.awsClientOptions.secretAccessKey` configuration. Please use the `providerOptions.s3.key` and `providerOptions.s3.secret` options instead.')
  207. }
  208. const s3ClientOptions = Object.assign({
  209. signatureVersion: 'v4',
  210. endpoint: s3ProviderOptions.endpoint,
  211. region: s3ProviderOptions.region,
  212. // backwards compat
  213. useAccelerateEndpoint: s3ProviderOptions.useAccelerateEndpoint
  214. }, rawClientOptions)
  215. // Use credentials to allow assumed roles to pass STS sessions in.
  216. // If the user doesn't specify key and secret, the default credentials (process-env)
  217. // will be used by S3 in calls below.
  218. if (s3ProviderOptions.key && s3ProviderOptions.secret && !s3ClientOptions.credentials) {
  219. s3ClientOptions.credentials = new AWS.Credentials(
  220. s3ProviderOptions.key,
  221. s3ProviderOptions.secret,
  222. s3ProviderOptions.sessionToken)
  223. }
  224. s3Client = new S3(s3ClientOptions)
  225. }
  226. /**
  227. * @param {object} req
  228. * @param {object} res
  229. * @param {function} next
  230. */
  231. const middleware = (req, res, next) => {
  232. const versionFromQuery = req.query.uppyVersions ? decodeURIComponent(req.query.uppyVersions) : null
  233. req.companion = {
  234. options,
  235. s3Client,
  236. authToken: req.header('uppy-auth-token') || req.query.uppyAuthToken,
  237. clientVersion: req.header('uppy-versions') || versionFromQuery || '1.0.0',
  238. buildURL: getURLBuilder(options)
  239. }
  240. logger.info(`uppy client version ${req.companion.clientVersion}`, 'companion.client.version')
  241. next()
  242. }
  243. return middleware
  244. }
  245. /**
  246. * Informs the logger about all provider secrets that should be masked
  247. * if they are found in a log message
  248. * @param {object} companionOptions
  249. */
  250. const maskLogger = (companionOptions) => {
  251. const secrets = []
  252. const { providerOptions, customProviders } = companionOptions
  253. Object.keys(providerOptions).forEach((provider) => {
  254. if (providerOptions[provider].secret) {
  255. secrets.push(providerOptions[provider].secret)
  256. }
  257. })
  258. if (customProviders) {
  259. Object.keys(customProviders).forEach((provider) => {
  260. if (customProviders[provider].config && customProviders[provider].config.secret) {
  261. secrets.push(customProviders[provider].config.secret)
  262. }
  263. })
  264. }
  265. logger.setMaskables(secrets)
  266. }
  267. /**
  268. * validates that the mandatory companion options are set.
  269. * If it is invalid, it will console an error of unset options and exits the process.
  270. * If it is valid, nothing happens.
  271. *
  272. * @param {object} companionOptions
  273. */
  274. const validateConfig = (companionOptions) => {
  275. const mandatoryOptions = ['secret', 'filePath', 'server.host']
  276. /** @type {string[]} */
  277. const unspecified = []
  278. mandatoryOptions.forEach((i) => {
  279. const value = i.split('.').reduce((prev, curr) => prev ? prev[curr] : undefined, companionOptions)
  280. if (!value) unspecified.push(`"${i}"`)
  281. })
  282. // vaidate that all required config is specified
  283. if (unspecified.length) {
  284. const messagePrefix = 'Please specify the following options to use companion:'
  285. throw new Error(`${messagePrefix}\n${unspecified.join(',\n')}`)
  286. }
  287. // validate that specified filePath is writeable/readable.
  288. try {
  289. // @ts-ignore
  290. fs.accessSync(`${companionOptions.filePath}`, fs.R_OK | fs.W_OK)
  291. } catch (err) {
  292. throw new Error(
  293. `No access to "${companionOptions.filePath}". Please ensure the directory exists and with read/write permissions.`
  294. )
  295. }
  296. const { providerOptions } = companionOptions
  297. if (providerOptions) {
  298. const deprecatedOptions = { microsoft: 'onedrive', google: 'drive' }
  299. Object.keys(deprecatedOptions).forEach((deprected) => {
  300. if (providerOptions[deprected]) {
  301. throw new Error(`The Provider option "${deprected}" is no longer supported. Please use the option "${deprecatedOptions[deprected]}" instead.`)
  302. }
  303. })
  304. }
  305. }