diff --git a/Dockerfile b/Dockerfile index 1b7e7cf337..5b315bb111 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ 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 \ @@ -67,21 +68,41 @@ 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 \ -&& 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 +&& yarn workspace @corona-dashboard/app build + +FROM node:lts-alpine as runner + +# 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 /app/.next/standalone +COPY --from=builder --chown=nextjs:nodejs /app/packages/app/.next/static /app/.next/standalone/packages/app/.next/static +COPY --from=builder /app/packages/app/next.config.js /app/.next/standalone/packages/app + +RUN mkdir -p /app/.next/standalone/packages/app/public/images/choropleth +RUN chown -R nextjs:nodejs /app/.next/standalone/packages/app/public/images/choropleth + +WORKDIR /app/.next/standalone/packages/app USER nextjs -CMD ["yarn", "start"] +ENV PORT=8080 + +CMD ["node", "server.js"] diff --git a/packages/app/next-server.js b/packages/app/next-server.js deleted file mode 100644 index cb310d514f..0000000000 --- a/packages/app/next-server.js +++ /dev/null @@ -1,214 +0,0 @@ -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 7419a0810b..423d94c210 100644 --- a/packages/app/next.config.js +++ b/packages/app/next.config.js @@ -14,6 +14,25 @@ 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. @@ -41,12 +60,22 @@ 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 @@ -70,11 +99,63 @@ 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, @@ -85,6 +166,16 @@ const nextConfig = { }, ], }, + { + source: '/:all*(html)', + locale: false, + headers: [ + { + key: 'Cache-Control', + value: 'no-cache, public', + }, + ], + }, ]; }, @@ -99,6 +190,10 @@ 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 86cd8a4209..126bf064a6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -39,7 +39,6 @@ "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", @@ -148,11 +147,12 @@ "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": "node next-server.js", + "dev:next": "next dev", "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": "cross-env NODE_ENV=production node next-server.js", + "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-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 new file mode 100644 index 0000000000..c56bd0cd42 --- /dev/null +++ b/packages/app/src/pages/_middleware.ts @@ -0,0 +1,29 @@ +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 1484b8420b..1cc5e9fffc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2622,7 +2622,6 @@ __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 @@ -15888,13 +15887,6 @@ __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"