From 523ec6e94854a755bc06530ce798bcfffeb622d6 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Tue, 1 Oct 2024 13:55:27 +0930 Subject: [PATCH] Deploy cloudfront function initial commit --- .../workflows/deploy_cloudfront_function.yml | 56 +++++ SmartFormsIgRouting.js | 193 ++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 .github/workflows/deploy_cloudfront_function.yml create mode 100644 SmartFormsIgRouting.js diff --git a/.github/workflows/deploy_cloudfront_function.yml b/.github/workflows/deploy_cloudfront_function.yml new file mode 100644 index 00000000..8d415381 --- /dev/null +++ b/.github/workflows/deploy_cloudfront_function.yml @@ -0,0 +1,56 @@ +name: Deploy IG Routing Cloudfront function Workflow + +on: + push: + +jobs: + build: + name: Deploy IG Routing Cloudfront function + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 16.x + uses: actions/setup-node@v4 + with: + node-version: 16 + cache: npm + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::209248795938:role/SmartFormsReactAppDeployment + aws-region: ap-southeast-2 + + - name: Describe the SmartFormsIgRouting function to get current ETag + id: describe_function + run: | + OUTPUT=$(aws cloudfront get-function --name SmartFormsIgRouting) + ETag=$(echo $OUTPUT | jq -r '.ETag') + echo "::set-output name=etag::$ETag" + shell: bash + + - name: Update the SmartFormsIgRouting Function + id: update_function + run: | + OUTPUT=$(aws cloudfront update-function \ + --name SmartFormsIgRouting \ + --if-match ${{ steps.describe_function.outputs.etag }} \ + --function-config "{\"Comment\":\"Manages routing within the Smart Forms IG\",\"Runtime\":\"cloudfront-js-2.0\"}" \ + --function-code fileb://./SmartFormsIgRouting.js) + + NEW_ETAG=$(echo $OUTPUT | jq -r '.ETag') + echo "::set-output name=new_etag::$NEW_ETAG" + shell: bash + + - name: Publish the SmartFormsIgRouting Function + run: | + aws cloudfront publish-function \ + --name SmartFormsIgRouting \ + --if-match ${{ steps.update_function.outputs.new_etag }} + shell: bash + + - name: Log the new ETag + run: | + echo "New ETag after updating and publishing: ${{ steps.update_function.outputs.new_etag }}" + shell: bash diff --git a/SmartFormsIgRouting.js b/SmartFormsIgRouting.js new file mode 100644 index 00000000..8573eaf4 --- /dev/null +++ b/SmartFormsIgRouting.js @@ -0,0 +1,193 @@ +/* + * This function is used to handle routing for the Smart Forms IG in Cloudfront. + * The base IG url should be https://smartforms.csiro.au/ig + * + * When updating the IG, the "latestVersion" variable (line 10) should be updated. + * It triggers a workflow in GitHub Actions to update the deployed function in Cloudfront. + */ + +// Latest IG version, update this every time the IG is updated +const latestVersion = "0.2.0-draft"; + +const basePathIg = "ig" +const implementationGuideCanonical = `/${basePathIg}/ImplementationGuide/csiro.fhir.au.smartforms` + +const validCanonicalResourceTypes = [ + "ActivityDefinition", + "CapabilityStatement", + "ChargeItemDefinition", + "CodeSystem", + "CompartmentDefinition", + "ConceptMap", + "EffectEvidenceSynthesis", + "EventDefinition", + "Evidence", + "EvidenceVariable", + "ExampleScenario", + "GraphDefinition", + "ImplementationGuide", + "Library", + "Measure", + "MessageDefinition", + "NamingSystem", + "OperationDefinition", + "PlanDefinition", + "Questionnaire", + "ResearchDefinition", + "ResearchElementDefinition", + "RiskEvidenceSynthesis", + "SearchParameter", + "StructureDefinition", + "StructureMap", + "TerminologyCapabilities", + "TestScript", + "ValueSet" +]; + +function handler(event) { + let request = event.request; + + // Add headers to control caching + request.headers['cache-control'] = { + 'value': 'no-store, no-cache, must-revalidate' + } + + // Ignore double slashes + request.uri = request.uri.replace(/\/\//g, '/'); + + let uri = request.uri; + + // Handle IG routes + if (uri.startsWith(`/${basePathIg}`)) { + // If the URI is /test-ig or /test-ig/ redirect to the index.html + if (uri === `/${basePathIg}` || uri === `/${basePathIg}/` || uri === implementationGuideCanonical) { + return { + statusCode: 301, + statusDescription: 'Moved Permanently', + headers: { + "location": {"value": `/${basePathIg}/index.html`}, + "cache-control": {"value": "no-store, no-cache, must-revalidate"} + } + }; + } + + // Handle URI with a version + if (uri.includes(".")) { + const parts = uri.split("."); + // if the last part is the version, redirect to the version's index.html + if (parts[parts.length - 1].match(/[0-9]/)) { + const pathRegex = new RegExp(`/${basePathIg}/[a-z0-9]+`, 'i'); // Create regex with basePath + const version = getVersionFromURI(uri, `/${basePathIg}`) + const versionIsLast = versionIsLastPart(uri, version) + if (uri.match(pathRegex) && version && versionIsLast) { + return { + statusCode: 301, + statusDescription: 'Moved Permanently', + headers: { + "location": {"value": `/${basePathIg}/${version}/index.html`}, + "cache-control": {"value": "no-store, no-cache, must-revalidate"} + } + }; + } + } + } + + + // Add latest version to URIs without a version + const subRoutes = uri + .split("/") + .filter((part) => part !== "") + .slice(1); + if (subRoutes.length > 0) { + const firstSubRoute = subRoutes[0]; + + // Check if the first sub route is a version, otherwise add the latest version to the URI + if (!/^[0-9]/.test(firstSubRoute)) { + request.uri = `/${basePathIg}/${latestVersion}/${subRoutes.join("/")}`; + } + + // Check if uri is canonical. if so, redirect to the resolvable link + if (isCanonicalUrl(uri, subRoutes)) { + return { + statusCode: 301, + headers: { + "location": {"value": transformCanonicalUrl(uri, basePathIg, latestVersion)}, + "cache-control": {"value": "no-store, no-cache, must-revalidate"} + } + }; + } + } + + // Add .html to URIs without an extension + let uriChunks = uri.split('/'); + let lastUriChunk = uriChunks[uriChunks.length - 1]; + if (!lastUriChunk.includes('.')) { + request.uri += '.html'; + return request; + } + } + + + return request; +} + + +function getVersionFromURI(uri, prefix) { + // Check if the uri starts with the prefix + if (uri.startsWith(prefix)) { + // Extract the part after the prefix + const afterPrefix = uri.substring(prefix.length + 1); + + // Split by the slash to isolate the version, i.e. 0.1.0-draft + return afterPrefix.split('/')[0]; + } + + return null; // Return null if the prefix is not found +} + +function versionIsLastPart(uri, version) { + // Split the uri by the slash + const parts = uri.split('/').filter(part => part !== ''); + + // Check if the last part is the version + return parts[parts.length - 1] === version; +} + +function isCanonicalUrl(uri, subRoutes) { + // Not a valid canonical URL if uri contains ".html" + if (uri.includes(".html")) { + return false; + } + + // Not a valid canonical URL if the first part has a version + if (subRoutes[0].match(/[0-9]/)) { + return false; + } + + const indexValidCanonicalResourceType = subRoutes.findIndex((subRoute) => + validCanonicalResourceTypes.includes(subRoute) + ); + + if (indexValidCanonicalResourceType === -1) { + return false; + } + + // Not a valid canonical URL if the last part is the index + if (indexValidCanonicalResourceType === subRoutes.length - 1) { + return false; + } + + return true; +} + +function transformCanonicalUrl(uri, basePathIg, latestVersion) { + const lastSlashIndex = uri.lastIndexOf("/"); + + const front = uri.substring(0, lastSlashIndex); + const back = uri.substring(lastSlashIndex + 1); + + const resolvableUrl = `${front}-${back}.html`; + + // Add latest version to the resolvable URL + return resolvableUrl.replace(basePathIg, `${basePathIg}/${latestVersion}`); +}