diff --git a/src/app/components/ChartbeatAnalytics/utils/index.test.ts b/src/app/components/ChartbeatAnalytics/utils/index.test.ts index 4e396e6ea0b..d071a7d5b20 100644 --- a/src/app/components/ChartbeatAnalytics/utils/index.test.ts +++ b/src/app/components/ChartbeatAnalytics/utils/index.test.ts @@ -13,6 +13,7 @@ import { AUDIO_PAGE, TV_PAGE, LIVE_TV_PAGE, + UNKNOWN_PAGE, } from '../../../routes/utils/pageTypes'; import { chartbeatUID, @@ -309,7 +310,7 @@ describe('Chartbeat utilities', () => { ${MEDIA_ASSET_PAGE} | ${'MAP Page Title'} | ${'MAP Page Title'} ${ARTICLE_PAGE} | ${'Article Page Title'} | ${'Article Page Title'} ${MEDIA_ARTICLE_PAGE} | ${'Media Article Page Title'} | ${'Media Article Page Title'} - ${'unknown'} | ${'Unknown Page Title'} | ${'Unknown Page Title'} + ${UNKNOWN_PAGE} | ${'Unknown Page Title'} | ${'Unknown Page Title'} `( 'should return correct title when pageType is $pageType', ({ pageType, title, expected }) => { diff --git a/src/app/routes/utils/pageTypes.ts b/src/app/routes/utils/pageTypes.ts index a2ebeb637d9..ee7fc20b4ec 100644 --- a/src/app/routes/utils/pageTypes.ts +++ b/src/app/routes/utils/pageTypes.ts @@ -19,3 +19,4 @@ export const AUDIO_PAGE = 'audio' as const; export const TV_PAGE = 'tv' as const; export const OFFLINE_PAGE = 'offline' as const; export const LIVE_TV_PAGE = 'liveTV' as const; +export const UNKNOWN_PAGE = 'Unknown' as const; diff --git a/src/server/index.jsx b/src/server/index.jsx index 55091016365..a5059e73381 100644 --- a/src/server/index.jsx +++ b/src/server/index.jsx @@ -41,6 +41,7 @@ import extractHeaders from './utilities/extractHeaders'; import addPlatformToRequestChainHeader from './utilities/addPlatformToRequestChainHeader'; import services from './utilities/serviceConfigs'; import createAdNonce from '../app/utilities/createAdNonce'; +import { UNKNOWN_PAGE } from '../app/routes/utils/pageTypes'; const morgan = require('morgan'); @@ -198,7 +199,7 @@ server.get( injectPlatformToRequestChainHeader, ], async ({ url, query, headers, path: urlPath }, res) => { - let derivedPageType = 'Unknown'; + let derivedPageType = UNKNOWN_PAGE; let serverSideExperiments = []; try { diff --git a/src/server/utilities/customMetrics/index.ts b/src/server/utilities/customMetrics/index.ts index f0ef21b0dca..b6ad424233f 100644 --- a/src/server/utilities/customMetrics/index.ts +++ b/src/server/utilities/customMetrics/index.ts @@ -5,7 +5,7 @@ import onEnvironment from '../onEnvironment'; export type Params = { metricName: string; statusCode?: number | string; - pageType: PageTypes | 'Unknown'; + pageType: PageTypes; requestUrl: string; }; diff --git a/src/server/utilities/getAssetOrigins/index.test.js b/src/server/utilities/getAssetOrigins/index.test.ts similarity index 100% rename from src/server/utilities/getAssetOrigins/index.test.js rename to src/server/utilities/getAssetOrigins/index.test.ts diff --git a/src/server/utilities/getAssetOrigins/index.js b/src/server/utilities/getAssetOrigins/index.ts similarity index 69% rename from src/server/utilities/getAssetOrigins/index.js rename to src/server/utilities/getAssetOrigins/index.ts index 4e2e7c478a4..61abd714100 100644 --- a/src/server/utilities/getAssetOrigins/index.js +++ b/src/server/utilities/getAssetOrigins/index.ts @@ -1,8 +1,7 @@ -const getAssetOrigins = () => { - const IMAGES_ORIGIN = 'https://ichef.bbci.co.uk'; - - const ANALYTICS_ORIGINS = ['https://ping.chartbeat.net']; +const IMAGES_ORIGIN = 'https://ichef.bbci.co.uk'; +const ANALYTICS_ORIGINS = ['https://ping.chartbeat.net']; +const getAssetOrigins = () => { const assetOrigins = [ IMAGES_ORIGIN, process.env.SIMORGH_PUBLIC_STATIC_ASSETS_ORIGIN, diff --git a/ws-nextjs-app/integration/utils/responseHeaderTests.ts b/ws-nextjs-app/integration/utils/responseHeaderTests.ts index e3b3096a648..d8428604599 100644 --- a/ws-nextjs-app/integration/utils/responseHeaderTests.ts +++ b/ws-nextjs-app/integration/utils/responseHeaderTests.ts @@ -1,6 +1,6 @@ export default () => { - // This header is set in the Next.js middleware/proxy file: 'ws-nextjs-app/middleware.ts' - // The presence of this header and its value including the word 'SIMORGH' indicates that middleware/proxy is working correctly + // These headers are set in the Next.js _app.page.tsx file + // The presence of this header and its value including the word 'SIMORGH' indicates that _app.page.tsx is setting headers correctly describe('req-svc-chain is set correctly', () => { it('should contain the correct svc chain', async () => { const fetchResponse = await fetch(window.location.href); @@ -8,4 +8,32 @@ export default () => { expect(reqSvcChain).toContain('SIMORGH'); }); }); + + describe('Onion-Location header is set correctly', () => { + it('should contain the correct Onion-Location header', async () => { + const fetchResponse = await fetch(window.location.href); + const onionHeader = fetchResponse.headers.get('Onion-Location'); + expect(onionHeader).toBe( + `https://www.bbcweb3hytmzhn5d532owbu6oqadra5z3ar726vq5kgwwn6aucdccrad.onion${new URL(window.location.href).pathname}`, + ); + }); + }); + + describe('Vary header is set correctly', () => { + it('should contain the correct Vary header', async () => { + const fetchResponse = await fetch(window.location.href); + const varyHeader = fetchResponse.headers.get('Vary'); + expect(varyHeader).toContain('X-Country, Accept-Encoding'); + }); + }); + + describe('Link header is set correctly', () => { + it('should contain the correct Link header for AMP pages', async () => { + const fetchResponse = await fetch(window.location.href); + const linkHeader = fetchResponse.headers.get('Link'); + expect(linkHeader).toContain( + '; rel="dns-prefetch"', + ); + }); + }); }; diff --git a/ws-nextjs-app/pages/[service]/articles/handleArticleRoute.ts b/ws-nextjs-app/pages/[service]/articles/handleArticleRoute.ts index 24b5ee71380..c7e5c1fc5ce 100644 --- a/ws-nextjs-app/pages/[service]/articles/handleArticleRoute.ts +++ b/ws-nextjs-app/pages/[service]/articles/handleArticleRoute.ts @@ -10,7 +10,6 @@ import handleError from '#app/routes/utils/handleError'; import { PageTypes } from '#app/models/types/global'; import { ArticleMetadata } from '#app/models/types/optimo'; -import { getServerExperiments } from '#server/utilities/experimentHeader'; import augmentWithDisclaimer from './augmentWithDisclaimer'; import shouldRender from '../../../utilities/shouldRender'; import getPageData from '../../../utilities/pageRequests/getPageData'; @@ -176,12 +175,6 @@ export default async (context: GetServerSidePropsContext) => { const derivedPageType = getDerivedArticleType(article.metadata); - const serverSideExperiments = getServerExperiments({ - headers: reqHeaders, - service, - pageType: derivedPageType, - }); - return { props: { country, @@ -201,7 +194,6 @@ export default async (context: GetServerSidePropsContext) => { }, pageType: derivedPageType, pathname: resolvedUrlWithoutQuery, - serverSideExperiments, service, status, variant: variant || null, diff --git a/ws-nextjs-app/pages/_app.page.tsx b/ws-nextjs-app/pages/_app.page.tsx index f7a0032b2fd..1edb7adee1f 100644 --- a/ws-nextjs-app/pages/_app.page.tsx +++ b/ws-nextjs-app/pages/_app.page.tsx @@ -1,5 +1,4 @@ -import type { AppContext, AppProps } from 'next/app'; -import { NextPageContext } from 'next/types'; +import App, { AppContext } from 'next/app'; import { ATIData } from '#app/components/ATIAnalytics/types'; import ThemeProvider from '#app/components/ThemeProvider'; import { ToggleContextProvider } from '#app/contexts/ToggleContext'; @@ -17,14 +16,17 @@ import { RequestContextProvider } from '#app/contexts/RequestContext'; import { EventTrackingContextProvider } from '#app/contexts/EventTrackingContext'; import { UserContextProvider } from '#app/contexts/UserContext'; import extractHeaders from '#src/server/utilities/extractHeaders'; +import { getServerExperiments } from '#src/server/utilities/experimentHeader'; import getToggles from '#app/lib/utilities/getToggles/withCache'; -import addPlatformToRequestChainHeader from '#src/server/utilities/addPlatformToRequestChainHeader'; -import cspHeaderResponse, { - CspHeaderResponseProps, -} from '#nextjs/utilities/cspHeaderResponse'; import getPathExtension from '#app/utilities/getPathExtension'; - -interface Props extends AppProps { +import addCspHeader from '#nextjs/utilities/addCspHeader'; +import derivePageType from '#nextjs/utilities/derivePageType'; +import addServiceChainHeader from '#nextjs/utilities/addServiceChainHeader'; +import addOnionLocationHeader from '#nextjs/utilities/addOnionLocationHeader'; +import addVaryHeader from '#nextjs/utilities/addVaryHeader'; +import addLinkHeader from '#nextjs/utilities/addLinkHeader'; + +interface Props { pageProps: { bbcOrigin?: string; id?: string; @@ -55,136 +57,128 @@ interface Props extends AppProps { }; } -export default function App({ Component, pageProps }: Props) { - const { - bbcOrigin, - id, - isAmp, - isApp = false, - isLite = false, - isNextJs = true, - isAvEmbeds = false, - serverSideExperiments = null, - pageData, - pageLang = '', - pageType, - pathname, - service, - showAdsBasedOnLocation, - showCookieBannerBasedOnCountry = true, - status, - timeOnServer, - toggles, - variant, - isUK, - country, - } = pageProps; +export default class CustomApp extends App { + // The 'pageProps' returned are passed down to ALL pages and merged with page + // specific 'pageProps' from their getInitialProps / getServerSideProps functions + static async getInitialProps({ ctx }: AppContext) { + const { asPath = '' } = ctx; + + const { isApp, isAmp, isLite } = getPathExtension(asPath); + + const routeSegments = asPath?.split('/')?.filter(Boolean); + + const [service] = (routeSegments || []) as [Services]; + + const toggles = await getToggles(service); + + const pageType = + (ctx.req?.headers['page-type'] as PageTypes) || derivePageType(asPath); + + const serverSideExperiments = getServerExperiments({ + headers: ctx.req?.headers || {}, + service, + pageType, + }); + + addServiceChainHeader({ ctx }); + addCspHeader({ ctx, service, toggles }); + addOnionLocationHeader({ ctx }); + addVaryHeader({ ctx, serverSideExperiments }); + addLinkHeader({ ctx }); + + return { + pageProps: { + ...extractHeaders(ctx.req?.headers || {}), + isApp, + isAmp, + isLite, + isNextJs: true, + serverSideExperiments, + toggles, + }, + }; + } - const { metadata: { atiAnalytics = undefined } = {} } = pageData ?? {}; + render() { + const { Component, pageProps } = this.props; - const RenderChildrenOrError = - status === 200 ? ( - - ) : ( - - ); - - return ( - - - + ) : ( + + ); + + return ( + + - - {isAvEmbeds ? ( - - {RenderChildrenOrError} - - ) : ( - + + + {isAvEmbeds ? ( - - {RenderChildrenOrError} - + {RenderChildrenOrError} - - )} - - - - - ); -} - -const addServiceChainAndCspHeaders = ({ - ctx, - service, - toggles, -}: CspHeaderResponseProps) => { - ctx.res?.setHeader( - 'req-svc-chain', - addPlatformToRequestChainHeader({ - headers: ctx.req?.headers as unknown as Headers, - }), - ); - - const hostname = ctx.req?.headers.host || ''; - - const LOCALHOST_DOMAINS = ['localhost', '127.0.0.1']; - - const isLocalhost = LOCALHOST_DOMAINS.includes(hostname.split(':')?.[0]); - - const PRODUCTION_ONLY = !isLocalhost && process.env.NODE_ENV === 'production'; - - if (PRODUCTION_ONLY) { - cspHeaderResponse({ ctx, service, toggles }); + ) : ( + + + + {RenderChildrenOrError} + + + + )} + + + + + ); } -}; - -// This runs on the server before rendering the page. -// The props returned are passed down to ALL pages and merged with page -// specific props from getInitialProps / getServerSideProps -App.getInitialProps = async ({ ctx }: AppContext) => { - const { req, asPath } = ctx as NextPageContext; - - const { isApp, isAmp, isLite } = getPathExtension(asPath || ''); - - const routeSegments = asPath?.split('/')?.filter(Boolean); - - const [service] = (routeSegments || []) as [Services]; - - const toggles = await getToggles(service); - - addServiceChainAndCspHeaders({ ctx, service, toggles }); - - return { - pageProps: { - ...extractHeaders(req?.headers || {}), - isApp, - isAmp, - isLite, - isNextJs: true, - toggles, - }, - }; -}; +} diff --git a/ws-nextjs-app/pages/_document.page.tsx b/ws-nextjs-app/pages/_document.page.tsx index ec46ce0fac5..a685b6b2196 100644 --- a/ws-nextjs-app/pages/_document.page.tsx +++ b/ws-nextjs-app/pages/_document.page.tsx @@ -22,67 +22,16 @@ import { import AmpRenderer from '#server/Document/Renderers/AmpRenderer'; import LiteRenderer from '#server/Document/Renderers/LiteRenderer'; import litePageTransforms from '#server/Document/Renderers/litePageTransforms'; -import sendCustomMetric from '#server/utilities/customMetrics'; -import { NON_200_RESPONSE } from '#server/utilities/customMetrics/metrics.const'; -import nodeLogger from '#lib/logger.node'; -import { - SERVER_SIDE_RENDER_REQUEST_RECEIVED, - SERVER_SIDE_REQUEST_FAILED, -} from '#lib/logger.const'; -import { OK, INTERNAL_SERVER_ERROR } from '#app/lib/statusCodes.const'; import NO_JS_CLASSNAME from '#app/lib/noJs.const'; import getPathExtension from '#app/utilities/getPathExtension'; import ReverbTemplate from '#src/server/Document/Renderers/ReverbTemplate'; -import { PageTypes } from '#app/models/types/global'; import ComponentTracking from '#src/server/Document/Renderers/ComponentTracking'; import addOperaMiniClassScript from '#app/lib/utilities/addOperaMiniClassScript'; -import removeSensitiveHeaders from '../utilities/removeSensitiveHeaders'; +import handleServerLogging from '#nextjs/utilities/handleServerLogging'; import derivePageType from '../utilities/derivePageType'; -const logger = nodeLogger(__filename); - -const handleServerLogging = ({ - ctx, - pageType, -}: { - ctx: DocumentContext; - pageType: PageTypes | 'Unknown'; -}) => { - const url = ctx.asPath || ''; - const headers = removeSensitiveHeaders(ctx.req?.headers); - const { statusCode } = ctx.res || {}; - const { cause, message, name, stack } = ctx.err || {}; - - switch (statusCode) { - case OK: - logger.debug(SERVER_SIDE_RENDER_REQUEST_RECEIVED, { - url, - headers, - pageType, - }); - break; - case INTERNAL_SERVER_ERROR: - sendCustomMetric({ - metricName: NON_200_RESPONSE, - statusCode, - pageType, - requestUrl: url, - }); - logger.error(SERVER_SIDE_REQUEST_FAILED, { - status: INTERNAL_SERVER_ERROR, - message: { cause, message, name, stack, url }, - url, - headers, - pageType, - }); - break; - default: - break; - } -}; - type DocProps = { clientSideEnvVariables: EnvConfig; css: string; diff --git a/ws-nextjs-app/pages/_error.page.tsx b/ws-nextjs-app/pages/_error.page.tsx index 5a6b0002fa7..100560e5fe7 100644 --- a/ws-nextjs-app/pages/_error.page.tsx +++ b/ws-nextjs-app/pages/_error.page.tsx @@ -1,22 +1,26 @@ -import { NextPageContext } from 'next'; +import { Component } from 'react'; +import { NextPageContext } from 'next/types'; import NextError from 'next/error'; import { NOT_FOUND } from '#app/lib/statusCodes.const'; -function Error({ statusCode }: { statusCode: number }) { - return ; -} +class Error extends Component<{ statusCode: number }> { + static getInitialProps({ res, err }: NextPageContext) { + let statusCode = NOT_FOUND; -Error.getInitialProps = ({ res, err }: NextPageContext) => { - let statusCode = NOT_FOUND; + if (res) { + statusCode = res.statusCode; + } else if (err) { + statusCode = + typeof err.statusCode === 'number' ? err.statusCode : statusCode; + } - if (res) { - statusCode = res.statusCode; - } else if (err) { - statusCode = - typeof err.statusCode === 'number' ? err.statusCode : statusCode; + return { statusCode }; } - return { statusCode }; -}; + render() { + const { statusCode } = this.props; + return ; + } +} export default Error; diff --git a/ws-nextjs-app/utilities/cspHeaderResponse/index.test.ts b/ws-nextjs-app/utilities/addCspHeader/index.test.ts similarity index 89% rename from ws-nextjs-app/utilities/cspHeaderResponse/index.test.ts rename to ws-nextjs-app/utilities/addCspHeader/index.test.ts index 72100ff88ca..f9c00eed379 100644 --- a/ws-nextjs-app/utilities/cspHeaderResponse/index.test.ts +++ b/ws-nextjs-app/utilities/addCspHeader/index.test.ts @@ -1,5 +1,5 @@ import { NextPageContext } from 'next/types'; -import cspHeaderResponse from '.'; +import addCspHeader from '.'; const createDocumentContext = (pathname: string, country?: string) => { const url = new URL(`https://www.test.bbc.com${pathname}`); @@ -32,11 +32,17 @@ const policies = [ 'upgrade-insecure-requests', ]; -describe('cspHeaderResponse', () => { +describe('addCspHeader', () => { + const processEnv = process.env; + + beforeEach(() => { + process.env = { ...processEnv, NODE_ENV: 'production' }; + }); + it.each(policies)('should set %s in the request CSP', policy => { const ctx = createDocumentContext('/pidgin/live/c7p765ynk9qt'); - cspHeaderResponse({ ctx, service: 'pidgin', toggles: {} }); + addCspHeader({ ctx, service: 'pidgin', toggles: {} }); const requestCsp = (ctx.res?.setHeader as jest.Mock).mock.calls.find( call => call[0] === 'Content-Security-Policy', @@ -44,9 +50,31 @@ describe('cspHeaderResponse', () => { expect((requestCsp as string).includes(policy)).toBe(true); }); + + it('should not set CSP headers in non-production environments', () => { + const ctx = createDocumentContext('/pidgin/live/c7p765ynk9qt'); + + process.env = { ...processEnv, NODE_ENV: 'development' }; + + addCspHeader({ ctx, service: 'pidgin', toggles: {} }); + + const setHeaderCalls = (ctx.res?.setHeader as jest.Mock).mock.calls; + + const cspHeaderCall = setHeaderCalls.find( + call => call[0] === 'Content-Security-Policy', + ); + + expect(cspHeaderCall).toBeUndefined(); + }); }); describe('shouldServeRelaxedCsp', () => { + const processEnv = process.env; + + beforeEach(() => { + process.env = { ...processEnv, NODE_ENV: 'production' }; + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -60,7 +88,7 @@ describe('shouldServeRelaxedCsp', () => { it('returns "relaxed" Csp when toggle is enabled but does not have values set', () => { const ctx = createDocumentContext('/pidgin/live/c7p765ynk9qt'); - cspHeaderResponse({ + addCspHeader({ ctx, service: 'pidgin', toggles: { @@ -78,7 +106,7 @@ describe('shouldServeRelaxedCsp', () => { it('returns "relaxed" Csp when country is not in omittedCountries', () => { const ctx = createDocumentContext('/pidgin/live/c7p765ynk9qt', 'ax'); - cspHeaderResponse({ + addCspHeader({ ctx, service: 'pidgin', toggles: { @@ -96,7 +124,7 @@ describe('shouldServeRelaxedCsp', () => { it('returns "full" CSP when toggle is enabled and given country is in omittedCountries', () => { const ctx = createDocumentContext('/pidgin/live/c7p765ynk9qt', 'gb'); - cspHeaderResponse({ + addCspHeader({ ctx, service: 'pidgin', toggles: { @@ -114,7 +142,7 @@ describe('shouldServeRelaxedCsp', () => { it('returns "full" CSP when adsNonce.enabled is false', () => { const ctx = createDocumentContext('/pidgin/live/c7p765ynk9qt', 'gb'); - cspHeaderResponse({ + addCspHeader({ ctx, service: 'pidgin', toggles: { adsNonce: { enabled: false, value: '' } }, diff --git a/ws-nextjs-app/utilities/cspHeaderResponse/index.ts b/ws-nextjs-app/utilities/addCspHeader/index.ts similarity index 85% rename from ws-nextjs-app/utilities/cspHeaderResponse/index.ts rename to ws-nextjs-app/utilities/addCspHeader/index.ts index d99a12bdab9..0b0b843baad 100644 --- a/ws-nextjs-app/utilities/cspHeaderResponse/index.ts +++ b/ws-nextjs-app/utilities/addCspHeader/index.ts @@ -5,6 +5,8 @@ import isLiveEnv from '#lib/utilities/isLive'; import { Services, Toggles } from '#app/models/types/global'; import SERVICES from '#app/lib/config/services'; +const LOCALHOST_DOMAINS = ['localhost', '127.0.0.1']; + const directiveToString = (directives: Record) => { const map = new Map(Object.entries(directives)); let cspValue = ''; @@ -35,17 +37,23 @@ const isRelaxedCspEnabled = ( return !omittedCountriesList.includes(country.toLowerCase()); }; -export type CspHeaderResponseProps = { +type AddCspHeaderProps = { ctx: NextPageContext; service: Services; toggles: Toggles; }; -const cspHeaderResponse = ({ - ctx, - service, - toggles, -}: CspHeaderResponseProps) => { +const addCspHeader = ({ ctx, service, toggles }: AddCspHeaderProps) => { + const hostname = ctx.req?.headers.host || ''; + + const isLocalhost = LOCALHOST_DOMAINS.some(domain => + hostname.includes(domain), + ); + + const PRODUCTION_ONLY = !isLocalhost && process.env.NODE_ENV === 'production'; + + if (!PRODUCTION_ONLY) return; + const reqUrl = ctx.req?.url || ''; const { isAmp } = getPathExtension(reqUrl); const isLive = isLiveEnv(); @@ -105,4 +113,4 @@ const cspHeaderResponse = ({ ); }; -export default cspHeaderResponse; +export default addCspHeader; diff --git a/ws-nextjs-app/utilities/addLinkHeader/index.test.ts b/ws-nextjs-app/utilities/addLinkHeader/index.test.ts new file mode 100644 index 00000000000..7232c1845ad --- /dev/null +++ b/ws-nextjs-app/utilities/addLinkHeader/index.test.ts @@ -0,0 +1,43 @@ +import { NextPageContext } from 'next/types'; +import addLinkHeader from '.'; + +describe('addLinkHeader', () => { + const mockSetHeader = jest.fn(); + const mockCtx = { + res: { + setHeader: mockSetHeader, + }, + } as unknown as NextPageContext; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should add the correct Link header for Test env', () => { + process.env.SIMORGH_PUBLIC_STATIC_ASSETS_ORIGIN = + 'https://static.test.files.bbci.co.uk'; + + process.env.SIMORGH_ATI_BASE_URL = 'https://logws1363.ati-host.net?'; + + addLinkHeader({ ctx: mockCtx }); + + expect(mockSetHeader).toHaveBeenCalledWith( + 'Link', + '; rel="dns-prefetch", ; rel="preconnect",; rel="dns-prefetch", ; rel="preconnect",; rel="dns-prefetch", ; rel="preconnect",; rel="dns-prefetch", ; rel="preconnect"', + ); + }); + + it('should add the correct Link header for Live env', () => { + process.env.SIMORGH_PUBLIC_STATIC_ASSETS_ORIGIN = + 'https://static.files.bbci.co.uk'; + + process.env.SIMORGH_ATI_BASE_URL = 'https://a1.api.bbc.co.uk/hit.xiti?'; + + addLinkHeader({ ctx: mockCtx }); + + expect(mockSetHeader).toHaveBeenCalledWith( + 'Link', + '; rel="dns-prefetch", ; rel="preconnect",; rel="dns-prefetch", ; rel="preconnect",; rel="preconnect"; crossorigin,; rel="dns-prefetch", ; rel="preconnect",; rel="dns-prefetch", ; rel="preconnect"', + ); + }); +}); diff --git a/ws-nextjs-app/utilities/addLinkHeader/index.ts b/ws-nextjs-app/utilities/addLinkHeader/index.ts new file mode 100644 index 00000000000..f8552f96426 --- /dev/null +++ b/ws-nextjs-app/utilities/addLinkHeader/index.ts @@ -0,0 +1,21 @@ +import { NextPageContext } from 'next/types'; +import getAssetOrigins from '#server/utilities/getAssetOrigins'; + +const addLinkHeader = ({ ctx }: { ctx: NextPageContext }) => { + const assetOrigins = getAssetOrigins(); + + ctx.res?.setHeader( + 'Link', + assetOrigins + .map(domainName => { + const crossOrigin = + domainName === 'https://static.files.bbci.co.uk' + ? `,<${domainName}>; rel="preconnect"; crossorigin` + : ''; + return `<${domainName}>; rel="dns-prefetch", <${domainName}>; rel="preconnect"${crossOrigin}`; + }) + .join(','), + ); +}; + +export default addLinkHeader; diff --git a/ws-nextjs-app/utilities/addOnionLocationHeader/index.test.ts b/ws-nextjs-app/utilities/addOnionLocationHeader/index.test.ts new file mode 100644 index 00000000000..036ed74e108 --- /dev/null +++ b/ws-nextjs-app/utilities/addOnionLocationHeader/index.test.ts @@ -0,0 +1,25 @@ +import { NextPageContext } from 'next/types'; +import addOnionLocationHeader from '.'; + +describe('addOnionLocationHeader', () => { + const mockSetHeader = jest.fn(); + const mockCtx = { + asPath: '/test-path', + res: { + setHeader: mockSetHeader, + }, + } as unknown as NextPageContext; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should add the correct onion-location header', () => { + addOnionLocationHeader({ ctx: mockCtx }); + + expect(mockSetHeader).toHaveBeenCalledWith( + 'onion-location', + 'https://www.bbcweb3hytmzhn5d532owbu6oqadra5z3ar726vq5kgwwn6aucdccrad.onion/test-path', + ); + }); +}); diff --git a/ws-nextjs-app/utilities/addOnionLocationHeader/index.ts b/ws-nextjs-app/utilities/addOnionLocationHeader/index.ts new file mode 100644 index 00000000000..8e494f458d4 --- /dev/null +++ b/ws-nextjs-app/utilities/addOnionLocationHeader/index.ts @@ -0,0 +1,12 @@ +import { NextPageContext } from 'next/types'; + +const addOnionLocationHeader = ({ ctx }: { ctx: NextPageContext }) => { + const { asPath } = ctx; + + ctx.res?.setHeader( + 'onion-location', + `https://www.bbcweb3hytmzhn5d532owbu6oqadra5z3ar726vq5kgwwn6aucdccrad.onion${asPath}`, + ); +}; + +export default addOnionLocationHeader; diff --git a/ws-nextjs-app/utilities/addServiceChainHeader/index.test.ts b/ws-nextjs-app/utilities/addServiceChainHeader/index.test.ts new file mode 100644 index 00000000000..f069e8a10e9 --- /dev/null +++ b/ws-nextjs-app/utilities/addServiceChainHeader/index.test.ts @@ -0,0 +1,29 @@ +import { NextPageContext } from 'next/types'; +import addServiceChainHeader from '.'; + +describe('addServiceChainHeader', () => { + const mockSetHeader = jest.fn(); + const mockCtx = { + req: { + headers: { + 'req-svc-chain': 'UPSTREAM_A', + }, + }, + res: { + setHeader: mockSetHeader, + }, + } as unknown as NextPageContext; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should add the correct req-svc-chain header', () => { + addServiceChainHeader({ ctx: mockCtx }); + + expect(mockSetHeader).toHaveBeenCalledWith( + 'req-svc-chain', + expect.stringContaining('UPSTREAM_A'), + ); + }); +}); diff --git a/ws-nextjs-app/utilities/addServiceChainHeader/index.ts b/ws-nextjs-app/utilities/addServiceChainHeader/index.ts new file mode 100644 index 00000000000..fdb1f2f3632 --- /dev/null +++ b/ws-nextjs-app/utilities/addServiceChainHeader/index.ts @@ -0,0 +1,13 @@ +import { NextPageContext } from 'next/types'; +import addPlatformToRequestChainHeader from '#src/server/utilities/addPlatformToRequestChainHeader'; + +const addServiceChainHeader = ({ ctx }: { ctx: NextPageContext }) => { + ctx.res?.setHeader( + 'req-svc-chain', + addPlatformToRequestChainHeader({ + headers: ctx.req?.headers as unknown as Headers, + }), + ); +}; + +export default addServiceChainHeader; diff --git a/ws-nextjs-app/utilities/addVaryHeader/index.test.ts b/ws-nextjs-app/utilities/addVaryHeader/index.test.ts new file mode 100644 index 00000000000..9745fe36ca0 --- /dev/null +++ b/ws-nextjs-app/utilities/addVaryHeader/index.test.ts @@ -0,0 +1,38 @@ +import { NextPageContext } from 'next/types'; +import addVaryHeaders from '.'; + +describe('addVaryHeaders', () => { + const mockSetHeader = jest.fn(); + const mockCtx = { + res: { + setHeader: mockSetHeader, + }, + } as unknown as NextPageContext; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should add the correct Vary headers', () => { + addVaryHeaders({ ctx: mockCtx, serverSideExperiments: [] }); + + expect(mockSetHeader).toHaveBeenCalledWith('Vary', ['X-Country']); + }); + + it('should include server-side experiment headers in Vary', () => { + const serverSideExperiments = [ + { + experimentName: 'experiment-1', + variation: 'A', + enabled: true, + }, + ]; + + addVaryHeaders({ ctx: mockCtx, serverSideExperiments }); + + expect(mockSetHeader).toHaveBeenCalledWith('Vary', [ + 'X-Country', + 'mvt-experiment-1', + ]); + }); +}); diff --git a/ws-nextjs-app/utilities/addVaryHeader/index.ts b/ws-nextjs-app/utilities/addVaryHeader/index.ts new file mode 100644 index 00000000000..b1dafce00cb --- /dev/null +++ b/ws-nextjs-app/utilities/addVaryHeader/index.ts @@ -0,0 +1,20 @@ +import { NextPageContext } from 'next/types'; +import { ServerSideExperiment } from '#app/models/types/global'; +import { getExperimentVaryHeaders } from '#src/server/utilities/experimentHeader'; + +const addVaryHeader = ({ + ctx, + serverSideExperiments, +}: { + ctx: NextPageContext; + serverSideExperiments: ServerSideExperiment[] | null; +}) => { + const allVaryHeaders = ['X-Country']; + const experimentVaryHeaders = + serverSideExperiments && getExperimentVaryHeaders(serverSideExperiments); + if (experimentVaryHeaders) allVaryHeaders.push(experimentVaryHeaders); + + ctx.res?.setHeader('Vary', allVaryHeaders); +}; + +export default addVaryHeader; diff --git a/ws-nextjs-app/utilities/derivePageType/index.test.ts b/ws-nextjs-app/utilities/derivePageType/index.test.ts index 6ebcb6ed3ef..8b7a20dda5c 100644 --- a/ws-nextjs-app/utilities/derivePageType/index.test.ts +++ b/ws-nextjs-app/utilities/derivePageType/index.test.ts @@ -1,39 +1,72 @@ -import { LIVE_PAGE, UGC_PAGE, HOME_PAGE } from '#app/routes/utils/pageTypes'; +import { + LIVE_PAGE, + UGC_PAGE, + AV_EMBEDS, + DOWNLOADS_PAGE, + ARTICLE_PAGE, + HOME_PAGE, + UNKNOWN_PAGE, +} from '#app/routes/utils/pageTypes'; import derivePageType from '.'; describe('derivePageType', () => { - it("should return UGC_PAGE if pathname includes 'send'", () => { - const pathname = '/burmese/send/xxxxxxxxx'; + it('should strip out query params from the pathname', () => { + const pathname = '/pidgin/live/xxxxxxxxx?foo=bar'; const result = derivePageType(pathname); - expect(result).toEqual(UGC_PAGE); + expect(result).toEqual(LIVE_PAGE); + }); + + it('should return HOME_PAGE for a base service homepage', () => { + const pathname = '/pidgin'; + const result = derivePageType(pathname); + expect(result).toEqual(HOME_PAGE); + }); + + it('should return HOME_PAGE for a service variant homepage', () => { + const pathname = '/serbian/lat'; + const result = derivePageType(pathname); + expect(result).toEqual(HOME_PAGE); }); it("should return LIVE_PAGE if pathname includes 'live'", () => { - const pathname = '/burmese/live/xxxxxxxxx'; + const pathname = '/pidgin/live/xxxxxxxxx'; const result = derivePageType(pathname); expect(result).toEqual(LIVE_PAGE); }); - it('should return Unknown if pathname does not include live or send', () => { - const pathname = '/burmese/xxxxxxxxx'; + it("should return UGC_PAGE if pathname includes 'send'", () => { + const pathname = '/pidgin/send/xxxxxxxxx'; const result = derivePageType(pathname); - expect(result).toEqual('Unknown'); + expect(result).toEqual(UGC_PAGE); }); - it('should strip our query params from the pathname', () => { - const pathname = '/burmese/live/xxxxxxxxx?foo=bar'; + it("should return AV_EMBEDS if pathname includes 'av-embeds'", () => { + const pathname = '/pidgin/av-embeds/xxxxxxxxx'; const result = derivePageType(pathname); - expect(result).toEqual(LIVE_PAGE); + expect(result).toEqual(AV_EMBEDS); }); - it('should return HOME_PAGE for a base service homepage', () => { - const pathname = '/pidgin'; + + it("should return DOWNLOADS_PAGE if pathname includes 'downloads'", () => { + const pathname = '/korean/downloads'; const result = derivePageType(pathname); - expect(result).toEqual(HOME_PAGE); + expect(result).toEqual(DOWNLOADS_PAGE); }); - it('should return HOME_PAGE for a service variant homepage', () => { - const pathname = '/serbian/lat'; + it('should return ARTICLE_PAGE if pathname matches Optimo ID pattern', () => { + const pathname = '/pidgin/articles/c0000000000o'; const result = derivePageType(pathname); - expect(result).toEqual(HOME_PAGE); + expect(result).toEqual(ARTICLE_PAGE); + }); + + it('should return ARTICLE_PAGE if pathname matches CPS ID pattern', () => { + const pathname = '/pidgin/institutional-1234567'; + const result = derivePageType(pathname); + expect(result).toEqual(ARTICLE_PAGE); + }); + + it('should return Unknown if pathname does not include live or send', () => { + const pathname = '/pidgin/xxxxxxxxx'; + const result = derivePageType(pathname); + expect(result).toEqual(UNKNOWN_PAGE); }); }); diff --git a/ws-nextjs-app/utilities/derivePageType/index.ts b/ws-nextjs-app/utilities/derivePageType/index.ts index 936eaecf4ca..d9545f0ff7a 100644 --- a/ws-nextjs-app/utilities/derivePageType/index.ts +++ b/ws-nextjs-app/utilities/derivePageType/index.ts @@ -6,6 +6,7 @@ import { LIVE_PAGE, UGC_PAGE, HOME_PAGE, + UNKNOWN_PAGE, } from '#app/routes/utils/pageTypes'; import { isOptimoIdCheck, @@ -38,9 +39,7 @@ const isHomePagePath = (pathname: string) => return false; }); -export default function derivePageType( - pathname: string, -): PageTypes | 'Unknown' { +export default function derivePageType(pathname: string): PageTypes { const sanitisedPathname = new URL( removeRendererExtension(pathname), 'http://bbc.com', @@ -54,5 +53,5 @@ export default function derivePageType( if (isOptimoIdCheck(sanitisedPathname)) return ARTICLE_PAGE; if (isCpsIdCheck(sanitisedPathname)) return ARTICLE_PAGE; - return 'Unknown'; + return UNKNOWN_PAGE; } diff --git a/ws-nextjs-app/utilities/handleServerLogging/index.ts b/ws-nextjs-app/utilities/handleServerLogging/index.ts new file mode 100644 index 00000000000..96631f86a07 --- /dev/null +++ b/ws-nextjs-app/utilities/handleServerLogging/index.ts @@ -0,0 +1,56 @@ +import { DocumentContext } from 'next/document'; +import { + SERVER_SIDE_RENDER_REQUEST_RECEIVED, + SERVER_SIDE_REQUEST_FAILED, +} from '#app/lib/logger.const'; +import { INTERNAL_SERVER_ERROR, OK } from '#app/lib/statusCodes.const'; +import nodeLogger from '#lib/logger.node'; + +import { PageTypes } from '#app/models/types/global'; +import sendCustomMetric from '#src/server/utilities/customMetrics'; +import { NON_200_RESPONSE } from '#src/server/utilities/customMetrics/metrics.const'; +import removeSensitiveHeaders from '../removeSensitiveHeaders'; + +const logger = nodeLogger(__filename); + +const handleServerLogging = ({ + ctx, + pageType, +}: { + ctx: DocumentContext; + pageType: PageTypes; +}) => { + const url = ctx.asPath || ''; + const headers = removeSensitiveHeaders(ctx.req?.headers); + const { statusCode } = ctx.res || {}; + const { cause, message, name, stack } = ctx.err || {}; + + switch (statusCode) { + case OK: + logger.debug(SERVER_SIDE_RENDER_REQUEST_RECEIVED, { + url, + headers, + pageType, + }); + break; + case INTERNAL_SERVER_ERROR: + sendCustomMetric({ + metricName: NON_200_RESPONSE, + statusCode, + pageType, + requestUrl: url, + }); + logger.error(SERVER_SIDE_REQUEST_FAILED, { + status: INTERNAL_SERVER_ERROR, + message: { cause, message, name, stack, url }, + url, + headers, + pageType, + }); + break; + default: + break; + } +}; + +export default handleServerLogging;