diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..3a39825b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug in current situation** +A clear and concise description of what the bug is. + +**Link bug to the User Story** + +**Impact of this bug** +Describe the impact, i.e. what the impact is, and number of users impacted. + +**Chance of Occurring (high/medium/low/very low)** + +**Pre Conditions: which Env, any pre-requesites or assumptions to execute steps?** + +**Steps to Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Actual/ observed behaviour/ results** + +**Expected behaviour** +A clear and concise description of what you expected to happen. Use the gherking language. + +**Screenshots/ Visual Reference/ Source** +If applicable, add screenshots to help explain your problem. You an use screengrab. diff --git a/.github/ISSUE_TEMPLATE/epic.md b/.github/ISSUE_TEMPLATE/epic.md new file mode 100644 index 00000000..ad2b035f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/epic.md @@ -0,0 +1,40 @@ +--- +name: Epic +about: Consisting of many user stories, tasks and UX activities, an Epic captures + a large chunk of user needs +title: '' +labels: Epic +assignees: '' + +--- + +## Description: +- Short Description of the needed Epic in the below format + +* As a (who/ person) +* I want (what/ goal) +* so that (why/ benefit proposal) + + +* What are the most critical features being built as part of this Epic +* Expected Outcome – Justification why this Feature must be built, what is expected value and justification for priority. + +---- +**Sizing** + +* T-shirt size: + + [ ] S + [ ] M + [ ] L + [ ] XL + +(S=1-2 Sprints, M=2-3 Sprints, L=4-5 Sprints, XL=not sure, probably more than 5 Sprints). + +* *Risks and Assumptions** + +1. [ ] What are assumptions? +2. [ ] What are the risks? +3. [ ] Have we done an RFC on a technical approach? +4. [ ] Are the dependencies (technical, business, regulatory/policy) known? +5. [ ] Has work on this Epic been prioritized with key stakeholders? diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md new file mode 100644 index 00000000..f0215b99 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.md @@ -0,0 +1,23 @@ +--- +name: Task +about: Capture tasks such as OCM, tech debt, UX , architecture and development +title: '' +labels: Task +assignees: '' + +--- + +Title of ticket: + +#### Description +Summarize issue + +#### Dependencies +Are there any dependencies? + +#### DOD +- [ ] List the items that need to be complete for this ticket to be considered done +- [ ] +- [ ] +- [ ] +- [ ] diff --git a/.github/ISSUE_TEMPLATE/user-story.md b/.github/ISSUE_TEMPLATE/user-story.md new file mode 100644 index 00000000..6601679e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/user-story.md @@ -0,0 +1,68 @@ +--- +name: User Story +about: User story capturing user needs. +title: '' +labels: Story +assignees: '' + +--- + +* As a (type of user) +* I want (goal) +* so that (reason/ why/ value) + +**Assumptions & Scope** +What are the assumptions for this story? + +What is in and not in scope? + +**Acceptance Criteria** + +**Scenario 1: xxxxxx** +* GIVEN ... (you or your condition) +* WHEN ... (what you do) +* THEN ... (what you see) + +**Scenario 2: xxxxxx** +* GIVEN ... (you or your condition) +* WHEN ... (what you do) +* THEN ... (what you see) + +**Scenario 3: xxxxxx** +... + +**Dependencies? What is the impact of this dependency? (If so, link dependency in the ticket, make it visible in a team´s backlog)** + + +**Validation Rules? (If yes, list here)** + +**Design** +@xxx - please link the Design here + +**Definition of Ready** + +1. [ ] Is there a well articulated User Story? +2. [ ] Is there Acceptance Criteria that covers all scenarios (happy/sad paths)? +3. [ ] If there is a user interface, is there a design? +4. [ ] Does the user story need user research/validation? +5. [ ] Does this User Story needs stakeholder approval? +6. [ ] Design / Solution accepted by Product Owner +7. [ ] Is this user story small enough to be completed in a Sprint? Should it be split? +8. [ ] Are the dependencies known/ understood? (technical, business, regulatory/policy) +9. [ ] Has the story been estimated? + +**Definition of Done** + +1. [ ] Passes developer unit tests +2. [ ] Passes peer code review +3. [ ] If there's a user interface, passes UX assurance +4. [ ] Passes QA of Acceptance Criteria with verification in Dev and Test +5. [ ] Confirm Test cases built and succeeding +6. [ ] No regression test failures +7. [ ] Test coverage acceptable by Product Owner +8. [ ] Ticket ready to be merged to master or story branch +9. [ ] Developer to list Config changes/ Update documents and designs +10. [ ] Can be demoed in Sprint Review +11. [ ] Tagged as part of a Release +12. [ ] Feature flagged if required +13. [ ] Change Management activities done? diff --git a/.github/workflows/api-cd.dev.yml b/.github/workflows/api-cd.dev.yml new file mode 100644 index 00000000..b2c2209c --- /dev/null +++ b/.github/workflows/api-cd.dev.yml @@ -0,0 +1,85 @@ +name: Deploy API to dev + +on: + push: + branches: + - dev + paths: + - "api/**" + - ".github/workflows/api-cd.dev.yml" + workflow_dispatch: + inputs: + environment: + description: "Environment (dev/test/prod)" + required: true + default: "dev" + +defaults: + run: + shell: bash + working-directory: ./api + +env: + APP_NAME: "api" + TAG_NAME: "dev" + +jobs: + api-cd-by-push: + runs-on: ubuntu-24.04 + + if: github.event_name == 'push' && github.repository == 'bcgov/foi-requests' + environment: + name: "dev" + + steps: + + # Checkout the repository + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Login to BC Gov Docker Image Repository + - name: Login to Openshift Docker + run : | + docker login ${{ secrets.PUBLIC_IMAGE_REPOSITORY }} -u ${{ secrets.OPENSHIFT_SA_NAME }} -p ${{ secrets.OPENSHIFT_SA_TOOLS_TOKEN }} + + # Build the APP image + - name: Build APP Image + run: | + docker build -t ${{ secrets.PUBLIC_IMAGE_REPOSITORY }}/${{ secrets.OPENSHIFT_NAMESPACE }}-tools/api:latest -f Dockerfile . + + # Push the APP image + - name: Push APP Image + run: | + docker push ${{ secrets.PUBLIC_IMAGE_REPOSITORY }}/${{ secrets.OPENSHIFT_NAMESPACE }}-tools/api:latest + + + - name: Install oc + uses: redhat-actions/oc-installer@v1 + with: + oc_version: '4.6' + + + - name: Login Openshift + shell: bash + run: | + oc login --server=${{secrets.OPENSHIFT4_LOGIN_REGISTRY}} --token=${{secrets.OPENSHIFT4_SA_TOKEN}} + + - name: Tools project + shell: bash + run: | + oc project ${{ secrets.OPENSHIFT4_FRONTEND_REPOSITORY }}-tools + + + - name: Tag+Deploy for dev + shell: bash + run: | + oc project ${{ secrets.OPENSHIFT_NAMESPACE }}-tools + oc tag api:latest api:${{ env.TAG_NAME }} + + - name: Rollout Restart Deployment + shell: bash + run: | + oc project ${{ secrets.OPENSHIFT_NAMESPACE }}-dev + oc rollout restart deployment/api diff --git a/.github/workflows/api-cd.test.yml b/.github/workflows/api-cd.test.yml new file mode 100644 index 00000000..904ddb98 --- /dev/null +++ b/.github/workflows/api-cd.test.yml @@ -0,0 +1,79 @@ +name: Deploy API to test + + +on: + push: + branches: + - master + paths: + - "api/**" + - ".github/workflows/api-cd.test.yml" + workflow_dispatch: + inputs: + environment: + description: "Environment (dev/test/prod)" + required: true + default: "test" + +defaults: + run: + shell: bash + working-directory: ./api + +env: + APP_NAME: "api" + TAG_NAME: "test" + +jobs: + api-cd-by-push: + runs-on: ubuntu-24.04 + + if: github.event_name == 'push' && github.repository == 'bcgov/foi-requests' + environment: + name: "test" + + steps: + # Checkout the repository + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Login to BC Gov Docker Image Repository + - name: Login to Openshift Docker + run : | + docker login ${{ secrets.PUBLIC_IMAGE_REPOSITORY }} -u ${{ secrets.OPENSHIFT_SA_NAME }} -p ${{ secrets.OPENSHIFT_SA_TOOLS_TOKEN }} + + # Build the APP image + - name: Build APP Image + run: | + docker build -t ${{ secrets.PUBLIC_IMAGE_REPOSITORY }}/${{ secrets.OPENSHIFT_NAMESPACE }}-tools/api:latest -f Dockerfile . + + # Push the APP image + - name: Push APP Image + run: | + docker push ${{ secrets.PUBLIC_IMAGE_REPOSITORY }}/${{ secrets.OPENSHIFT_NAMESPACE }}-tools/api:latest + + - name: Install oc + uses: redhat-actions/oc-installer@v1 + with: + oc_version: '4.6' + + + - name: Login Openshift + shell: bash + run: | + oc login --server=${{secrets.OPENSHIFT4_LOGIN_REGISTRY}} --token=${{secrets.OPENSHIFT4_SA_TOKEN}} + + + - name: Tag+Deploy for test + shell: bash + run: | + oc project ${{ secrets.OPENSHIFT_NAMESPACE }}-tools + oc tag api:latest api:${{ env.TAG_NAME }} + + - name: Rollout Restart Deployment + shell: bash + run: | + oc project ${{ secrets.OPENSHIFT_NAMESPACE }}-test + oc rollout restart deployment/api diff --git a/.github/workflows/api.txt b/.github/workflows/api.txt new file mode 100644 index 00000000..30f4c6ad --- /dev/null +++ b/.github/workflows/api.txt @@ -0,0 +1,81 @@ +# name: Test & Build API +# on: +# push: +# paths: +# - "api/src/**/*.js" +# - "api/package*.json" +# workflow_dispatch: +# defaults: +# run: +# working-directory: ./api +# jobs: +# test: +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@master +# - uses: actions/setup-node@master +# with: +# node-version: "10" +# - name: Install dependencies +# run: npm ci +# - name: Unit tests w/o coverage +# run: npm run test +# # - name: Unit tests w/ coverage +# # run: npm run test:coverage +# # - name: LINTing +# # run: npm run test:lint +# # - name: OpenAPI Schema +# # run: npm run test:schema +# # - name: Upload coverage report +# # env: +# # CC_TEST_REPORTER_ID: ${{ secrets.TestReporterID }} +# # CI: "true" +# # run: | +# # curl -Ls https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter +# # chmod +x ./cc-test-reporter +# # ./cc-test-reporter format-coverage -t lcov -o codeclimate.json +# # ./cc-test-reporter upload-coverage -i codeclimate.json +# build: +# runs-on: ubuntu-latest +# needs: [test] +# strategy: +# matrix: +# node-version: [10.x] +# steps: +# - uses: actions/checkout@v1 +# - uses: actions/setup-node@v1 +# with: +# node-version: ${{ matrix.node-version }} +# - name: Cache node modules +# uses: actions/cache@v1 +# with: +# path: node_modules +# key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} +# restore-keys: | +# ${{ runner.OS }}-build-${{ env.cache-name }}- +# ${{ runner.OS }}-build- +# ${{ runner.OS }}- +# - name: npm install and build +# run: | +# npm ci +# npm run build --if-present +# env: +# CI: "true" +# s2i-build: +# if: github.event_name == 'push' && github.ref == 'refs/heads/master' +# runs-on: ubuntu-latest +# needs: [test, build] +# steps: +# - name: Image Build +# env: +# NAMESPACE: 04d1a3-tools +# BUILD_NAME: api-master-build +# IMAGE_NAME: api +# uses: redhat-developer/openshift-actions@v1.1 +# with: +# version: "latest" +# openshift_server_url: ${{secrets.OpenShiftServerURL}} +# parameters: '{"apitoken": "${{secrets.OpenShiftToken}}", "acceptUntrustedCerts": "true"}' +# cmd: | +# 'version' +# 'start-build ${BUILD_NAME} -n ${NAMESPACE} --follow' diff --git a/.github/workflows/web-cd.dev.yml b/.github/workflows/web-cd.dev.yml new file mode 100644 index 00000000..f3fe6c35 --- /dev/null +++ b/.github/workflows/web-cd.dev.yml @@ -0,0 +1,82 @@ +name: Deploy web to dev + + +on: + push: + branches: + - dev + paths: + - "web/**" + - ".github/workflows/web-cd.dev.yml" + workflow_dispatch: + inputs: + environment: + description: "Environment (dev/test/prod)" + required: true + default: "dev" + +defaults: + run: + shell: bash + working-directory: ./web + +env: + APP_NAME: "web" + TAG_NAME: "dev" + +jobs: + web-cd-by-push: + runs-on: ubuntu-24.04 + + if: github.event_name == 'push' && github.repository == 'bcgov/foi-requests' + environment: + name: "dev" + + steps: + # Checkout the repository + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Login to BC Gov Docker Image Repository + - name: Login to Openshift Docker + run : | + docker login ${{ secrets.PUBLIC_IMAGE_REPOSITORY }} -u ${{ secrets.OPENSHIFT_SA_NAME }} -p ${{ secrets.OPENSHIFT_SA_TOOLS_TOKEN }} + + # Build the APP image + - name: Build APP Image + run: | + docker build -t ${{ secrets.PUBLIC_IMAGE_REPOSITORY }}/${{ secrets.OPENSHIFT_NAMESPACE }}-tools/web:latest -f Dockerfile . + + # Push the APP image + - name: Push APP Image + run: | + docker push ${{ secrets.PUBLIC_IMAGE_REPOSITORY }}/${{ secrets.OPENSHIFT_NAMESPACE }}-tools/web:latest + + - name: Install oc + uses: redhat-actions/oc-installer@v1 + with: + oc_version: '4.6' + + - name: Install oc + uses: redhat-actions/oc-installer@v1 + with: + oc_version: '4.6' + + - name: Login Openshift + shell: bash + run: | + oc login --server=${{secrets.OPENSHIFT4_LOGIN_REGISTRY}} --token=${{secrets.OPENSHIFT4_SA_TOKEN}} + + - name: Tag+Deploy for dev + shell: bash + run: | + oc project ${{ secrets.OPENSHIFT_NAMESPACE }}-tools + oc tag web:latest web:${{ env.TAG_NAME }} + + - name: Rollout Restart Deployment + shell: bash + run: | + oc project ${{ secrets.OPENSHIFT_NAMESPACE }}-dev + oc rollout restart deployment/web diff --git a/.github/workflows/web-cd.test.yml b/.github/workflows/web-cd.test.yml new file mode 100644 index 00000000..7258fc3d --- /dev/null +++ b/.github/workflows/web-cd.test.yml @@ -0,0 +1,78 @@ +name: Deploy web to test + + +on: + push: + branches: + - master + paths: + - "web/**" + - ".github/workflows/web-cd.test.yml" + workflow_dispatch: + inputs: + environment: + description: "Environment (dev/test/prod)" + required: true + default: "test" + +defaults: + run: + shell: bash + working-directory: ./web + +env: + APP_NAME: "web" + TAG_NAME: "test" + +jobs: + web-cd-by-push: + runs-on: ubuntu-24.04 + + if: github.event_name == 'push' && github.repository == 'bcgov/foi-requests' + environment: + name: "test" + + steps: + # Checkout the repository + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Login to BC Gov Docker Image Repository + - name: Login to Openshift Docker + run : | + docker login ${{ secrets.PUBLIC_IMAGE_REPOSITORY }} -u ${{ secrets.OPENSHIFT_SA_NAME }} -p ${{ secrets.OPENSHIFT_SA_TOOLS_TOKEN }} + + # Build the APP image + - name: Build APP Image + run: | + docker build -t ${{ secrets.PUBLIC_IMAGE_REPOSITORY }}/${{ secrets.OPENSHIFT_NAMESPACE }}-tools/web:latest -f Dockerfile . + + # Push the APP image + - name: Push APP Image + run: | + docker push ${{ secrets.PUBLIC_IMAGE_REPOSITORY }}/${{ secrets.OPENSHIFT_NAMESPACE }}-tools/web:latest + + - name: Install oc + uses: redhat-actions/oc-installer@v1 + with: + oc_version: '4.6' + + - name: Login Openshift + shell: bash + run: | + oc login --server=${{secrets.OPENSHIFT4_LOGIN_REGISTRY}} --token=${{secrets.OPENSHIFT4_SA_TOKEN}} + + + - name: Tag+Deploy for test + shell: bash + run: | + oc project ${{ secrets.OPENSHIFT_NAMESPACE }}-tools + oc tag web:latest web:${{ env.TAG_NAME }} + + - name: Rollout Restart Deployment + shell: bash + run: | + oc project ${{ secrets.OPENSHIFT_NAMESPACE }}-test + oc rollout restart deployment/web diff --git a/.github/workflows/web.txt b/.github/workflows/web.txt new file mode 100644 index 00000000..b9e7084d --- /dev/null +++ b/.github/workflows/web.txt @@ -0,0 +1,81 @@ +# name: Test & Build Web +# on: +# push: +# paths: +# - "web/src/**/*.ts" +# - "web/src/package*.json" +# - "web/src/public/**/*" +# workflow_dispatch: +# defaults: +# run: +# working-directory: ./web +# jobs: +# # test: +# # runs-on: ubuntu-latest +# # steps: +# # - uses: actions/checkout@master +# # - uses: actions/setup-node@master +# # with: +# # node-version: "12" +# # - name: Install dependencies +# # run: npm ci +# # - name: Unit tests +# # run: npm run test +# build: +# runs-on: ubuntu-latest +# # needs: [test] +# strategy: +# matrix: +# node-version: [10.x] +# steps: +# - uses: actions/checkout@v1 +# - uses: actions/setup-node@v1 +# with: +# node-version: ${{ matrix.node-version }} +# - name: Cache node modules +# uses: actions/cache@v1 +# with: +# path: node_modules +# key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} +# restore-keys: | +# ${{ runner.OS }}-build-${{ env.cache-name }}- +# ${{ runner.OS }}-build- +# ${{ runner.OS }}- +# - name: npm install and build +# run: | +# npm ci +# npm run build --if-present +# env: +# CI: "true" +# s2i-build: +# if: github.event_name == 'push' && github.ref == 'refs/heads/master' +# runs-on: ubuntu-latest +# # needs: [test, build] +# needs: [build] +# steps: +# - name: Artifact Build +# env: +# NAMESPACE: 04d1a3-tools +# BUILD_NAME: web-artifact-build +# IMAGE_NAME: web-artifacts +# uses: redhat-developer/openshift-actions@v1.1 +# with: +# version: "latest" +# openshift_server_url: ${{ secrets.OpenShiftServerURL}} +# parameters: '{"apitoken": "${{ secrets.OpenShiftToken }}", "acceptUntrustedCerts": "true"}' +# cmd: | +# 'version' +# 'start-build ${BUILD_NAME} -n ${NAMESPACE} --follow' +# - name: Image Build +# env: +# NAMESPACE: 04d1a3-tools +# BUILD_NAME: web-image-build +# IMAGE_NAME: web +# uses: redhat-developer/openshift-actions@v1.1 +# with: +# version: "latest" +# openshift_server_url: ${{ secrets.OpenShiftServerURL}} +# parameters: '{"apitoken": "${{ secrets.OpenShiftToken }}", "acceptUntrustedCerts": "true"}' +# cmd: | +# 'version' +# 'start-build ${BUILD_NAME} -n ${NAMESPACE} --follow' diff --git a/.gitignore b/.gitignore index b5175a57..cc37bf1e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,12 @@ # See http://help.github.com/ignore-files/ for more about ignoring files. # compiled output -/dist +dist/ /tmp /out-tsc # dependencies -/node_modules +node_modules # profiling files chrome-profiler-events.json @@ -40,3 +40,14 @@ testem.log # System Files .DS_Store Thumbs.db + +# OpenShift +*-secret.yaml +*-secret.json +*-secret.properties + +# SSL +*.pem +*.key +*.csr +web/.angular/cache/* diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 00000000..0dbc6ca1 --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,17 @@ +sonar.projectName=foi-requests + +# Path to sources +sonar.sources=api/,web/ +sonar.exclusions=e2e/,docs/,cicd_old/,config/,openshift/ +#sonar.inclusions= + +# Path to tests +# sonar.tests= +#sonar.test.exclusions= +#sonar.test.inclusions= + +# Source encoding +sonar.sourceEncoding=UTF-8 + +# Exclusions for copy-paste detection +sonar.cpd.exclusions=e2e/,docs/,cicd_old/,config/,openshift/ \ No newline at end of file diff --git a/README.md b/README.md index bc19bb2a..45150ce9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -# About +# About + This repo was created to be the future home of a new application for citizens to submit Freedom of Information (FOI) requests. [Here is the existing online form](https://forms.gov.bc.ca/governments/foi/) for submitting FOI requests. @@ -7,7 +8,7 @@ This repo was created to be the future home of a new application for citizens to # bcfoi -This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 7.3.0. +This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.1. ## Development server @@ -25,9 +26,6 @@ Run `ng build` to build the project. The build artifacts will be stored in the ` Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). -## Running end-to-end tests - -Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). ## Further help diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 00000000..3109df0f --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.nyc_output +.git +*.log \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 00000000..c0ca06c4 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,22 @@ + # Use the official Node.js image from the Docker Hub +FROM node:22-alpine AS build + +# Set the working directory inside the container +WORKDIR /app + +# Copy package.json and package-lock.json to the working directory +COPY package.json package-lock.json ./ + +# Install the application dependencies +RUN npm install --legacy-peer-deps --ignore-scripts + +# Copy the rest of the application code to the working directory +COPY *.js *.sh *.env ./ + +RUN chgrp -R 0 /app && \ + chmod -R g+rwX /app && \ + chmod +x ./container-entrypoint.sh + +USER node + +ENTRYPOINT ["./container-entrypoint.sh"] diff --git a/api/apiAuth.js b/api/apiAuth.js new file mode 100644 index 00000000..3c7383d9 --- /dev/null +++ b/api/apiAuth.js @@ -0,0 +1,56 @@ +'use strict'; + + +var jwt = require('jsonwebtoken'); +var jwksClient = require('jwks-rsa'); + + + +function authInit(options) { + + var client = jwksClient({ + jwksUri: options.JWKS_URI + }) + + const JWT_TOKEN_HEADER = options.CAPTCHA_TOKEN_HEADER || 'Authorization'; // the request header where we expect the jwt token + const CAPTCHA_NONCE_HEADER = options.CAPTCHA_NONCE_HEADER || 'captcha-nonce'; // the request header where we expect the client nonce + + function getKey(header, callback){ + client.getSigningKey(header.kid, function(err, key) { + var signingKey = key.publicKey || key.rsaPublicKey; + callback(null, signingKey); + }); + } + + return { + + verifyJWTResponseMiddleware: function(req, res, next) { + if (req.headers[CAPTCHA_NONCE_HEADER]) { + req.isAuthorised = false + console.log('Request is already validated in verifyJWTResponseMiddleware'); + return next(); + } + var token = req.headers[JWT_TOKEN_HEADER.toLowerCase()] || ''; + token = token.replace('Bearer ', ''); + + jwt.verify(token, getKey, options, function(err, decoded) { + if (err){ + req.isAuthorised = false + return next(); + } else { + req.isAuthorised = true + req.userDetails = {"firstName":decoded.firstName, + "lastName":decoded.lastName, + "email":decoded.email, + } + next(); + } + }); + + } + + } + +} + +module.exports = authInit; diff --git a/api/apiCaptcha.js b/api/apiCaptcha.js index 4c6d78be..e11997dc 100644 --- a/api/apiCaptcha.js +++ b/api/apiCaptcha.js @@ -7,7 +7,7 @@ var wav = require('wav'); var text2wav = require('text2wav'); var arrayBufferToBuffer = require('arraybuffer-to-buffer'); var streamifier = require('streamifier'); -var lame = require('lame'); +var lame = require('lamejs'); function captchaInit(options) { @@ -156,7 +156,7 @@ function captchaInit(options) { // we have to wait for the "format" event before we can start encoding reader.on('format', function(format) { // init encoder - var encoder = new lame.Encoder(format); + var encoder = new lame.Mp3Encoder(format); // Pipe Wav reader to the encoder and capture the output stream // As the stream is encoded, convert the mp3 array buffer chunks into base64 string with mime type @@ -202,6 +202,7 @@ function captchaInit(options) { return { valid: false }; } } catch (e) { + console.log(e) return { valid: false }; } } @@ -221,17 +222,24 @@ function captchaInit(options) { getAudio(req.body, req).then(function(ret) { res.send(ret); next(); - }); + }); }, verifyJWTResponseMiddleware: function(req, res, next) { + // if there is a captcha header , it shud be validated by captcha verifier + if(req.isAuthorised && req.userDetails && !req.headers[CAPTCHA_NONCE_HEADER]) { + console.log('Request is already validated'); + return next(); + } + var token = req.headers[CAPTCHA_TOKEN_HEADER.toLowerCase()] || ''; token = token.replace('Bearer ', ''); var nonce = req.headers[CAPTCHA_NONCE_HEADER]; var ret = verifyJWTResponse(token, nonce); //TODO retrieve the nonce and pass it in if (ret.valid) { + console.log('Captcha validated in apiCaptcha'); next(); } else { - res.send(401, 'Not Authorized'); + res. send(401, 'Not Authorized'); next('Invalid Captcha Token'); } } diff --git a/api/apiCustomFunctions.js b/api/apiCustomFunctions.js index 10403ee4..f9f910af 100644 --- a/api/apiCustomFunctions.js +++ b/api/apiCustomFunctions.js @@ -1,68 +1,497 @@ +/* Submit FOI Request + * 1. Save raw request data to DB + * 2. Send email with request details +*/ + 'use strict'; const fs = require('fs'); -const EmailLayout = require('./emailLayout'); +const {EmailLayout, ConfirmationEmailLayout} = require('./emailLayout'); const restifyErrors = require('restify-errors'); +const { RequestAPI } = require('./foiRequestApiService'); -function submitFoiRequest(server, req, res, next) { - const transomMailer = server.registry.get('transomSmtp'); - const emailLayout = new EmailLayout(); - const foiRequestInbox = process.env.FOI_REQUEST_INBOX; +const foiRequestAPIBackend = process.env.FOI_REQUEST_API_BACKEND; +const foiRequestInbox = process.env.FOI_REQUEST_INBOX; +const requestAPI = new RequestAPI(); +const MAX_ATTACH_MB = 5; +const maxAttachBytes = MAX_ATTACH_MB * 1024 * 1024; - const MAX_ATTACH_MB = 5; - const maxAttachBytes = MAX_ATTACH_MB * 1024 *1024; +const submitFoiRequest = async (server, req, res, next) => { + console.log( + '[API]', + new Date().toISOString(), + 'submitFoiRequest API called' + ); + console.trace('submitFoiRequest API'); + + const apiUrl = `${foiRequestAPIBackend}/foirawrequests`; req.params.requestData = JSON.parse(req.params.requestData); + + req.params.requestData.isPIIRedacted = false; const data = { envMessage: process.env.NODE_ENV, - params: req.params, - files: req.files + params: { + ...req.params, + requestData: req.params.requestData, + }, + files: req.files, }; - req.log.info(`Sending message to ${foiRequestInbox}`, data); - const foiHtml = emailLayout.renderEmail(data.params); - const foiAttachments = []; if (req.files) { - Object.keys(req.files).map(f => { - const file = req.files[f]; - if (file.size < maxAttachBytes) { - foiAttachments.push({ + data.params["requestData"].Attachments = convertFilesToBase64( + req.files, + maxAttachBytes, + next + ); + } + + try { + + const needsPayment = doesNeedPayment(req); + data.params.requestData.requiresPayment = needsPayment + + console.log("calling RAW FOI Request"); + const response = await requestAPI.invokeRequestAPI(JSON.stringify(data.params), apiUrl); + + console.log(`API response = DATA: ${JSON.stringify(response.data)}, STATUS: ${response.status}`); + + if(response.status === 200 && response.data.status) { + console.log(`response id: ${response.data.id}`); + // if request needs payment, return earlier to prevent sending email as it will be sent after payment. + if(needsPayment) { + req.log.info('Success:', response.data.message); + res.send({ result: 'success', id: response.data.id, pendingPayment: true }); + return next(); + } + + console.log(`Sending message to ${foiRequestInbox}`); + req.log.info(`Sending message to ${foiRequestInbox}`); + await sendSubmissionEmail(req, next, server); + + res.send({ + EmailSuccess: true, + message: 'success', + pendingPayment: false + }); + + } else { + req.log.info('Failed:', response); + const unavailable = new restifyErrors.ServiceUnavailableError('Service is unavailable.'); + return next(unavailable); + } + } + catch(error) { + console.log(`${error}`); + console.log("FOI API STATUS:", error.response.status); + console.log("FOI API DATA:", error.response.data); + req.log.info('Failed:', error); + let unavailable = ""; + if (error.response.status === 409) { + // Handle duplicate request + unavailable = new restifyErrors.ConflictError(error.response.data.message); + } else { + unavailable = new restifyErrors.ServiceUnavailableError(error.message || 'Service is unavailable.'); + } + return next(unavailable); + } +} + +const submitFoiRequestEmail = async (server, req, res, next) => { + console.log( + '[API]', + new Date().toISOString(), + 'submitFoiRequestEmail API called' + ); + console.trace('submitFoiRequestEmail API'); + + req.params.requestData = JSON.parse(req.params.requestData); + + try { + + const receipt = []; + + try{ + const receiptResponse = await postGenerateReceipt({ + requestData: req.params.requestData, + requestId: req.params.requestData.requestId, + paymentId: req.params.requestData.paymentInfo.paymentId, + }); + + if(receiptResponse.status === 200 && receiptResponse.data) { + var base64String = Buffer.from(receiptResponse.data).toString("base64"); + + const receiptAttachement = { + content: base64String, + filename: "Receipt.pdf", + encoding: "base64" + }; + + receipt.push(receiptAttachement) + + } + } + catch(genreceipterror){ + console.log("---submitFoiRequestEmail Generate Receipt Error starts--"); + console.log(genreceipterror); + req.log.info('Generate receipt Error:', genreceipterror); + console.log("---submitFoiRequestEmail Generate Receipt Error ends--"); + } + + req.log.info(`Sending message to ${foiRequestInbox}`, req.params); + await sendSubmissionEmail(req, next, server, receipt); + const confirmationResponse = await sendConfirmationEmail( + req, + server, + receipt + ); + + req.log.info('FOI Request email submission success'); + + res.send({ + EmailSuccess: true, + message: 'success', + ConfirmationEmailSuccess: confirmationResponse.EmailSuccess, + ConfirmationEmailMessage: confirmationResponse.message + }); + + next(); + + } catch(error) { + console.log(`${error}`); + req.log.info('Failed:', error); + const unavailable = new restifyErrors.InternalServerError(error.message || 'Service is unavailable.'); + return next(unavailable); + } +} + +const sendSubmissionEmail = async (req, next, server, extraAttachements = []) => { + + let foiAttachments = getAttachments(req.files, maxAttachBytes, next); + + if (extraAttachements.length > 0) { + foiAttachments = [...foiAttachments, ...extraAttachements]; + } + + const submissionEmailLayout = new EmailLayout(); + const submissionHtml = submissionEmailLayout.renderEmail(req.params ,req.isAuthorised, req.userDetails) + const response = await sendEmail(submissionHtml, foiAttachments, server, foiRequestInbox, 'FOI Request Submission', req); + + if(!response.EmailSuccess) { + throw Error('Submission email failed') + } + + return response; + +} + +const sendConfirmationEmail = async (req, server, attachmets = []) => { + try { + const requestData = req.params.requestData + const userEmail = requestData.contactInfoOptions.email + + if(!userEmail) { + return { success: true } + } + const comfirmationEmailLayout = new ConfirmationEmailLayout(); + const confirmationHtml = comfirmationEmailLayout.renderEmail(requestData) + const response = await sendEmail( + confirmationHtml, + attachmets, + server, + userEmail, + "FOI Request Confirmation", + req + ); + + return response; + + } catch(e) { + + return {EmailSuccess: false, message: "Failed to send confirmation email"} + } +} + +const getFeeDetails = (server, req, res, next) => { + const apiUrl = `${foiRequestAPIBackend}/fees/${req.params.feeCode}?quantity=${req.params.quantity}`; + requestAPI.invokeGetFeeDetails(apiUrl) + .then(response => { + return res.json(response.data); + }) + .catch(error => { + if(error.response) { + return res.send(error.response.status, error.response.data) + } + + req.log.info('Failed:', error); + const unavailable = new restifyErrors.ServiceUnavailableError('Service is unavailable.'); + return next(unavailable); + }) + +} + +const createPayment = (server, req, res, next) => { + const {requestId, requestData} = req.params; + + const apiUrl = `${foiRequestAPIBackend}/foirawrequests/${requestId}/payments`; + requestAPI.invokeCreatePayment(JSON.stringify(requestData), apiUrl) + .then(response => { + return res.json(response.data); + }) + .catch(error => { + if(error.response) { + return res.send(error.response.status, error.response.data) + } + + req.log.info('Failed:', error); + const unavailable = new restifyErrors.ServiceUnavailableError('Service is unavailable.'); + return next(unavailable); + }); +} + +const updatePayment = (server, req, res, next) => { + const {requestId, requestData, paymentId} = req.params; + + const apiUrl = `${foiRequestAPIBackend}/foirawrequests/${requestId}/payments/${paymentId}`; + + requestAPI.invokeUpdatePayment(JSON.stringify(requestData), apiUrl) + .then(response => { + return res.json(response.data); + }) + .catch(error => { + if(error.response) { + return res.send(error.response.status, error.response.data) + } + + req.log.info('Failed:', error); + const unavailable = new restifyErrors.ServiceUnavailableError('Service is unavailable.'); + return next(unavailable); + }); +} + +const postGenerateReceipt = ({requestData, requestId, paymentId}) => { + + const receiptData = formReceiptData(requestData); + + const apiUrl = `${foiRequestAPIBackend}/foirawrequests/${requestId}/payments/${paymentId}/receipt`; + + return requestAPI.invokeGenerateReceipt( + JSON.stringify(receiptData), + apiUrl + ); +} + +const generateReceipt = (server, req, res, next) => { + try { + const { requestId, requestData, paymentId } = req.params; + + postGenerateReceipt({requestData, requestId, paymentId}) + .then( + (response) => { + [ + "Content-Disposition", + "Content-Type", + "Content-Length", + "Content-Transfer-Encoding", + "X-Report-Name", + ].forEach((h) => { + res.setHeader(h.toLowerCase(), response.headers[h.toLowerCase()]); + }); + return res.end(response.data); + }) + .catch((error) => { + if (error.response) { + return res.send(error.response.status, error.response.data); + } + + const unavailable = new restifyErrors.ServiceUnavailableError(error); + return next(unavailable); + });; + + } catch(error) { + const unavailable = new restifyErrors.ServiceUnavailableError( + error + ); + return next(unavailable); + } +}; + +const formReceiptData = (requestData) => { + const ministryMap = new Map(); + + requestData.ministry.selectedMinistry.forEach((ministry) => { + if (ministryMap.has(ministry.publicBody)) { + ministryMap.get(ministry.publicBody).push(ministry.name); + } else { + ministryMap.set(ministry.publicBody, [ministry.name]); + } + }); + + const receiptData = { + selectedPublicBodies: Array.from(ministryMap).map(([key, value]) => { + return { + publicBody: key, + ministry: value + .filter((ministry) => ministry !== key) + .map((ministry) => { + return { name: ministry }; + }), + }; + }), + header: { + firstName: requestData.contactInfo.firstName, + lastName: requestData.contactInfo.lastName, + dateSubmitted: requestData.paymentInfo.transactionDate + }, + paymentInfo: { + totalAmount: requestData.paymentInfo.amount, + transactionNumber: requestData.paymentInfo.transactionNumber, + transactionOrderId: requestData.paymentInfo.transactionOrderId, + cardType: requestData.paymentInfo.cardType + } + }; + + return receiptData; +}; + +const sendEmail = async (foiHtml, foiAttachments, server, inbox, subject, req) => { + try { + let pollingAttempts = 0; + const result = { + EmailSuccess: null, + message: "" + } + const transomMailer = server.registry.get('transomSmtp'); + transomMailer.sendFromNoReply( + { + subject: subject, + to: inbox, + html: foiHtml, + attachments: foiAttachments + }, + async (err, response) => { + // Delete all attachments on the submission. + foiAttachments.map(file => { + if(file.path) { + fs.unlinkSync(file.path); + } + }); + // After files are deleted, process the result. + // setTimeout(()=> { + if (err) { + result.message = err.message; + result.EmailSuccess = false; + req.log.info('Failed:', err); + } + else{ + result.message = "Email \"" + subject + "\" Sent Successfully"; + result.EmailSuccess = true; + req.log.info('EmailSent:', response); + } + console.log(`Sent Email? : ${result.EmailSuccess}, Message: ${result.message}`); + // }, 500); + }); + + const executePoll = async (resolve, reject) => { + pollingAttempts++; + console.log('pollingAttempts:', pollingAttempts); + if (result.EmailSuccess !== null) { + console.log('Result:',result); + return resolve(result); + } else if (pollingAttempts > 20) { + return reject(new Error('Exceeded max attempts')); + } else { + console.log('Inside Timeout'); + setTimeout(executePoll, 300, resolve, reject); + } + }; + + return new Promise(executePoll); + } catch (e) { + return {EmailSuccess: false, message: e} + } +} + +const getAttachments = (files, maxFileSize, next) => { + + const attachments = []; + if (files && Object.keys(files).length > 0) { + Object.keys(files).map(f => { + const file = files[f]; + if (file.size < maxFileSize) { + + attachments.push({ filename: file.name, path: file.path }); + } else { - const tooLarge = new restifyErrors.PayloadTooLargeError(`Attachment is too large! Max file size is ${maxAttachBytes} bytes.`); - console.log('Attachment too large; size:', file.size, 'max:', maxAttachBytes); + const tooLarge = new restifyErrors.PayloadTooLargeError(`Attachment is too large! Max file size is ${maxFileSize} bytes.`); + console.log('Attachment too large; size:', file.size, 'max:', maxFileSize); return next(tooLarge); } }); } + return attachments; +} - transomMailer.sendFromNoReply( - { - subject: 'New FOI Request', - to: foiRequestInbox, - html: foiHtml, - attachments: foiAttachments - }, - (err, response) => { - // Delete all attachments on the submission. - foiAttachments.map(file => { - fs.unlinkSync(file.path); - }); - // After files are deleted, process the result. - // setTimeout(()=> { - if (err) { - req.log.info('Failed:', err); - const unavailable = new restifyErrors.ServiceUnavailableError(err.message || 'Service is unavailable.'); - return next(unavailable); - } - req.log.info('Sent!', response); - res.send({ result: 'success' }); - next(); - // }, 5000); +const convertFilesToBase64 = (files, maxFileSize, next) => { + const attachments = []; + if (files && Object.keys(files).length > 0) { + Object.keys(files).map((f) => { + const file = files[f]; + if (file.size < maxFileSize) { + const filedata = fs.readFileSync(file.path, { encoding: "base64" }); + attachments.push({ + filename: file.name, + base64data: filedata, + }); + } else { + const tooLarge = new restifyErrors.PayloadTooLargeError( + `Attachment is too large! Max file size is ${maxFileSize} bytes.` + ); + console.log( + "Attachment too large; size:", + file.size, + "max:", + maxFileSize + ); + return next(tooLarge); + } + }); + } + return attachments; +}; + +const doesNeedPayment = (req) => { + const data = req.params.requestData + + if (!data.requestType) { + throw new Error("Request type is missing") + } + + if (!data.contactInfo) { + throw new Error("Contant info is missing") + } + + if (data.requestType.requestType === "general") { + if(data.contactInfo.IGE) { + return false } - ); + return true + } + else if (data.requestType.requestType === "personal") { + return false + } + + throw new Error("Invalid input data") } -module.exports = { submitFoiRequest }; +module.exports = { + submitFoiRequest, + submitFoiRequestEmail, + getFeeDetails, + createPayment, + updatePayment, + generateReceipt, +}; \ No newline at end of file diff --git a/api/apiDefinition.js b/api/apiDefinition.js index 7210a5be..8d903d59 100644 --- a/api/apiDefinition.js +++ b/api/apiDefinition.js @@ -1,7 +1,10 @@ const customFunctions = require('./apiCustomFunctions'); const apiCaptchaFx = require('./apiCaptcha'); +const apiAuthFx = require('./apiAuth'); const captchaCfg = require('./captchaCfg'); +const authCfg = require('./authCfg'); const apiCaptcha = apiCaptchaFx(captchaCfg); +const apiAuth = apiAuthFx(authCfg); const RotatingFileStream = require('bunyan-rotating-file-stream'); module.exports = { @@ -48,9 +51,29 @@ module.exports = { functions: { submitFoiRequest: { methods: ['POST'], - preMiddleware: [apiCaptcha.verifyJWTResponseMiddleware], + preMiddleware: [apiAuth.verifyJWTResponseMiddleware, apiCaptcha.verifyJWTResponseMiddleware], function: customFunctions.submitFoiRequest - } + }, + submitFoiRequestEmail: { + methods: ['POST'], + function: customFunctions.submitFoiRequestEmail + }, + fees: { + methods: ['GET'], + function: customFunctions.getFeeDetails + }, + createPayment: { + methods: ['POST'], + function: customFunctions.createPayment + }, + updatePayment: { + methods: ['POST'], + function: customFunctions.updatePayment + }, + generateReceipt: { + methods: ['POST'], + function: customFunctions.generateReceipt, + }, } } }; diff --git a/api/authCfg.js b/api/authCfg.js new file mode 100644 index 00000000..7703fb8d --- /dev/null +++ b/api/authCfg.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = { + CAPTCHA_TOKEN_HEADER: 'Authorization', + CAPTCHA_NONCE_HEADER: 'captcha-nonce', + JWKS_URI: process.env.JWKS_URI +}; diff --git a/api/container-entrypoint.sh b/api/container-entrypoint.sh new file mode 100644 index 00000000..10f74fc5 --- /dev/null +++ b/api/container-entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +# Print a message indicating that the container is starting +echo "Starting Node.js application..." + +# Finally, start the Node.js application +exec node ./index.js \ No newline at end of file diff --git a/api/emailLayout.js b/api/emailLayout.js index 5a638b8b..1307d0c0 100644 --- a/api/emailLayout.js +++ b/api/emailLayout.js @@ -1,7 +1,7 @@ /** * Keep the email layout functions together, outside of index.js */ -module.exports = function EmailLayout() { +function EmailLayout() { this.table = function(rows) { return `
| ${value} | \n`; }; + this.tableRowNoLabel = function(value) { + return `
| ${value} |