diff --git a/Dockerfile b/Dockerfile index a751cdb836..1b7e7cf337 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,6 @@ COPY packages/cli/package.json ./packages/cli/ COPY packages/cms/package.json ./packages/cms/ COPY packages/common/package.json ./packages/common/ COPY packages/icons/package.json ./packages/icons/ - RUN apk add --no-cache --virtual \ build-dependencies \ python3 \ @@ -47,9 +46,7 @@ RUN apk add --no-cache --virtual \ # Layer cache for rebuilds without sourcecode changes. # This relies on the JSONS being downloaded by the builder. FROM deps as builder -WORKDIR /app COPY . . - RUN yarn workspace @corona-dashboard/common build \ && yarn workspace @corona-dashboard/cli generate-data-types \ && yarn workspace @corona-dashboard/icons build \ @@ -70,42 +67,21 @@ ENV NEXT_PUBLIC_PHASE=$ARG_NEXT_PUBLIC_PHASE ENV NEXT_PUBLIC_HOT_RELOAD_LOKALIZE=ARG_NEXT_PUBLIC_HOT_RELOAD_LOKALIZE ENV API_URL=$ARG_API_URL +# Layer that always gets executed +FROM builder + # Yarn download uses the API_URL env variable to download the zip with JSONs from the provided URL. RUN yarn download \ && yarn workspace @corona-dashboard/cli validate-json-all \ && yarn workspace @corona-dashboard/cli validate-last-values --fail-early \ && yarn workspace @corona-dashboard/cms lokalize:import --dataset=$NEXT_PUBLIC_SANITY_DATASET \ -&& yarn workspace @corona-dashboard/app build - -FROM node:lts-alpine as runner -WORKDIR /app - -# Required runtime dependencies for `canvas.node` - generating choropleths as image -RUN apk add --no-cache \ - cairo \ - jpeg \ - pango \ - musl \ - giflib \ - pixman \ - pangomm \ - libjpeg-turbo \ - freetype - -RUN addgroup -g 1001 -S nodejs \ -&& adduser -S nextjs -u 1001 - -COPY --from=builder --chown=nextjs:nodejs /app/packages/app/.next/standalone ./.next/standalone -COPY --from=builder --chown=nextjs:nodejs /app/packages/app/.next/static ./.next/standalone/packages/app/.next/static -COPY --from=builder /app/packages/app/next.config.js ./.next/standalone/packages/app - -RUN mkdir -p ./.next/standalone/packages/app/public/images/choropleth -RUN chown -R nextjs:nodejs ./.next/standalone/packages/app/public/images/choropleth - -WORKDIR ./.next/standalone/packages/app +&& yarn workspace @corona-dashboard/app build \ +&& mkdir -p /app/packages/app/public/images/choropleth \ +&& addgroup -g 1001 -S nodejs \ +&& adduser -S nextjs -u 1001 \ +&& chown -R nextjs:nodejs /app/packages/app/.next \ +&& chown -R nextjs:nodejs /app/packages/app/public/images/choropleth USER nextjs -ENV PORT=8080 - -CMD ["node", "server.js"] +CMD ["yarn", "start"] diff --git a/packages/app/next-server.js b/packages/app/next-server.js new file mode 100644 index 0000000000..cb310d514f --- /dev/null +++ b/packages/app/next-server.js @@ -0,0 +1,214 @@ +const express = require('express'); +const helmet = require('helmet'); +const next = require('next'); +const { + createProxyMiddleware, + responseInterceptor, +} = require('http-proxy-middleware'); +const dotenv = require('dotenv'); +const path = require('path'); +const { imageResizeTargets } = require('@corona-dashboard/common'); +const intercept = require('intercept-stdout'); + +const SIX_MONTHS_IN_SECONDS = 15768000; + +const ALLOWED_SENTRY_IMAGE_PARAMS = { + w: imageResizeTargets.map((x) => x.toString()), + q: ['65'], + auto: ['format'], +}; + +dotenv.config({ + path: path.resolve(process.cwd(), '.env.local'), +}); + +if (!process.env.NEXT_PUBLIC_SANITY_DATASET) { + throw new Error('Provide NEXT_PUBLIC_SANITY_DATASET'); +} + +if (!process.env.NEXT_PUBLIC_SANITY_PROJECT_ID) { + throw new Error('Provide NEXT_PUBLIC_SANITY_PROJECT_ID'); +} + +const IS_PRODUCTION_BUILD = process.env.NODE_ENV === 'production'; +const IS_DEVELOPMENT_PHASE = process.env.NEXT_PUBLIC_PHASE === 'develop'; +const app = next({ dev: !IS_PRODUCTION_BUILD }); +const handle = app.getRequestHandler(); + +const PORT = process.env.EXPRESS_PORT || (IS_PRODUCTION_BUILD ? 8080 : 3000); +const SANITY_PATH = `${process.env.NEXT_PUBLIC_SANITY_PROJECT_ID}/${process.env.NEXT_PUBLIC_SANITY_DATASET}`; + +const STATIC_ASSET_MAX_AGE_IN_SECONDS = 14 * 24 * 60 * 60; // two weeks +const STATIC_ASSET_HTTP_DATE = new Date( + Date.now() + STATIC_ASSET_MAX_AGE_IN_SECONDS * 1000 +).toUTCString(); + +(async function () { + await app.prepare().then(async () => { + // in front of all other code + intercept((text) => { + if ( + text.indexOf( + 'Anonymous arrow functions cause Fast Refresh to not preserve local component state' + ) > -1 + ) + return ''; + return text; + }); + }); + + const server = express(); + + server.use(helmet()); + server.disable('x-powered-by'); + + /** + * Explicitly reject all POST, PUT and DELETE requests + */ + server.post('*', function (_, res) { + res.status(403).end(); + }); + + server.put('*', function (_, res) { + res.status(403).end(); + }); + + server.delete('*', function (_, res) { + res.status(403).end(); + }); + + /** + * Ensure the correct language by resetting the original hostname + * Next.js will use the hostname to detect the language it should serve. + */ + server.use(function (req, res, next) { + req.headers.host = req.headers['x-original-host'] || req.headers.host; + next(); + }); + + server.use( + '/cms-:type(images|files)', + createProxyMiddleware(filterImageRequests, { + target: 'https://cdn.sanity.io', + changeOrigin: true, + selfHandleResponse: true, + pathRewrite: function (path) { + /** + * Rewrite + * /cms-images/filename.ext + * to + * /images/NEXT_PUBLIC_SANITY_PROJECT_ID/NEXT_PUBLIC_SANITY_DATASET/filename.ext + */ + const newPath = path.replace( + /^\/cms-(images|files)/, + `/$1/${SANITY_PATH}` + ); + + return newPath; + }, + onProxyRes: responseInterceptor(async function ( + responseBuffer, + proxyRes, + req, + res + ) { + setResponseHeaders(res, SIX_MONTHS_IN_SECONDS, false); + return responseBuffer; + }), + }) + ); + + /** + * Redirect traffic from /en and /nl; + * due to Next.js bug these routes become available. + * @TODO: remove when bug in Next.js is fixed. + */ + server.get('/en/*', function (_, res) { + res.redirect('/'); + }); + + server.get('/nl/*', function (_, res) { + res.redirect('/'); + }); + + /** + * Set these headers for all non-Sanity-cdn routes + */ + server.use(function (req, res, tick) { + const isHtml = req.url.indexOf('.') === -1; + setResponseHeaders(res, SIX_MONTHS_IN_SECONDS, isHtml); + tick(); + }); + + server.get('*', function (req, res) { + return handle(req, res); + }); + + await server.listen(PORT); + + console.log(`> Ready on http://localhost:${PORT}`); // eslint-disable-line no-console + + /** + * Set headers for a response + */ + function setResponseHeaders( + res, + maxAge = SIX_MONTHS_IN_SECONDS, + noCache = false + ) { + const contentSecurityPolicy = + IS_PRODUCTION_BUILD && !IS_DEVELOPMENT_PHASE + ? "default-src 'self'; img-src 'self' statistiek.rijksoverheid.nl data:; style-src 'self' 'unsafe-inline'; script-src 'self' statistiek.rijksoverheid.nl; font-src 'self'; frame-ancestors 'none'; object-src 'none'; form-action 'none';" + : "default-src 'self'; img-src 'self' statistiek.rijksoverheid.nl data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval' 'unsafe-inline' statistiek.rijksoverheid.nl; font-src 'self'; frame-ancestors 'none'; object-src 'none'; form-action 'none'; connect-src 'self' 5mog5ask.api.sanity.io * ws: wss:;"; + res.set('Content-Security-Policy', contentSecurityPolicy); + res.set('Referrer-Policy', 'no-referrer'); + res.set('X-Content-Type-Options', 'nosniff'); + res.set('X-Frame-Options', 'DENY'); + res.set('X-XSS-Protection', '1; mode=block'); + res.set( + 'Strict-Transport-Security', + `max-age=${maxAge}; includeSubdomains; preload` + ); + res.set('Permissions-Policy', 'interest-cohort=()'); + + if (noCache) { + /** + * HTML pages are only cached shortly by the CDN + */ + res.set('Cache-control', 'no-cache, public'); + } else { + /** + * Non-HTML requests are are cached indefinitely and are provided with a hash to be able to cache-bust them. + * These are not applied to assets in the public folder. (See headers() in next.config.js for that.) + */ + res.setHeader( + 'Cache-Control', + `public, max-age=${STATIC_ASSET_MAX_AGE_IN_SECONDS}` + ); + res.setHeader('Vary', 'content-type'); + res.setHeader('Expires', STATIC_ASSET_HTTP_DATE); + } + + res.removeHeader('via'); + res.removeHeader('X-Powered-By'); + } +})(); + +/** + * Filters requests to Sanity image API to prevent unwanted params to be sent along. + */ +function filterImageRequests(pathname, req) { + return Object.entries(req.query).every(([key, value]) => { + const allowedValues = ALLOWED_SENTRY_IMAGE_PARAMS[key]; + + if (!allowedValues) { + return false; + } + + if (!allowedValues.includes(value)) { + return false; + } + + return true; + }); +} diff --git a/packages/app/next.config.js b/packages/app/next.config.js index 423d94c210..7419a0810b 100644 --- a/packages/app/next.config.js +++ b/packages/app/next.config.js @@ -14,25 +14,6 @@ const withTranspileModules = require('next-transpile-modules')([ const path = require('path'); const { DuplicatesPlugin } = require('inspectpack/plugin'); -if (!process.env.NEXT_PUBLIC_SANITY_DATASET) { - throw new Error('Provide NEXT_PUBLIC_SANITY_DATASET'); -} - -if (!process.env.NEXT_PUBLIC_SANITY_PROJECT_ID) { - throw new Error('Provide NEXT_PUBLIC_SANITY_PROJECT_ID'); -} - -const IS_PRODUCTION_BUILD = process.env.NODE_ENV === 'production'; -const IS_DEVELOPMENT_PHASE = process.env.NEXT_PUBLIC_PHASE === 'develop'; - -const SANITY_PATH = `${process.env.NEXT_PUBLIC_SANITY_PROJECT_ID}/${process.env.NEXT_PUBLIC_SANITY_DATASET}`; -const SIX_MONTHS_IN_SECONDS = 15768000; - -const STATIC_ASSET_MAX_AGE_IN_SECONDS = 14 * 24 * 60 * 60; // two weeks -const STATIC_ASSET_HTTP_DATE = new Date( - Date.now() + STATIC_ASSET_MAX_AGE_IN_SECONDS * 1000 -).toUTCString(); - // When municipal reorganizations happened we want to redirect to the new municipality when // using the former municipality code. `from` contains the old municipality codes and `to` is // the new municipality code to link to. @@ -60,22 +41,12 @@ const gmRedirects = [ ]; const nextConfig = { - experimental: - IS_PRODUCTION_BUILD && !IS_DEVELOPMENT_PHASE - ? { - outputStandalone: true, - outputFileTracingRoot: path.join(__dirname, '../../'), - } - : undefined, - /** * Enables react strict mode * https://nextjs.org/docs/api-reference/next.config.js/react-strict-mode */ reactStrictMode: true, - poweredByHeader: false, - i18n: { // These are all the locales you want to support in // your application @@ -99,63 +70,11 @@ const nextConfig = { ], }, + /** + * More header management is done by the next.server.js for the HTML pages and JS/CSS assets. + */ async headers() { - const contentSecurityPolicy = - IS_PRODUCTION_BUILD && !IS_DEVELOPMENT_PHASE - ? "default-src 'self'; img-src 'self' statistiek.rijksoverheid.nl data:; style-src 'self' 'unsafe-inline'; script-src 'self' statistiek.rijksoverheid.nl; font-src 'self'; frame-ancestors 'none'; object-src 'none'; form-action 'none';" - : "default-src 'self'; img-src 'self' statistiek.rijksoverheid.nl data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval' 'unsafe-inline' statistiek.rijksoverheid.nl; font-src 'self'; frame-ancestors 'none'; object-src 'none'; form-action 'none'; connect-src 'self' 5mog5ask.api.sanity.io * ws: wss:;"; - return [ - { - source: '/:all*', - locale: false, - headers: [ - { - key: 'Content-Security-Policy', - value: contentSecurityPolicy, - }, - { - key: 'Referrer-Policy', - value: 'no-referrer', - }, - { - key: 'X-Content-Type-Options', - value: 'nosniff', - }, - { - key: 'X-Frame-Options', - value: 'DENY', - }, - { - key: 'X-XSS-Protection', - value: '1; mode=block', - }, - { - key: 'Strict-Transport-Security', - value: `max-age=${SIX_MONTHS_IN_SECONDS}; includeSubdomains; preload`, - }, - { - key: 'Permissions-Policy', - value: 'interest-cohort=()', - }, - { - key: 'Cache-Control', - value: `public, max-age=${STATIC_ASSET_MAX_AGE_IN_SECONDS}`, - }, - { - key: 'Vary', - value: 'content-type', - }, - { - key: 'Expires', - value: STATIC_ASSET_HTTP_DATE, - }, - { - key: 'X-DNS-Prefetch-Control', - value: 'on', - }, - ], - }, { source: '/:all*(svg|jpg|png|woff|woff2)', locale: false, @@ -166,16 +85,6 @@ const nextConfig = { }, ], }, - { - source: '/:all*(html)', - locale: false, - headers: [ - { - key: 'Cache-Control', - value: 'no-cache, public', - }, - ], - }, ]; }, @@ -190,10 +99,6 @@ const nextConfig = { source: '/veiligheidsregio/(v|V)(r|R):nr(\\d{2})/:page*', destination: '/veiligheidsregio/VR:nr/:page*', }, - { - source: `/cms-(files|images)/:filename`, - destination: `https://cdn.sanity.io/images/${SANITY_PATH}/:filename`, - }, ], }; }, diff --git a/packages/app/package.json b/packages/app/package.json index 126bf064a6..86cd8a4209 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -39,6 +39,7 @@ "geojson": "^0.5.0", "globby": "^12.0.2", "hash-sum": "^2.0.0", + "helmet": "^4.6.0", "http-proxy-middleware": "^2.0.1", "intercept-stdout": "^0.1.2", "konva": "^7.2.5", @@ -147,12 +148,11 @@ "bootstrap": "exit 0", "export": "next export", "dev": "yarn workspace @corona-dashboard/icons build && yarn workspace @corona-dashboard/common build && run-p dev:common dev:next dev:lokalize", - "dev:next": "next dev", + "dev:next": "node next-server.js", "dev:lokalize": "chokidar \"./src/locale/nl_export.json\" -c \"yarn workspace @corona-dashboard/cms lokalize:generate-types\"", "dev:common": "yarn workspace @corona-dashboard/common build:watch", "build": "cross-env NEXT_TELEMETRY_DISABLED=1 && next build", - "start": "yarn copy-static && PORT=8080 node ./.next/standalone/packages/app/server.js", - "copy-static": "cp -r ./.next/static ./.next/standalone/packages/app/.next/static", + "start": "cross-env NODE_ENV=production node next-server.js", "start-e2e": "next start -H 0.0.0.0 -p 3000", "test": "cross-env TS_NODE_PROJECT=tsconfig.test.json uvu -r ts-node/register", "test:coverage": "c8 --include=src yarn test", diff --git a/packages/app/src/pages/_middleware.ts b/packages/app/src/pages/_middleware.ts deleted file mode 100644 index c56bd0cd42..0000000000 --- a/packages/app/src/pages/_middleware.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextResponse, NextRequest } from 'next/server'; - -const DISALLOWED_HTTP_METHODS = ['POST', 'PUT', 'DELETE']; - -export function middleware(req: NextRequest) { - const method = req.method.toUpperCase(); - if (DISALLOWED_HTTP_METHODS.includes(method)) { - return new Response(undefined, { - status: 403, - }); - } - - /** - * Ensure the correct language by resetting the original hostname - * Next.js will use the hostname to detect the language it should serve. - */ - const response = NextResponse.next(); - response.headers.set( - 'host', - req.headers.get('x-original-host') || req.headers.get('host') || '' - ); - - /** - * Remove header from Sanity proxyed assets - */ - response.headers.delete('via'); - - return response; -} diff --git a/yarn.lock b/yarn.lock index 1cc5e9fffc..1484b8420b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2622,6 +2622,7 @@ __metadata: geojson: ^0.5.0 globby: ^12.0.2 hash-sum: ^2.0.0 + helmet: ^4.6.0 http-proxy-middleware: ^2.0.1 identity-obj-proxy: ^3.0.0 inspectpack: ^4.7.1 @@ -15887,6 +15888,13 @@ __metadata: languageName: node linkType: hard +"helmet@npm:^4.6.0": + version: 4.6.0 + resolution: "helmet@npm:4.6.0" + checksum: 139ad678d1cab207b043c206f50f6744eff2ef1f463e4626d36718b45b337485c77d10260ef9d89d292fa678da5153d86b08172b3b365cc8e680241015ed3a49 + languageName: node + linkType: hard + "hex-color-regex@npm:^1.1.0": version: 1.1.0 resolution: "hex-color-regex@npm:1.1.0"