From 1ac4763d11b0415c94d0fc716d878ccbaec83ad4 Mon Sep 17 00:00:00 2001 From: ivan-aksamentov Date: Wed, 24 Aug 2022 21:29:10 +0200 Subject: [PATCH] chore: use compression --- .github/workflows/ci.yml | 16 +- .../lambda-at-edge/OriginRequest.lambda.js | 28 ++++ .../lambda-at-edge/ViewerResponse.lambda.js | 68 +++++++++ infra/s3-cors-permissions.json | 9 ++ .../lambda-at-edge/OriginRequest.lambda.js | 28 ++++ .../lambda-at-edge/ViewerResponse.lambda.js | 144 ++++++++++++++++++ 6 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 infra/data/lambda-at-edge/OriginRequest.lambda.js create mode 100644 infra/data/lambda-at-edge/ViewerResponse.lambda.js create mode 100644 infra/s3-cors-permissions.json create mode 100644 infra/web/lambda-at-edge/OriginRequest.lambda.js create mode 100644 infra/web/lambda-at-edge/ViewerResponse.lambda.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 793b0bf..92e1039 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,11 @@ jobs: with: node-version: 10 + - name: Install system dependencies + run: | + sudo apt-get update -qq --yes + sudo apt-get install pigz + - name: Install NPM dependencies run: | npm ci @@ -70,7 +75,10 @@ jobs: run: | : ${DATA_ROOT_URL?"Environment variable is required"} npm run build - ls -a1 public/*.html + + - name: Compress + run: | + pigz -kfr public/ || true - name: Check requirements if: endsWith(github.ref, '/static-app') || endsWith(github.ref, '/release') @@ -93,21 +101,23 @@ jobs: aws s3 cp \ --recursive \ --cache-control "max-age=2592000, public" \ + --metadata-directive REPLACE \ --exclude "*.html" \ + --exclude "*.html.gz" \ "./" "s3://${AWS_S3_BUCKET}/" - name: Deploy HTML files, stripping extension if: endsWith(github.ref, '/static-app') || endsWith(github.ref, '/release') run: | cd public/ - find * -type f -name "*.html" -print0 | xargs -0 -P4 -n1 -I '{}' -- bash -c '\ + find * -type f \( -name "*.html" -o -iname "*.html.gz" \) -print0 | xargs -0 -P4 -n1 -I '{}' -- bash -c '\ file={}; \ aws s3 cp \ --content-type "text/html" \ --cache-control "no-cache" \ --metadata-directive REPLACE \ $file \ - s3://${AWS_S3_BUCKET}/${file%.html}' + s3://${AWS_S3_BUCKET}/${file//.html/}' - name: Invalidate AWS Cloudfront cache if: endsWith(github.ref, '/static-app') || endsWith(github.ref, '/release') diff --git a/infra/data/lambda-at-edge/OriginRequest.lambda.js b/infra/data/lambda-at-edge/OriginRequest.lambda.js new file mode 100644 index 0000000..dcd73a8 --- /dev/null +++ b/infra/data/lambda-at-edge/OriginRequest.lambda.js @@ -0,0 +1,28 @@ +// Implements rewrite of non-gz to gz URLs using AWS Lambda@Edge. This is +// useful if you have precompressed your files. +// +// Usage: Create an AWS Lambda function and attach it to "Origin Request" event +// of a Cloudfront distribution + +ARCHIVE_EXTS = [ + '.7z', + '.br', + '.bz2', + '.gz', + '.lzma', + '.xz', + '.zip', + '.zst', +] + +exports.handler = (event, context, callback) => { + const request = event.Records[0].cf.request + + // If not an archive file (which are not precompressed), rewrite the URL to + // get the corresponding .gz file + if(!ARCHIVE_EXTS.every(ext => request.uri.endsWith(ext))) { + request.uri += '.gz' + } + + callback(null, request) +} diff --git a/infra/data/lambda-at-edge/ViewerResponse.lambda.js b/infra/data/lambda-at-edge/ViewerResponse.lambda.js new file mode 100644 index 0000000..fe2f515 --- /dev/null +++ b/infra/data/lambda-at-edge/ViewerResponse.lambda.js @@ -0,0 +1,68 @@ +/* eslint-disable prefer-destructuring */ +// Adds additional headers to the response, including security headers and CORS. +// Suited for serving files and APIs. +// +// See also: +// - https://securityheaders.com/ +// +// Usage: Create an AWS Lambda@Edge function and attach it to "Viewer Response" +// event of a Cloudfront distribution + + +const NEW_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Content-Security-Policy': `default-src 'none'; frame-ancestors 'none'`, + 'Strict-Transport-Security': 'max-age=15768000; includeSubDomains; preload', + 'X-Content-Type-Options': 'nosniff', + 'X-DNS-Prefetch-Control': 'off', + 'X-Download-Options': 'noopen', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block', +} + +function addHeaders(headersObject) { + return Object.fromEntries( + Object.entries(headersObject).map(([header, value]) => [header.toLowerCase(), [{ + key: header, + value + }]]), + ) +} + +const HEADERS_TO_REMOVE = new Set(['server', 'via']) + +function filterHeaders(headers) { + return Object.entries(headers).reduce((result, [key, value]) => { + if(HEADERS_TO_REMOVE.has(key.toLowerCase())) { + return result + } + + if(key.toLowerCase().includes('powered-by')) { + return result + } + + return { ...result, [key.toLowerCase()]: value } + }, {}) +} + +function modifyHeaders({ request, response }) { + let newHeaders = addHeaders(NEW_HEADERS) + + newHeaders = { + ...response.headers, + ...newHeaders, + } + + newHeaders = filterHeaders(newHeaders) + + return newHeaders +} + +exports.handler = (event, context, callback) => { + const { request, response } = event.Records[0].cf + response.headers = modifyHeaders({ request, response }) + callback(null, response) +} + +exports.modifyHeaders = modifyHeaders diff --git a/infra/s3-cors-permissions.json b/infra/s3-cors-permissions.json new file mode 100644 index 0000000..d705ef4 --- /dev/null +++ b/infra/s3-cors-permissions.json @@ -0,0 +1,9 @@ +[ + { + "AllowedHeaders": ["*"], + "AllowedMethods": ["GET", "HEAD"], + "AllowedOrigins": ["*"], + "ExposeHeaders": [], + "MaxAgeSeconds": 3000 + } +] diff --git a/infra/web/lambda-at-edge/OriginRequest.lambda.js b/infra/web/lambda-at-edge/OriginRequest.lambda.js new file mode 100644 index 0000000..dcd73a8 --- /dev/null +++ b/infra/web/lambda-at-edge/OriginRequest.lambda.js @@ -0,0 +1,28 @@ +// Implements rewrite of non-gz to gz URLs using AWS Lambda@Edge. This is +// useful if you have precompressed your files. +// +// Usage: Create an AWS Lambda function and attach it to "Origin Request" event +// of a Cloudfront distribution + +ARCHIVE_EXTS = [ + '.7z', + '.br', + '.bz2', + '.gz', + '.lzma', + '.xz', + '.zip', + '.zst', +] + +exports.handler = (event, context, callback) => { + const request = event.Records[0].cf.request + + // If not an archive file (which are not precompressed), rewrite the URL to + // get the corresponding .gz file + if(!ARCHIVE_EXTS.every(ext => request.uri.endsWith(ext))) { + request.uri += '.gz' + } + + callback(null, request) +} diff --git a/infra/web/lambda-at-edge/ViewerResponse.lambda.js b/infra/web/lambda-at-edge/ViewerResponse.lambda.js new file mode 100644 index 0000000..dade2bd --- /dev/null +++ b/infra/web/lambda-at-edge/ViewerResponse.lambda.js @@ -0,0 +1,144 @@ +/* eslint-disable prefer-destructuring */ +// Adds additional headers to the response, including security headers. +// Suited for websites. +// +// See also: +// - https://securityheaders.com/ +// +// Usage: Create an AWS Lambda@Edge function and attach it to "Viewer Response" +// event of a Cloudfront distribution + +const FEATURE_POLICY = { + 'accelerometer': `'none'`, + 'autoplay': `'none'`, + 'camera': `'none'`, + 'document-domain': `'none'`, + 'encrypted-media': `'none'`, + 'fullscreen': `'none'`, + 'geolocation': `'none'`, + 'gyroscope': `'none'`, + 'magnetometer': `'none'`, + 'microphone': `'none'`, + 'midi': `'none'`, + 'payment': `'none'`, + 'picture-in-picture': `'none'`, + 'sync-xhr': `'none'`, + 'usb': `'none'`, + 'xr-spatial-tracking': `'none'`, +} + +function generateFeaturePolicyHeader(featurePolicyObject) { + return Object.entries(featurePolicyObject) + .map(([policy, value]) => `${policy} ${value}`) + .join('; ') +} + +const PERMISSIONS_POLICY = { + 'accelerometer': '()', + 'ambient-light-sensor': '()', + 'autoplay': '()', + 'battery': '()', + 'camera': '()', + 'clipboard-read': '()', + 'clipboard-write': '()', + 'conversion-measurement': '()', + 'cross-origin-isolated': '()', + 'display-capture': '()', + 'document-domain': '()', + 'encrypted-media': '()', + 'execution-while-not-rendered': '()', + 'execution-while-out-of-viewport': '()', + 'focus-without-user-activation': '()', + 'fullscreen': '()', + 'gamepad': '()', + 'geolocation': '()', + 'gyroscope': '()', + 'hid': '()', + 'idle-detection': '()', + 'interest-cohort': '()', + 'keyboard-map': '()', + 'magnetometer': '()', + 'microphone': '()', + 'midi': '()', + 'navigation-override': '()', + 'payment': '()', + 'picture-in-picture': '()', + 'publickey-credentials-get': '()', + 'screen-wake-lock': '()', + 'serial': '()', + 'speaker-selection': '()', + 'sync-script': '()', + 'sync-xhr': '()', + 'trust-token-redemption': '()', + 'usb': '()', + 'vertical-scroll': '()', + 'web-share': '()', + 'window-placement': '()', + 'xr-spatial-tracking': '()', +} + +function generatePermissionsPolicyHeader(permissionsPolicyObject) { + return Object.entries(permissionsPolicyObject) + .map(([policy, value]) => `${policy}=${value}`) + .join(', ') +} + +const NEW_HEADERS = { + 'Content-Security-Policy': + `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: ; connect-src *`, + 'Referrer-Policy': 'no-referrer', + 'Strict-Transport-Security': 'max-age=15768000; includeSubDomains; preload', + 'X-Content-Type-Options': 'nosniff', + 'X-DNS-Prefetch-Control': 'off', + 'X-Download-Options': 'noopen', + 'X-Frame-Options': 'SAMEORIGIN', + 'X-XSS-Protection': '1; mode=block', + 'Feature-Policy': generateFeaturePolicyHeader(FEATURE_POLICY), + 'Permissions-Policy': generatePermissionsPolicyHeader(PERMISSIONS_POLICY), +} + +function addHeaders(headersObject) { + return Object.fromEntries( + Object.entries(headersObject).map(([header, value]) => [header.toLowerCase(), [{ + key: header, + value + }]]), + ) +} + +const HEADERS_TO_REMOVE = new Set(['server', 'via']) + +function filterHeaders(headers) { + return Object.entries(headers).reduce((result, [key, value]) => { + if(HEADERS_TO_REMOVE.has(key.toLowerCase())) { + return result + } + + if(key.toLowerCase().includes('powered-by')) { + return result + } + + return { ...result, [key.toLowerCase()]: value } + }, {}) +} + +function modifyHeaders({ request, response }) { + let newHeaders = addHeaders(NEW_HEADERS) + + newHeaders = { + ...response.headers, + ...newHeaders, + } + + newHeaders = filterHeaders(newHeaders) + + return newHeaders +} + +exports.handler = (event, context, callback) => { + const { request, response } = event.Records[0].cf + response.headers = modifyHeaders({ request, response }) + callback(null, response) +} + +exports.modifyHeaders = modifyHeaders