packages/fxa-payments-server/server/lib/server.js (262 lines of code) (raw):
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
module.exports = () => {
const path = require('path');
const fs = require('fs');
const { Container } = require('typedi');
// setup version first for the rest of the modules
const log = require('./logging/log');
const logger = log('server.main');
const version = require('./version');
const config = require('../config');
logger.info(`source set to: ${version.source}`);
logger.info(`version set to: ${version.version}`);
logger.info(`commit hash set to: ${version.commit}`);
const express = require('express');
const helmet = require('helmet');
const noCache = require('nocache');
const Sentry = require('@sentry/node');
const serveStatic = require('serve-static');
const bodyParser = require('body-parser');
const csp = require('../lib/csp');
const cspRulesBlocking = require('../lib/csp/blocking')(config);
const cspRulesReportOnly = require('../lib/csp/report-only')(config);
const { cors, routing } = require('fxa-shared/express').express();
const { v4: uuid } = require('uuid');
const NOOP = () => {};
const StatsD = require('hot-shots');
const statsdConfig = config.get('statsd');
const statsd = statsdConfig.enabled
? new StatsD({
...statsdConfig,
errorHandler: (err) => {
// eslint-disable-next-line no-use-before-define
logger.error('statsd.error', err);
},
})
: {
timing: NOOP,
increment: NOOP,
};
Container.set(StatsD, statsd);
const routes = require('./routes')(statsd);
const app = express();
// Minimal feature flag support from server config
// TODO: implement better feature flag support - i.e. with types, from redis
const FEATURE_FLAGS = config.get('featureFlags') || {};
// Each of these config values (e.g., 'servers.content') will be exposed as the given
// variable to the client/browser (via fxa-config)
const CLIENT_CONFIG = {
env: config.get('env'),
googleAnalytics: {
enabled: config.get('googleAnalytics.enabled'),
measurementId: config.get('googleAnalytics.measurementId'),
supportedProductIds: config.get('googleAnalytics.supportedProductIds'),
debugMode: config.get('googleAnalytics.debugMode'),
},
legalDocLinks: {
privacyNotice: config.get('legalDocLinks.privacyNotice'),
termsOfService: config.get('legalDocLinks.termsOfService'),
},
productRedirectURLs: config.get('productRedirectURLs'),
sentry: {
dsn: config.get('sentry.dsn'),
env: config.get('sentry.env'),
sampleRate: config.get('sentry.sampleRate'),
serverName: config.get('sentry.serverName'),
clientName: config.get('sentry.clientName'),
},
servers: {
auth: {
url: config.get('servers.auth.url'),
},
content: {
url: config.get('servers.content.url'),
},
oauth: {
url: config.get('servers.oauth.url'),
clientId: config.get('servers.oauth.clientId'),
},
profile: {
url: config.get('servers.profile.url'),
},
},
paypal: {
apiUrl: config.get('paypal.apiUrl'),
clientId: config.get('paypal.clientId'),
scriptUrl: config.get('paypal.scriptUrl'),
},
stripe: {
apiKey: config.get('stripe.apiKey'),
},
version: version.version,
};
// This is a list of all the paths that should resolve to index.html:
const INDEX_ROUTES = [
'/',
'/subscriptions',
'/checkout/:productId',
'/products/:productId',
];
app.disable('x-powered-by');
const sentryConfig = config.get('sentry');
const hstsEnabled = config.get('hstsEnabled');
if (hstsEnabled) {
app.use(
helmet.hsts({
force: true,
includeSubDomains: true,
maxAge: config.get('hstsMaxAge'),
})
);
}
app.use(
// Side effect - Adds default_fxa and dev_fxa to express.logger formats
require('./logging/route-logging')(),
helmet.frameguard({
action: 'deny',
}),
helmet.xssFilter(),
helmet.noSniff(),
require('./no-robots'),
bodyParser.text({
type: 'text/plain',
}),
bodyParser.json({
// the 3 entries:
// json file types,
// all json content-types
// csp reports
type: ['json', '*/json', 'application/csp-report'],
})
);
if (config.get('csp.enabled')) {
app.use(function (req, res, next) {
// Generate nonce for CSP to allow paypal inline script.
res.paypalCspNonce = uuid();
if (config.get('googleAnalytics.enabled')) {
// Generate nonce for CSP to allow GA inline script.
res.gaCspNonce = uuid();
}
next();
});
// Add nonce for paypal's inline script.
cspRulesBlocking.directives.scriptSrc.push((req, res) => {
return `'nonce-${res.paypalCspNonce}'`;
});
if (config.get('googleAnalytics.enabled')) {
// Add nonce for GA's inline script.
cspRulesBlocking.directives.scriptSrc.push((req, res) => {
return `'nonce-${res.gaCspNonce}'`;
});
}
app.use(csp({ rules: cspRulesBlocking }));
}
if (config.get('csp.reportOnlyEnabled')) {
// There has to be more than a `reportUri`
// to enable reportOnly CSP.
if (Object.keys(cspRulesReportOnly.directives).length > 1) {
app.use(csp({ rules: cspRulesReportOnly }));
}
}
if (isCorsRequired()) {
// JS, CSS and web font resources served from a CDN
// will be ignored unless CORS headers are present.
const corsOptions = {
origin: config.get('listen.publicUrl'),
};
app.route(/\.(js|css|woff|woff2|eot|ttf)$/).get(cors(corsOptions));
}
const routeHelpers = routing(app, logger);
routes.forEach(routeHelpers.addRoute);
app.get('/__lbheartbeat__', (req, res) => {
res.type('txt').send('Ok');
});
app.get('/__version__', (req, res) => {
res.type('application/json').send(JSON.stringify(version));
});
function injectMetaContent(html, metaContent = {}) {
let result = html;
Object.keys(metaContent).forEach((k) => {
result = result.replace(
k,
encodeURIComponent(JSON.stringify(metaContent[k]))
);
});
return result;
}
function injectHtmlConfig(
html,
config,
featureFlags,
paypalCspNonce,
gaCspNonce
) {
return injectMetaContent(html, {
__SERVER_CONFIG__: config,
__FEATURE_FLAGS__: featureFlags,
__PAYPAL_CSP_NONCE__: paypalCspNonce,
__GA_CSP_NONCE__: gaCspNonce,
});
}
// Note - the static route handlers must come last
// because the proxyUrl handler's app.use('/') captures
// all requests that match no others.
const proxyUrl = config.get('proxyStaticResourcesFrom');
if (proxyUrl) {
logger.info('static.proxying', { url: proxyUrl });
const proxy = require('express-http-proxy');
app.use(
'/',
noCache(),
proxy(proxyUrl, {
userResDecorator: function (proxyRes, proxyResData, userReq, userRes) {
const contentType = proxyRes.headers['content-type'];
if (!contentType || !contentType.startsWith('text/html')) {
return proxyResData;
}
if (userReq.url.startsWith('/sockjs-node/')) {
// This is a development WebPack channel that we don't want to modify
return proxyResData;
}
const body = proxyResData.toString('utf8');
return injectHtmlConfig(
body,
CLIENT_CONFIG,
FEATURE_FLAGS,
userRes.paypalCspNonce,
userRes.gaCspNonce
);
},
})
);
} else {
const STATIC_DIRECTORY = path.join(
__dirname,
'..',
'..',
config.get('staticResources.directory')
);
logger.info('static.directory', { directory: STATIC_DIRECTORY });
const STATIC_INDEX_HTML = fs.readFileSync(
path.join(STATIC_DIRECTORY, 'index.html'),
{ encoding: 'utf-8' }
);
INDEX_ROUTES.forEach((route) => {
// FIXME: should set ETag, Not-Modified:
app.get(route, noCache(), (req, res) => {
res.send(
injectHtmlConfig(
STATIC_INDEX_HTML,
CLIENT_CONFIG,
FEATURE_FLAGS,
res.paypalCspNonce,
res.gaCspNonce
)
);
});
});
app.use(
serveStatic(STATIC_DIRECTORY, {
maxAge: config.get('staticResources.maxAge'),
})
);
}
// it's a four-oh-four not found.
app.use(require('./404'));
app.use(routeHelpers.validationErrorHandler);
if (sentryConfig.dsn) {
// Send errors to sentry.
Sentry.setupExpressErrorHandler(app);
}
return {
listen,
app, // for testing
};
function isCorsRequired() {
return config.get('staticResources.url') !== config.get('listen.publicUrl');
}
function listen() {
const port = config.get('listen.port');
const host = config.get('listen.host');
logger.info('server.starting', { port });
app.listen(port, host, (error) => {
if (error) {
logger.error('server.start.error', { error });
return;
}
logger.info('server.started', { port });
});
}
};