diff --git a/.github/workflows/build-base-images.yml b/.github/workflows/build-base-images.yml new file mode 100644 index 0000000000..3bf5ba9526 --- /dev/null +++ b/.github/workflows/build-base-images.yml @@ -0,0 +1,128 @@ +name: Build Base Docker Images + +on: + workflow_call: + inputs: + force-rebuild: + description: 'Force rebuild even if images exist' + required: false + type: boolean + default: false + outputs: + build-base-image: + description: 'Full build-base image reference' + value: ${{ jobs.build-base.outputs.image }} + runtime-base-image: + description: 'Full runtime-base image reference' + value: ${{ jobs.build-base.outputs.runtime-image }} + workflow_dispatch: + inputs: + force-rebuild: + description: 'Force rebuild even if images exist' + required: false + type: boolean + default: false + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ghcr.io/opengovsg/formsg + +jobs: + build-base: + name: Build base images + runs-on: ubuntu-latest + outputs: + image: ${{ steps.set-outputs.outputs.build-image }} + runtime-image: ${{ steps.set-outputs.outputs.runtime-image }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compute dependency hash + id: deps-hash + run: | + HASH=$(cat \ + pnpm-lock.yaml \ + package.json \ + pnpm-workspace.yaml \ + Dockerfile.base \ + apps/backend/package.json \ + apps/frontend/package.json \ + packages/shared/package.json \ + packages/sdk/package.json \ + | sha256sum | cut -c1-16) + echo "hash=$HASH" >> "$GITHUB_OUTPUT" + echo "Dependency hash: $HASH" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check if images already exist + id: check-exists + if: ${{ !inputs.force-rebuild }} + run: | + BUILD_EXISTS=false + RUNTIME_EXISTS=false + + if docker manifest inspect "${{ env.IMAGE_NAME }}:build-${{ steps.deps-hash.outputs.hash }}" > /dev/null 2>&1; then + BUILD_EXISTS=true + echo "build-base image already exists" + fi + + if docker manifest inspect "${{ env.IMAGE_NAME }}:runtime-${{ steps.deps-hash.outputs.hash }}" > /dev/null 2>&1; then + RUNTIME_EXISTS=true + echo "runtime-base image already exists" + fi + + if [[ "$BUILD_EXISTS" == "true" && "$RUNTIME_EXISTS" == "true" ]]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Both images exist, skipping build" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Docker Buildx + if: steps.check-exists.outputs.skip != 'true' + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build and push build-base image + if: steps.check-exists.outputs.skip != 'true' + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + file: Dockerfile.base + target: build-base + push: true + tags: | + ${{ env.IMAGE_NAME }}:build-${{ steps.deps-hash.outputs.hash }} + ${{ env.IMAGE_NAME }}:build-latest + cache-from: | + type=registry,ref=${{ env.IMAGE_NAME }}:build-latest + + - name: Build and push runtime-base image + if: steps.check-exists.outputs.skip != 'true' + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + file: Dockerfile.base + target: runtime-base + push: true + tags: | + ${{ env.IMAGE_NAME }}:runtime-${{ steps.deps-hash.outputs.hash }} + ${{ env.IMAGE_NAME }}:runtime-latest + cache-from: | + type=registry,ref=${{ env.IMAGE_NAME }}:runtime-latest + + - name: Set outputs + id: set-outputs + run: | + echo "build-image=${{ env.IMAGE_NAME }}:build-${{ steps.deps-hash.outputs.hash }}" >> "$GITHUB_OUTPUT" + echo "runtime-image=${{ env.IMAGE_NAME }}:runtime-${{ steps.deps-hash.outputs.hash }}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/build-release-image.yml b/.github/workflows/build-release-image.yml new file mode 100644 index 0000000000..01e6a9173b --- /dev/null +++ b/.github/workflows/build-release-image.yml @@ -0,0 +1,84 @@ +name: Build Release Image + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Version tag to build (e.g., v7.4.5)' + required: true + type: string + +permissions: + contents: read + packages: write + +env: + IMAGE_NAME: ghcr.io/opengovsg/formsg + +jobs: + ensure-base-images: + name: Ensure base images + uses: ./.github/workflows/build-base-images.yml + permissions: + contents: read + packages: write + + build-release: + name: Build release image + needs: ensure-base-images + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine version tag + id: version + run: | + if [[ "${{ github.event_name }}" == "push" ]]; then + TAG="${GITHUB_REF#refs/tags/}" + else + TAG="${{ inputs.tag }}" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Building release image for $TAG" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check if release image already exists + id: check-exists + run: | + IMAGE="${{ env.IMAGE_NAME }}:release-${{ steps.version.outputs.tag }}" + if docker manifest inspect "$IMAGE" > /dev/null 2>&1; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Release image already exists: $IMAGE" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Docker Buildx + if: steps.check-exists.outputs.skip != 'true' + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build and push release image + if: steps.check-exists.outputs.skip != 'true' + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + file: Dockerfile.production + push: true + tags: | + ${{ env.IMAGE_NAME }}:release-${{ steps.version.outputs.tag }} + build-args: | + BUILD_BASE_IMAGE=${{ needs.ensure-base-images.outputs.build-base-image }} + APP_VERSION=${{ steps.version.outputs.tag }} + DD_RUM_APP_ID=${{ secrets.DD_RUM_APP_ID }} + DD_RUM_CLIENT_TOKEN=${{ secrets.DD_RUM_CLIENT_TOKEN }} + cache-from: | + type=registry,ref=${{ env.IMAGE_NAME }}:build-latest diff --git a/.github/workflows/deploy-ecs.yml b/.github/workflows/deploy-ecs.yml index 508d5579ce..503549955f 100644 --- a/.github/workflows/deploy-ecs.yml +++ b/.github/workflows/deploy-ecs.yml @@ -114,10 +114,19 @@ on: permissions: id-token: write contents: read + packages: write jobs: + ensure-base-images: + name: Ensure base images + uses: ./.github/workflows/build-base-images.yml + permissions: + contents: read + packages: write + deploy: name: Deploy to ECS + needs: ensure-base-images runs-on: ubuntu-latest environment: ${{ inputs.gha-environment }} env: @@ -133,24 +142,6 @@ jobs: run: | echo "APP_VERSION=$(jq -r .version package.json)-$(echo ${GITHUB_REF##*/})-$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_ENV - - name: Inject frontend build env vars - env: - VITE_APP_DD_RUM_APP_ID: ${{ secrets.dd-rum-app-id }} - VITE_APP_DD_RUM_CLIENT_TOKEN: ${{ secrets.dd-rum-client-token }} - VITE_APP_DD_RUM_ENV: ${{ inputs.dd-env }} - VITE_APP_DD_SAMPLE_RATE: ${{ secrets.dd-sample-rate }} - VITE_APP_GA_TRACKING_ID: ${{ secrets.ga-tracking-id }} - VITE_APP_FORMSG_SDK_MODE: ${{ inputs.react-app-form-sg-sdk-mode }} - VITE_APP_URL: ${{ inputs.app-url }} - run: | - sed -i -e "s|@VITE_APP_URL|${{ env.VITE_APP_URL }}|g" -e "s/@VITE_APP_DD_RUM_APP_ID/$VITE_APP_DD_RUM_APP_ID/g" -e "s/@VITE_APP_DD_RUM_CLIENT_TOKEN/$VITE_APP_DD_RUM_CLIENT_TOKEN/g" -e "s/@VITE_APP_DD_RUM_ENV/$VITE_APP_DD_RUM_ENV/g" -e "s/@VITE_APP_VERSION/${{env.APP_VERSION}}/g" -e "s/@VITE_APP_DD_SAMPLE_RATE/$VITE_APP_DD_SAMPLE_RATE/g" apps/frontend/datadog-chunk.ts - echo VITE_APP_VERSION=${{ inputs.app-version }} > apps/frontend/.env - echo VITE_APP_URL=$VITE_APP_URL > apps/frontend/.env - echo VITE_APP_GA_TRACKING_ID=$VITE_APP_GA_TRACKING_ID >> apps/frontend/.env - echo VITE_APP_FORMSG_SDK_MODE=$VITE_APP_FORMSG_SDK_MODE >> apps/frontend/.env - echo VITE_APP_DD_RUM_CLIENT_TOKEN=$VITE_APP_DD_RUM_CLIENT_TOKEN >> apps/frontend/.env - echo VITE_APP_DD_RUM_ENV=$VITE_APP_DD_RUM_ENV >> apps/frontend/.env - - name: Grant runner AWS credentials uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 with: @@ -161,27 +152,61 @@ jobs: id: login-ecr uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2.1.2 + - name: Login to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check for pre-built release image + id: check-release-image + run: | + VERSION="v$(jq -r .version package.json)" + IMAGE="ghcr.io/opengovsg/formsg:release-${VERSION}" + echo "Looking for pre-built release image: $IMAGE" + if docker manifest inspect "$IMAGE" > /dev/null 2>&1; then + echo "image=$IMAGE" >> "$GITHUB_OUTPUT" + echo "Found pre-built release image, will skip Docker build" + else + echo "image=" >> "$GITHUB_OUTPUT" + echo "No pre-built release image found, will build from scratch" + fi + + - name: Pull and push pre-built image to ECR + if: steps.check-release-image.outputs.image != '' + env: + ECR_REPOSITORY: ${{ inputs.ecr-repository }} + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + run: | + docker pull ${{ steps.check-release-image.outputs.image }} + docker tag ${{ steps.check-release-image.outputs.image }} $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + - name: Set up Docker Buildx + if: steps.check-release-image.outputs.image == '' uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build and push Docker image + if: steps.check-release-image.outputs.image == '' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 env: - DD_API_KEY: ${{ secrets.DD_API_KEY }} - DD_ENV: ${{ inputs.dd-env }} ECR_REPOSITORY: ${{ inputs.ecr-repository }} ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} with: context: . file: Dockerfile.production push: true + load: true tags: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} build-args: | - APP_VERSION=${{ inputs.app-version }} - APP_URL=${{ inputs.app-url }} - REPO_URL=${{ github.server_url }}/${{ github.repository }} - secrets: | - "dd_api_key=${{ secrets.DD_API_KEY }}" + BUILD_BASE_IMAGE=${{ needs.ensure-base-images.outputs.build-base-image }} + RUNTIME_BASE_IMAGE=${{ needs.ensure-base-images.outputs.runtime-base-image }} + APP_VERSION=${{ env.APP_VERSION }} + DD_RUM_APP_ID=${{ secrets.dd-rum-app-id }} + DD_RUM_CLIENT_TOKEN=${{ secrets.dd-rum-client-token }} + DD_SAMPLE_RATE=${{ secrets.dd-sample-rate }} + GA_TRACKING_ID=${{ secrets.ga-tracking-id }} - name: Replace variables in task definition file id: replace-variables @@ -220,6 +245,18 @@ jobs: docker run --rm -v /tmp/s3static:/tmp/s3static $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG cp -r ./apps/frontend/dist/. /tmp/s3static aws s3 sync /tmp/s3static s3://$S3_STATIC_ASSETS_BUCKET_NAME + - name: Upload Datadog sourcemaps + env: + DATADOG_API_KEY: ${{ secrets.DD_API_KEY }} + run: | + curl -L --fail "https://github.com/DataDog/datadog-ci/releases/download/v5.11.0/datadog-ci_linux-x64" --output /usr/local/bin/datadog-ci + chmod +x /usr/local/bin/datadog-ci + datadog-ci sourcemaps upload /tmp/s3static \ + --service=formsg-react \ + --release-version=${{ env.APP_VERSION }} \ + --minified-path-prefix=${{ inputs.app-url }} \ + --repository-url=${{ github.server_url }}/${{ github.repository }} + - name: Deploy Amazon ECS task definition uses: aws-actions/amazon-ecs-deploy-task-definition@df9643053eda01f169e64a0e60233aacca83799a # v1.4.11 with: diff --git a/Dockerfile.base b/Dockerfile.base new file mode 100644 index 0000000000..e1ef503958 --- /dev/null +++ b/Dockerfile.base @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1 + +# ============================================================================= +# build-base: Full build toolchain with pre-installed node_modules +# ============================================================================= +FROM node:22.22.2-alpine3.22 AS build-base +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && corepack prepare pnpm@10.30.3 --activate + +# Install build dependencies (same as Dockerfile.production build stage) +RUN apk upgrade --no-cache && \ + apk --no-cache add --virtual native-deps \ + g++ gcc libgcc libstdc++ linux-headers autoconf automake make nasm python3 git curl \ + build-base cairo-dev pango-dev giflib-dev libjpeg-turbo-dev librsvg-dev && \ + pnpm add -g node-gyp@12.2.0 + +WORKDIR /build + +# Copy only dependency-related files for layer caching +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/backend/package.json ./apps/backend/ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/sdk/package.json ./packages/sdk/ +COPY apps/frontend/package.json ./apps/frontend/ + +RUN pnpm install --frozen-lockfile --filter "!services/*" --filter "!packages/react-email-preview/*" + +# ============================================================================= +# runtime-base: Minimal runtime image with system deps and non-root user +# ============================================================================= +FROM node:22.22.2-alpine3.22 AS runtime-base +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && corepack prepare pnpm@10.30.3 --activate + +RUN apk upgrade --no-cache && \ + apk add --no-cache \ + nss \ + freetype \ + freetype-dev \ + harfbuzz \ + ca-certificates \ + ttf-freefont \ + tini \ + cairo \ + pango \ + librsvg \ + giflib + +# Pre-create non-root user +RUN addgroup -S formsguser && adduser -S -g formsguser formsguser diff --git a/Dockerfile.production b/Dockerfile.production index abd8a701f8..7eecc0def7 100644 --- a/Dockerfile.production +++ b/Dockerfile.production @@ -1,55 +1,31 @@ # syntax=docker/dockerfile:1 -FROM node:22.22.2-alpine3.22 AS base -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -# Install pnpm -RUN corepack enable && corepack prepare pnpm@10.30.3 --activate +ARG BUILD_BASE_IMAGE=ghcr.io/opengovsg/formsg:build-latest +ARG RUNTIME_BASE_IMAGE=ghcr.io/opengovsg/formsg:runtime-latest -FROM base AS build # node-modules-builder stage installs/compiles the node_modules folder -# Python version must be specified starting in alpine3.12 -RUN apk --no-cache add --virtual native-deps \ - g++ gcc libgcc libstdc++ linux-headers autoconf automake make nasm python3 git curl \ - build-base cairo-dev pango-dev giflib-dev libjpeg-turbo-dev librsvg-dev && \ - pnpm add -g node-gyp@12.2.0 +# Build stage: node_modules already present in base image — just copy source and build +FROM ${BUILD_BASE_IMAGE} AS build +ARG APP_VERSION="" +ARG DD_RUM_APP_ID="" +ARG DD_RUM_CLIENT_TOKEN="" WORKDIR /build -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY apps/backend/package.json ./apps/backend/ -COPY packages/shared/package.json ./packages/shared/ -COPY packages/sdk/package.json ./packages/sdk/ -COPY apps/frontend/package.json ./apps/frontend/ - -RUN pnpm install --frozen-lockfile --filter "!services/*" --filter "!packages/react-email-preview/*" - COPY . ./ # These options are only used in the build stage, not the start stage. ENV NODE_OPTIONS="--max-old-space-size=4096" +ENV VITE_APP_VERSION=$APP_VERSION +ENV VITE_APP_DD_RUM_APP_ID=$DD_RUM_APP_ID +ENV VITE_APP_DD_RUM_CLIENT_TOKEN=$DD_RUM_CLIENT_TOKEN RUN pnpm build -ARG APP_VERSION -ARG APP_URL -ARG REPO_URL - -# Upload datadog source maps from secret mount, id MUST be `id=dd_api_key` in the docker build command -# Mount creates a file inside /run/secrets/dd_api_key, and source to load the environment variables -# For the content of the mounted file is only accessible in the RUN command where it’s referred in, use && command. -RUN --mount=type=secret,id=dd_api_key \ - export DATADOG_API_KEY=$(cat /run/secrets/dd_api_key) && \ - pnpm exec datadog-ci sourcemaps upload ./apps/frontend/dist \ - --service=formsg-react \ - --release-version=$APP_VERSION \ - --minified-path-prefix=$APP_URL \ - --repository-url=$REPO_URL -# This stage builds the final container -FROM base +# Final stage: runtime deps and user already in base image +FROM ${RUNTIME_BASE_IMAGE} LABEL maintainer=FormSG WORKDIR /opt/formsg - # Copy the built frontend dist for the backend to serve COPY --from=build /build/apps/frontend/dist /opt/formsg/apps/frontend/dist # Copy the built formsg-shared package and package.json for the backend to resolve @@ -71,21 +47,7 @@ COPY --from=build /build/apps/backend/node_modules /opt/formsg/apps/backend/node COPY --from=build /build/apps/backend/package.json /opt/formsg/apps/backend/package.json COPY --from=build /build/apps/backend/dist /opt/formsg/apps/backend/dist -RUN apk add --no-cache \ - nss \ - freetype \ - freetype-dev \ - harfbuzz \ - ca-certificates \ - ttf-freefont \ - tini \ - cairo \ - pango \ - librsvg \ - giflib - -# Run as non-privileged user -RUN addgroup -S formsguser && adduser -S -g formsguser formsguser +# Run as non-privileged user (pre-created in runtime-base) USER formsguser ENV NODE_ENV=production diff --git a/apps/backend/src/app/loaders/express/constants.ts b/apps/backend/src/app/loaders/express/constants.ts index 140f2c2b2e..8c94b404a1 100644 --- a/apps/backend/src/app/loaders/express/constants.ts +++ b/apps/backend/src/app/loaders/express/constants.ts @@ -1,6 +1,7 @@ import { RequestHandler } from 'express' import config from '../../config/config' +import { envScriptCspHash } from '../../modules/frontend/frontend.service' export const CSP_CORE_DIRECTIVES = { imgSrc: [ @@ -35,6 +36,7 @@ export const CSP_CORE_DIRECTIVES = { 'https://www.gstatic.cn/recaptcha/releases/', (_req: Parameters[0], res: Parameters[1]) => `'nonce-${res.locals.nonce}'`, + envScriptCspHash, ], connectSrc: [ "'self'", diff --git a/apps/backend/src/app/modules/frontend/frontend.controller.ts b/apps/backend/src/app/modules/frontend/frontend.controller.ts index 5f5a02f762..5c393040d4 100644 --- a/apps/backend/src/app/modules/frontend/frontend.controller.ts +++ b/apps/backend/src/app/modules/frontend/frontend.controller.ts @@ -9,7 +9,7 @@ import { ControllerHandler } from '../core/core.types' import * as FormService from '../form/form.service' import { createMetatags } from '../form/public-form/public-form.service' -import { getClientEnvVars } from './frontend.service' +import { envScript, getClientEnvVars } from './frontend.service' const logger = createLoggerWithLabel(module) @@ -29,6 +29,7 @@ const replaceWithMetaTags = ({ image, }: MetaTags): string => { return reactHtml + .replace('', envScript) .replace(/(__OG_TITLE__)/g, escape(title)) .replace(/(__OG_DESCRIPTION__)/g, escape(description)) .replace(/(__OG_IMAGE__)/g, escape(image)) diff --git a/apps/backend/src/app/modules/frontend/frontend.service.ts b/apps/backend/src/app/modules/frontend/frontend.service.ts index ed21c2d646..a486ad8c9d 100644 --- a/apps/backend/src/app/modules/frontend/frontend.service.ts +++ b/apps/backend/src/app/modules/frontend/frontend.service.ts @@ -1,4 +1,5 @@ -import { ClientEnvVars } from 'formsg-shared/types/core' +import crypto from 'crypto' +import { ClientEnvVars, FrontendRuntimeEnv } from 'formsg-shared/types/core' import config from '../../config/config' import { captchaConfig } from '../../config/features/captcha.config' @@ -8,6 +9,26 @@ import { paymentConfig } from '../../config/features/payment.config' import { spcpMyInfoConfig } from '../../config/features/spcp-myinfo.config' import { turnstileConfig } from '../../config/features/turnstile.config' +export const getFrontendRuntimeEnv = (): FrontendRuntimeEnv => ({ + appUrl: config.app.appUrl, + apiBaseUrl: '/api/v3', + gaTrackingId: process.env.GA_TRACKING_ID ?? '', + formsgSdkMode: config.formsgSdkMode, + ddRumEnv: process.env.DD_ENV ?? '', + ddSampleRate: 5, +}) + +const serializeForInlineScript = (value: FrontendRuntimeEnv): string => + JSON.stringify(value) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026') + +// Computed once at startup — values are static for the container lifetime +const envScriptContent = `window.__ENV__=${serializeForInlineScript(getFrontendRuntimeEnv())}` +export const envScript = `` +export const envScriptCspHash = `'sha256-${crypto.createHash('sha256').update(envScriptContent).digest('base64')}'` + export const getClientEnvVars = (): ClientEnvVars => { return { isGeneralMaintenance: config.isGeneralMaintenance, diff --git a/apps/frontend/datadog-chunk.ts b/apps/frontend/datadog-chunk.ts index 671188b7c4..2d70490542 100644 --- a/apps/frontend/datadog-chunk.ts +++ b/apps/frontend/datadog-chunk.ts @@ -1,10 +1,20 @@ /** - * This file compiles to datadog-chunk.js which is then loaded in the of the react app - * This ensures that datadog is initialised before the react app + * This file compiles to datadog-chunk.js and initializes Datadog RUM when it is loaded. + * + * Build-time vars (import.meta.env): VITE_APP_DD_RUM_APP_ID, VITE_APP_DD_RUM_CLIENT_TOKEN, VITE_APP_VERSION + * Runtime vars (window.__ENV__): ddRumEnv, appUrl, ddSampleRate */ import { datadogRum, RumInitConfiguration } from '@datadog/browser-rum' +import type { FrontendRuntimeEnv } from 'formsg-shared/types' + +declare global { + interface Window { + __ENV__?: FrontendRuntimeEnv + } +} + // Discard benign RUM errors. // Ensure that beforeSend returns true to keep the event and false to discard it. const ddBeforeSend: RumInitConfiguration['beforeSend'] = (event) => { @@ -27,18 +37,15 @@ const ddBeforeSend: RumInitConfiguration['beforeSend'] = (event) => { } // Init Datadog RUM -// Values for VITE_APP_DD_RUM_APP_ID, VITE_APP_DD_RUM_CLIENT_TOKEN, VITE_APP_DD_RUM_ENV, VITE_APP_VERSION, VITE_APP_DD_SAMPLE_RATE will be injected at build time datadogRum.init({ applicationId: '@VITE_APP_DD_RUM_APP_ID', clientToken: '@VITE_APP_DD_RUM_CLIENT_TOKEN', - env: '@VITE_APP_DD_RUM_ENV', + env: window.__ENV__?.ddRumEnv ?? '', site: 'datadoghq.com', service: 'formsg-react', - allowedTracingUrls: ['@VITE_APP_URL'], - - // Specify a version number to identify the deployed version of your application in Datadog + allowedTracingUrls: [window.__ENV__?.appUrl ?? window.location.origin], version: '@VITE_APP_VERSION', - sessionSampleRate: Number('@VITE_APP_DD_SAMPLE_RATE') || 5, + sessionSampleRate: window.__ENV__?.ddSampleRate ?? 5, sessionReplaySampleRate: 100, trackUserInteractions: true, defaultPrivacyLevel: 'mask-user-input', diff --git a/apps/frontend/index.html b/apps/frontend/index.html index 9b9c8f9e21..357a578884 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -32,6 +32,7 @@ + diff --git a/apps/frontend/src/app/App.tsx b/apps/frontend/src/app/App.tsx index 07caa70fa8..5c45fb7130 100644 --- a/apps/frontend/src/app/App.tsx +++ b/apps/frontend/src/app/App.tsx @@ -34,7 +34,7 @@ const queryClient = new QueryClient({ // Init Datadog browser logs datadogLogs.init({ clientToken: import.meta.env.VITE_APP_DD_RUM_CLIENT_TOKEN || '', - env: import.meta.env.VITE_APP_DD_RUM_ENV, + env: window.__ENV__?.ddRumEnv ?? import.meta.env.VITE_APP_DD_RUM_ENV, site: 'datadoghq.com', service: 'formsg', // Specify a version number to identify the deployed version of your application in Datadog diff --git a/apps/frontend/src/app/AppHelmet.tsx b/apps/frontend/src/app/AppHelmet.tsx index 965d664333..cfc20f4a1b 100644 --- a/apps/frontend/src/app/AppHelmet.tsx +++ b/apps/frontend/src/app/AppHelmet.tsx @@ -1,7 +1,9 @@ import { Helmet } from 'react-helmet-async' +import { env } from '~/env' + export const AppHelmet = (): JSX.Element => { - const GATrackingID = import.meta.env.VITE_APP_GA_TRACKING_ID + const GATrackingID = env.gaTrackingId return ( {GATrackingID ? ( diff --git a/apps/frontend/src/env.ts b/apps/frontend/src/env.ts new file mode 100644 index 0000000000..938df84cd8 --- /dev/null +++ b/apps/frontend/src/env.ts @@ -0,0 +1,33 @@ +import type { FrontendRuntimeEnv } from 'formsg-shared/types' + +declare global { + interface Window { + __ENV__?: FrontendRuntimeEnv + } +} + +export const env: FrontendRuntimeEnv = { + appUrl: window.__ENV__?.appUrl ?? import.meta.env.VITE_APP_URL ?? '', + apiBaseUrl: + window.__ENV__?.apiBaseUrl ?? + import.meta.env.VITE_APP_BASE_URL ?? + '/api/v3', + gaTrackingId: + window.__ENV__?.gaTrackingId ?? + import.meta.env.VITE_APP_GA_TRACKING_ID ?? + '', + formsgSdkMode: + window.__ENV__?.formsgSdkMode ?? + (import.meta.env.VITE_APP_FORMSG_SDK_MODE as + | FrontendRuntimeEnv['formsgSdkMode'] + | undefined) ?? + 'production', + ddRumEnv: + window.__ENV__?.ddRumEnv ?? import.meta.env.VITE_APP_DD_RUM_ENV ?? '', + ddSampleRate: + window.__ENV__?.ddSampleRate ?? + (() => { + const rate = Number(import.meta.env.VITE_APP_DD_SAMPLE_RATE) + return Number.isNaN(rate) ? 5 : rate + })(), +} diff --git a/apps/frontend/src/features/admin-form/responses/common/utils/getPaymentDataView.ts b/apps/frontend/src/features/admin-form/responses/common/utils/getPaymentDataView.ts index 63e045549d..e0820b4dcc 100644 --- a/apps/frontend/src/features/admin-form/responses/common/utils/getPaymentDataView.ts +++ b/apps/frontend/src/features/admin-form/responses/common/utils/getPaymentDataView.ts @@ -1,6 +1,5 @@ import { SubmissionPaymentDto } from 'formsg-shared/types' - -import { getPaymentInvoiceDownloadUrl } from '~features/public-form/utils/urls' +import { getPaymentInvoiceDownloadUrlPath } from 'formsg-shared/utils/urls' type PaymentDataViewItem = { key: keyof SubmissionPaymentDto @@ -28,7 +27,7 @@ const getFullInvoiceDownloadUrl = ( formId: string, paymentId: string, ): string => { - const pathName = getPaymentInvoiceDownloadUrl(formId, paymentId) + const pathName = `/api/v3/${getPaymentInvoiceDownloadUrlPath(formId, paymentId)}` const url = new URL(pathName, hostOrigin) return url.toString() } diff --git a/apps/frontend/src/features/public-form/utils/axiosDebugFlow.tsx b/apps/frontend/src/features/public-form/utils/axiosDebugFlow.tsx index 226ec96bb4..8538d6e496 100644 --- a/apps/frontend/src/features/public-form/utils/axiosDebugFlow.tsx +++ b/apps/frontend/src/features/public-form/utils/axiosDebugFlow.tsx @@ -2,19 +2,19 @@ import { datadogLogs } from '@datadog/browser-logs' import { ClientEnvVars } from 'formsg-shared/types' +import { env } from '~/env' + import { ApiService } from '~services/ApiService' const getClientEnvWithFetch = async () => { - const response = await fetch( - `${import.meta.env.VITE_APP_URL}/api/v3/client/env`, - ) + const response = await fetch(`${env.appUrl}/api/v3/client/env`) if (response.ok) { - const env = await response.json() + const clientEnv = await response.json() datadogLogs.logger.info(`handleSubmitForm: fetch env vars successful`, { meta: { action: 'handleSubmitForm', method: 'fetch', - env, + env: clientEnv, }, }) } else { @@ -29,15 +29,15 @@ const getClientEnvWithFetch = async () => { const getClientEnvWithAxios = async () => { try { - const env = await ApiService.get( - `${import.meta.env.VITE_APP_URL}/api/v3/client/env`, + const clientEnv = await ApiService.get( + `${env.appUrl}/api/v3/client/env`, ).then(({ data }) => data) datadogLogs.logger.info(`handleSubmitForm: axios env vars successful`, { meta: { action: 'handleSubmitForm', method: 'axios', - env, + env: clientEnv, }, }) } catch (error) { diff --git a/apps/frontend/src/growthbook.ts b/apps/frontend/src/growthbook.ts index 1d9d355dcd..6ca1abd2e2 100644 --- a/apps/frontend/src/growthbook.ts +++ b/apps/frontend/src/growthbook.ts @@ -3,11 +3,13 @@ import { GrowthBook } from '@growthbook/growthbook-react' import { GROWTHBOOK_DEV_PROXY } from 'formsg-shared/constants/links' import { GROWTHBOOK_API_HOST_PATH } from 'formsg-shared/constants/routes' +import { env } from '~/env' + export const createGrowthbookInstance = (clientKey: string) => { const isDev = import.meta.env.MODE === 'development' const apiHost = `${ - isDev ? GROWTHBOOK_DEV_PROXY : import.meta.env.VITE_APP_URL + isDev ? GROWTHBOOK_DEV_PROXY : env.appUrl }${GROWTHBOOK_API_HOST_PATH}` return new GrowthBook({ diff --git a/apps/frontend/src/index.tsx b/apps/frontend/src/index.tsx index ffd3b83bdc..54e12ba088 100644 --- a/apps/frontend/src/index.tsx +++ b/apps/frontend/src/index.tsx @@ -7,6 +7,7 @@ import { createRoot } from 'react-dom/client' import { App } from './app/App' import * as dayjs from './utils/dayjs' +import { env } from './env' if (import.meta.env.MODE === 'test') { import('./mocks/msw/browser').then(({ worker }) => worker.start()) @@ -24,7 +25,7 @@ function gtag(...args: unknown[]) { dataLayer.push(arguments) } gtag('js', new Date()) -gtag('config', import.meta.env.VITE_APP_GA_TRACKING_ID || '') +gtag('config', env.gaTrackingId || '') window.gtag = gtag // Init dayjs diff --git a/apps/frontend/src/services/ApiService.ts b/apps/frontend/src/services/ApiService.ts index 048e549051..4e129fedd7 100644 --- a/apps/frontend/src/services/ApiService.ts +++ b/apps/frontend/src/services/ApiService.ts @@ -4,6 +4,8 @@ import { StatusCodes } from 'http-status-codes' import { ErrorCode } from 'formsg-shared/types/errorCodes' +import { env } from '~/env' + import { ApiError } from '~typings/core' import { LOCAL_STORAGE_EVENT, LOGGED_IN_KEY } from '~constants/localStorage' @@ -13,7 +15,7 @@ import { handleCloudflareChallengeError, } from '~features/turnstile/handleCloudflareChallenge' -export const API_BASE_URL = import.meta.env.VITE_APP_BASE_URL ?? '/api/v3' +export const API_BASE_URL = env.apiBaseUrl export class HttpError extends Error { code: number constructor(message: string, code: number) { diff --git a/apps/frontend/src/utils/formSdk.ts b/apps/frontend/src/utils/formSdk.ts index 3a39259f6c..afd4f2fb43 100644 --- a/apps/frontend/src/utils/formSdk.ts +++ b/apps/frontend/src/utils/formSdk.ts @@ -3,25 +3,10 @@ import { PackageMode } from '@opengovsg/formsg-sdk/dist/types' import { TRANSACTION_EXPIRE_AFTER_SECONDS } from 'formsg-shared/utils/verification' -/** - * Typeguard to check if sdkMode is valid PackageMode - * @param sdkMode defined in VITE_APP_FORMSG_SDK_MODE env var - * @returns true if sdkMode is valid PackageMode - */ -const isPackageMode = (sdkMode?: string): sdkMode is PackageMode => { - return ( - !!sdkMode && - ['staging', 'production', 'development', 'test'].includes(sdkMode) - ) -} +import { env } from '~/env' const formsgSdk = formsgPackage({ - // Either the sdk mode is set in VITE_APP_FORMSG_SDK_MODE env var, or fall back to NODE_ENV - // NODE_ENV is set automatically to development (when using pnpm start), - // test (when using pnpm test) or production (when using pnpm build) - mode: isPackageMode(import.meta.env.VITE_APP_FORMSG_SDK_MODE) - ? import.meta.env.VITE_APP_FORMSG_SDK_MODE - : (import.meta.env.MODE as PackageMode), + mode: env.formsgSdkMode as PackageMode, verificationOptions: { transactionExpiry: TRANSACTION_EXPIRE_AFTER_SECONDS, }, diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 565d69d73c..0633831f6e 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -1,10 +1,35 @@ import react from '@vitejs/plugin-react' -import { BuildOptions, defineConfig } from 'vite' +import { BuildOptions, defineConfig, PluginOption } from 'vite' // @ts-expect-error missing type definitions import nodePolyfills from 'vite-plugin-node-stdlib-browser' import svgr from 'vite-plugin-svgr' import tsconfigPaths from 'vite-tsconfig-paths' +/** + * Replaces @VITE_APP_* placeholders in source with their corresponding + * environment variable values at build time. This is used for values that + * must be baked into the JS bundle (e.g. Datadog applicationId, clientToken). + */ +function replaceEnvPlaceholders(): PluginOption { + const replacements: Record = { + '@VITE_APP_DD_RUM_APP_ID': process.env.VITE_APP_DD_RUM_APP_ID ?? '', + '@VITE_APP_DD_RUM_CLIENT_TOKEN': + process.env.VITE_APP_DD_RUM_CLIENT_TOKEN ?? '', + '@VITE_APP_VERSION': process.env.VITE_APP_VERSION ?? '', + } + + return { + name: 'replace-env-placeholders', + transform(code) { + let result = code + for (const [key, value] of Object.entries(replacements)) { + result = result.replaceAll(key, value) + } + return result !== code ? result : null + }, + } +} + const baseRollupOptions = { // Silence Rollup "use client" warnings // Adapted from https://github.com/vitejs/vite-plugin-react/pull/144 @@ -46,6 +71,7 @@ export default defineConfig(() => { }, }, plugins: [ + replaceEnvPlaceholders(), tsconfigPaths(), nodePolyfills(), react(), diff --git a/packages/shared/types/core.ts b/packages/shared/types/core.ts index 58aba9a350..045041e522 100644 --- a/packages/shared/types/core.ts +++ b/packages/shared/types/core.ts @@ -1,3 +1,12 @@ +export type FrontendRuntimeEnv = { + appUrl: string + apiBaseUrl: string + gaTrackingId: string + formsgSdkMode: 'staging' | 'production' | 'development' | 'test' + ddRumEnv: string + ddSampleRate: number +} + export interface ErrorDto { message: string }