export function trailingSlashesMiddleware( req, res, next, { _config = config } = {}, ) { const UrlParts = req.originalUrl.split('?'); const UrlSlashSeparated = UrlParts[0].replace(/^\//, '').split('/'); // If part of this URL should include a lang or clientApp, check for one // and make sure they're valid. if (isValidLang(UrlSlashSeparated[0], { _config })) { UrlSlashSeparated[0] = '$lang'; } if (isValidClientApp(UrlSlashSeparated[1], { _config })) { UrlSlashSeparated[1] = '$clientApp'; } else if (isValidClientApp(UrlSlashSeparated[0], { _config })) { // It's possible there is a clientApp in the first part of the URL if this // URL is in validLocaleUrlExceptions. UrlSlashSeparated[0] = '$clientApp'; } const urlToCheck = `/${UrlSlashSeparated.join('/')}`; // If the URL doesn't end with a trailing slash, and it isn't an exception, // we'll add a trailing slash. if ( !isValidTrailingSlashUrlException(urlToCheck, { _config }) && UrlParts[0].substr(-1) !== '/' ) { UrlParts[0] = `${UrlParts[0]}/`; return res.redirect(301, UrlParts.join('?')); } if (isValidTrailingSlashUrlException(urlToCheck, { _config })) { log.info( `Not adding a trailing slash; validTrailingSlashUrlException found: ${urlToCheck}`, ); } return next(); }
export function prefixMiddleWare(req, res, next, { _config = config } = {}) { // Split on slashes after removing the leading slash. const URLParts = req.originalUrl.replace(/^\//, '').split('/'); log.debug(URLParts); // Get lang and app parts from the URL. At this stage they may be incorrect // or missing. const [langFromURL, appFromURL] = URLParts; // Get language from URL or fall-back to detecting it from accept-language header. const acceptLanguage = req.headers['accept-language']; const { lang, isLangFromHeader } = getLanguage({ lang: langFromURL, acceptLanguage }); const hasValidLang = isValidLang(langFromURL); const hasValidClientApp = isValidClientApp(appFromURL, { _config }); let prependedLang = false; if ((hasValidLang && langFromURL !== lang) || (!hasValidLang && hasValidClientApp)) { // Replace the first part of the URL if: // * It's valid and we've mapped it e.g: pt -> pt-PT. // * The lang is invalid but we have a valid application // e.g. /bogus/firefox/. log.info(`Replacing lang in URL ${URLParts[0]} -> ${lang}`); URLParts[0] = lang; } else if (!hasValidLang) { // If lang wasn't valid or was missing prepend one. log.info(`Prepending lang to URL: ${lang}`); URLParts.splice(0, 0, lang); prependedLang = true; } let isApplicationFromHeader = false; if (!hasValidClientApp && prependedLang && isValidClientApp(URLParts[1], { _config })) { // We skip prepending an app if we'd previously prepended a lang and the // 2nd part of the URL is now a valid app. log.info('Application in URL is valid following prepending a lang.'); } else if (!hasValidClientApp) { // If the app supplied is not valid we need to prepend one. const application = getClientApp(req.headers['user-agent']); log.info(`Prepending application to URL: ${application}`); URLParts.splice(1, 0, application); isApplicationFromHeader = true; } // Redirect to the new URL. // For safety we'll deny a redirect to a URL starting with '//' since // that will be treated as a protocol-free URL. const newURL = `/${URLParts.join('/')}`; if (newURL !== req.originalUrl && !newURL.startsWith('//')) { // Collect vary headers to apply to the redirect // so we can make it cacheable. // TODO: Make the redirects cacheable by adding expires headers. const varyHeaders = []; if (isLangFromHeader) { varyHeaders.push('accept-language'); } if (isApplicationFromHeader) { varyHeaders.push('user-agent'); } res.set('vary', varyHeaders); return res.redirect(302, newURL); } // Add the data to res.locals to be utilised later. /* eslint-disable no-param-reassign */ const [newLang, newApp] = URLParts; res.locals.lang = newLang; res.locals.clientApp = newApp; /* eslint-enable no-param-reassign */ return next(); }
match({ location: req.url, routes }, ( err, redirectLocation, renderProps ) => { cookie.plugToRequest(req, res); if (err) { log.error({ err, req }); return showErrorPage(res, 500); } if (!renderProps) { return showErrorPage(res, 404); } const store = createStore(); const token = cookie.load(config.get('cookieName')); if (token) { store.dispatch(setJwt(token)); } // 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); const locale = langToLocale(lang); store.dispatch(setLang(lang)); if (res.locals.clientApp) { store.dispatch(setClientApp(res.locals.clientApp)); } else { log.warn(`No clientApp for this URL: ${req.url}`); } function hydrateOnClient(props = {}) { const pageProps = { appName: appInstanceName, assets: webpackIsomorphicTools.assets(), htmlLang: lang, htmlDir: dir, includeSri: isDeployed, noScriptStyles, sriData, store, trackingEnabled: convertBoolean(config.get('trackingEnabled')), ...props, }; const HTML = ReactDOM.renderToString( <ServerHtml {...pageProps} />); res.send(`<!DOCTYPE html>\n${HTML}`); } // Set disableSSR to true to debug // client-side-only render. if (config.get('disableSSR') === true) { return Promise.resolve(hydrateOnClient()); } return loadOnServer({ ...renderProps, store }) .then(() => { // eslint-disable-next-line global-require let i18nData = {}; try { if (locale !== langToLocale(config.get('defaultLang'))) { // eslint-disable-next-line global-require, import/no-dynamic-require i18nData = require( `../../locale/${locale}/${appInstanceName}.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, lang); const InitialComponent = ( <I18nProvider i18n={i18n}> <Provider store={store} key="provider"> <ReduxAsyncConnect {...renderProps} /> </Provider> </I18nProvider> ); const asyncConnectLoadState = store.getState().reduxAsyncConnect.loadState || {}; const reduxResult = getReduxConnectError(asyncConnectLoadState); if (reduxResult.status) { return showErrorPage(res, reduxResult.status); } return hydrateOnClient({ component: InitialComponent }); }) .catch((error) => { log.error({ err: error }); return showErrorPage(res, 500); }); });
match({ routes, location: req.url }, (err, redirectLocation, renderProps) => { cookie.plugToRequest(req, res); if (err) { log.error({ err, req }); return showErrorPage(res, 500); } if (!renderProps) { return showErrorPage(res, 404); } const store = createStore(); const token = cookie.load(config.get('cookieName')); if (token) { store.dispatch(setJWT(token)); } // 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); const locale = langToLocale(lang); store.dispatch(setLang(lang)); function hydrateOnClient(props = {}) { const pageProps = { appName: appInstanceName, assets: webpackIsomorphicTools.assets(), htmlLang: lang, htmlDir: dir, includeSri: isDeployed, sriData, store, trackingEnabled: convertBoolean(config.get('trackingEnabled')), ...props, }; const HTML = ReactDOM.renderToString( <ServerHtml {...pageProps} />); res.send(`<!DOCTYPE html>\n${HTML}`); } // Set disableSSR to true to debug // client-side-only render. if (config.get('disableSSR') === true) { return Promise.resolve(hydrateOnClient()); } return loadOnServer({ ...renderProps, store }) .then(() => { // eslint-disable-next-line global-require let jedData = {}; try { if (locale !== langToLocale(config.get('defaultLang'))) { // eslint-disable-next-line global-require, import/no-dynamic-require jedData = require(`json!../../locale/${locale}/${appInstanceName}.json`); } } 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 = new Jed(jedData); const InitialComponent = ( <I18nProvider i18n={i18n}> <Provider store={store} key="provider"> <ReduxAsyncConnect {...renderProps} /> </Provider> </I18nProvider> ); const asyncConnectLoadState = store.getState().reduxAsyncConnect.loadState || {}; // Create a list of any apiErrors detected. const apiErrors = Object.keys(asyncConnectLoadState) .map((item) => asyncConnectLoadState[item].error) .filter((item) => item); if (apiErrors.length === 1) { // If we have a single API error reflect that in the page's response. const apiStatus = apiErrors[0].response.status; return showErrorPage(res, apiStatus); } else if (apiErrors.length > 1) { // Otherwise we have multiple api errors it should be logged // and throw a 500. log.error(apiErrors); return showErrorPage(res, 500); } return hydrateOnClient({ component: InitialComponent }); }) .catch((error) => { log.error({ err: error }); return showErrorPage(res, 500); }); });
it('should see bogus value as invalid lang', () => { expect(utils.isValidLang('awooga')).toEqual(false); });
it('should see pt as an invalid lang since it requires mapping', () => { expect(utils.isValidLang('pt')).toEqual(false); });
it('should see en-US as a valid lang', () => { expect(utils.isValidLang('en-US')).toEqual(true); });
it('should see incorrect type as invalid lang', () => { expect(utils.isValidLang(1)).toEqual(false); });
it('should see pt as an invalid lang since it requires mapping', () => { assert.equal(utils.isValidLang('pt'), false); });
it('should see en_US as an invalid lang', () => { expect(utils.isValidLang('en_US')).toEqual(false); });
it('should see bogus value as invalid lang', () => { assert.equal(utils.isValidLang('awooga'), false); });
it('should see incorrect type as invalid lang', () => { assert.equal(utils.isValidLang(1), false); });
it('should see en-US as a valid lang', () => { assert.equal(utils.isValidLang('en-US'), true); });
it('should see en_US as an invalid lang', () => { assert.equal(utils.isValidLang('en_US'), false); });
export function prefixMiddleware(req, res, next, { _config = config } = {}) { // Split on slashes after removing the leading slash. const URLParts = req.originalUrl.replace(/^\//, '').split('/'); // Get lang and app parts from the URL. At this stage they may be incorrect // or missing. const [langFromURL, appFromURL] = URLParts; // Get language from URL or fall-back to detecting it from accept-language // header. const acceptLanguage = req.headers['accept-language']; const { lang, isLangFromHeader } = getLanguage({ lang: langFromURL, acceptLanguage, }); // Get the application from the UA if one wasn't specified in the URL (or // if it turns out to be invalid). const application = getClientApp(req.headers['user-agent']); // clientApp values that are allowed through to the router // TODO: This can be removed when we upgrade to react-router v4. const clientAppRoutes = _config.get('clientAppRoutes'); const hasValidLang = isValidLang(langFromURL); const hasValidLocaleException = isValidLocaleUrlException(appFromURL, { _config, }); const hasValidClientApp = isValidClientApp(appFromURL, { _config }); let hasValidClientAppUrlException = isValidClientAppUrlException(appFromURL, { _config, }); let isApplicationFromHeader = false; let prependedOrMovedApplication = false; if (hasValidLocaleException) { log.info(oneLine`Second part of URL is a locale exception (${URLParts[1]}); make sure the clientApp is valid`); // Normally we look for a clientApp in the second part of a URL, but URLs // that match a locale exception don't have a locale so we look for the // clientApp in the first part of the URL. if (!isValidClientApp(langFromURL, { _config })) { URLParts[0] = application; isApplicationFromHeader = true; prependedOrMovedApplication = true; } } else if ( (hasValidLang && langFromURL !== lang) || hasValidClientApp || hasValidClientAppUrlException ) { // Replace the first part of the URL if: // * It's valid and we've mapped it e.g: pt -> pt-PT. // * The lang is invalid but we have a valid application // e.g. /bogus/firefox/. log.info(`Replacing lang in URL ${URLParts[0]} -> ${lang}`); URLParts[0] = lang; } else if (isValidLocaleUrlException(URLParts[0], { _config })) { log.info(`Prepending clientApp to URL: ${application}`); URLParts.splice(0, 0, application); isApplicationFromHeader = true; prependedOrMovedApplication = true; } else if (!hasValidLang) { // If lang wasn't valid or was missing prepend one. log.info(`Prepending lang to URL: ${lang}`); URLParts.splice(0, 0, lang); // If we've prepended the lang to the URL we need to re-check our // URL exception and make sure it's valid. hasValidClientAppUrlException = isValidClientAppUrlException(URLParts[1], { _config, }); } if (!hasValidClientApp && isValidClientApp(URLParts[1], { _config })) { // We skip prepending an app if we'd previously prepended a lang and the // 2nd part of the URL is now a valid app. log.info('Application in URL is valid following prepending a lang.'); } else if (prependedOrMovedApplication) { log.info( 'URL is valid because we added/changed the first part to a clientApp.', ); } else if (hasValidLocaleException || hasValidClientAppUrlException) { if ( clientAppRoutes.includes(URLParts[1]) === false && (hasValidLang || hasValidLocaleException) ) { log.info('Exception in URL found; we fallback to addons-server.'); // TODO: Remove this once upgraded to react-router 4. res.status(404).end(oneLine`This page does not exist in addons-frontend. Returning 404; this error should trigger upstream (usually addons-server) to return a valid response.`); return next(); } log.info('Exception in URL found; prepending lang to URL.'); } else if (!hasValidClientApp) { // If the app supplied is not valid we need to prepend one. log.info(`Prepending application to URL: ${application}`); URLParts.splice(1, 0, application); isApplicationFromHeader = true; } // Redirect to the new URL. // For safety we'll deny a redirect to a URL starting with '//' since // that will be treated as a protocol-free URL. const newURL = `/${URLParts.join('/')}`; if (newURL !== req.originalUrl && !newURL.startsWith('//')) { // Collect vary headers to apply to the redirect // so we can make it cacheable. // TODO: Make the redirects cacheable by adding expires headers. if (isLangFromHeader) { res.vary('accept-language'); } if (isApplicationFromHeader) { res.vary('user-agent'); } return res.redirect(301, newURL); } // Add the data to res.locals to be utilised later. /* eslint-disable no-param-reassign */ const [newLang, newApp] = URLParts; res.locals.lang = newLang; // The newApp part of the URL might not be a client application // so it's important to re-check that here before assuming it's good. res.locals.clientApp = isValidClientApp(newApp) ? newApp : application; // Get detailed info on the current user agent so we can make sure add-ons // are compatible with the current clientApp/version combo. res.locals.userAgent = req.headers['user-agent']; /* eslint-enable no-param-reassign */ return next(); }