diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..cc895e6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,238 @@ +name: Build and Deploy + +on: + push: + branches: + - main + - staging + - dev + + workflow_dispatch: + inputs: + channel: + description: "Release channel" + required: true + type: choice + options: + - dev + - staging + - main + default: "staging" + dry-run: + description: "Dry run (skip publishing)" + required: false + type: boolean + default: false + +env: + NODE_VERSION: "22" + PNPM_VERSION: "9" + DOCKER_REGISTRY: cr.vetra.io + PROJECT_NAME: ecosystem-api + +jobs: + prepare: + name: Prepare + runs-on: ubuntu-latest + outputs: + channel: ${{ steps.params.outputs.channel }} + branch: ${{ steps.params.outputs.branch }} + dry_run: ${{ steps.params.outputs.dry_run }} + version: ${{ steps.params.outputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine parameters + id: params + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + CHANNEL="${{ inputs.channel }}" + DRY_RUN="${{ inputs.dry-run }}" + else + # Determine channel from branch + BRANCH="${{ github.ref_name }}" + case "$BRANCH" in + main) CHANNEL="main" ;; + staging) CHANNEL="staging" ;; + dev) CHANNEL="dev" ;; + *) CHANNEL="dev" ;; + esac + DRY_RUN="false" + fi + + BRANCH="${{ github.ref_name }}" + VERSION=$(node -p "require('./package.json').version") + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + + # Create version tag + if [ "$CHANNEL" = "main" ]; then + VERSION_TAG="v${VERSION}" + else + VERSION_TAG="v${VERSION}-${CHANNEL}.${SHORT_SHA}" + fi + + echo "channel=$CHANNEL" >> $GITHUB_OUTPUT + echo "branch=$BRANCH" >> $GITHUB_OUTPUT + echo "dry_run=$DRY_RUN" >> $GITHUB_OUTPUT + echo "version=$VERSION_TAG" >> $GITHUB_OUTPUT + + echo "Channel: $CHANNEL" + echo "Branch: $BRANCH" + echo "Version: $VERSION_TAG" + echo "Dry Run: $DRY_RUN" + + build-docker: + name: Build Docker Image + needs: [prepare] + if: needs.prepare.outputs.dry_run != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Ensure Docker project exists + run: | + PROJECT_NAME="${{ env.PROJECT_NAME }}" + + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -u "${{ secrets.DOCKER_USERNAME }}:${{ secrets.DOCKER_PASSWORD }}" \ + "https://${{ env.DOCKER_REGISTRY }}/api/v2.0/projects?name=${PROJECT_NAME}") + + if [ "$STATUS" = "200" ]; then + EXISTS=$(curl -s \ + -u "${{ secrets.DOCKER_USERNAME }}:${{ secrets.DOCKER_PASSWORD }}" \ + "https://${{ env.DOCKER_REGISTRY }}/api/v2.0/projects?name=${PROJECT_NAME}" | \ + jq -r ".[] | select(.name==\"${PROJECT_NAME}\") | .name") + + if [ "$EXISTS" = "$PROJECT_NAME" ]; then + echo "Project ${PROJECT_NAME} already exists" + else + echo "Creating project ${PROJECT_NAME}..." + curl -X POST \ + -u "${{ secrets.DOCKER_USERNAME }}:${{ secrets.DOCKER_PASSWORD }}" \ + -H "Content-Type: application/json" \ + -d "{\"project_name\": \"${PROJECT_NAME}\", \"public\": false}" \ + "https://${{ env.DOCKER_REGISTRY }}/api/v2.0/projects" + fi + else + echo "Creating project ${PROJECT_NAME}..." + curl -X POST \ + -u "${{ secrets.DOCKER_USERNAME }}:${{ secrets.DOCKER_PASSWORD }}" \ + -H "Content-Type: application/json" \ + -d "{\"project_name\": \"${PROJECT_NAME}\", \"public\": false}" \ + "https://${{ env.DOCKER_REGISTRY }}/api/v2.0/projects" + fi + + - name: Login to Docker Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Determine image tags + id: tags + run: | + VERSION="${{ needs.prepare.outputs.version }}" + CHANNEL="${{ needs.prepare.outputs.channel }}" + + DOCKER_BASE="${{ env.DOCKER_REGISTRY }}/${{ env.PROJECT_NAME }}/api" + + TAGS="${DOCKER_BASE}:${VERSION}" + + if [ "$CHANNEL" = "main" ]; then + TAGS="${TAGS},${DOCKER_BASE}:latest" + else + TAGS="${TAGS},${DOCKER_BASE}:${CHANNEL}" + fi + + echo "tags=$TAGS" >> $GITHUB_OUTPUT + echo "Image tags: $TAGS" + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.tags.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max + + update-k8s: + name: Update K8s Cluster + needs: [prepare, build-docker] + if: | + needs.prepare.outputs.dry_run != 'true' && + (needs.prepare.outputs.channel == 'dev' || needs.prepare.outputs.channel == 'staging') + runs-on: ubuntu-latest + steps: + - name: Checkout k8s-hosting repository + uses: actions/checkout@v4 + with: + repository: powerhouse-inc/powerhouse-k8s-hosting + token: ${{ secrets.K8S_REPO_PAT }} + path: k8s-hosting + + - name: Install yq + uses: mikefarah/yq@v4 + + - name: Update image tags + run: | + CHANNEL="${{ needs.prepare.outputs.channel }}" + VERSION="${{ needs.prepare.outputs.version }}" + VALUES_FILE="k8s-hosting/tenants/${CHANNEL}/ecosystem-api-values.yaml" + + echo "Updating ${VALUES_FILE} with version ${VERSION}" + + yq -i ".image.tag = \"${VERSION}\"" "$VALUES_FILE" + + echo "Updated image tag to ${VERSION}" + cat "$VALUES_FILE" + + - name: Commit and push changes + run: | + cd k8s-hosting + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + CHANNEL="${{ needs.prepare.outputs.channel }}" + VERSION="${{ needs.prepare.outputs.version }}" + + git add "tenants/${CHANNEL}/ecosystem-api-values.yaml" + + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "chore(${CHANNEL}): update ecosystem-api image tag to ${VERSION}" + git push + fi + + summary: + name: Release Summary + needs: [prepare, build-docker, update-k8s] + if: always() + runs-on: ubuntu-latest + steps: + - name: Summary + run: | + echo "## Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Channel | ${{ needs.prepare.outputs.channel }} |" >> $GITHUB_STEP_SUMMARY + echo "| Branch | ${{ needs.prepare.outputs.branch }} |" >> $GITHUB_STEP_SUMMARY + echo "| Version | ${{ needs.prepare.outputs.version }} |" >> $GITHUB_STEP_SUMMARY + echo "| Dry Run | ${{ needs.prepare.outputs.dry_run }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Docker Image" >> $GITHUB_STEP_SUMMARY + echo "\`${{ env.DOCKER_REGISTRY }}/${{ env.PROJECT_NAME }}/api:${{ needs.prepare.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY diff --git a/Dockerfile b/Dockerfile index 234c6c9..fe6c93b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ # builder FROM node:22.5-alpine AS builder -RUN npm install -g typescript pnpm +RUN npm install -g typescript WORKDIR /app -COPY package.json pnpm-lock.yaml knexfile.js ./ -RUN pnpm install +COPY package.json package-lock.json knexfile.js ./ +RUN npm ci COPY . ./ RUN tsc -p ./config/tsconfig.json @@ -13,9 +13,20 @@ RUN tsc -p ./config/tsconfig.json # runner FROM node:22.5-alpine AS runner +RUN apk add --no-cache curl RUN npm install -g knex WORKDIR /app COPY --from=builder /app/ ./ +COPY docker/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh -ENTRYPOINT [ "node" ] +ENV NODE_ENV=production +ENV PORT=4000 + +EXPOSE ${PORT} + +HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:${PORT}/healthz || exit 1 + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..e10dd98 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +# Run migrations if not skipped +if [ "$SKIP_DB_MIGRATIONS" != "true" ] && [ -n "$PG_CONNECTION_STRING" ]; then + echo "[entrypoint] Running database migrations..." + knex migrate:latest --knexfile ./knexfile.js + echo "[entrypoint] Migrations completed" +fi + +echo "[entrypoint] Starting ecosystem-api on port ${PORT:-4000}..." +exec node ./build/index.js diff --git a/scripts/snapshots/data/blockNumbers-ADs.js b/scripts/snapshots/data/blockNumbers-ADs.js index 74972d8..dab98f7 100644 --- a/scripts/snapshots/data/blockNumbers-ADs.js +++ b/scripts/snapshots/data/blockNumbers-ADs.js @@ -25,5 +25,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22675856, + "2025/07": 22895856 }; diff --git a/scripts/snapshots/data/blockNumbers-DAIF.js b/scripts/snapshots/data/blockNumbers-DAIF.js index ab14dfd..f1f5038 100644 --- a/scripts/snapshots/data/blockNumbers-DAIF.js +++ b/scripts/snapshots/data/blockNumbers-DAIF.js @@ -44,5 +44,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22816918, + "2025/07": 23036191 }; diff --git a/scripts/snapshots/data/blockNumbers-DEWIZ.js b/scripts/snapshots/data/blockNumbers-DEWIZ.js index 45d971f..3cf306c 100644 --- a/scripts/snapshots/data/blockNumbers-DEWIZ.js +++ b/scripts/snapshots/data/blockNumbers-DEWIZ.js @@ -23,5 +23,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22816918, + "2025/07": 23036191 }; diff --git a/scripts/snapshots/data/blockNumbers-DRAFT-KEEPERS.js b/scripts/snapshots/data/blockNumbers-DRAFT-KEEPERS.js index c960583..f64f464 100644 --- a/scripts/snapshots/data/blockNumbers-DRAFT-KEEPERS.js +++ b/scripts/snapshots/data/blockNumbers-DRAFT-KEEPERS.js @@ -36,5 +36,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22816918, + "2025/07": 23036191 }; diff --git a/scripts/snapshots/data/blockNumbers-DRAFT.js b/scripts/snapshots/data/blockNumbers-DRAFT.js index 9f666a8..e7f2dbb 100644 --- a/scripts/snapshots/data/blockNumbers-DRAFT.js +++ b/scripts/snapshots/data/blockNumbers-DRAFT.js @@ -51,5 +51,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22816918, + "2025/07": 23036191 }; diff --git a/scripts/snapshots/data/blockNumbers-IS.js b/scripts/snapshots/data/blockNumbers-IS.js index b4625ee..6e1f2d8 100644 --- a/scripts/snapshots/data/blockNumbers-IS.js +++ b/scripts/snapshots/data/blockNumbers-IS.js @@ -29,5 +29,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22816918, + "2025/07": 23036191, }; diff --git a/scripts/snapshots/data/blockNumbers-JETSTREAM.js b/scripts/snapshots/data/blockNumbers-JETSTREAM.js index f954357..f6d17c9 100644 --- a/scripts/snapshots/data/blockNumbers-JETSTREAM.js +++ b/scripts/snapshots/data/blockNumbers-JETSTREAM.js @@ -22,5 +22,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22816918, + "2025/07": 23036191 }; diff --git a/scripts/snapshots/data/blockNumbers-PH.js b/scripts/snapshots/data/blockNumbers-PH.js index c06529a..ebdd5d1 100644 --- a/scripts/snapshots/data/blockNumbers-PH.js +++ b/scripts/snapshots/data/blockNumbers-PH.js @@ -13,5 +13,7 @@ export default { "2025/02": 21969838, "2025/03": 22181687, "2025/04": 22365931, - "2025/05": 22455856 + "2025/05": 22597645, + "2025/06": 22816918, + "2025/07": 23036191, }; diff --git a/scripts/snapshots/data/blockNumbers-TECH-EA.js b/scripts/snapshots/data/blockNumbers-TECH-EA.js index 9292725..32a5c15 100644 --- a/scripts/snapshots/data/blockNumbers-TECH-EA.js +++ b/scripts/snapshots/data/blockNumbers-TECH-EA.js @@ -20,5 +20,7 @@ export default { "2025/02": 21815725, "2025/03": 22015856, "2025/04": 22235856, - "2025/05": 22455856 + "2025/05": 22455856, + "2025/06": 22816918, + "2025/07": 23036191, }; diff --git a/src/index.ts b/src/index.ts index c8e040e..e2b64bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,11 @@ import { ApolloServer } from "@apollo/server"; import { expressMiddleware } from '@apollo/server/express4'; import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'; +import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default'; import express from "express"; import http from "http"; +import crypto from "crypto"; import cors from "cors"; import compression from "compression"; @@ -51,7 +53,14 @@ function buildExpressApp() { }); }); app.get('/update-cache', async (_req, res) => { - if (_req.headers['refresh-cache'] !== process.env.REFRESH_CACHE_SECRET) { + const headerVal = _req.headers['refresh-cache']; + const secretVal = process.env.REFRESH_CACHE_SECRET; + if ( + typeof headerVal !== 'string' || + !secretVal || + Buffer.byteLength(headerVal) !== Buffer.byteLength(secretVal) || + !crypto.timingSafeEqual(Buffer.from(headerVal), Buffer.from(secretVal)) + ) { return res.status(401).json({ status: 'error', message: 'Unauthorized', @@ -82,10 +91,15 @@ async function startApolloServer( resolvers: apiModules.resolvers, }); - const plugins = [ApolloServerPluginDrainHttpServer({ httpServer }), responseCachePlugin()]; + const plugins = [ + ApolloServerPluginDrainHttpServer({ httpServer }), + responseCachePlugin(), + ApolloServerPluginLandingPageLocalDefault({ embed: true }), + ]; const server = new ApolloServer({ schema, plugins, + introspection: true, persistedQueries: { cache: new InMemoryLRUCache({ maxSize: 1000, diff --git a/src/modules/Auth/db.ts b/src/modules/Auth/db.ts index af24d7c..7f7a74a 100644 --- a/src/modules/Auth/db.ts +++ b/src/modules/Auth/db.ts @@ -29,7 +29,11 @@ export class AuthModel { paramName: string, paramValue: string | number | boolean, ): Promise { - return this.knex("User").where(`${paramName}`, paramValue); + const allowedColumns = ['id', 'username', 'password', 'active']; + if (!allowedColumns.includes(paramName)) { + throw new Error(`Invalid column name: ${paramName}`); + } + return this.knex("User").where(paramName, paramValue); } async getResourceId(userId: number) { @@ -257,8 +261,12 @@ export class AuthModel { }) .orderBy("User.id", "asc"); if (paramName !== undefined && paramValue !== undefined) { + const allowedColumns = ['id', 'username', 'active']; + if (!allowedColumns.includes(paramName)) { + throw new Error(`Invalid column name: ${paramName}`); + } return await baseQuery.where( - paramName === "id" ? "User.id" : `${paramName}`, + paramName === "id" ? "User.id" : paramName, paramValue, ); } diff --git a/src/modules/BudgetStatement/ResolverCache.ts b/src/modules/BudgetStatement/ResolverCache.ts index 0f13c49..9333bb8 100644 --- a/src/modules/BudgetStatement/ResolverCache.ts +++ b/src/modules/BudgetStatement/ResolverCache.ts @@ -97,7 +97,7 @@ export class ResolverCache { .insert({ hash, expiry: this._knex.raw( - "CURRENT_TIMESTAMP + interval '" + expirationInMinutes + " minutes'", + "CURRENT_TIMESTAMP + make_interval(mins => ?)", [expirationInMinutes], ), data: JSON.stringify(outputGroups), }) diff --git a/src/modules/BudgetStatement/db.ts b/src/modules/BudgetStatement/db.ts index 13096ff..75b3bc4 100644 --- a/src/modules/BudgetStatement/db.ts +++ b/src/modules/BudgetStatement/db.ts @@ -230,17 +230,18 @@ export class BudgetStatementModel { // use other join query if lastModified filter is set if (filter?.filter?.sortByLastModified) { + const sortDir = filter.filter.sortByLastModified.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; const subquery = this.knex .select(this.knex.raw("DISTINCT ON ((params->>'budgetStatementId')::integer) params->>'budgetStatementId' as id, created_at")) .from("ChangeTrackingEvents") .whereRaw("params->>'budgetStatementId' IS NOT NULL") - .orderByRaw(`(params->>'budgetStatementId')::integer, created_at ${filter?.filter?.sortByLastModified.toUpperCase()}`); + .orderByRaw(`(params->>'budgetStatementId')::integer, created_at ${sortDir}`); query = this.knex .select("BudgetStatement.*", "cte.created_at as last_modified") .from("BudgetStatement") .leftJoin(subquery.as('cte'), 'BudgetStatement.id', this.knex.raw('cte.id::integer')) - .orderByRaw(`last_modified ${filter?.filter?.sortByLastModified} NULLS LAST, month DESC`); + .orderByRaw(`last_modified ${sortDir} NULLS LAST, month DESC`); } if (filter?.limit !== undefined && filter?.offset !== undefined) { @@ -386,6 +387,13 @@ export class BudgetStatementModel { .select("*") .from("BudgetStatementLineItem") .orderBy("month", "desc"); + const allowedLineItemColumns = ['id', 'budgetStatementWalletId', 'month', 'position', 'group', 'budgetCategory', 'forecast', 'actual', 'comments', 'canonicalBudgetCategory', 'headcountExpense', 'budgetCap', 'payment']; + if (paramName !== undefined && !allowedLineItemColumns.includes(paramName)) { + throw new Error(`Invalid column name: ${paramName}`); + } + if (secondParamName !== undefined && !allowedLineItemColumns.includes(secondParamName)) { + throw new Error(`Invalid column name: ${secondParamName}`); + } if (offset != undefined && limit != undefined) { return baseQuery.limit(limit).offset(offset); } else if ( @@ -394,7 +402,7 @@ export class BudgetStatementModel { secondParamName === undefined && secondParamValue === undefined ) { - return baseQuery.where(`${paramName}`, paramValue); + return baseQuery.where(paramName, paramValue); } else if ( paramName !== undefined && paramValue !== undefined && @@ -402,8 +410,8 @@ export class BudgetStatementModel { secondParamValue !== undefined ) { return baseQuery - .where(`${paramName}`, paramValue) - .andWhere(`${secondParamName}`, secondParamValue); + .where(paramName, paramValue) + .andWhere(secondParamName, secondParamValue); } else { return baseQuery; } @@ -474,6 +482,13 @@ export class BudgetStatementModel { secondParamName?: string | undefined, secondParamValue?: string | number | boolean | undefined, ): Promise { + const allowedCommentColumns = ['id', 'budgetStatementId', 'authorId', 'timestamp', 'comment', 'status']; + if (paramName !== undefined && !allowedCommentColumns.includes(paramName)) { + throw new Error(`Invalid column name: ${paramName}`); + } + if (secondParamName !== undefined && !allowedCommentColumns.includes(secondParamName)) { + throw new Error(`Invalid column name: ${secondParamName}`); + } const baseQuery = this.knex .select("*") .from("BudgetStatementComment") @@ -484,7 +499,7 @@ export class BudgetStatementModel { secondParamName === undefined && secondParamValue === undefined ) { - return baseQuery.where(`${paramName}`, paramValue); + return baseQuery.where(paramName, paramValue); } else if ( paramName !== undefined && paramValue !== undefined && @@ -492,8 +507,8 @@ export class BudgetStatementModel { secondParamValue !== undefined ) { return baseQuery - .where(`${paramName}`, paramValue) - .andWhere(`${secondParamName}`, secondParamValue); + .where(paramName, paramValue) + .andWhere(secondParamName, secondParamValue); } else { return baseQuery; } diff --git a/src/modules/BudgetStatement/schema/budgetStatement.ts b/src/modules/BudgetStatement/schema/budgetStatement.ts index 8aa97c3..a2cd1ba 100644 --- a/src/modules/BudgetStatement/schema/budgetStatement.ts +++ b/src/modules/BudgetStatement/schema/budgetStatement.ts @@ -756,7 +756,7 @@ export const resolvers = { input[0].ownerId, ); } - if (parseInt(allowed[0].count) > 0 || input[0].ownerId === null) { + if (parseInt(allowed[0].count) > 0) { if (input.length < 1) { throw new Error("No input data"); } @@ -803,15 +803,17 @@ export const resolvers = { throw new Error("Account disabled. Reach admin for more info."); } const cuIdFromInput = input.pop(); - let allowed = { count: 0 }; - if (cuIdFromInput.ownerType !== "Delegates") { + let allowed: any = { count: 0 }; + if (cuIdFromInput.ownerType === "Delegates") { + [allowed] = await dataSources.db.Auth.canUpdate(userObj.id, "Delegates", null); + } else { [allowed] = await dataSources.db.Auth.canUpdateCoreUnit( userObj.id, cuIdFromInput.ownerType, cuIdFromInput.cuId, ); } - if (allowed.count > 0 || cuIdFromInput.ownerType === "Delegates") { + if (parseInt(allowed.count) > 0) { //Tacking Change let CU; if (cuIdFromInput.ownerType === "Delegates") { @@ -961,15 +963,17 @@ export const resolvers = { throw new Error("Account disabled. Reach admin for more info."); } const cuIdFromInput = input.pop(); - let allowed = { count: 0 }; - if (cuIdFromInput.ownerType !== "Delegates") { + let allowed: any = { count: 0 }; + if (cuIdFromInput.ownerType === "Delegates") { + [allowed] = await dataSources.db.Auth.canUpdate(userObj.id, "Delegates", null); + } else { [allowed] = await dataSources.db.Auth.canUpdateCoreUnit( userObj.id, cuIdFromInput.ownerType, cuIdFromInput.cuId, ); } - if (allowed.count > 0 || cuIdFromInput.ownerType === "Delegates") { + if (parseInt(allowed.count) > 0) { //Tacking Change let CU; if (cuIdFromInput.ownerType === "Delegates") { diff --git a/src/modules/BudgetStatement/schema/utils.ts b/src/modules/BudgetStatement/schema/utils.ts index 0c5e939..d00e683 100644 --- a/src/modules/BudgetStatement/schema/utils.ts +++ b/src/modules/BudgetStatement/schema/utils.ts @@ -128,8 +128,8 @@ export const resolveBudgetPath = async (path: string, knex: Knex) => { // Escape single quotes in path // include / at the end to ensure we are matching the full path path = path.endsWith('/') ? path : `${path}/`; - const escapedPath = path.replace(/'/g, "''"); - query = query.where('path', 'like', `${escapedPath}%`); + const escapedPath = path.replace(/[%_\\]/g, '\\$&'); + query = query.whereRaw(`"path" LIKE ? ESCAPE '\\'`, [`${escapedPath}%`]); const result = await query; return result.map((item: any) => item.budgetStatementId); diff --git a/src/modules/ChangeTracking/db.ts b/src/modules/ChangeTracking/db.ts index 5a0c0c5..c13dd48 100644 --- a/src/modules/ChangeTracking/db.ts +++ b/src/modules/ChangeTracking/db.ts @@ -122,10 +122,16 @@ export class ChangeTrackingModel { } getBsEvents(bsId: string) { + let parsedBsId: any; + try { + parsedBsId = JSON.parse(bsId); + } catch { + return this.knex.select("*").from("ChangeTrackingEvents").whereRaw("1 = 0"); + } return this.knex .select("*") .from("ChangeTrackingEvents") - .whereRaw("params->>? = ?", ["budgetStatementId", JSON.parse(bsId)]) + .whereRaw("params->>? = ?", ["budgetStatementId", parsedBsId]) .orderBy("ChangeTrackingEvents.id", "desc"); } @@ -333,6 +339,13 @@ export class ChangeTrackingModel { secondParamName: string | undefined, secondParamValue: string | undefined, ) { + const allowedColumns = ['id', 'userId', 'collection', 'data', 'lastVisit']; + if (paramName !== undefined && !allowedColumns.includes(paramName)) { + throw new Error(`Invalid column name: ${paramName}`); + } + if (secondParamName !== undefined && !allowedColumns.includes(secondParamName)) { + throw new Error(`Invalid column name: ${secondParamName}`); + } if ( paramName === undefined && paramValue === undefined && @@ -342,13 +355,13 @@ export class ChangeTrackingModel { return this.knex("UserActivity").orderBy("id", "desc").limit(1); } else if (secondParamName == undefined && secondParamValue == undefined) { return this.knex("UserActivity") - .where(`${paramName}`, paramValue) + .where(paramName!, paramValue) .orderBy("id", "desc") .limit(1); } else { return this.knex("UserActivity") - .where(`${paramName}`, paramValue) - .andWhere(`${secondParamName}`, secondParamValue) + .where(paramName!, paramValue) + .andWhere(secondParamName!, secondParamValue) .orderBy("id", "desc") .limit(1); } diff --git a/src/modules/Roadmap/db.ts b/src/modules/Roadmap/db.ts index a111357..5b56908 100644 --- a/src/modules/Roadmap/db.ts +++ b/src/modules/Roadmap/db.ts @@ -94,13 +94,20 @@ export class RoadmapModel { this.coreUnitModel = coreUnitModel; } + private validateColumn(paramName: string, allowedColumns: string[]): void { + if (!allowedColumns.includes(paramName)) { + throw new Error(`Invalid column name: ${paramName}`); + } + } + async getRoadmaps( paramName?: string | undefined, paramValue?: string | number | boolean | undefined, ): Promise { const baseQuery = this.knex.select("*").from("Roadmap").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'ownerCuId', 'roadmapCode', 'roadmapName', 'comments', 'roadmapStatus', 'strategicInitiative', 'roadmapSummary']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -115,7 +122,8 @@ export class RoadmapModel { .from("RoadmapStakeholder") .orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'stakeholderId', 'roadmapId', 'stakeholderRoleId']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -130,7 +138,8 @@ export class RoadmapModel { .from("StakeholderRole") .orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'stakeholderRoleName']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -142,7 +151,8 @@ export class RoadmapModel { ): Promise { const baseQuery = this.knex.select("*").from("Stakeholder").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'name', 'stakeholderContributorId', 'stakeholderCuCode']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -154,7 +164,8 @@ export class RoadmapModel { ): Promise { const baseQuery = this.knex.select("*").from("RoadmapOutput").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'outputId', 'roadmapId', 'outputTypeId']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -166,7 +177,8 @@ export class RoadmapModel { ): Promise { const baseQuery = this.knex.select("*").from("Output").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'name', 'outputUrl', 'outputDate']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -178,7 +190,8 @@ export class RoadmapModel { ): Promise { const baseQuery = this.knex.select("*").from("OutputType").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'outputType']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -190,7 +203,8 @@ export class RoadmapModel { ): Promise { const baseQuery = this.knex.select("*").from("Milestone").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'roadmapId', 'taskId']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -202,7 +216,8 @@ export class RoadmapModel { ): Promise { const baseQuery = this.knex.select("*").from("Task").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'parentId', 'taskName', 'taskStatus', 'ownerStakeholderId', 'startDate', 'target', 'completedPercentage', 'confidenceLevel', 'comments']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; } @@ -214,7 +229,8 @@ export class RoadmapModel { ): Promise { const baseQuery = this.knex.select("*").from("Review").orderBy("id"); if (paramName !== undefined && paramValue !== undefined) { - return baseQuery.where(`${paramName}`, paramValue); + this.validateColumn(paramName, ['id', 'taskId', 'reviewDate', 'reviewOutcome']); + return baseQuery.where(paramName, paramValue); } else { return baseQuery; }