src/amo/server/base.js (460 lines of code) (raw):
import fs from 'fs';
import path from 'path';
import https from 'https';
import 'amo/polyfill';
import { oneLine } from 'common-tags';
import compression from 'compression';
import defaultConfig from 'config';
import Express from 'express';
import httpContext from 'express-http-context';
import helmet from 'helmet';
import { createMemoryHistory } from 'history';
import * as React from 'react';
import ReactDOM from 'react-dom/server';
import NestedStatus from 'react-nested-status';
import { END } from 'redux-saga';
import cookiesMiddleware from 'universal-cookie-express';
import WebpackIsomorphicTools from 'webpack-isomorphic-tools';
import log from 'amo/logger';
import { REGION_CODE_HEADER, createApiError } from 'amo/api';
import Root from 'amo/components/Root';
import { AMO_REQUEST_ID_HEADER } from 'amo/constants';
import ServerHtml from 'amo/components/ServerHtml';
import * as middleware from 'amo/middleware';
import requestId from 'amo/middleware/requestId';
import { loadErrorPage } from 'amo/reducers/errorPage';
import { addQueryParamsToHistory, convertBoolean } from 'amo/utils';
import {
viewFrontendVersionHandler,
viewHeartbeatHandler,
} from 'amo/utils/server';
import {
setAuthToken,
setClientApp,
setLang,
setRegionCode,
setRequestId,
setUserAgent,
} from 'amo/reducers/api';
import {
getDirection,
isValidLang,
langToLocale,
makeI18n,
} from 'amo/i18n/utils';
import { fetchSiteStatus, loadedPageIsAnonymous } from 'amo/reducers/site';
import WebpackIsomorphicToolsConfig from './webpack-isomorphic-tools-config';
export const createHistory = ({ req }) => {
return addQueryParamsToHistory({
history: createMemoryHistory({ initialEntries: [req.url] }),
});
};
export function getPageProps({ store, req, res, config }) {
const isDeployed = config.get('isDeployed');
// Get SRI for deployed services only.
const sriData = isDeployed
? JSON.parse(
fs.readFileSync(path.join(config.get('basePath'), 'dist/sri.json')),
)
: {};
// Check the lang supplied by res.locals.lang for validity
// or fall-back to the default.
const lang = isValidLang(res.locals.lang)
? res.locals.lang
: config.get('defaultLang');
const dir = getDirection(lang);
store.dispatch(setLang(lang));
if (res.locals.clientApp) {
store.dispatch(setClientApp(res.locals.clientApp));
} else if (req && req.url) {
log.warn(`No clientApp for this URL: ${req.url}`);
} else {
log.warn('No clientApp (error)');
}
if (res.locals.userAgent) {
store.dispatch(setUserAgent(res.locals.userAgent));
} else {
log.debug('No userAgent found in request headers.');
}
const regionCode = req.get(REGION_CODE_HEADER);
if (regionCode) {
store.dispatch(setRegionCode(regionCode));
} else {
log.debug(`No ${REGION_CODE_HEADER} found in request headers.`);
}
return {
assets: webpackIsomorphicTools.assets(),
htmlLang: lang,
htmlDir: dir,
includeSri: isDeployed,
sriData,
// A DNT header set to "1" means Do Not Track
trackingEnabled:
convertBoolean(config.get('trackingEnabled')) &&
req.header('dnt') !== '1',
};
}
function renderHTML({ props = {}, pageProps, store }) {
// Capture the store state before beginning to render any components.
// This will ensure that no other components in the render tree will
// modify state before ServerHtml has a chance to serialize it.
// https://github.com/mozilla/addons-frontend/issues/6729
const appState = store.getState();
return ReactDOM.renderToString(
<ServerHtml {...pageProps} {...props} appState={appState} />,
);
}
function showErrorPage({
_createHistory,
createStore,
error = {},
req,
res,
status,
config,
}) {
const { store } = createStore({ history: _createHistory({ req }) });
const pageProps = getPageProps({ store, req, res, config });
const componentDeclaredStatus = NestedStatus.rewind();
let adjustedStatus = status || componentDeclaredStatus || 500;
if (error.response && error.response.status) {
adjustedStatus = error.response.status;
}
const apiError = createApiError({ response: { status: adjustedStatus } });
store.dispatch(loadErrorPage({ error: apiError }));
const HTML = renderHTML({ pageProps, store });
return res.status(adjustedStatus).send(`<!DOCTYPE html>\n${HTML}`).end();
}
function sendHTML({ res, html }) {
const componentDeclaredStatus = NestedStatus.rewind();
return res
.status(componentDeclaredStatus)
.send(`<!DOCTYPE html>\n${html}`)
.end();
}
function hydrateOnClient({ res, props = {}, pageProps, store }) {
return sendHTML({
html: renderHTML({ props, pageProps, store }),
res,
});
}
function baseServer(
App,
createStore,
{
_HotShots,
_createHistory = createHistory,
_log = log,
appSagas,
config = defaultConfig,
} = {},
) {
const app = new Express();
app.disable('x-powered-by');
if (config.get('enableRequestID')) {
// This middleware must be set very early.
app.use(httpContext.middleware);
app.use(requestId);
}
app.use(middleware.responseTime({ _config: config, _HotShots }));
// Enable gzip compression
app.use(compression());
// Set HTTP Strict Transport Security headers
app.use(middleware.hsts());
// Sets X-Frame-Options
app.use(middleware.frameguard());
// Sets x-content-type-options:"nosniff"
app.use(helmet.noSniff());
// Sets x-xss-protection:"1; mode=block"
app.use(helmet.xssFilter());
// CSP configuration.
app.use(middleware.csp());
// Serve assets locally from node ap (no-op by default).
if (config.get('enableNodeStatics')) {
app.use(config.get('staticPath'), middleware.serveAssetsLocally());
}
// This middleware adds `universalCookies` to the Express request.
app.use(cookiesMiddleware());
// Following the ops monitoring Dockerflow convention, return version info at
// this URL. See: https://github.com/mozilla-services/Dockerflow
app.get('/__version__', viewFrontendVersionHandler());
// For AMO, this helps differentiate from /__version__ served by addons-server.
app.get('/__frontend_version__', viewFrontendVersionHandler());
// Also return info for requests to __heartbeat__ and __lbheartbeat__.
app.get('/__frontend_heartbeat__', viewHeartbeatHandler());
app.get('/__frontend_lbheartbeat__', (req, res) => {
return res.status(200).end('ok');
});
// Return 200 for csp reports - this will need to be overridden when deployed.
app.post('/__cspreport__', (req, res) => res.status(200).end('ok'));
const isDevelopment = config.get('isDevelopment');
// Handle application and lang redirections.
if (config.get('enablePrefixMiddleware')) {
app.use(middleware.prefixMiddleware);
}
// Add trailing slashes to URLs
if (config.get('enableTrailingSlashesMiddleware')) {
app.use(middleware.trailingSlashesMiddleware);
}
app.use(async (req, res, next) => {
try {
if (isDevelopment) {
_log.info(oneLine`Clearing require cache for webpack isomorphic tools.
[Development Mode]`);
// clear require() cache if in development mode
webpackIsomorphicTools.refresh();
}
const isAnonymousPage =
config
.get('anonymousPagePatterns')
.filter((pattern) => new RegExp(pattern).test(req.originalUrl))
.length !== 0;
// Make sure the initial page does not get stored in browser caches.
// Specifically, we don't want the auth token in Redux state to hang
// around. See https://github.com/mozilla/addons-frontend/issues/6217
//
// The site operates as a single page app so this should really
// only affect how the browser loads the page when clicking
// the back button.
res.set('Cache-Control', isAnonymousPage ? ['public'] : ['max-age=0']);
// Vary the cache on Do Not Track headers, because if enabled we serve
// a different HTML without Google Analytics script included.
res.vary('DNT');
// Vary on User-Agent, because we serve different install buttons or
// banners depending on the user-agent.
res.vary('User-Agent');
let history;
try {
history = _createHistory({ req });
} catch (error) {
// See https://github.com/mozilla/addons-frontend/issues/10061
if (error instanceof URIError) {
_log.error(`Caught an error during createHistory: ${error}`);
return res
.status(404)
.send(
`<!DOCTYPE html>\nWe're sorry, we were unable to parse the request URL: ${error}`,
)
.end();
}
throw error;
}
const { connectedHistory, sagaMiddleware, store } = createStore({
history,
});
let pageProps;
let runningSagas;
const thisRequestId = res.get(AMO_REQUEST_ID_HEADER);
if (thisRequestId) {
store.dispatch(setRequestId(thisRequestId));
}
const token = req.universalCookies.get(config.get('cookieName'));
try {
let sagas = appSagas;
if (!sagas) {
// eslint-disable-next-line global-require, import/no-dynamic-require
sagas = require('amo/sagas').default;
}
runningSagas = sagaMiddleware.run(sagas);
if (isAnonymousPage) {
store.dispatch(loadedPageIsAnonymous());
} else if (token) {
// TODO: synchronize cookies with Redux store more automatically.
// See https://github.com/mozilla/addons-frontend/issues/5617
store.dispatch(setAuthToken(token));
} else {
// We only need to do this without a token because the user login
// saga already sets the site status (the Users API returns site
// status in its response).
store.dispatch(fetchSiteStatus());
}
pageProps = getPageProps({ store, req, res, config });
if (config.get('disableSSR') === true) {
// This stops all running sagas.
store.dispatch(END);
await runningSagas.toPromise();
_log.warn('Server side rendering is disabled.');
return hydrateOnClient({ res, pageProps, store });
}
} catch (preLoadError) {
_log.error(`Caught an error before rendering: ${preLoadError}`);
return next(preLoadError);
}
let i18nData = {};
const { htmlLang } = pageProps;
const locale = langToLocale(htmlLang);
try {
if (locale !== langToLocale(config.get('defaultLang'))) {
// eslint-disable-next-line global-require, import/no-dynamic-require
i18nData = require(`../../locale/${locale}/amo.js`);
}
} catch (e) {
_log.info(`Locale JSON not found or required for locale: "${locale}"`);
_log.info(
`Falling back to default lang: "${config.get('defaultLang')}"`,
);
}
const i18n = makeI18n(i18nData, htmlLang);
const props = {
component: (
<Root
cookies={req.universalCookies}
history={connectedHistory}
i18n={i18n}
store={store}
>
<App />
</Root>
),
};
// We need to render once because it will force components to
// dispatch data loading actions which get processed by sagas.
_log.debug('First component render to dispatch loading actions');
renderHTML({ props, pageProps, store });
// Send the redux-saga END action to stop sagas from running
// indefinitely. This is only done for server-side rendering.
store.dispatch(END);
try {
// Once all sagas have completed, we load the page.
await runningSagas.toPromise();
_log.debug('Second component render after sagas have finished');
const finalHTML = renderHTML({ props, pageProps, store });
// We can only call rewind() once, so only peek() to get the status
// code from nested component so that we can still call rewind() later.
const componentDeclaredStatus = NestedStatus.peek();
const { redirectTo, users } = store.getState();
const isRedirecting = redirectTo && redirectTo.url;
const responseStatusCode = isRedirecting
? redirectTo.status
: componentDeclaredStatus || res.statusCode;
if (
['GET', 'HEAD'].includes(req.method) &&
responseStatusCode >= 200 &&
responseStatusCode < 400 &&
!token
) {
// We tell public caches (nginx, proxies, CDN) to cache all succesful
// anonymous responses coming from "safe" requests: GET/HEAD verb,
// 20x/30x status, no special cookies. nginx/CDN config has similar
// config to only serve cached responses to such requests.
// Note that we have both max-age and s-maxage set. The latter
// overrides the former, but just for shared caches, so browsers
// continue to not cache our pages while nginx/CDN can if it's safe
// to do so.
_log.debug(oneLine`${req.method} -> ${responseStatusCode},
redirecting=${isRedirecting} anonymous=${!token}:
response should be cached.`);
res.append('Cache-Control', 's-maxage=360');
} else {
_log.debug(oneLine`${req.method} -> ${responseStatusCode},
redirecting=${isRedirecting} anonymous=${!token}:
response should *not* be cached.`);
// Redundant since we set max-age=0 by default but doesn't hurt, and
// leaves us free to change our policy around browser caching in the
// long run without affecting shared caches.
res.append('Cache-Control', 's-maxage=0');
}
// A redirection has been requested, let's do it.
if (isRedirecting) {
_log.debug(oneLine`Redirection requested:
url=${redirectTo.url} status=${redirectTo.status}`);
return res.redirect(redirectTo.status, redirectTo.url);
}
// See: https://github.com/mozilla/addons-frontend/issues/9482
if (users && users.currentUserWasLoggedOut === true) {
req.universalCookies.remove(config.get('cookieName'), {
domain: config.get('cookieDomain'),
httpOnly: true,
path: '/',
sameSite: config.get('cookieSameSite'),
secure: config.get('cookieSecure'),
});
// This is the Django session cookie, it needs to be removed to allow
// users to log in again successfully after account deletion.
// See: https://github.com/mozilla/addons-frontend/issues/9495
req.universalCookies.remove('sessionid', {
domain: config.get('cookieDomain'),
httpOnly: true,
path: '/',
sameSite: config.get('cookieSameSite'),
secure: config.get('cookieSecure'),
});
_log.debug('Cleared cookies');
}
return sendHTML({ res, html: finalHTML });
} catch (error) {
_log.error(`Caught error during rendering: ${error}`);
return next(error);
}
} catch (handlerError) {
_log.error(oneLine`Caught an unexpected error while handling the request:
${handlerError}`);
return next(handlerError);
}
});
// Error handlers:
app.use((error, req, res, next) => {
try {
if (res.headersSent) {
_log.warn(oneLine`Ignoring error for ${req.url} because a response was
already sent; error: ${error}`);
return next(error);
}
_log.error(`Showing 500 page for error: ${error}`);
// eslint-disable-next-line amo/only-log-strings
_log.error('%o', error); // log the stack trace too.
return showErrorPage({
_createHistory,
createStore,
error,
status: 500,
req,
res,
config,
});
} catch (recoveryError) {
_log.error(oneLine`Additionally, the error handler caught an error:
${recoveryError}`);
// eslint-disable-next-line amo/only-log-strings
_log.error('%o', recoveryError); // log the stack trace too.
// Pass the original error to the next error handler.
return next(error);
}
});
return app;
}
export function runServer({
listen = true,
exitProcess = true,
config = defaultConfig,
} = {}) {
const port = config.get('serverPort');
const host = config.get('serverHost');
const useHttpsForDev = process.env.USE_HTTPS_FOR_DEV;
const isoMorphicServer = new WebpackIsomorphicTools(
WebpackIsomorphicToolsConfig,
);
return isoMorphicServer
.server(config.get('basePath'))
.then(() => {
global.webpackIsomorphicTools = isoMorphicServer;
// Webpack Isomorphic tools is ready
// now fire up the actual server.
return new Promise((resolve, reject) => {
/* eslint-disable global-require, import/no-dynamic-require */
const App = require('amo/components/App').default;
const createStore = require('amo/store').default;
/* eslint-enable global-require, import/no-dynamic-require */
let server = baseServer(App, createStore);
if (listen === true) {
if (useHttpsForDev) {
if (host === 'example.com') {
const options = {
key: fs.readFileSync(
'bin/local-dev-server-certs/example.com-key.pem',
),
cert: fs.readFileSync(
'bin/local-dev-server-certs/example.com.pem',
),
passphrase: '',
};
server = https.createServer(options, server);
} else {
log.debug(
`To use the HTTPS server you must serve the site at example.com (host was "${host}")`,
);
}
}
server.listen(port, host, (err) => {
if (err) {
return reject(err);
}
const proxyEnabled = convertBoolean(config.get('proxyEnabled'));
// Not using oneLine here since it seems to change ' ' to ' '.
// eslint-disable-next-line amo/only-log-strings
log.info(
[
`🔥 Addons-frontend server is running`,
`[ENV:${config.util.getEnv('NODE_ENV')}]`,
`[isDevelopment:${config.get('isDevelopment')}]`,
`[isDeployed:${config.get('isDeployed')}]`,
`[apiHost:${config.get('apiHost')}]`,
`[apiPath:${config.get('apiPath')}]`,
`[apiVersion:${config.get('apiVersion')}]`,
].join(' '),
);
if (proxyEnabled) {
const proxyPort = config.get('proxyPort');
log.debug(
`🚦 Proxy detected, frontend running at http://${host}:${port}.`,
);
log.debug(
`👁 Open your browser at http${
useHttpsForDev ? 's' : ''
}://${host}:${proxyPort} to view it.`,
);
} else {
log.debug(
`👁 Open your browser at http${
useHttpsForDev ? 's' : ''
}://${host}:${port} to view it.`,
);
}
return resolve(server);
});
} else {
resolve(server);
}
});
})
.catch((err) => {
log.error(`${err}`);
if (exitProcess) {
process.exit(1);
} else {
throw err;
}
});
}
export default baseServer;