UNPKG

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