123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349 |
- const fs = require('fs')
- const express = require('express')
- // @ts-ignore
- const Grant = require('grant').express()
- const grantConfig = require('./config/grant')()
- const providerManager = require('./server/provider')
- const controllers = require('./server/controllers')
- const s3 = require('./server/controllers/s3')
- const url = require('./server/controllers/url')
- const SocketServer = require('ws').Server
- const emitter = require('./server/emitter')
- const merge = require('lodash.merge')
- const redis = require('./server/redis')
- const cookieParser = require('cookie-parser')
- const { jsonStringify, getURLBuilder } = require('./server/helpers/utils')
- const jobs = require('./server/jobs')
- const interceptor = require('express-interceptor')
- const logger = require('./server/logger')
- const { STORAGE_PREFIX } = require('./server/Uploader')
- const middlewares = require('./server/middlewares')
- const { shortenToken } = require('./server/Uploader')
- const { ProviderApiError, ProviderAuthError } = require('./server/provider/error')
- const ms = require('ms')
- const defaultOptions = {
- server: {
- protocol: 'http',
- path: ''
- },
- providerOptions: {
- s3: {
- acl: 'public-read',
- endpoint: 'https://{service}.{region}.amazonaws.com',
- conditions: [],
- useAccelerateEndpoint: false,
- getKey: (req, filename) => filename,
- expires: ms('5 minutes') / 1000
- }
- },
- debug: true
- }
- // make the errors available publicly for custom providers
- module.exports.errors = { ProviderApiError, ProviderAuthError }
- /**
- * Entry point into initializing the Companion app.
- *
- * @param {object} options
- */
- module.exports.app = (options = {}) => {
- validateConfig(options)
- options = merge({}, defaultOptions, options)
- const providers = providerManager.getDefaultProviders(options)
- providerManager.addProviderOptions(options, grantConfig)
- const customProviders = options.customProviders
- if (customProviders) {
- providerManager.addCustomProviders(customProviders, providers, grantConfig)
- }
- // mask provider secrets from log messages
- maskLogger(options)
- // create singleton redis client
- if (options.redisUrl) {
- redis.client(merge({ url: options.redisUrl }, options.redisOptions || {}))
- }
- emitter(options.multipleInstances && options.redisUrl)
- const app = express()
- app.use(cookieParser()) // server tokens are added to cookies
- app.use(interceptGrantErrorResponse)
- app.use(Grant(grantConfig))
- app.use((req, res, next) => {
- res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE')
- res.header(
- 'Access-Control-Allow-Headers',
- [
- 'uppy-auth-token',
- 'uppy-versions',
- res.get('Access-Control-Allow-Headers')
- ].join(',')
- )
- const exposedHeaders = [
- // exposed so it can be accessed for our custom uppy preflight
- 'Access-Control-Allow-Headers'
- ]
- if (options.sendSelfEndpoint) {
- // add it to the exposed headers.
- exposedHeaders.push('i-am')
- const { protocol } = options.server
- res.header('i-am', `${protocol}://${options.sendSelfEndpoint}`)
- }
- if (res.get('Access-Control-Expose-Headers')) {
- // if the header had been previously set, the values should be added too
- exposedHeaders.push(res.get('Access-Control-Expose-Headers'))
- }
- res.header('Access-Control-Expose-Headers', exposedHeaders.join(','))
- next()
- })
- // add uppy options to the request object so it can be accessed by subsequent handlers.
- app.use('*', getOptionsMiddleware(options))
- app.use('/s3', s3(options.providerOptions.s3))
- app.use('/url', url())
- app.get('/:providerName/connect', middlewares.hasSessionAndProvider, controllers.connect)
- app.get('/:providerName/redirect', middlewares.hasSessionAndProvider, controllers.redirect)
- app.get('/:providerName/callback', middlewares.hasSessionAndProvider, controllers.callback)
- app.post('/:providerName/deauthorization/callback', middlewares.hasSessionAndProvider, controllers.deauthorizationCallback)
- app.get('/:providerName/logout', middlewares.hasSessionAndProvider, middlewares.gentleVerifyToken, controllers.logout)
- app.get('/:providerName/send-token', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.sendToken)
- app.get('/:providerName/list/:id?', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.list)
- app.post('/:providerName/get/:id', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.get)
- app.get('/:providerName/thumbnail/:id', middlewares.hasSessionAndProvider, middlewares.cookieAuthToken, middlewares.verifyToken, controllers.thumbnail)
- app.param('providerName', providerManager.getProviderMiddleware(providers))
- if (app.get('env') !== 'test') {
- jobs.startCleanUpJob(options.filePath)
- }
- return app
- }
- /**
- * the socket is used to send progress events during an upload
- *
- * @param {object} server
- */
- module.exports.socket = (server) => {
- const wss = new SocketServer({ server })
- const redisClient = redis.client()
- // A new connection is usually created when an upload begins,
- // or when connection fails while an upload is on-going and,
- // client attempts to reconnect.
- wss.on('connection', (ws, req) => {
- // @ts-ignore
- const fullPath = req.url
- // the token identifies which ongoing upload's progress, the socket
- // connection wishes to listen to.
- const token = fullPath.replace(/^.*\/api\//, '')
- logger.info(`connection received from ${token}`, 'socket.connect')
- /**
- *
- * @param {{action: string, payload: object}} data
- */
- function sendProgress (data) {
- ws.send(jsonStringify(data), (err) => {
- if (err) logger.error(err, 'socket.progress.error', shortenToken(token))
- })
- }
- // if the redisClient is available, then we attempt to check the storage
- // if we have any already stored progress data on the upload.
- if (redisClient) {
- redisClient.get(`${STORAGE_PREFIX}:${token}`, (err, data) => {
- if (err) logger.error(err, 'socket.redis.error', shortenToken(token))
- if (data) {
- const dataObj = JSON.parse(data.toString())
- if (dataObj.action) sendProgress(dataObj)
- }
- })
- }
- emitter().emit(`connection:${token}`)
- emitter().on(token, sendProgress)
- ws.on('message', (jsonData) => {
- const data = JSON.parse(jsonData.toString())
- // whitelist triggered actions
- if (['pause', 'resume', 'cancel'].includes(data.action)) {
- emitter().emit(`${data.action}:${token}`)
- }
- })
- ws.on('close', () => {
- emitter().removeListener(token, sendProgress)
- })
- })
- }
- // intercepts grantJS' default response error when something goes
- // wrong during oauth process.
- const interceptGrantErrorResponse = interceptor((req, res) => {
- return {
- isInterceptable: () => {
- // match grant.js' callback url
- return /^\/connect\/\w+\/callback/.test(req.path)
- },
- intercept: (body, send) => {
- const unwantedBody = 'error=Grant%3A%20missing%20session%20or%20misconfigured%20provider'
- if (body === unwantedBody) {
- logger.error(`grant.js responded with error: ${body}`, 'grant.oauth.error', req.id)
- res.set('Content-Type', 'text/plain')
- const reqHint = req.id ? `Request ID: ${req.id}` : ''
- send([
- 'Companion was unable to complete the OAuth process :(',
- 'Error: User session is missing or the Provider was misconfigured',
- reqHint
- ].join('\n'))
- } else {
- send(body)
- }
- }
- }
- })
- /**
- *
- * @param {object} options
- */
- const getOptionsMiddleware = (options) => {
- let s3Client = null
- if (options.providerOptions.s3) {
- const S3 = require('aws-sdk/clients/s3')
- const AWS = require('aws-sdk')
- const s3ProviderOptions = options.providerOptions.s3
- if (s3ProviderOptions.accessKeyId || s3ProviderOptions.secretAccessKey) {
- 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`.')
- }
- const rawClientOptions = s3ProviderOptions.awsClientOptions
- if (rawClientOptions && (rawClientOptions.accessKeyId || rawClientOptions.secretAccessKey)) {
- 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.')
- }
- const s3ClientOptions = Object.assign({
- signatureVersion: 'v4',
- endpoint: s3ProviderOptions.endpoint,
- region: s3ProviderOptions.region,
- // backwards compat
- useAccelerateEndpoint: s3ProviderOptions.useAccelerateEndpoint
- }, rawClientOptions)
- // Use credentials to allow assumed roles to pass STS sessions in.
- // If the user doesn't specify key and secret, the default credentials (process-env)
- // will be used by S3 in calls below.
- if (s3ProviderOptions.key && s3ProviderOptions.secret && !s3ClientOptions.credentials) {
- s3ClientOptions.credentials = new AWS.Credentials(
- s3ProviderOptions.key,
- s3ProviderOptions.secret,
- s3ProviderOptions.sessionToken)
- }
- s3Client = new S3(s3ClientOptions)
- }
- /**
- * @param {object} req
- * @param {object} res
- * @param {function} next
- */
- const middleware = (req, res, next) => {
- const versionFromQuery = req.query.uppyVersions ? decodeURIComponent(req.query.uppyVersions) : null
- req.companion = {
- options,
- s3Client,
- authToken: req.header('uppy-auth-token') || req.query.uppyAuthToken,
- clientVersion: req.header('uppy-versions') || versionFromQuery || '1.0.0',
- buildURL: getURLBuilder(options)
- }
- logger.info(`uppy client version ${req.companion.clientVersion}`, 'companion.client.version')
- next()
- }
- return middleware
- }
- /**
- * Informs the logger about all provider secrets that should be masked
- * if they are found in a log message
- * @param {object} companionOptions
- */
- const maskLogger = (companionOptions) => {
- const secrets = []
- const { providerOptions, customProviders } = companionOptions
- Object.keys(providerOptions).forEach((provider) => {
- if (providerOptions[provider].secret) {
- secrets.push(providerOptions[provider].secret)
- }
- })
- if (customProviders) {
- Object.keys(customProviders).forEach((provider) => {
- if (customProviders[provider].config && customProviders[provider].config.secret) {
- secrets.push(customProviders[provider].config.secret)
- }
- })
- }
- logger.setMaskables(secrets)
- }
- /**
- * validates that the mandatory companion options are set.
- * If it is invalid, it will console an error of unset options and exits the process.
- * If it is valid, nothing happens.
- *
- * @param {object} companionOptions
- */
- const validateConfig = (companionOptions) => {
- const mandatoryOptions = ['secret', 'filePath', 'server.host']
- /** @type {string[]} */
- const unspecified = []
- mandatoryOptions.forEach((i) => {
- const value = i.split('.').reduce((prev, curr) => prev ? prev[curr] : undefined, companionOptions)
- if (!value) unspecified.push(`"${i}"`)
- })
- // vaidate that all required config is specified
- if (unspecified.length) {
- const messagePrefix = 'Please specify the following options to use companion:'
- throw new Error(`${messagePrefix}\n${unspecified.join(',\n')}`)
- }
- // validate that specified filePath is writeable/readable.
- try {
- // @ts-ignore
- fs.accessSync(`${companionOptions.filePath}`, fs.R_OK | fs.W_OK)
- } catch (err) {
- throw new Error(
- `No access to "${companionOptions.filePath}". Please ensure the directory exists and with read/write permissions.`
- )
- }
- const { providerOptions } = companionOptions
- if (providerOptions) {
- const deprecatedOptions = { microsoft: 'onedrive', google: 'drive' }
- Object.keys(deprecatedOptions).forEach((deprected) => {
- if (providerOptions[deprected]) {
- throw new Error(`The Provider option "${deprected}" is no longer supported. Please use the option "${deprecatedOptions[deprected]}" instead.`)
- }
- })
- }
- }
|