From ddc0a06205cf556527cbebb9aca621a6ac6be4f9 Mon Sep 17 00:00:00 2001 From: Phil Moorhouse Date: Tue, 27 Aug 2024 16:59:36 +0100 Subject: [PATCH 01/11] add some structure --- .env-example | 7 + package-lock.json | 524 ++++++++++++------ package.json | 27 +- src/app.ts | 102 +--- src/config/authenticate.ts | 10 - src/controllers/api.ts | 13 +- src/middleware/ensure-authenticated.ts | 9 + src/middleware/rate-limiter.ts | 13 + src/middleware/session.ts | 10 + .../i18next.ts => middleware/translation.ts} | 13 +- src/resources/locales/en-GB.json | 3 +- src/route/publish.ts | 202 ------- src/{config/auth_config.ts => routes/auth.ts} | 2 - src/{route => routes}/healthcheck.ts | 7 +- src/routes/publish.ts | 132 +++++ src/{route => routes}/view.ts | 23 +- src/server.ts | 17 + src/utils/logger.ts | 35 ++ src/views/login.ejs | 6 +- test/app.test.ts | 8 +- 20 files changed, 662 insertions(+), 501 deletions(-) create mode 100644 .env-example delete mode 100644 src/config/authenticate.ts create mode 100644 src/middleware/ensure-authenticated.ts create mode 100644 src/middleware/rate-limiter.ts create mode 100644 src/middleware/session.ts rename src/{config/i18next.ts => middleware/translation.ts} (67%) delete mode 100644 src/route/publish.ts rename src/{config/auth_config.ts => routes/auth.ts} (97%) rename src/{route => routes}/healthcheck.ts (81%) create mode 100644 src/routes/publish.ts rename src/{route => routes}/view.ts (69%) create mode 100644 src/utils/logger.ts diff --git a/.env-example b/.env-example new file mode 100644 index 0000000..6ace7ec --- /dev/null +++ b/.env-example @@ -0,0 +1,7 @@ +BACKEND_SERVER=localhost +BACKEND_PROTOCOL=http +BACKEND_PORT=3001 + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +SESSION_SECRET= diff --git a/package-lock.json b/package-lock.json index ec05565..b6ecd47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,12 +22,15 @@ "i18next": "^23.10.1", "i18next-fs-backend": "^2.3.1", "i18next-http-middleware": "^3.5.0", + "lodash": "^4.17.21", "multer": "^1.4.5-lts.1", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-local": "^1.0.0", - "pino": "^8.19.0", - "ts-node-dev": "^2.0.0" + "pino": "^8.21.0", + "pino-http": "^10.2.0", + "ts-node-dev": "^2.0.0", + "uuid": "^10.0.0" }, "devDependencies": { "@shopify/eslint-plugin": "^44.0.0", @@ -35,12 +38,14 @@ "@types/express": "^4.17.21", "@types/fs-extra": "^9.0.13", "@types/jest": "^29.5.12", + "@types/lodash": "^4.17.7", "@types/multer": "^1.4.11", "@types/node": "^20.11.27", "@types/passport-google-oauth20": "^2.0.16", "@types/pino": "^7.0.5", "@types/shelljs": "^0.8.15", "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/parser": "^7.0.1", "eslint": "^8.56.0", @@ -53,6 +58,7 @@ "msw": "^2.3.1", "nodemon": "^3.1.0", "npm-run-all": "^4.1.5", + "pino-colada": "^2.2.2", "prettier": "^3.2.5", "rimraf": "^3.0.2", "shelljs": "^0.8.5", @@ -2380,6 +2386,13 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2579,6 +2592,13 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/wrap-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", @@ -2601,22 +2621,21 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.5.0.tgz", - "integrity": "sha512-HpqNTH8Du34nLxbKgVMGljZMG0rJd2O9ecvr2QLYp+7512ty1j42KnsFwspPXg1Vh8an9YImf6CokUBltisZFQ==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "7.5.0", - "@typescript-eslint/type-utils": "7.5.0", - "@typescript-eslint/utils": "7.5.0", - "@typescript-eslint/visitor-keys": "7.5.0", - "debug": "^4.3.4", + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", "graphemer": "^1.4.0", - "ignore": "^5.2.4", + "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "ts-api-utils": "^1.3.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2635,49 +2654,17 @@ } } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@typescript-eslint/parser": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.5.0.tgz", - "integrity": "sha512-cj+XGhNujfD2/wzR1tabNsidnYRaFfEkcULdcIyVBYcXjBvBKOes+mpMBP7hMpOyk+gBcfXsrg4NBGAStQyxjQ==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "7.5.0", - "@typescript-eslint/types": "7.5.0", - "@typescript-eslint/typescript-estree": "7.5.0", - "@typescript-eslint/visitor-keys": "7.5.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4" }, "engines": { @@ -2697,13 +2684,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.5.0.tgz", - "integrity": "sha512-Z1r7uJY0MDeUlql9XJ6kRVgk/sP11sr3HKXn268HZyqL7i4cEfrdFuSSY/0tUqT37l5zT0tJOsuDP16kio85iA==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.5.0", - "@typescript-eslint/visitor-keys": "7.5.0" + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2714,15 +2702,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.5.0.tgz", - "integrity": "sha512-A021Rj33+G8mx2Dqh0nMO9GyjjIBK3MqgVgZ2qlKf6CJy51wY/lkkFqq3TqqnH34XyAHUkq27IjlUkWlQRpLHw==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "7.5.0", - "@typescript-eslint/utils": "7.5.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" + "ts-api-utils": "^1.3.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2741,10 +2730,11 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.5.0.tgz", - "integrity": "sha512-tv5B4IHeAdhR7uS4+bf8Ov3k793VEVHd45viRRkehIUZxm0WF82VPiLgHzA/Xl4TGPg1ZD49vfxBKFPecD5/mg==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || >=20.0.0" }, @@ -2754,19 +2744,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.5.0.tgz", - "integrity": "sha512-YklQQfe0Rv2PZEueLTUffiQGKQneiIEKKnfIqPIOxgM9lKSZFCjT5Ad4VqRKj/U4+kQE3fa8YQpskViL7WjdPQ==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "7.5.0", - "@typescript-eslint/visitor-keys": "7.5.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2781,26 +2772,28 @@ } } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, + "license": "ISC", "dependencies": { - "yallist": "^4.0.0" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -2808,25 +2801,17 @@ "node": ">=10" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@typescript-eslint/utils": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.5.0.tgz", - "integrity": "sha512-3vZl9u0R+/FLQcpy2EHyRGNqAS/ofJ3Ji8aebilfJe+fobK8+LbIFmrHciLVDxjDoONmufDcnVSF38KwMEOjzw==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "7.5.0", - "@typescript-eslint/types": "7.5.0", - "@typescript-eslint/typescript-estree": "7.5.0", - "semver": "^7.5.4" + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2839,47 +2824,15 @@ "eslint": "^8.56.0" } }, - "node_modules/@typescript-eslint/utils/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.5.0.tgz", - "integrity": "sha512-mcuHM/QircmA6O7fy6nn2w/3ditQkj+SgtOc8DW3uQ10Yfj42amm2i+6F2K4YAOPNNTmE6iM1ynM6lrSwdendA==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.5.0", - "eslint-visitor-keys": "^3.4.1" + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2894,6 +2847,7 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -5793,6 +5747,13 @@ "node": ">= 6" } }, + "node_modules/fast-json-parse": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fast-json-parse/-/fast-json-parse-1.0.3.tgz", + "integrity": "sha512-FRWsaZRWEJ1ESVNbDWmsAlqDk96gPQezzLghafp5J4GUKjbCz3OkAHuZs5TuPEtkbVQERysLp9xv6c24fBm8Aw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -6078,7 +6039,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -7899,6 +7859,16 @@ "xml": "^1.0.1" } }, + "node_modules/jest-junit/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/jest-leak-detector": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", @@ -9098,6 +9068,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -9252,6 +9228,7 @@ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -10082,6 +10059,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", + "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -10265,30 +10252,32 @@ } }, "node_modules/pino": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-8.19.0.tgz", - "integrity": "sha512-oswmokxkav9bADfJ2ifrvfHUwad6MLp73Uat0IkQWY3iAw5xTRoznXbXksZs8oaOUMpmhVWD+PZogNzllWpJaA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-8.21.0.tgz", + "integrity": "sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q==", + "license": "MIT", "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "v1.1.0", + "pino-abstract-transport": "^1.2.0", "pino-std-serializers": "^6.0.0", "process-warning": "^3.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^3.7.0", - "thread-stream": "^2.0.0" + "thread-stream": "^2.6.0" }, "bin": { "pino": "bin.js" } }, "node_modules/pino-abstract-transport": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.1.0.tgz", - "integrity": "sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz", + "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", + "license": "MIT", "dependencies": { "readable-stream": "^4.0.0", "split2": "^4.0.0" @@ -10317,6 +10306,185 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/pino-colada": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/pino-colada/-/pino-colada-2.2.2.tgz", + "integrity": "sha512-tzZl6j4D2v9WSQ4vEa7s8j15v16U6+z6M0fAWJOu5gBKxpy+XnOAgFBzeZGkxm6W3AQ04WvdSpqVLnDBdJlvOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^3.0.0", + "fast-json-parse": "^1.0.2", + "prettier-bytes": "^1.0.3", + "pretty-ms": "^5.0.0", + "split2": "^3.0.0" + }, + "bin": { + "pino-colada": "bin.js" + } + }, + "node_modules/pino-colada/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pino-colada/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pino-colada/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/pino-colada/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pino-colada/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pino-colada/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pino-colada/node_modules/split2": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", + "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", + "dev": true, + "license": "ISC", + "dependencies": { + "readable-stream": "^3.0.0" + } + }, + "node_modules/pino-colada/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pino-http": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.2.0.tgz", + "integrity": "sha512-am03BxnV3Ckx68OkbH0iZs3indsrH78wncQ6w1w51KroIbvJZNImBKX2X1wjdY8lSyaJ0UrX/dnO2DY3cTeCRw==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^3.0.0" + } + }, + "node_modules/pino-http/node_modules/pino": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.3.2.tgz", + "integrity": "sha512-WtARBjgZ7LNEkrGWxMBN/jvlFiE17LTbBoH0konmBU684Kd0uIiDwBXlcTCW7iJnA6HfIKwUssS/2AC6cDEanw==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^1.2.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^4.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-http/node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "license": "MIT" + }, + "node_modules/pino-http/node_modules/pino/node_modules/process-warning": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.0.tgz", + "integrity": "sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==", + "license": "MIT" + }, + "node_modules/pino-http/node_modules/sonic-boom": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.0.1.tgz", + "integrity": "sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/pino-http/node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/pino-std-serializers": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz", @@ -10385,6 +10553,13 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-bytes": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/prettier-bytes/-/prettier-bytes-1.0.4.tgz", + "integrity": "sha512-dLbWOa4xBn+qeWeIF60qRoB6Pk2jX5P3DIVgOQyMyvBpu931Q+8dXz8X0snJiFkQdohDDLnZQECjzsAj75hgZQ==", + "dev": true, + "license": "ISC" + }, "node_modules/prettier-linter-helpers": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", @@ -10423,6 +10598,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-ms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-5.1.0.tgz", + "integrity": "sha512-4gaK1skD2gwscCfkswYQRmddUb2GJZtzDGRjHWadVHtK/DIKFufa12MvES6/xu1tVbUYeia5bmLcwJtZJQUqnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^2.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -11593,9 +11784,10 @@ "dev": true }, "node_modules/thread-stream": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.4.1.tgz", - "integrity": "sha512-d/Ex2iWd1whipbT681JmTINKw0ZwOUBZm7+Gjs64DHuX34mmw8vJL2bFAaNacaW72zYiTJxSHi5abUuOi5nsfg==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.7.0.tgz", + "integrity": "sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==", + "license": "MIT", "dependencies": { "real-require": "^0.2.0" } @@ -11667,12 +11859,14 @@ } }, "node_modules/ts-jest": { - "version": "29.1.2", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.2.tgz", - "integrity": "sha512-br6GJoH/WUX4pu7FbZXuWGKGNDuU7b8Uj77g/Sp7puZV6EXzuByl6JrECvm0MzVzSTkSHWTihsXt+5XYER5b+g==", + "version": "29.2.4", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.4.tgz", + "integrity": "sha512-3d6tgDyhCI29HlpwIq87sNuI+3Q6GLTTCeYRHCs7vDz+/3GCMwEtV9jezLyl4ZtnBgx00I7hm8PCP8cTksMGrw==", "dev": true, + "license": "MIT", "dependencies": { "bs-logger": "0.x", + "ejs": "^3.1.10", "fast-json-stable-stringify": "2.x", "jest-util": "^29.0.0", "json5": "^2.2.3", @@ -11685,10 +11879,11 @@ "ts-jest": "cli.js" }, "engines": { - "node": "^16.10.0 || ^18.0.0 || >=20.0.0" + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0", "@jest/types": "^29.0.0", "babel-jest": "^29.0.0", "jest": "^29.0.0", @@ -11698,6 +11893,9 @@ "@babel/core": { "optional": true }, + "@jest/transform": { + "optional": true + }, "@jest/types": { "optional": true }, @@ -12181,10 +12379,14 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } diff --git a/package.json b/package.json index ef845b0..26bc7b4 100644 --- a/package.json +++ b/package.json @@ -7,17 +7,18 @@ "clean": "rimraf dist/*", "copy-assets": "ts-node tools/copyAssets", "tsc": "tsc", - "prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write", - "lint-and-fix": "eslint . --ext .ts --fix", - "lint": "eslint . --ext .ts", + "prettier:ci": "prettier --config .prettierrc 'src/**/*.ts' --check", + "prettier:fix": "prettier --config .prettierrc 'src/**/*.ts' --write", + "lint:ci": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "build": "npm-run-all clean tsc copy-assets", "test": "jest --coverage", "test:ci": "jest --ci --coverage --config=jest.config.ts", - "build": "npm-run-all clean lint test tsc copy-assets", + "check": "npm-run-all prettier:fix lint:fix test build", + "dev:check": "npm-run-all check dev", "dev": "nodemon --watch src -e ts,ejs --exec npm run start", - "start": "npm run build && node dist/server.js", - "start:container": "node dist/server.js", - "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js", - "migration": "npm run typeorm migration:run" + "start": "npm run build && node dist/server.js | pino-colada", + "start:container": "node dist/server.js" }, "keywords": [], "author": "", @@ -28,12 +29,14 @@ "@types/express": "^4.17.21", "@types/fs-extra": "^9.0.13", "@types/jest": "^29.5.12", + "@types/lodash": "^4.17.7", "@types/multer": "^1.4.11", "@types/node": "^20.11.27", "@types/passport-google-oauth20": "^2.0.16", "@types/pino": "^7.0.5", "@types/shelljs": "^0.8.15", "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/parser": "^7.0.1", "eslint": "^8.56.0", @@ -46,6 +49,7 @@ "msw": "^2.3.1", "nodemon": "^3.1.0", "npm-run-all": "^4.1.5", + "pino-colada": "^2.2.2", "prettier": "^3.2.5", "rimraf": "^3.0.2", "shelljs": "^0.8.5", @@ -69,11 +73,14 @@ "i18next": "^23.10.1", "i18next-fs-backend": "^2.3.1", "i18next-http-middleware": "^3.5.0", + "lodash": "^4.17.21", "multer": "^1.4.5-lts.1", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-local": "^1.0.0", - "pino": "^8.19.0", - "ts-node-dev": "^2.0.0" + "pino": "^8.21.0", + "pino-http": "^10.2.0", + "ts-node-dev": "^2.0.0", + "uuid": "^10.0.0" } } diff --git a/src/app.ts b/src/app.ts index bcdab50..17edf1f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,102 +1,48 @@ -import path from 'path'; +import path from 'node:path'; -import i18next from 'i18next'; -import Backend from 'i18next-fs-backend'; -import i18nextMiddleware from 'i18next-http-middleware'; -import rateLimit from 'express-rate-limit'; import express, { Application, Request, Response } from 'express'; -import session from 'express-session'; -import './config/i18next'; -import passport, { auth } from './config/auth_config'; -import { ensureAuthenticated } from './config/authenticate'; -import { healthcheck } from './route/healthcheck'; -import { publish } from './route/publish'; -import { view } from './route/view'; - -if (process.env.NODE_ENV !== 'test') { - const variables = [ - 'BACKEND_SERVER', - 'BACKEND_PORT', - 'BACKEND_PROTOCOL', - 'SESSION_SECRET', - 'GOOGLE_CLIENT_ID', - 'GOOGLE_CLIENT_SECRET' - ]; - - variables.forEach((variable) => { - if (!process.env[variable]) { - throw new Error(`Environment variable ${variable} is missing`); - } - }); -} +import passport, { auth } from './routes/auth'; +import session from './middleware/session'; +import { ensureAuthenticated } from './middleware/ensure-authenticated'; +import { rateLimiter } from './middleware/rate-limiter'; +import { i18next, i18nextMiddleware } from './middleware/translation'; +import { healthcheck } from './routes/healthcheck'; +import { publish } from './routes/publish'; +import { view } from './routes/view'; +import { httpLogger } from './utils/logger'; const app: Application = express(); -i18next - .use(Backend) - .use(i18nextMiddleware.LanguageDetector) - .init({ - detection: { - order: ['path', 'header'], - lookupHeader: 'accept-language', - caches: false, - ignoreRoutes: ['/healthcheck', '/public', '/css', '/assets'] - }, - backend: { - loadPath: `${__dirname}/resources/locales/{{lng}}.json` - }, - fallbackLng: 'en-GB', - preload: ['en-GB', 'cy-GB'], - debug: false - }); - -const apiLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 100, - standardHeaders: true, - legacyHeaders: false, - handler(req, res) { - res.status(429).json({ - message: 'Too many requests, please try again later.' - }); - } -}); - -const sessionConfig = { - secret: process.env.SESSION_SECRET === undefined ? 'default' : process.env.SESSION_SECRET, - resave: false, - saveUninitialized: false, - cookie: { - secure: false - } -}; +app.disable('x-powered-by'); if (app.get('env') === 'production') { app.set('trust proxy', 1); - sessionConfig.cookie.secure = true; } -// Middleware Config +// enable middleware app.use(express.urlencoded({ extended: true })); -app.use(session(sessionConfig)); +app.use(httpLogger); +app.use(i18nextMiddleware.handle(i18next)); +app.use(session); app.use(passport.initialize()); app.use(passport.session()); -app.use(i18nextMiddleware.handle(i18next)); + +// configure the view engine app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); // Load Routes -app.use('/auth', auth); -app.use('/:lang/publish', publish, apiLimiter, ensureAuthenticated); -app.use('/:lang/dataset', view, apiLimiter, ensureAuthenticated); -app.use('/:lang/healthcheck', healthcheck); -app.use('/healthcheck', healthcheck); app.use('/public', express.static(`${__dirname}/public`)); app.use('/css', express.static(`${__dirname}/css`)); app.use('/assets', express.static(`${__dirname}/assets`)); +app.use('/auth', auth); +app.use('/healthcheck', healthcheck); + +app.use('/:lang/publish', publish, rateLimiter, ensureAuthenticated); +app.use('/:lang/dataset', view, rateLimiter, ensureAuthenticated); +app.use('/:lang/healthcheck', healthcheck); -// App Root Routes app.get('/', (req: Request, res: Response) => { const lang = req.headers['accept-language'] || req.headers['Accept-Language'] || req.i18n.language || 'en-GB'; if (lang.includes('cy')) { @@ -106,7 +52,7 @@ app.get('/', (req: Request, res: Response) => { } }); -app.get('/:lang/', apiLimiter, ensureAuthenticated, (req: Request, res: Response) => { +app.get('/:lang/', rateLimiter, ensureAuthenticated, (req: Request, res: Response) => { res.render('index'); }); diff --git a/src/config/authenticate.ts b/src/config/authenticate.ts deleted file mode 100644 index 2af814c..0000000 --- a/src/config/authenticate.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Request, Response, NextFunction } from 'express'; - -// eslint-disable-next-line consistent-return -export function ensureAuthenticated(req: Request, res: Response, next: NextFunction) { - if (req.isAuthenticated()) { - return next(); - } - // If not authenticated, redirect to login page - res.redirect('/auth/login'); -} diff --git a/src/controllers/api.ts b/src/controllers/api.ts index b1e6bb4..3f55dcd 100644 --- a/src/controllers/api.ts +++ b/src/controllers/api.ts @@ -1,17 +1,11 @@ -import { env } from 'process'; - -import pino from 'pino'; +import { env } from 'node:process'; import { FileListError, FileList } from '../dtos2/filelist'; import { ViewDTO, ViewErrDTO } from '../dtos2/view-dto'; import { Healthcheck } from '../dtos2/healthcehck'; import { UploadDTO, UploadErrDTO } from '../dtos2/upload-dto'; import { DatasetDTO } from '../dtos2/dataset-dto'; - -const logger = pino({ - name: 'StatsWales-Alpha-App: API', - level: 'debug' -}); +import { logger } from '../utils/logger'; class HttpError extends Error { public status: number; @@ -43,6 +37,9 @@ export class API { } public async getFileList(lang: string) { + logger.debug( + `Fetching file list from ${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset` + ); const filelist: FileList = await fetch( `${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset` ) diff --git a/src/middleware/ensure-authenticated.ts b/src/middleware/ensure-authenticated.ts new file mode 100644 index 0000000..7e97dae --- /dev/null +++ b/src/middleware/ensure-authenticated.ts @@ -0,0 +1,9 @@ +import { RequestHandler } from 'express'; + +export const ensureAuthenticated: RequestHandler = (req, res, next) => { + if (req.isAuthenticated()) { + return next(); + } + // If not authenticated, redirect to login page + return res.redirect('/auth/login'); +}; diff --git a/src/middleware/rate-limiter.ts b/src/middleware/rate-limiter.ts new file mode 100644 index 0000000..5a0d7dd --- /dev/null +++ b/src/middleware/rate-limiter.ts @@ -0,0 +1,13 @@ +import rateLimit from 'express-rate-limit'; + +export const rateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + res.status(429).json({ + message: 'Too many requests, please try again later.' + }); + } +}); diff --git a/src/middleware/session.ts b/src/middleware/session.ts new file mode 100644 index 0000000..96d4a29 --- /dev/null +++ b/src/middleware/session.ts @@ -0,0 +1,10 @@ +import session from 'express-session'; + +export default session({ + secret: process.env.SESSION_SECRET === undefined ? 'default' : process.env.SESSION_SECRET, + resave: false, + saveUninitialized: false, + cookie: { + secure: true + } +}); diff --git a/src/config/i18next.ts b/src/middleware/translation.ts similarity index 67% rename from src/config/i18next.ts rename to src/middleware/translation.ts index 032b2db..2c98454 100644 --- a/src/config/i18next.ts +++ b/src/middleware/translation.ts @@ -2,6 +2,9 @@ import i18next from 'i18next'; import Backend from 'i18next-fs-backend'; import i18nextMiddleware from 'i18next-http-middleware'; +const ENGLISH = 'en-GB'; +const WELSH = 'cy-GB'; + i18next .use(Backend) .use(i18nextMiddleware.LanguageDetector) @@ -13,13 +16,11 @@ i18next ignoreRoutes: ['/healthcheck', '/public', '/css', '/assets'] }, backend: { - loadPath: `${__dirname}/resources/locales/{{lng}}.json` + loadPath: `${__dirname}/../resources/locales/{{lng}}.json` }, - fallbackLng: 'en-GB', - preload: ['en-GB', 'cy-GB'], + fallbackLng: ENGLISH, + preload: [ENGLISH, WELSH], debug: false }); -export const ENGLISH = 'en-GB'; -export const WELSH = 'cy-GB'; -export const t = i18next.t; +export { i18next, i18nextMiddleware, ENGLISH, WELSH }; diff --git a/src/resources/locales/en-GB.json b/src/resources/locales/en-GB.json index abfc947..4e8a585 100644 --- a/src/resources/locales/en-GB.json +++ b/src/resources/locales/en-GB.json @@ -16,7 +16,8 @@ "continue": "Continue", "back": "Back", "cancel": "Cancel", - "choose_different": "Choose a different data table" + "choose_different": "Choose a different data table", + "login-google": "Login with Google" }, "pagination": { "previous": "Previous", diff --git a/src/route/publish.ts b/src/route/publish.ts deleted file mode 100644 index c32aa0c..0000000 --- a/src/route/publish.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { Blob } from 'buffer'; - -import { Request, Response, Router } from 'express'; -import pino from 'pino'; -import multer from 'multer'; - -import { t } from '../config/i18next'; -import { API } from '../controllers/api'; -import { ViewDTO, ViewErrDTO } from '../dtos2/view-dto'; - -const storage = multer.memoryStorage(); -const upload = multer({ storage }); -const APIInstance = new API(); - -const logger = pino({ - name: 'StatsWales-Alpha-App: Publish', - level: 'debug' -}); - -export const publish = Router(); - -publish.get('/', (req: Request, res: Response) => { - res.render('publish/start'); -}); - -publish.get('/title', (req: Request, res: Response) => { - res.render('publish/title'); -}); - -publish.post('/title', upload.none(), (req: Request, res: Response) => { - if (!req.body?.title) { - logger.debug('Title was missing on request'); - const err: ViewErrDTO = { - success: false, - status: 400, - dataset_id: undefined, - errors: [ - { - field: 'title', - message: [ - { - lang: req.i18n.language, - message: t('errors.title.missing') - } - ], - tag: { - name: 'errors.title.missing', - params: {} - } - } - ] - }; - res.status(400); - res.render('publish/title', err); - return; - } - const title: string = req.body.title; - res.render('publish/upload', { title }); -}); - -publish.post('/upload', upload.single('csv'), async (req: Request, res: Response) => { - const lang = req.i18n.language; - if (!req.body?.title) { - logger.debug('title was missing on request'); - const err: ViewErrDTO = { - success: false, - status: 400, - dataset_id: undefined, - errors: [ - { - field: 'title', - message: [ - { - lang: req.i18n.language, - message: t('errors.title.missing') - } - ], - tag: { - name: 'errors.title.missing', - params: {} - } - } - ] - }; - res.status(400); - res.render('publish/title', err); - return; - } - logger.debug(`Title: ${req.body.title}`); - const title: string = req.body.title; - if (!req.file) { - logger.debug('Attached file was missing on this request'); - const err: ViewErrDTO = { - success: false, - status: 400, - dataset_id: undefined, - errors: [ - { - field: 'csv', - message: [ - { - lang: req.i18n.language, - message: t('errors.upload.no-csv-data') - } - ], - tag: { - name: 'errors.upload.no-csv-data', - params: {} - } - } - ] - }; - res.status(400); - res.render('publish/upload', err); - return; - } - - const fileName = req.file?.originalname; - const fileData = new Blob([req.file?.buffer], { type: req.file?.mimetype }); - - const processedCSV = await APIInstance.uploadCSV(lang, fileData, fileName, title); - if (processedCSV.success) { - // eslint-disable-next-line require-atomic-updates - req.session.currentDataset = processedCSV.dataset; - req.session.save(); - res.redirect(`/${req.i18n.language}/publish/preview`); - } else { - res.status(400); - res.render('publish/upload', processedCSV); - } -}); - -publish.get('/preview', async (req: Request, res: Response) => { - const dataset = req.session.currentDataset; - if (!dataset) { - logger.debug('No dataset in session'); - res.redirect(`/${req.i18n.language}/publish/title`); - return; - } - const lang = req.i18n.language; - const page_number: number = Number.parseInt(req.query.page_number as string, 10) || 1; - const page_size: number = Number.parseInt(req.query.page_size as string, 10) || 10; - const previewData = await APIInstance.getDatasetDatafilePreview( - lang, - dataset.id, - dataset.revisions[0].id, - dataset.revisions[0].imports[0].id, - page_number, - page_size - ); - if (!previewData.success) { - logger.error('Failed to get preview data from the backend'); - res.status(500); - res.render('publish/start'); - } - const data = previewData as ViewDTO; - res.render('publish/preview', data); -}); - -publish.post('/confirm', upload.none(), (req: Request, res: Response) => { - const dataset = req.session.currentDataset; - if (!dataset) { - logger.debug('No dataset in session'); - res.redirect(`/${req.i18n.language}/publish/title`); - return; - } - // const lang = req.i18n.language; - const confirmData = req.body?.confirm; - if (!confirmData) { - logger.debug('No confirmation data was provided'); - const err: ViewErrDTO = { - success: false, - status: 400, - dataset_id: dataset.id, - errors: [ - { - field: 'confirm', - message: [ - { - lang: req.i18n.language, - message: t('errors.confirm.missing') - } - ], - tag: { - name: 'errors.confirm.missing', - params: {} - } - } - ] - }; - res.status(400); - res.render('publish/preview', err); - return; - } - if (confirmData === 'true') { - res.status(200); - res.json({ confirm: true, dataset_id: dataset.id, message: 'Datatable has been confirmed as correct' }); - } else { - res.status(200); - res.json({ confirm: false, dataset_id: dataset.id, message: 'Datatable has been rejected as incorrect' }); - } -}); diff --git a/src/config/auth_config.ts b/src/routes/auth.ts similarity index 97% rename from src/config/auth_config.ts rename to src/routes/auth.ts index a0a1432..f459667 100644 --- a/src/config/auth_config.ts +++ b/src/routes/auth.ts @@ -1,4 +1,3 @@ -import dotenv from 'dotenv'; import { Router, Request, Response, NextFunction } from 'express'; import passport from 'passport'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; @@ -8,7 +7,6 @@ export interface User { displayName: string; } -dotenv.config(); const users: User[] = []; passport.use( diff --git a/src/route/healthcheck.ts b/src/routes/healthcheck.ts similarity index 81% rename from src/route/healthcheck.ts rename to src/routes/healthcheck.ts index 0ef1a38..a87419a 100644 --- a/src/route/healthcheck.ts +++ b/src/routes/healthcheck.ts @@ -1,12 +1,7 @@ import { Router } from 'express'; -import pino from 'pino'; import { API } from '../controllers/api'; - -export const logger = pino({ - name: 'StatsWales-Alpha-App: Healthcheck', - level: 'debug' -}); +import { logger } from '../utils/logger'; const APIInstance = new API(); diff --git a/src/routes/publish.ts b/src/routes/publish.ts new file mode 100644 index 0000000..201583e --- /dev/null +++ b/src/routes/publish.ts @@ -0,0 +1,132 @@ +import { Blob } from 'buffer'; + +import { Request, Response, Router } from 'express'; +import pino from 'pino'; +import multer from 'multer'; + +import { ViewErrDTO } from '../dtos/view-dto'; +import { i18next } from '../middleware/translation'; +import { API } from '../controllers/api'; + +const t = i18next.t; +const storage = multer.memoryStorage(); +const upload = multer({ storage }); +const APIInstance = new API(); + +const logger = pino({ + name: 'StatsWales-Alpha-App: Publish', + level: 'debug' +}); + +export const publish = Router(); + +publish.get('/', (req: Request, res: Response) => { + res.render('publish/start'); +}); + +publish.get('/name', (req: Request, res: Response) => { + res.render('publish/name'); +}); + +publish.get('/title', (req: Request, res: Response) => { + res.render('publish/title'); +}); + +publish.post('/name', upload.none(), (req: Request, res: Response) => { + if (!req.body?.internal_name) { + logger.debug('Internal name was missing on request'); + const err: ViewErrDTO = { + success: false, + status: 400, + dataset_id: undefined, + errors: [ + { + field: 'internal_name', + message: [ + { + lang: req.i18n.language, + message: t('errors.name_missing') + } + ], + tag: { + name: 'errors.name_missing', + params: {} + } + } + ] + }; + res.status(400); + res.render('publish/name', err); + return; + } + const internalName: string = req.body.internal_name; + res.render('publish/upload', { internal_name: internalName }); +}); + +publish.post('/upload', upload.single('csv'), async (req: Request, res: Response) => { + const lang = req.i18n.language; + if (!req.body?.internal_name) { + logger.debug('Internal name was missing on request'); + const err: ViewErrDTO = { + success: false, + status: 400, + dataset_id: undefined, + errors: [ + { + field: 'internal_name', + message: [ + { + lang: req.i18n.language, + message: t('errors.name_missing') + } + ], + tag: { + name: 'errors.name_missing', + params: {} + } + } + ] + }; + res.status(400); + res.render('publish/name', err); + return; + } + logger.debug(`Internal name: ${req.body.internal_name}`); + const internalName: string = req.body.internal_name; + if (!req.file) { + logger.debug('Attached file was missing on this request'); + const err: ViewErrDTO = { + success: false, + status: 400, + dataset_id: undefined, + errors: [ + { + field: 'csv', + message: [ + { + lang: req.i18n.language, + message: t('errors.upload.no-csv-data') + } + ], + tag: { + name: 'errors.upload.no-csv-data', + params: {} + } + } + ] + }; + res.status(400); + res.render('publish/upload', err); + return; + } + + const fileData = new Blob([req.file?.buffer]); + + const processedCSV = await APIInstance.uploadCSV(lang, fileData, internalName); + if (processedCSV.success) { + res.redirect(`/${req.i18n.language}/dataset/${processedCSV.dataset?.id}`); + } else { + res.status(400); + res.render('publish/upload', processedCSV); + } +}); diff --git a/src/route/view.ts b/src/routes/view.ts similarity index 69% rename from src/route/view.ts rename to src/routes/view.ts index c42d4e9..94be1be 100644 --- a/src/route/view.ts +++ b/src/routes/view.ts @@ -1,29 +1,31 @@ import { Router, Request, Response } from 'express'; -import { t } from '../config/i18next'; import { API } from '../controllers/api'; -import { FileList } from '../dtos2/filelist'; -import { ViewErrDTO } from '../dtos2/view-dto'; +import { FileList } from '../dtos/filelist'; +import { ViewErrDTO } from '../dtos/view-dto'; +import { i18next } from '../middleware/translation'; +import { logger } from '../utils/logger'; +const t = i18next.t; const APIInstance = new API(); export const view = Router(); view.get('/', async (req: Request, res: Response) => { const lang = req.i18n.language; const fileList: FileList = await APIInstance.getFileList(lang); - console.log(`FileList from server = ${JSON.stringify(fileList)}`); + logger.debug(`FileList from server = ${JSON.stringify(fileList)}`); res.render('list', fileList); }); -view.get('/:datasetId', async (req: Request, res: Response) => { +view.get('/:file', async (req: Request, res: Response) => { const lang = req.i18n.language; const page_number: number = Number.parseInt(req.query.page_number as string, 10) || 1; const page_size: number = Number.parseInt(req.query.page_size as string, 10) || 100; - if (!req.params.datasetId) { + if (!req.params.file) { const err: ViewErrDTO = { success: false, - status: 400, + status: 404, dataset_id: undefined, errors: [ { @@ -45,12 +47,11 @@ view.get('/:datasetId', async (req: Request, res: Response) => { res.render('data', err); return; } - const datasetId = req.params.datasetId; - const file = await APIInstance.getDatasetView(lang, datasetId, page_number, page_size); + const file_id = req.params.file; + const file = await APIInstance.getFileData(lang, file_id, page_number, page_size); if (!file.success) { - const error = file as ViewErrDTO; - res.status(error.status); + res.status((file as ViewErrDTO).status); } res.render('data', file); }); diff --git a/src/server.ts b/src/server.ts index f3b7e40..9c6a5bc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,23 @@ dotenv.config(); const PORT = process.env.PORT || 3000; +if (process.env.NODE_ENV !== 'test') { + const requiredEnvVars = [ + 'BACKEND_SERVER', + 'BACKEND_PORT', + 'BACKEND_PROTOCOL', + 'SESSION_SECRET', + 'GOOGLE_CLIENT_ID', + 'GOOGLE_CLIENT_SECRET' + ]; + + requiredEnvVars.forEach((variable) => { + if (!process.env[variable]) { + throw new Error(`Environment variable ${variable} is missing`); + } + }); +} + app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); }); diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..128a95c --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,35 @@ +import pino from 'pino'; +import pinoHttp from 'pino-http'; +import pick from 'lodash/pick'; + +export const logger = pino({ + level: process.env.LOG_LEVEL || 'debug' +}); + +export const httpLogger = pinoHttp({ + logger, + autoLogging: { + ignore: (req) => { + const ignorePathsRx = /^\/css|\/public|\/assets/; + return ignorePathsRx.test(req.url || ''); + } + }, + customLogLevel(req, res, err) { + if (res.statusCode >= 400 && res.statusCode < 500) { + return 'warn'; + } else if (res.statusCode >= 500 || err) { + return 'error'; + } else if (res.statusCode >= 300 && res.statusCode < 400) { + return 'silent'; + } + return 'info'; + }, + serializers: { + req(req) { + return pick(req, ['method', 'url', 'query', 'params']); + }, + res(res) { + return pick(res, ['statusCode']); + } + } +}); diff --git a/src/views/login.ejs b/src/views/login.ejs index 7bffb8f..56a897f 100644 --- a/src/views/login.ejs +++ b/src/views/login.ejs @@ -5,10 +5,10 @@

Login

- -<%- include("partials/bottom"); %> \ No newline at end of file + +<%- include("partials/bottom"); %> diff --git a/test/app.test.ts b/test/app.test.ts index c302ebc..b86d672 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -1,11 +1,11 @@ -import path from 'path'; +import path from 'node:path'; import request from 'supertest'; import { Request, Response, NextFunction } from 'express'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; -import { ENGLISH, WELSH, t } from '../src/config/i18next'; +import { ENGLISH, WELSH, i18next } from '../src/middleware/translation'; import app from '../src/app'; import { DatasetDTO } from '../src/dtos2/dataset-dto'; @@ -15,7 +15,9 @@ declare module 'express-session' { } } -jest.mock('../src/config/authenticate', () => ({ +const t = i18next.t; + +jest.mock('../src/middleware/ensure-authenticated', () => ({ ensureAuthenticated: (req: Request, res: Response, next: NextFunction) => next() })); From 0c6a7372287920a63a8407ab56c60ef0399b6a19 Mon Sep 17 00:00:00 2001 From: Phil Moorhouse Date: Fri, 30 Aug 2024 17:14:32 +0100 Subject: [PATCH 02/11] simplify server --- src/server.ts | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/server.ts b/src/server.ts index 9c6a5bc..69bb172 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,28 +1,9 @@ -import dotenv from 'dotenv'; +import 'dotenv/config'; import app from './app'; -dotenv.config(); - const PORT = process.env.PORT || 3000; -if (process.env.NODE_ENV !== 'test') { - const requiredEnvVars = [ - 'BACKEND_SERVER', - 'BACKEND_PORT', - 'BACKEND_PROTOCOL', - 'SESSION_SECRET', - 'GOOGLE_CLIENT_ID', - 'GOOGLE_CLIENT_SECRET' - ]; - - requiredEnvVars.forEach((variable) => { - if (!process.env[variable]) { - throw new Error(`Environment variable ${variable} is missing`); - } - }); -} - app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); }); From 6947b1f73ebf7e4fbc126095e35daa17c3c43767 Mon Sep 17 00:00:00 2001 From: Phil Moorhouse Date: Fri, 30 Aug 2024 17:23:53 +0100 Subject: [PATCH 03/11] move auth to backend and use jwt for frontend --- .env-example | 8 +- README.md | 13 +- package-lock.json | 255 +++++++++++++++--------- package.json | 7 +- src/app.ts | 12 +- src/interfaces/jwt-payload-with-user.ts | 7 + src/interfaces/user.interface.ts | 6 + src/middleware/ensure-authenticated.ts | 26 ++- src/middleware/session.ts | 2 +- src/resources/locales/en-GB.json | 13 +- src/routes/auth.ts | 88 ++++---- src/routes/healthcheck.ts | 2 +- src/routes/publish.ts | 2 +- src/routes/view.ts | 2 +- src/{controllers => services}/api.ts | 117 +++-------- src/utils/check-config.ts | 11 + src/utils/logger.ts | 4 +- src/views/auth/login.ejs | 31 +++ src/views/login.ejs | 14 -- test/.jest/setEnvVars.ts | 4 +- 20 files changed, 333 insertions(+), 291 deletions(-) create mode 100644 src/interfaces/jwt-payload-with-user.ts create mode 100644 src/interfaces/user.interface.ts rename src/{controllers => services}/api.ts (51%) create mode 100644 src/utils/check-config.ts create mode 100644 src/views/auth/login.ejs delete mode 100644 src/views/login.ejs diff --git a/.env-example b/.env-example index 6ace7ec..e14912a 100644 --- a/.env-example +++ b/.env-example @@ -1,7 +1,5 @@ -BACKEND_SERVER=localhost -BACKEND_PROTOCOL=http -BACKEND_PORT=3001 +FRONTEND_URL=http://localhost:3000 +BACKEND_URL=http://localhost:3001 -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= SESSION_SECRET= +JWT_SECRET= diff --git a/README.md b/README.md index 4f3de26..ace00fe 100644 --- a/README.md +++ b/README.md @@ -8,18 +8,9 @@ - Node 20+ - An Instance of the StatsWales backend Service -## To get going +## Configuration -You'll need to define the following environment variables either in to the environment or in to a `.env` file: - -```env -BACKEND_SERVER -BACKEND_PROTOCOL -BACKEND_PORT -SESSION_SECRET -GOOGLE_CLIENT_ID -GOOGLE_CLIENT_SECRET -``` +Copy the [.env-example](.env-example) file to `.env` and provide the missing values. To run the app should be as simple as: diff --git a/package-lock.json b/package-lock.json index b6ecd47..0e2feea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@types/express-session": "^1.18.0", "@types/passport": "^1.0.16", "@types/passport-local": "^1.0.38", + "cookie-parser": "^1.4.6", "dotenv": "^16.4.4", "ejs": "^3.1.9", "express": "^4.19.2", @@ -22,11 +23,10 @@ "i18next": "^23.10.1", "i18next-fs-backend": "^2.3.1", "i18next-http-middleware": "^3.5.0", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "multer": "^1.4.5-lts.1", "passport": "^0.7.0", - "passport-google-oauth20": "^2.0.0", - "passport-local": "^1.0.0", "pino": "^8.21.0", "pino-http": "^10.2.0", "ts-node-dev": "^2.0.0", @@ -35,13 +35,14 @@ "devDependencies": { "@shopify/eslint-plugin": "^44.0.0", "@types/better-sqlite3": "^7.6.9", + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", "@types/fs-extra": "^9.0.13", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.6", "@types/lodash": "^4.17.7", "@types/multer": "^1.4.11", "@types/node": "^20.11.27", - "@types/passport-google-oauth20": "^2.0.16", "@types/pino": "^7.0.5", "@types/shelljs": "^0.8.15", "@types/supertest": "^6.0.2", @@ -2271,6 +2272,16 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "dev": true }, + "node_modules/@types/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -2386,6 +2397,16 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", + "integrity": "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/lodash": { "version": "4.17.7", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", @@ -2436,15 +2457,6 @@ "undici-types": "~5.26.4" } }, - "node_modules/@types/oauth": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.5.tgz", - "integrity": "sha512-+oQ3C2Zx6ambINOcdIARF5Z3Tu3x//HipE889/fqo3sgpQZbe9c6ExdQFtN6qlhpR7p83lTZfPJt0tCAW29dog==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/passport": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.16.tgz", @@ -2453,17 +2465,6 @@ "@types/express": "*" } }, - "node_modules/@types/passport-google-oauth20": { - "version": "2.0.16", - "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.16.tgz", - "integrity": "sha512-ayXK2CJ7uVieqhYOc6k/pIr5pcQxOLB6kBev+QUGS7oEZeTgIs1odDobXRqgfBPvXzl0wXCQHftV5220czZCPA==", - "dev": true, - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-oauth2": "*" - } - }, "node_modules/@types/passport-local": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", @@ -2474,17 +2475,6 @@ "@types/passport-strategy": "*" } }, - "node_modules/@types/passport-oauth2": { - "version": "1.4.17", - "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.4.17.tgz", - "integrity": "sha512-ODiAHvso6JcWJ6ZkHHroVp05EHGhqQN533PtFNBkg8Fy5mERDqsr030AX81M0D69ZcaMvhF92SRckEk2B0HYYg==", - "dev": true, - "dependencies": { - "@types/express": "*", - "@types/oauth": "*", - "@types/passport": "*" - } - }, "node_modules/@types/passport-strategy": { "version": "0.2.38", "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", @@ -3445,14 +3435,6 @@ } ] }, - "node_modules/base64url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", - "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3595,6 +3577,12 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3953,6 +3941,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "license": "MIT", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -4339,6 +4349,15 @@ "xtend": "^4.0.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8937,6 +8956,40 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -8952,6 +9005,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9074,6 +9148,42 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -9086,6 +9196,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -9313,8 +9429,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/msw": { "version": "2.3.1", @@ -9779,11 +9894,6 @@ "node": ">=8" } }, - "node_modules/oauth": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.0.tgz", - "integrity": "sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10091,6 +10201,7 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -10104,47 +10215,6 @@ "url": "https://github.com/sponsors/jaredhanson" } }, - "node_modules/passport-google-oauth20": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", - "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", - "dependencies": { - "passport-oauth2": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/passport-local": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", - "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", - "dependencies": { - "passport-strategy": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/passport-oauth2": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", - "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", - "dependencies": { - "base64url": "3.x.x", - "oauth": "0.10.x", - "passport-strategy": "1.x.x", - "uid2": "0.0.x", - "utils-merge": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jaredhanson" - } - }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -12260,11 +12330,6 @@ "node": ">= 0.8" } }, - "node_modules/uid2": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", - "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" - }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", diff --git a/package.json b/package.json index 26bc7b4..c713030 100644 --- a/package.json +++ b/package.json @@ -26,13 +26,14 @@ "devDependencies": { "@shopify/eslint-plugin": "^44.0.0", "@types/better-sqlite3": "^7.6.9", + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", "@types/fs-extra": "^9.0.13", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.6", "@types/lodash": "^4.17.7", "@types/multer": "^1.4.11", "@types/node": "^20.11.27", - "@types/passport-google-oauth20": "^2.0.16", "@types/pino": "^7.0.5", "@types/shelljs": "^0.8.15", "@types/supertest": "^6.0.2", @@ -63,6 +64,7 @@ "@types/express-session": "^1.18.0", "@types/passport": "^1.0.16", "@types/passport-local": "^1.0.38", + "cookie-parser": "^1.4.6", "dotenv": "^16.4.4", "ejs": "^3.1.9", "express": "^4.19.2", @@ -73,11 +75,10 @@ "i18next": "^23.10.1", "i18next-fs-backend": "^2.3.1", "i18next-http-middleware": "^3.5.0", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "multer": "^1.4.5-lts.1", "passport": "^0.7.0", - "passport-google-oauth20": "^2.0.0", - "passport-local": "^1.0.0", "pino": "^8.21.0", "pino-http": "^10.2.0", "ts-node-dev": "^2.0.0", diff --git a/src/app.ts b/src/app.ts index 17edf1f..a6f86bf 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,16 +1,20 @@ import path from 'node:path'; import express, { Application, Request, Response } from 'express'; +import cookieParser from 'cookie-parser'; -import passport, { auth } from './routes/auth'; +import { checkConfig } from './utils/check-config'; +import { httpLogger } from './utils/logger'; import session from './middleware/session'; import { ensureAuthenticated } from './middleware/ensure-authenticated'; import { rateLimiter } from './middleware/rate-limiter'; import { i18next, i18nextMiddleware } from './middleware/translation'; +import { auth } from './routes/auth'; import { healthcheck } from './routes/healthcheck'; import { publish } from './routes/publish'; import { view } from './routes/view'; -import { httpLogger } from './utils/logger'; + +checkConfig(); const app: Application = express(); @@ -22,11 +26,11 @@ if (app.get('env') === 'production') { // enable middleware app.use(express.urlencoded({ extended: true })); +app.use(express.json()); app.use(httpLogger); app.use(i18nextMiddleware.handle(i18next)); app.use(session); -app.use(passport.initialize()); -app.use(passport.session()); +app.use(cookieParser()); // configure the view engine app.set('views', path.join(__dirname, 'views')); diff --git a/src/interfaces/jwt-payload-with-user.ts b/src/interfaces/jwt-payload-with-user.ts new file mode 100644 index 0000000..902551e --- /dev/null +++ b/src/interfaces/jwt-payload-with-user.ts @@ -0,0 +1,7 @@ +import { JwtPayload } from 'jsonwebtoken'; + +import { User } from './user.interface'; + +export interface JWTPayloadWithUser extends JwtPayload { + user: User; +} diff --git a/src/interfaces/user.interface.ts b/src/interfaces/user.interface.ts new file mode 100644 index 0000000..2c8ea60 --- /dev/null +++ b/src/interfaces/user.interface.ts @@ -0,0 +1,6 @@ +export interface User { + id: string; + email: string; + firstName: string; + lastName: string; +} diff --git a/src/middleware/ensure-authenticated.ts b/src/middleware/ensure-authenticated.ts index 7e97dae..1492a4a 100644 --- a/src/middleware/ensure-authenticated.ts +++ b/src/middleware/ensure-authenticated.ts @@ -1,9 +1,27 @@ import { RequestHandler } from 'express'; +import JWT from 'jsonwebtoken'; + +import { JWTPayloadWithUser } from '../interfaces/jwt-payload-with-user'; +import { logger } from '../utils/logger'; export const ensureAuthenticated: RequestHandler = (req, res, next) => { - if (req.isAuthenticated()) { - return next(); + logger.debug('checking if user is authenticated...'); + + if (!req.cookies.jwt) { + logger.error('JWT cookie not found'); + return res.redirect('/auth/login'); } - // If not authenticated, redirect to login page - return res.redirect('/auth/login'); + + const secret = process.env.JWT_SECRET || ''; + const decoded = JWT.verify(req.cookies.jwt, secret) as JWTPayloadWithUser; + + if (decoded.exp && decoded.exp <= Date.now() / 1000) { + logger.error('JWT token has expired'); + return res.redirect('/auth/login'); + } + + req.user = decoded.user; + logger.info('user is authenticated'); + + return next(); }; diff --git a/src/middleware/session.ts b/src/middleware/session.ts index 96d4a29..054e79e 100644 --- a/src/middleware/session.ts +++ b/src/middleware/session.ts @@ -1,7 +1,7 @@ import session from 'express-session'; export default session({ - secret: process.env.SESSION_SECRET === undefined ? 'default' : process.env.SESSION_SECRET, + secret: process.env.SESSION_SECRET || 'default', resave: false, saveUninitialized: false, cookie: { diff --git a/src/resources/locales/en-GB.json b/src/resources/locales/en-GB.json index 4e8a585..241f1e4 100644 --- a/src/resources/locales/en-GB.json +++ b/src/resources/locales/en-GB.json @@ -16,14 +16,23 @@ "continue": "Continue", "back": "Back", "cancel": "Cancel", - "choose_different": "Choose a different data table", - "login-google": "Login with Google" + "choose_different": "Choose a different data table" }, "pagination": { "previous": "Previous", "next": "Next", "update": "Update" }, + "login": { + "heading": "Login", + "buttons": { + "google": "Login with Google" + }, + "error": { + "summary-title": "There is a problem", + "message": "You could not be logged in. Please try again later." + } + }, "homepage": { "title": "Welcome to StatsWales Beta", "welcome": "We're going to use the GovUK Design system until we're able to get hold of the Welsh Government GEL", diff --git a/src/routes/auth.ts b/src/routes/auth.ts index f459667..605abb3 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,61 +1,49 @@ -import { Router, Request, Response, NextFunction } from 'express'; -import passport from 'passport'; -import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; - -export interface User { - id: string; - displayName: string; -} - -const users: User[] = []; - -passport.use( - new GoogleStrategy( - { - clientID: process.env.GOOGLE_CLIENT_ID || 'client_id', - clientSecret: process.env.GOOGLE_CLIENT_SECRET || 'client_secret', - callbackURL: '/auth/google/callback' - }, - (accessToken, refreshToken, profile, done) => { - let user = users.find((usr) => usr.id === profile.id); - if (!user) { - user = { id: profile.id, displayName: profile.displayName }; - users.push(user); - } - return done(null, user); - } - ) -); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -passport.serializeUser((user: any, done) => { - done(null, user.id); -}); +import { Router, Request, Response } from 'express'; +import JWT from 'jsonwebtoken'; -passport.deserializeUser((id: string, done) => { - const user = users.find((usr) => usr.id === id); - done(null, user); -}); +import { logger } from '../utils/logger'; +import { JWTPayloadWithUser } from '../interfaces/jwt-payload-with-user'; -export default passport; export const auth = Router(); -auth.get('/login', (req, res) => { - res.render('login'); +auth.get('/login', (req: Request, res: Response) => { + res.render('auth/login'); }); -auth.get('/logout', (req: Request, res: Response, next: NextFunction) => { - // eslint-disable-next-line consistent-return - req.logout((err): void => { - if (err) { - return next(err); - } - res.redirect('/'); - }); +auth.get('/google', (req: Request, res: Response) => { + logger.debug('Sending user to backend for authentication'); + res.redirect(`${process.env.BACKEND_URL}/auth/google`); }); -auth.get('/google', passport.authenticate('google', { scope: ['profile'] })); +auth.get('/callback', (req: Request, res: Response) => { + logger.debug('returning from auth backend'); -auth.get('/google/callback', passport.authenticate('google', { failureRedirect: '/login' }), (req, res) => { + try { + if (!req.cookies.jwt) { + logger.error('JWT cookie not found'); + throw new Error('JWT cookie not found'); + } + + if (req.query.error) { + logger.error(`Error from auth backend: ${req.query.error}`); + throw new Error(`Error from auth backend: ${req.query.error}`); + } + + const secret = process.env.JWT_SECRET || ''; + const decoded = JWT.verify(req.cookies.jwt, secret) as JWTPayloadWithUser; + req.user = decoded.user; + } catch (err) { + logger.error(`Error verifying JWT: ${err}`); + res.status(400); + res.render('auth/login', { errors: ['login.error.message'] }); + return; + } + console.log(req.user); + logger.debug('User successfully logged in'); res.redirect('/'); }); + +auth.get('/logout', (req: Request, res: Response) => { + res.clearCookie('jwt'); + res.redirect('/auth/login'); +}); diff --git a/src/routes/healthcheck.ts b/src/routes/healthcheck.ts index a87419a..8e2cd9f 100644 --- a/src/routes/healthcheck.ts +++ b/src/routes/healthcheck.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; -import { API } from '../controllers/api'; +import { API } from '../services/api'; import { logger } from '../utils/logger'; const APIInstance = new API(); diff --git a/src/routes/publish.ts b/src/routes/publish.ts index 201583e..d9f910e 100644 --- a/src/routes/publish.ts +++ b/src/routes/publish.ts @@ -6,7 +6,7 @@ import multer from 'multer'; import { ViewErrDTO } from '../dtos/view-dto'; import { i18next } from '../middleware/translation'; -import { API } from '../controllers/api'; +import { API } from '../services/api'; const t = i18next.t; const storage = multer.memoryStorage(); diff --git a/src/routes/view.ts b/src/routes/view.ts index 94be1be..e865924 100644 --- a/src/routes/view.ts +++ b/src/routes/view.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; -import { API } from '../controllers/api'; +import { API } from '../services/api'; import { FileList } from '../dtos/filelist'; import { ViewErrDTO } from '../dtos/view-dto'; import { i18next } from '../middleware/translation'; diff --git a/src/controllers/api.ts b/src/services/api.ts similarity index 51% rename from src/controllers/api.ts rename to src/services/api.ts index 3f55dcd..846a5fc 100644 --- a/src/controllers/api.ts +++ b/src/services/api.ts @@ -1,10 +1,7 @@ -import { env } from 'node:process'; - -import { FileListError, FileList } from '../dtos2/filelist'; -import { ViewDTO, ViewErrDTO } from '../dtos2/view-dto'; -import { Healthcheck } from '../dtos2/healthcehck'; -import { UploadDTO, UploadErrDTO } from '../dtos2/upload-dto'; -import { DatasetDTO } from '../dtos2/dataset-dto'; +import { FileListError, FileList } from '../dtos/filelist'; +import { ViewDTO, ViewErrDTO } from '../dtos/view-dto'; +import { Healthcheck } from '../dtos/healthcehck'; +import { UploadDTO, UploadErrDTO } from '../dtos/upload-dto'; import { logger } from '../utils/logger'; class HttpError extends Error { @@ -22,27 +19,15 @@ class HttpError extends Error { } export class API { - private readonly backend_server: string | undefined; - private readonly backend_port: string | undefined; - private readonly backend_protocol: string; + private readonly backendUrl: string | undefined; constructor() { - this.backend_server = env.BACKEND_SERVER; - this.backend_port = env.BACKEND_PORT; - if (env.BACKEND_PROTOCOL === 'https') { - this.backend_protocol = 'https'; - } else { - this.backend_protocol = 'http'; - } + this.backendUrl = process.env.BACKEND_URL; } public async getFileList(lang: string) { - logger.debug( - `Fetching file list from ${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset` - ); - const filelist: FileList = await fetch( - `${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset` - ) + logger.debug(`Fetching file list from ${this.backendUrl}/${lang}/dataset`); + const filelist: FileList = await fetch(`${this.backendUrl}/${lang}/dataset`) .then((response) => { if (response.ok) { return response.json(); @@ -61,12 +46,9 @@ export class API { return filelist; } - public async getDatasetView(lang: string, datasetId: string, page_number: number, page_size: number) { - logger.info( - `Fetching dataset view from ${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset/${datasetId}/view?page_number=${page_number}&page_size=${page_size}` - ); + public async getFileData(lang: string, file_id: string, page_number: number, page_size: number) { const file = await fetch( - `${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset/${datasetId}/view?page_number=${page_number}&page_size=${page_size}` + `${this.backendUrl}/${lang}/dataset/${file_id}/view?page_number=${page_number}&page_size=${page_size}` ) .then((response) => { if (response.ok) { @@ -99,72 +81,21 @@ export class API { } } ], - dataset_id: datasetId + dataset_id: file_id } as ViewErrDTO; }); return file; } - public async getDatasetDatafilePreview( - lang: string, - datasetId: string, - revisionId: string, - importId: string, - page_number: number, - page_size: number - ) { - const file = await fetch( - `${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset/${datasetId}/revision/by-id/${revisionId}/import/by-id/${importId}/preview?page_number=${page_number}&page_size=${page_size}` - ) - .then((response) => { - if (response.ok) { - return response.json(); - } - const err = new HttpError(response.status); - err.handleMessage(response.text()); - throw err; - }) - .then((api_res) => { - return api_res as ViewDTO; - }) - .catch((error) => { - logger.error(`An HTTP error occured with status ${error.status} and message "${error.message}"`); - return { - success: false, - status: error.status, - errors: [ - { - field: 'file', - message: [ - { - lang, - message: 'errors.dataset_missing' - } - ], - tag: { - name: 'errors.dataset_missing', - params: {} - } - } - ], - dataset_id: datasetId - } as ViewErrDTO; - }); - return file; - } - - public async uploadCSV(lang: string, file: Blob, filename: string, title: string) { + public async uploadCSV(lang: string, file: Blob, filename: string) { const formData = new FormData(); - formData.set('csv', file, filename); - formData.set('title', title); + formData.append('csv', file, filename); + formData.append('internal_name', filename); - const processedCSV = await fetch( - `${this.backend_protocol}://${this.backend_server}:${this.backend_port}/${lang}/dataset/`, - { - method: 'POST', - body: formData - } - ) + const processedCSV = await fetch(`${this.backendUrl}/${lang}/dataset/`, { + method: 'POST', + body: formData + }) .then((response) => { if (response.ok) { return response.json(); @@ -174,11 +105,7 @@ export class API { throw err; }) .then((api_res) => { - const datasetDTO = api_res as DatasetDTO; - return { - success: true, - dataset: datasetDTO - } as UploadDTO; + return api_res as UploadDTO; }) .catch((error) => { logger.error(`An HTTP error occured with status ${error.status} and message "${error.message}"`); @@ -207,10 +134,14 @@ export class API { } public async ping() { - const health = await fetch(`${this.backend_protocol}://${this.backend_server}:${this.backend_port}/healthcheck`) + const health = await fetch(`${this.backendUrl}/healthcheck`) .then((api_res) => api_res.json()) .then((api_res) => { return api_res as Healthcheck; + }) + .catch((error) => { + logger.error(`An HTTP error occured with status ${error.status} and message "${error.message}"`); + return { status: 'App is not running' } as Healthcheck; }); return health.status === 'App is running'; } diff --git a/src/utils/check-config.ts b/src/utils/check-config.ts new file mode 100644 index 0000000..22bc1ca --- /dev/null +++ b/src/utils/check-config.ts @@ -0,0 +1,11 @@ +export const checkConfig = () => { + if (process.env.NODE_ENV === 'test') return; + + const requiredEnvVars = ['FRONTEND_URL', 'BACKEND_URL', 'SESSION_SECRET', 'JWT_SECRET']; + + requiredEnvVars.forEach((variable) => { + if (!process.env[variable]) { + throw new Error(`Environment variable ${variable} is missing`); + } + }); +}; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 128a95c..8e4f234 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -10,7 +10,7 @@ export const httpLogger = pinoHttp({ logger, autoLogging: { ignore: (req) => { - const ignorePathsRx = /^\/css|\/public|\/assets/; + const ignorePathsRx = /^\/css|\/public|\/assets|\/favicon/; return ignorePathsRx.test(req.url || ''); } }, @@ -19,8 +19,6 @@ export const httpLogger = pinoHttp({ return 'warn'; } else if (res.statusCode >= 500 || err) { return 'error'; - } else if (res.statusCode >= 300 && res.statusCode < 400) { - return 'silent'; } return 'info'; }, diff --git a/src/views/auth/login.ejs b/src/views/auth/login.ejs new file mode 100644 index 0000000..d273fcb --- /dev/null +++ b/src/views/auth/login.ejs @@ -0,0 +1,31 @@ +<%- include("../partials/top"); %> + +
+
+

<%= t('login.heading') %>

+ + <% if (locals?.errors) { %> +
+
+

+ <%= t('errors.problem') %> +

+
+ +
+
+
+ <% } %> + + +
+
+ +<%- include("../partials/bottom"); %> diff --git a/src/views/login.ejs b/src/views/login.ejs deleted file mode 100644 index 56a897f..0000000 --- a/src/views/login.ejs +++ /dev/null @@ -1,14 +0,0 @@ -<%- include("partials/top"); %> - - - -<%- include("partials/bottom"); %> diff --git a/test/.jest/setEnvVars.ts b/test/.jest/setEnvVars.ts index ac2d7b3..a603395 100644 --- a/test/.jest/setEnvVars.ts +++ b/test/.jest/setEnvVars.ts @@ -1,3 +1 @@ -process.env.BACKEND_SERVER = 'somehost.com'; -process.env.BACKEND_PORT = '3001'; -process.env.BACKEND_PROTOCOL = 'http'; +process.env.BACKEND_URL = 'http://somehost.com:3001'; From 72bef0dab5288d2e748dbe3d4f7af8d84c525e12 Mon Sep 17 00:00:00 2001 From: Phil Moorhouse Date: Mon, 2 Sep 2024 11:16:52 +0100 Subject: [PATCH 04/11] fix ci issues --- .env-example | 5 +++++ src/app.ts | 10 +++++----- src/middleware/rate-limiter.ts | 4 ++-- src/middleware/session.ts | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.env-example b/.env-example index e14912a..5f4827f 100644 --- a/.env-example +++ b/.env-example @@ -1,5 +1,10 @@ +# connection details for this service FRONTEND_URL=http://localhost:3000 BACKEND_URL=http://localhost:3001 SESSION_SECRET= JWT_SECRET= + +# rate limiting +RATE_LIMIT_WINDOW_MS=15*60*1000 +RATE_LIMIT_MAX_REQ=100 diff --git a/src/app.ts b/src/app.ts index a6f86bf..93a6dcd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -40,12 +40,12 @@ app.set('view engine', 'ejs'); app.use('/public', express.static(`${__dirname}/public`)); app.use('/css', express.static(`${__dirname}/css`)); app.use('/assets', express.static(`${__dirname}/assets`)); -app.use('/auth', auth); -app.use('/healthcheck', healthcheck); +app.use('/auth', rateLimiter, auth); +app.use('/healthcheck', rateLimiter, healthcheck); -app.use('/:lang/publish', publish, rateLimiter, ensureAuthenticated); -app.use('/:lang/dataset', view, rateLimiter, ensureAuthenticated); -app.use('/:lang/healthcheck', healthcheck); +app.use('/:lang/publish', rateLimiter, ensureAuthenticated, publish); +app.use('/:lang/dataset', rateLimiter, ensureAuthenticated, view); +app.use('/:lang/healthcheck', rateLimiter, healthcheck); app.get('/', (req: Request, res: Response) => { const lang = req.headers['accept-language'] || req.headers['Accept-Language'] || req.i18n.language || 'en-GB'; diff --git a/src/middleware/rate-limiter.ts b/src/middleware/rate-limiter.ts index 5a0d7dd..149e6fa 100644 --- a/src/middleware/rate-limiter.ts +++ b/src/middleware/rate-limiter.ts @@ -1,8 +1,8 @@ import rateLimit from 'express-rate-limit'; export const rateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 100, + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10), + max: parseInt(process.env.RATE_LIMIT_MAX_REQ || '100', 10), standardHeaders: true, legacyHeaders: false, handler: (req, res) => { diff --git a/src/middleware/session.ts b/src/middleware/session.ts index 054e79e..dede9f4 100644 --- a/src/middleware/session.ts +++ b/src/middleware/session.ts @@ -1,7 +1,7 @@ import session from 'express-session'; export default session({ - secret: process.env.SESSION_SECRET || 'default', + secret: process.env.SESSION_SECRET || '', resave: false, saveUninitialized: false, cookie: { From 5db60efa28595026748a249529884f6e539cd4c4 Mon Sep 17 00:00:00 2001 From: Phil Moorhouse Date: Mon, 2 Sep 2024 13:21:52 +0100 Subject: [PATCH 05/11] add missing env vars for jest --- test/.jest/setEnvVars.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/.jest/setEnvVars.ts b/test/.jest/setEnvVars.ts index a603395..8fca3aa 100644 --- a/test/.jest/setEnvVars.ts +++ b/test/.jest/setEnvVars.ts @@ -1 +1,4 @@ -process.env.BACKEND_URL = 'http://somehost.com:3001'; +process.env.FRONTEND_URL = 'http://example.com:3000'; +process.env.BACKEND_URL = 'http://example.com:3001'; +process.env.SESSION_SECRET = 'mysecret'; +process.env.JWT_SECRET = 'mysecret'; From 87ec65141a33486eed8567be7caa673ec9c46ae3 Mon Sep 17 00:00:00 2001 From: Phil Moorhouse Date: Mon, 2 Sep 2024 17:06:51 +0100 Subject: [PATCH 06/11] store jwt and add it in auth header for api requests --- .eslintrc | 3 +- src/interfaces/authed-request.ts | 5 +++ src/interfaces/jwt-payload-with-user.ts | 2 +- src/middleware/ensure-authenticated.ts | 34 +++++++++++-------- src/routes/healthcheck.ts | 7 ++-- src/routes/publish.ts | 23 +++++++------ src/routes/view.ts | 20 +++++++----- src/services/{api.ts => stats-wales-api.ts} | 36 +++++++++++++-------- test/app.test.ts | 12 +++---- 9 files changed, 84 insertions(+), 58 deletions(-) create mode 100644 src/interfaces/authed-request.ts rename src/services/{api.ts => stats-wales-api.ts} (82%) diff --git a/.eslintrc b/.eslintrc index 6874c4a..c811b02 100644 --- a/.eslintrc +++ b/.eslintrc @@ -21,7 +21,8 @@ "rules": { "prettier/prettier": 2, "no-console": 0, - "no-process-env": 0 + "no-process-env": 0, + "line-comment-position": 0 }, "globals": { "NodeJS": true diff --git a/src/interfaces/authed-request.ts b/src/interfaces/authed-request.ts new file mode 100644 index 0000000..5a2e696 --- /dev/null +++ b/src/interfaces/authed-request.ts @@ -0,0 +1,5 @@ +import { Request } from 'express'; + +export interface AuthedRequest extends Request { + jwt?: string; +} diff --git a/src/interfaces/jwt-payload-with-user.ts b/src/interfaces/jwt-payload-with-user.ts index 902551e..2f554c5 100644 --- a/src/interfaces/jwt-payload-with-user.ts +++ b/src/interfaces/jwt-payload-with-user.ts @@ -3,5 +3,5 @@ import { JwtPayload } from 'jsonwebtoken'; import { User } from './user.interface'; export interface JWTPayloadWithUser extends JwtPayload { - user: User; + user?: User; } diff --git a/src/middleware/ensure-authenticated.ts b/src/middleware/ensure-authenticated.ts index 1492a4a..ac4757f 100644 --- a/src/middleware/ensure-authenticated.ts +++ b/src/middleware/ensure-authenticated.ts @@ -1,27 +1,35 @@ import { RequestHandler } from 'express'; import JWT from 'jsonwebtoken'; +import { AuthedRequest } from '../interfaces/authed-request'; import { JWTPayloadWithUser } from '../interfaces/jwt-payload-with-user'; import { logger } from '../utils/logger'; -export const ensureAuthenticated: RequestHandler = (req, res, next) => { +export const ensureAuthenticated: RequestHandler = (req: AuthedRequest, res, next) => { logger.debug('checking if user is authenticated...'); - if (!req.cookies.jwt) { - logger.error('JWT cookie not found'); - return res.redirect('/auth/login'); - } + try { + if (!req.cookies.jwt) { + throw new Error('JWT cookie not found'); + } - const secret = process.env.JWT_SECRET || ''; - const decoded = JWT.verify(req.cookies.jwt, secret) as JWTPayloadWithUser; + const secret = process.env.JWT_SECRET || ''; + const token = req.cookies.jwt; + const decoded = JWT.verify(token, secret) as JWTPayloadWithUser; - if (decoded.exp && decoded.exp <= Date.now() / 1000) { - logger.error('JWT token has expired'); - return res.redirect('/auth/login'); - } + if (decoded.exp && decoded.exp <= Date.now() / 1000) { + throw new Error('JWT token has expired'); + } - req.user = decoded.user; - logger.info('user is authenticated'); + // store the token string as we need it for the auth header in the API requests + req.jwt = token; + req.user = decoded.user; + logger.info('user is authenticated'); + } catch (err) { + logger.error(err); + res.status(401); + return res.redirect('/auth/login?error=unauthenticated'); + } return next(); }; diff --git a/src/routes/healthcheck.ts b/src/routes/healthcheck.ts index 8e2cd9f..751041b 100644 --- a/src/routes/healthcheck.ts +++ b/src/routes/healthcheck.ts @@ -1,17 +1,18 @@ import { Router } from 'express'; -import { API } from '../services/api'; +import { StatsWalesApi } from '../services/stats-wales-api'; import { logger } from '../utils/logger'; -const APIInstance = new API(); - export const healthcheck = Router(); healthcheck.get('/', async (req, res) => { const lang = req.i18n.language || 'en-GB'; + const APIInstance = new StatsWalesApi(lang); logger.info(`Healthcheck requested in ${lang}`); + const statusMsg = req.t('app-running'); const beConnected = await APIInstance.ping(); + res.json({ status: statusMsg, notes: req.t('health-notes'), diff --git a/src/routes/publish.ts b/src/routes/publish.ts index d9f910e..5d1ef9d 100644 --- a/src/routes/publish.ts +++ b/src/routes/publish.ts @@ -1,22 +1,16 @@ import { Blob } from 'buffer'; import { Request, Response, Router } from 'express'; -import pino from 'pino'; import multer from 'multer'; +import { logger } from '../utils/logger'; import { ViewErrDTO } from '../dtos/view-dto'; import { i18next } from '../middleware/translation'; -import { API } from '../services/api'; +import { StatsWalesApi } from '../services/stats-wales-api'; const t = i18next.t; const storage = multer.memoryStorage(); const upload = multer({ storage }); -const APIInstance = new API(); - -const logger = pino({ - name: 'StatsWales-Alpha-App: Publish', - level: 'debug' -}); export const publish = Router(); @@ -65,6 +59,8 @@ publish.post('/name', upload.none(), (req: Request, res: Response) => { publish.post('/upload', upload.single('csv'), async (req: Request, res: Response) => { const lang = req.i18n.language; + const statsWalesApi = new StatsWalesApi(lang); + if (!req.body?.internal_name) { logger.debug('Internal name was missing on request'); const err: ViewErrDTO = { @@ -76,7 +72,7 @@ publish.post('/upload', upload.single('csv'), async (req: Request, res: Response field: 'internal_name', message: [ { - lang: req.i18n.language, + lang, message: t('errors.name_missing') } ], @@ -91,8 +87,10 @@ publish.post('/upload', upload.single('csv'), async (req: Request, res: Response res.render('publish/name', err); return; } + logger.debug(`Internal name: ${req.body.internal_name}`); const internalName: string = req.body.internal_name; + if (!req.file) { logger.debug('Attached file was missing on this request'); const err: ViewErrDTO = { @@ -104,7 +102,7 @@ publish.post('/upload', upload.single('csv'), async (req: Request, res: Response field: 'csv', message: [ { - lang: req.i18n.language, + lang, message: t('errors.upload.no-csv-data') } ], @@ -122,9 +120,10 @@ publish.post('/upload', upload.single('csv'), async (req: Request, res: Response const fileData = new Blob([req.file?.buffer]); - const processedCSV = await APIInstance.uploadCSV(lang, fileData, internalName); + const processedCSV = await statsWalesApi.uploadCSV(fileData, internalName); + if (processedCSV.success) { - res.redirect(`/${req.i18n.language}/dataset/${processedCSV.dataset?.id}`); + res.redirect(`/${lang}/dataset/${processedCSV.dataset?.id}`); } else { res.status(400); res.render('publish/upload', processedCSV); diff --git a/src/routes/view.ts b/src/routes/view.ts index e865924..a3e1635 100644 --- a/src/routes/view.ts +++ b/src/routes/view.ts @@ -1,24 +1,28 @@ -import { Router, Request, Response } from 'express'; +import { Router, Response } from 'express'; -import { API } from '../services/api'; +import { StatsWalesApi } from '../services/stats-wales-api'; import { FileList } from '../dtos/filelist'; import { ViewErrDTO } from '../dtos/view-dto'; import { i18next } from '../middleware/translation'; import { logger } from '../utils/logger'; +import { AuthedRequest } from '../interfaces/authed-request'; const t = i18next.t; -const APIInstance = new API(); export const view = Router(); -view.get('/', async (req: Request, res: Response) => { +const statsWalesApi = (req: AuthedRequest) => { const lang = req.i18n.language; - const fileList: FileList = await APIInstance.getFileList(lang); + const token = req.jwt; + return new StatsWalesApi(lang, token); +}; + +view.get('/', async (req: AuthedRequest, res: Response) => { + const fileList: FileList = await statsWalesApi(req).getFileList(); logger.debug(`FileList from server = ${JSON.stringify(fileList)}`); res.render('list', fileList); }); -view.get('/:file', async (req: Request, res: Response) => { - const lang = req.i18n.language; +view.get('/:file', async (req: AuthedRequest, res: Response) => { const page_number: number = Number.parseInt(req.query.page_number as string, 10) || 1; const page_size: number = Number.parseInt(req.query.page_size as string, 10) || 100; @@ -49,7 +53,7 @@ view.get('/:file', async (req: Request, res: Response) => { } const file_id = req.params.file; - const file = await APIInstance.getFileData(lang, file_id, page_number, page_size); + const file = await statsWalesApi(req).getFileData(file_id, page_number, page_size); if (!file.success) { res.status((file as ViewErrDTO).status); } diff --git a/src/services/api.ts b/src/services/stats-wales-api.ts similarity index 82% rename from src/services/api.ts rename to src/services/stats-wales-api.ts index 846a5fc..befa3d9 100644 --- a/src/services/api.ts +++ b/src/services/stats-wales-api.ts @@ -18,16 +18,22 @@ class HttpError extends Error { } } -export class API { - private readonly backendUrl: string | undefined; +export class StatsWalesApi { + private readonly backendUrl = process.env.BACKEND_URL || ''; + private readonly authHeader: Record; - constructor() { - this.backendUrl = process.env.BACKEND_URL; + constructor( + private lang: string, + private token?: string + ) { + this.lang = lang; + this.authHeader = token ? { Authorization: `Bearer ${token}` } : {}; } - public async getFileList(lang: string) { - logger.debug(`Fetching file list from ${this.backendUrl}/${lang}/dataset`); - const filelist: FileList = await fetch(`${this.backendUrl}/${lang}/dataset`) + public async getFileList() { + logger.debug(`Fetching file list from ${this.backendUrl}/${this.lang}/dataset`); + + const filelist: FileList = await fetch(`${this.backendUrl}/${this.lang}/dataset`, { headers: this.authHeader }) .then((response) => { if (response.ok) { return response.json(); @@ -46,9 +52,10 @@ export class API { return filelist; } - public async getFileData(lang: string, file_id: string, page_number: number, page_size: number) { + public async getFileData(file_id: string, page_number: number, page_size: number) { const file = await fetch( - `${this.backendUrl}/${lang}/dataset/${file_id}/view?page_number=${page_number}&page_size=${page_size}` + `${this.backendUrl}/${this.lang}/dataset/${file_id}/view?page_number=${page_number}&page_size=${page_size}`, + { headers: this.authHeader } ) .then((response) => { if (response.ok) { @@ -71,7 +78,7 @@ export class API { field: 'file', message: [ { - lang, + lang: this.lang, message: 'errors.dataset_missing' } ], @@ -87,14 +94,15 @@ export class API { return file; } - public async uploadCSV(lang: string, file: Blob, filename: string) { + public async uploadCSV(file: Blob, filename: string) { const formData = new FormData(); formData.append('csv', file, filename); formData.append('internal_name', filename); - const processedCSV = await fetch(`${this.backendUrl}/${lang}/dataset/`, { + const processedCSV = await fetch(`${this.backendUrl}/${this.lang}/dataset/`, { method: 'POST', - body: formData + body: formData, + headers: this.authHeader }) .then((response) => { if (response.ok) { @@ -117,7 +125,7 @@ export class API { field: 'csv', message: [ { - lang, + lang: this.lang, message: 'errors.upload.no-csv-data' } ], diff --git a/test/app.test.ts b/test/app.test.ts index b86d672..33e10d3 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -22,7 +22,7 @@ jest.mock('../src/middleware/ensure-authenticated', () => ({ })); const server = setupServer( - http.get('http://somehost.com:3001/en-GB/dataset', () => { + http.get('http://example.com:3001/en-GB/dataset', () => { return HttpResponse.json({ filelist: [ { @@ -46,13 +46,13 @@ const server = setupServer( ] }); }), - http.get('http://somehost.com:3001/en-GB/dataset/missing-id/view', () => { + http.get('http://example.com:3001/en-GB/dataset/missing-id/view', () => { return new HttpResponse(null, { status: 404, statusText: '{}' }); }), - http.get('http://somehost.com:3001/en-GB/dataset/5caeb8ed-ea64-4a58-8cf0-b728308833e5', () => { + http.get('http://example.com:3001/en-GB/dataset/5caeb8ed-ea64-4a58-8cf0-b728308833e5', () => { return HttpResponse.json({ id: '5caeb8ed-ea64-4a58-8cf0-b728308833e5', creation_date: '2024-09-05T10:05:03.871Z', @@ -94,7 +94,7 @@ const server = setupServer( ] }); }), - http.get('http://somehost.com:3001/en-GB/dataset/5caeb8ed-ea64-4a58-8cf0-b728308833e5/view', () => { + http.get('http://example.com:3001/en-GB/dataset/5caeb8ed-ea64-4a58-8cf0-b728308833e5/view', () => { return HttpResponse.json({ success: true, dataset: { @@ -140,7 +140,7 @@ const server = setupServer( ] }); }), - http.post('http://somehost.com:3001/en-GB/dataset/', async (req) => { + http.post('http://example.com:3001/en-GB/dataset/', async (req) => { const data = await req.request.formData(); const title = data.get('title') as string; if (title === 'test-data-3.csv fail test') { @@ -203,7 +203,7 @@ const server = setupServer( ] }); }), - http.get('http://somehost.com:3001/healthcheck', () => { + http.get('http://example.com:3001/healthcheck', () => { return HttpResponse.json({ status: 'App is running', notes: 'Expand endpoint to check for database connection and other services.' From 6c6b80f72578a9344eb7ca1034d60e9bccc5ef8e Mon Sep 17 00:00:00 2001 From: Phil Moorhouse Date: Wed, 4 Sep 2024 17:57:17 +0100 Subject: [PATCH 07/11] add one login auth --- package.json | 2 +- src/middleware/ensure-authenticated.ts | 16 ++++++++++---- src/middleware/session.ts | 2 +- src/resources/locales/en-GB.json | 4 +++- src/routes/auth.ts | 30 +++++++++++++++++--------- src/views/auth/login.ejs | 1 + 6 files changed, 38 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index c713030..02c35a5 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test:ci": "jest --ci --coverage --config=jest.config.ts", "check": "npm-run-all prettier:fix lint:fix test build", "dev:check": "npm-run-all check dev", - "dev": "nodemon --watch src -e ts,ejs --exec npm run start", + "dev": "NODE_ENV=dev nodemon --watch src -e ts,ejs --exec npm run start", "start": "npm run build && node dist/server.js | pino-colada", "start:container": "node dist/server.js" }, diff --git a/src/middleware/ensure-authenticated.ts b/src/middleware/ensure-authenticated.ts index ac4757f..c9c9555 100644 --- a/src/middleware/ensure-authenticated.ts +++ b/src/middleware/ensure-authenticated.ts @@ -13,22 +13,30 @@ export const ensureAuthenticated: RequestHandler = (req: AuthedRequest, res, nex throw new Error('JWT cookie not found'); } + // JWT_SECRET must be the same as the backend or the token will fail verification const secret = process.env.JWT_SECRET || ''; const token = req.cookies.jwt; + + // verify the JWT token was signed by us const decoded = JWT.verify(token, secret) as JWTPayloadWithUser; if (decoded.exp && decoded.exp <= Date.now() / 1000) { - throw new Error('JWT token has expired'); + logger.error('JWT token has expired'); + res.status(401); + return res.redirect('/auth/login?error=expired'); } - // store the token string as we need it for the auth header in the API requests + // store the token string in the request as we need it for Authorization header in API requests req.jwt = token; + logger.debug(`JWT: ${token}`); + + // store the user object in the request for use in the frontend req.user = decoded.user; logger.info('user is authenticated'); } catch (err) { - logger.error(err); + logger.error(`authentication failed: ${err}`); res.status(401); - return res.redirect('/auth/login?error=unauthenticated'); + return res.redirect('/auth/login'); } return next(); diff --git a/src/middleware/session.ts b/src/middleware/session.ts index dede9f4..2831b5a 100644 --- a/src/middleware/session.ts +++ b/src/middleware/session.ts @@ -5,6 +5,6 @@ export default session({ resave: false, saveUninitialized: false, cookie: { - secure: true + secure: process.env.NODE_ENV !== 'dev' } }); diff --git a/src/resources/locales/en-GB.json b/src/resources/locales/en-GB.json index 241f1e4..cb0a77d 100644 --- a/src/resources/locales/en-GB.json +++ b/src/resources/locales/en-GB.json @@ -26,11 +26,13 @@ "login": { "heading": "Login", "buttons": { + "onelogin": "Login with OneLogin", "google": "Login with Google" }, "error": { "summary-title": "There is a problem", - "message": "You could not be logged in. Please try again later." + "generic": "You could not be logged in. Please try again later.", + "expired": "Your session has expired. Please log in again." } }, "homepage": { diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 605abb3..29d0317 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -7,38 +7,48 @@ import { JWTPayloadWithUser } from '../interfaces/jwt-payload-with-user'; export const auth = Router(); auth.get('/login', (req: Request, res: Response) => { + if (req.query.error && req.query.error === 'expired') { + logger.error(`Authentication token has expired`); + res.status(400); + res.render('auth/login', { errors: ['login.error.expired'] }); + return; + } res.render('auth/login'); }); auth.get('/google', (req: Request, res: Response) => { - logger.debug('Sending user to backend for authentication'); + logger.debug('Sending user to backend for google authentication'); res.redirect(`${process.env.BACKEND_URL}/auth/google`); }); +auth.get('/onelogin', (req: Request, res: Response) => { + logger.debug('Sending user to backend for onelogin authentication'); + res.redirect(`${process.env.BACKEND_URL}/auth/onelogin`); +}); + auth.get('/callback', (req: Request, res: Response) => { logger.debug('returning from auth backend'); try { - if (!req.cookies.jwt) { - logger.error('JWT cookie not found'); - throw new Error('JWT cookie not found'); + if (req.query.error) { + throw new Error(`auth backend returned an error: ${req.query.error}`); } - if (req.query.error) { - logger.error(`Error from auth backend: ${req.query.error}`); - throw new Error(`Error from auth backend: ${req.query.error}`); + // the backend stores the JWT token in a cookie before retuning the user to the frontend + if (!req.cookies.jwt) { + throw new Error('JWT cookie not found'); } const secret = process.env.JWT_SECRET || ''; const decoded = JWT.verify(req.cookies.jwt, secret) as JWTPayloadWithUser; req.user = decoded.user; } catch (err) { - logger.error(`Error verifying JWT: ${err}`); + logger.error(`problem authenticating user ${err}`); res.status(400); - res.render('auth/login', { errors: ['login.error.message'] }); + res.render('auth/login', { errors: ['login.error.generic'] }); return; } - console.log(req.user); + logger.debug('User successfully logged in'); res.redirect('/'); }); diff --git a/src/views/auth/login.ejs b/src/views/auth/login.ejs index d273fcb..6df191b 100644 --- a/src/views/auth/login.ejs +++ b/src/views/auth/login.ejs @@ -22,6 +22,7 @@ <% } %> From fd68048aa50763948f795f1ecd03786306f96863 Mon Sep 17 00:00:00 2001 From: Phil Moorhouse Date: Wed, 11 Sep 2024 11:47:28 +0100 Subject: [PATCH 08/11] resolve merge issues --- src/routes/publish.ts | 127 +++++++++++++++++++++++++------- src/routes/view.ts | 20 ++--- src/services/stats-wales-api.ts | 82 +++++++++++++++++---- 3 files changed, 178 insertions(+), 51 deletions(-) diff --git a/src/routes/publish.ts b/src/routes/publish.ts index 5d1ef9d..699db12 100644 --- a/src/routes/publish.ts +++ b/src/routes/publish.ts @@ -4,9 +4,10 @@ import { Request, Response, Router } from 'express'; import multer from 'multer'; import { logger } from '../utils/logger'; -import { ViewErrDTO } from '../dtos/view-dto'; -import { i18next } from '../middleware/translation'; import { StatsWalesApi } from '../services/stats-wales-api'; +import { ViewDTO, ViewErrDTO } from '../dtos2/view-dto'; +import { i18next } from '../middleware/translation'; +import { AuthedRequest } from '../interfaces/authed-request'; const t = i18next.t; const storage = multer.memoryStorage(); @@ -18,50 +19,46 @@ publish.get('/', (req: Request, res: Response) => { res.render('publish/start'); }); -publish.get('/name', (req: Request, res: Response) => { - res.render('publish/name'); -}); - publish.get('/title', (req: Request, res: Response) => { res.render('publish/title'); }); -publish.post('/name', upload.none(), (req: Request, res: Response) => { - if (!req.body?.internal_name) { - logger.debug('Internal name was missing on request'); +publish.post('/title', upload.none(), (req: Request, res: Response) => { + if (!req.body?.title) { + logger.debug('Title was missing on request'); const err: ViewErrDTO = { success: false, status: 400, dataset_id: undefined, errors: [ { - field: 'internal_name', + field: 'title', message: [ { lang: req.i18n.language, - message: t('errors.name_missing') + message: t('errors.title.missing') } ], tag: { - name: 'errors.name_missing', + name: 'errors.title.missing', params: {} } } ] }; res.status(400); - res.render('publish/name', err); + res.render('publish/title', err); return; } - const internalName: string = req.body.internal_name; - res.render('publish/upload', { internal_name: internalName }); + const title: string = req.body.title; + res.render('publish/upload', { title }); }); -publish.post('/upload', upload.single('csv'), async (req: Request, res: Response) => { +publish.post('/upload', upload.single('csv'), async (req: AuthedRequest, res: Response) => { const lang = req.i18n.language; - const statsWalesApi = new StatsWalesApi(lang); + const statsWalesApi = new StatsWalesApi(lang, req.jwt); - if (!req.body?.internal_name) { + if (!req.body?.title) { logger.debug('Internal name was missing on request'); const err: ViewErrDTO = { success: false, @@ -69,27 +66,27 @@ publish.post('/upload', upload.single('csv'), async (req: Request, res: Response dataset_id: undefined, errors: [ { - field: 'internal_name', + field: 'title', message: [ { lang, - message: t('errors.name_missing') + message: t('errors.title.missing') } ], tag: { - name: 'errors.name_missing', + name: 'errors.title.missing', params: {} } } ] }; res.status(400); - res.render('publish/name', err); + res.render('publish/title', err); return; } - logger.debug(`Internal name: ${req.body.internal_name}`); - const internalName: string = req.body.internal_name; + logger.debug(`Title name: ${req.body.title}`); + const title: string = req.body.title; if (!req.file) { logger.debug('Attached file was missing on this request'); @@ -118,14 +115,90 @@ publish.post('/upload', upload.single('csv'), async (req: Request, res: Response return; } - const fileData = new Blob([req.file?.buffer]); + const fileName = req.file?.originalname; + const fileData = new Blob([req.file?.buffer], { type: req.file?.mimetype }); - const processedCSV = await statsWalesApi.uploadCSV(fileData, internalName); + const processedCSV = await statsWalesApi.uploadCSV(fileData, fileName, title); if (processedCSV.success) { - res.redirect(`/${lang}/dataset/${processedCSV.dataset?.id}`); + // eslint-disable-next-line require-atomic-updates + req.session.currentDataset = processedCSV.dataset; + req.session.save(); + res.redirect(`/${lang}/publish/preview`); } else { res.status(400); res.render('publish/upload', processedCSV); } }); + +publish.get('/preview', async (req: Request, res: Response) => { + const lang = req.i18n.language; + const statsWalesApi = new StatsWalesApi(lang); + + const dataset = req.session.currentDataset; + if (!dataset) { + logger.debug('No dataset in session'); + res.redirect(`/${req.i18n.language}/publish/title`); + return; + } + const page_number: number = Number.parseInt(req.query.page_number as string, 10) || 1; + const page_size: number = Number.parseInt(req.query.page_size as string, 10) || 10; + const previewData = await statsWalesApi.getDatasetDatafilePreview( + dataset.id, + dataset.revisions[0].id, + dataset.revisions[0].imports[0].id, + page_number, + page_size + ); + if (!previewData.success) { + logger.error('Failed to get preview data from the backend'); + res.status(500); + res.render('publish/start'); + } + const data = previewData as ViewDTO; + res.render('publish/preview', data); +}); + +publish.post('/confirm', upload.none(), (req: Request, res: Response) => { + const dataset = req.session.currentDataset; + if (!dataset) { + logger.debug('No dataset in session'); + res.redirect(`/${req.i18n.language}/publish/title`); + return; + } + const lang = req.i18n.language; + const confirmData = req.body?.confirm; + if (!confirmData) { + logger.debug('No confirmation data was provided'); + const err: ViewErrDTO = { + success: false, + status: 400, + dataset_id: dataset.id, + errors: [ + { + field: 'confirm', + message: [ + { + lang, + message: t('errors.confirm.missing') + } + ], + tag: { + name: 'errors.confirm.missing', + params: {} + } + } + ] + }; + res.status(400); + res.render('publish/preview', err); + return; + } + if (confirmData === 'true') { + res.status(200); + res.json({ confirm: true, dataset_id: dataset.id, message: 'Datatable has been confirmed as correct' }); + } else { + res.status(200); + res.json({ confirm: false, dataset_id: dataset.id, message: 'Datatable has been rejected as incorrect' }); + } +}); diff --git a/src/routes/view.ts b/src/routes/view.ts index a3e1635..6465df9 100644 --- a/src/routes/view.ts +++ b/src/routes/view.ts @@ -1,8 +1,8 @@ import { Router, Response } from 'express'; import { StatsWalesApi } from '../services/stats-wales-api'; -import { FileList } from '../dtos/filelist'; -import { ViewErrDTO } from '../dtos/view-dto'; +import { FileList } from '../dtos2/filelist'; +import { ViewErrDTO } from '../dtos2/view-dto'; import { i18next } from '../middleware/translation'; import { logger } from '../utils/logger'; import { AuthedRequest } from '../interfaces/authed-request'; @@ -22,21 +22,22 @@ view.get('/', async (req: AuthedRequest, res: Response) => { res.render('list', fileList); }); -view.get('/:file', async (req: AuthedRequest, res: Response) => { +view.get('/:datasetId', async (req: AuthedRequest, res: Response) => { + const lang = req.i18n.language; const page_number: number = Number.parseInt(req.query.page_number as string, 10) || 1; const page_size: number = Number.parseInt(req.query.page_size as string, 10) || 100; - if (!req.params.file) { + if (!req.params.datasetId) { const err: ViewErrDTO = { success: false, - status: 404, + status: 400, dataset_id: undefined, errors: [ { field: 'file', message: [ { - lang: req.i18n.language, + lang, message: t('errors.dataset_missing') } ], @@ -51,11 +52,12 @@ view.get('/:file', async (req: AuthedRequest, res: Response) => { res.render('data', err); return; } + const datasetId = req.params.datasetId; - const file_id = req.params.file; - const file = await statsWalesApi(req).getFileData(file_id, page_number, page_size); + const file = await statsWalesApi(req).getDatasetView(datasetId, page_number, page_size); if (!file.success) { - res.status((file as ViewErrDTO).status); + const error = file as ViewErrDTO; + res.status(error.status); } res.render('data', file); }); diff --git a/src/services/stats-wales-api.ts b/src/services/stats-wales-api.ts index befa3d9..5a7aba3 100644 --- a/src/services/stats-wales-api.ts +++ b/src/services/stats-wales-api.ts @@ -1,7 +1,8 @@ -import { FileListError, FileList } from '../dtos/filelist'; -import { ViewDTO, ViewErrDTO } from '../dtos/view-dto'; -import { Healthcheck } from '../dtos/healthcehck'; -import { UploadDTO, UploadErrDTO } from '../dtos/upload-dto'; +import { FileListError, FileList } from '../dtos2/filelist'; +import { ViewDTO, ViewErrDTO } from '../dtos2/view-dto'; +import { Healthcheck } from '../dtos2/healthcehck'; +import { UploadDTO, UploadErrDTO } from '../dtos2/upload-dto'; +import { DatasetDTO } from '../dtos2/dataset-dto'; import { logger } from '../utils/logger'; class HttpError extends Error { @@ -52,9 +53,12 @@ export class StatsWalesApi { return filelist; } - public async getFileData(file_id: string, page_number: number, page_size: number) { + public async getDatasetView(datasetId: string, pageNumber: number, pageSize: number) { + logger.info( + `Fetching dataset view from ${this.backendUrl}/${this.lang}/dataset/${datasetId}/view?page_number=${pageNumber}&page_size=${pageSize}` + ); const file = await fetch( - `${this.backendUrl}/${this.lang}/dataset/${file_id}/view?page_number=${page_number}&page_size=${page_size}`, + `${this.backendUrl}/${this.lang}/dataset/${datasetId}/view?page_number=${pageNumber}&page_size=${pageSize}`, { headers: this.authHeader } ) .then((response) => { @@ -88,16 +92,64 @@ export class StatsWalesApi { } } ], - dataset_id: file_id + dataset_id: datasetId } as ViewErrDTO; }); return file; } - public async uploadCSV(file: Blob, filename: string) { + public async getDatasetDatafilePreview( + datasetId: string, + revisionId: string, + importId: string, + pageNumber: number, + pageSize: number + ) { + const file = await fetch( + `${this.backendUrl}/${this.lang}/dataset/${datasetId}/revision/by-id/${revisionId}/import/by-id/${importId}/preview?page_number=${pageNumber}&page_size=${pageSize}`, + { headers: this.authHeader } + ) + .then((response) => { + if (response.ok) { + return response.json(); + } + const err = new HttpError(response.status); + err.handleMessage(response.text()); + throw err; + }) + .then((api_res) => { + return api_res as ViewDTO; + }) + .catch((error) => { + logger.error(`An HTTP error occured with status ${error.status} and message "${error.message}"`); + return { + success: false, + status: error.status, + errors: [ + { + field: 'file', + message: [ + { + lang: this.lang, + message: 'errors.dataset_missing' + } + ], + tag: { + name: 'errors.dataset_missing', + params: {} + } + } + ], + dataset_id: datasetId + } as ViewErrDTO; + }); + return file; + } + + public async uploadCSV(file: Blob, filename: string, title: string) { const formData = new FormData(); - formData.append('csv', file, filename); - formData.append('internal_name', filename); + formData.set('csv', file, filename); + formData.set('title', title); const processedCSV = await fetch(`${this.backendUrl}/${this.lang}/dataset/`, { method: 'POST', @@ -113,7 +165,11 @@ export class StatsWalesApi { throw err; }) .then((api_res) => { - return api_res as UploadDTO; + const datasetDTO = api_res as DatasetDTO; + return { + success: true, + dataset: datasetDTO + } as UploadDTO; }) .catch((error) => { logger.error(`An HTTP error occured with status ${error.status} and message "${error.message}"`); @@ -146,10 +202,6 @@ export class StatsWalesApi { .then((api_res) => api_res.json()) .then((api_res) => { return api_res as Healthcheck; - }) - .catch((error) => { - logger.error(`An HTTP error occured with status ${error.status} and message "${error.message}"`); - return { status: 'App is not running' } as Healthcheck; }); return health.status === 'App is running'; } From a2e990fd4c355f9865c040adae10439d5b2c5283 Mon Sep 17 00:00:00 2001 From: Phil Moorhouse Date: Wed, 11 Sep 2024 12:17:24 +0100 Subject: [PATCH 09/11] add csrf protection --- package-lock.json | 102 ++++++++++++++++++++++++---------------------- package.json | 11 +++-- src/app.ts | 2 + 3 files changed, 60 insertions(+), 55 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e2feea..48cf290 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,6 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@types/express-session": "^1.18.0", - "@types/passport": "^1.0.16", - "@types/passport-local": "^1.0.38", "cookie-parser": "^1.4.6", "dotenv": "^16.4.4", "ejs": "^3.1.9", @@ -25,28 +22,30 @@ "i18next-http-middleware": "^3.5.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", + "lusca": "^1.7.0", "multer": "^1.4.5-lts.1", "passport": "^0.7.0", "pino": "^8.21.0", "pino-http": "^10.2.0", - "ts-node-dev": "^2.0.0", - "uuid": "^10.0.0" + "ts-node-dev": "^2.0.0" }, "devDependencies": { "@shopify/eslint-plugin": "^44.0.0", "@types/better-sqlite3": "^7.6.9", "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", + "@types/express-session": "^1.18.0", "@types/fs-extra": "^9.0.13", "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.6", "@types/lodash": "^4.17.7", + "@types/lusca": "^1.7.5", "@types/multer": "^1.4.11", "@types/node": "^20.11.27", + "@types/passport": "^1.0.16", "@types/pino": "^7.0.5", "@types/shelljs": "^0.8.15", "@types/supertest": "^6.0.2", - "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/parser": "^7.0.1", "eslint": "^8.56.0", @@ -2253,6 +2252,7 @@ "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -2262,6 +2262,7 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, "dependencies": { "@types/node": "*" } @@ -2292,6 +2293,7 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dev": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -2303,6 +2305,7 @@ "version": "4.17.43", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz", "integrity": "sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==", + "dev": true, "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -2314,6 +2317,8 @@ "version": "1.18.0", "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.0.tgz", "integrity": "sha512-27JdDRgor6PoYlURY+Y5kCakqp5ulC0kmf7y+QwaY+hv9jEFuQOThgkjyA53RP3jmKuBsH5GR6qEfFmvb8mwOA==", + "dev": true, + "license": "MIT", "dependencies": { "@types/express": "*" } @@ -2349,7 +2354,8 @@ "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -2414,6 +2420,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lusca": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@types/lusca/-/lusca-1.7.5.tgz", + "integrity": "sha512-l49gAf8pu2iMzbKejLcz6Pqj+51H2na6BgORv1ElnE8ByPFcBdh/eZ0WNR1Va/6ZuNSZa01Hoy1DTZ3IZ+y+kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2423,7 +2439,8 @@ "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true }, "node_modules/@types/minimatch": { "version": "5.1.2", @@ -2461,29 +2478,12 @@ "version": "1.0.16", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.16.tgz", "integrity": "sha512-FD0qD5hbPWQzaM0wHUnJ/T0BBCJBxCeemtnCwc/ThhTg3x9jfrAcRUmj5Dopza+MfFS9acTe3wk7rcVnRIp/0A==", + "dev": true, + "license": "MIT", "dependencies": { "@types/express": "*" } }, - "node_modules/@types/passport-local": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", - "integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==", - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-strategy": "*" - } - }, - "node_modules/@types/passport-strategy": { - "version": "0.2.38", - "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", - "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", - "dependencies": { - "@types/express": "*", - "@types/passport": "*" - } - }, "node_modules/@types/pino": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/@types/pino/-/pino-7.0.5.tgz", @@ -2497,12 +2497,14 @@ "node_modules/@types/qs": { "version": "6.9.14", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.14.tgz", - "integrity": "sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==" + "integrity": "sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==", + "dev": true }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true }, "node_modules/@types/semver": { "version": "7.5.8", @@ -2514,6 +2516,7 @@ "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, "dependencies": { "@types/mime": "^1", "@types/node": "*" @@ -2523,6 +2526,7 @@ "version": "1.15.5", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", + "dev": true, "dependencies": { "@types/http-errors": "*", "@types/mime": "*", @@ -2582,13 +2586,6 @@ "@types/superagent": "^8.1.0" } }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/wrap-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", @@ -9232,6 +9229,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lusca": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lusca/-/lusca-1.7.0.tgz", + "integrity": "sha512-msnrplCfY7zaqlZBDEloCIKld+RUeMZVeWzSPaGUKeRXFlruNSdKg2XxCyR+zj6BqzcXhXlRnvcvx6rAGgsvMA==", + "dependencies": { + "tsscmp": "^1.0.5" + }, + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -12163,6 +12171,15 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "dev": true }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/tsutils": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", @@ -12443,19 +12460,6 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index 02c35a5..8429143 100644 --- a/package.json +++ b/package.json @@ -28,16 +28,18 @@ "@types/better-sqlite3": "^7.6.9", "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", + "@types/express-session": "^1.18.0", "@types/fs-extra": "^9.0.13", "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.6", "@types/lodash": "^4.17.7", + "@types/lusca": "^1.7.5", "@types/multer": "^1.4.11", "@types/node": "^20.11.27", + "@types/passport": "^1.0.16", "@types/pino": "^7.0.5", "@types/shelljs": "^0.8.15", "@types/supertest": "^6.0.2", - "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/parser": "^7.0.1", "eslint": "^8.56.0", @@ -61,9 +63,6 @@ "typescript": "^5.5.2" }, "dependencies": { - "@types/express-session": "^1.18.0", - "@types/passport": "^1.0.16", - "@types/passport-local": "^1.0.38", "cookie-parser": "^1.4.6", "dotenv": "^16.4.4", "ejs": "^3.1.9", @@ -77,11 +76,11 @@ "i18next-http-middleware": "^3.5.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", + "lusca": "^1.7.0", "multer": "^1.4.5-lts.1", "passport": "^0.7.0", "pino": "^8.21.0", "pino-http": "^10.2.0", - "ts-node-dev": "^2.0.0", - "uuid": "^10.0.0" + "ts-node-dev": "^2.0.0" } } diff --git a/src/app.ts b/src/app.ts index 93a6dcd..dc46849 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import express, { Application, Request, Response } from 'express'; import cookieParser from 'cookie-parser'; +import { csrf } from 'lusca'; import { checkConfig } from './utils/check-config'; import { httpLogger } from './utils/logger'; @@ -31,6 +32,7 @@ app.use(httpLogger); app.use(i18nextMiddleware.handle(i18next)); app.use(session); app.use(cookieParser()); +app.use(csrf()); // configure the view engine app.set('views', path.join(__dirname, 'views')); From 28852196fae64d708db18421789c5776998b070b Mon Sep 17 00:00:00 2001 From: Phil Moorhouse Date: Wed, 11 Sep 2024 12:39:09 +0100 Subject: [PATCH 10/11] remove csrf protection for now --- package-lock.json | 32 -------------------------------- package.json | 2 -- src/app.ts | 2 -- 3 files changed, 36 deletions(-) diff --git a/package-lock.json b/package-lock.json index 48cf290..39b1b63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "i18next-http-middleware": "^3.5.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", - "lusca": "^1.7.0", "multer": "^1.4.5-lts.1", "passport": "^0.7.0", "pino": "^8.21.0", @@ -39,7 +38,6 @@ "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.6", "@types/lodash": "^4.17.7", - "@types/lusca": "^1.7.5", "@types/multer": "^1.4.11", "@types/node": "^20.11.27", "@types/passport": "^1.0.16", @@ -2420,16 +2418,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/lusca": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@types/lusca/-/lusca-1.7.5.tgz", - "integrity": "sha512-l49gAf8pu2iMzbKejLcz6Pqj+51H2na6BgORv1ElnE8ByPFcBdh/eZ0WNR1Va/6ZuNSZa01Hoy1DTZ3IZ+y+kA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -9229,17 +9217,6 @@ "yallist": "^3.0.2" } }, - "node_modules/lusca": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/lusca/-/lusca-1.7.0.tgz", - "integrity": "sha512-msnrplCfY7zaqlZBDEloCIKld+RUeMZVeWzSPaGUKeRXFlruNSdKg2XxCyR+zj6BqzcXhXlRnvcvx6rAGgsvMA==", - "dependencies": { - "tsscmp": "^1.0.5" - }, - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -12171,15 +12148,6 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "dev": true }, - "node_modules/tsscmp": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", - "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", - "license": "MIT", - "engines": { - "node": ">=0.6.x" - } - }, "node_modules/tsutils": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", diff --git a/package.json b/package.json index 8429143..abc66d7 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.6", "@types/lodash": "^4.17.7", - "@types/lusca": "^1.7.5", "@types/multer": "^1.4.11", "@types/node": "^20.11.27", "@types/passport": "^1.0.16", @@ -76,7 +75,6 @@ "i18next-http-middleware": "^3.5.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", - "lusca": "^1.7.0", "multer": "^1.4.5-lts.1", "passport": "^0.7.0", "pino": "^8.21.0", diff --git a/src/app.ts b/src/app.ts index dc46849..93a6dcd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,7 +2,6 @@ import path from 'node:path'; import express, { Application, Request, Response } from 'express'; import cookieParser from 'cookie-parser'; -import { csrf } from 'lusca'; import { checkConfig } from './utils/check-config'; import { httpLogger } from './utils/logger'; @@ -32,7 +31,6 @@ app.use(httpLogger); app.use(i18nextMiddleware.handle(i18next)); app.use(session); app.use(cookieParser()); -app.use(csrf()); // configure the view engine app.set('views', path.join(__dirname, 'views')); From 8bf4db49ed5d6590c5c73e52976aa1ff1c042b9f Mon Sep 17 00:00:00 2001 From: Phil Moorhouse Date: Wed, 11 Sep 2024 12:48:26 +0100 Subject: [PATCH 11/11] validate datasetId is uuid before using it in api req --- package-lock.json | 24 +++++++++++++++++++++++- package.json | 4 +++- src/routes/view.ts | 4 +++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39b1b63..cfe2a7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "passport": "^0.7.0", "pino": "^8.21.0", "pino-http": "^10.2.0", - "ts-node-dev": "^2.0.0" + "ts-node-dev": "^2.0.0", + "uuid": "^10.0.0" }, "devDependencies": { "@shopify/eslint-plugin": "^44.0.0", @@ -44,6 +45,7 @@ "@types/pino": "^7.0.5", "@types/shelljs": "^0.8.15", "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/parser": "^7.0.1", "eslint": "^8.56.0", @@ -2574,6 +2576,13 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/wrap-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", @@ -12428,6 +12437,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index abc66d7..5605ce2 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@types/pino": "^7.0.5", "@types/shelljs": "^0.8.15", "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/parser": "^7.0.1", "eslint": "^8.56.0", @@ -79,6 +80,7 @@ "passport": "^0.7.0", "pino": "^8.21.0", "pino-http": "^10.2.0", - "ts-node-dev": "^2.0.0" + "ts-node-dev": "^2.0.0", + "uuid": "^10.0.0" } } diff --git a/src/routes/view.ts b/src/routes/view.ts index 6465df9..8f2dc9f 100644 --- a/src/routes/view.ts +++ b/src/routes/view.ts @@ -1,4 +1,5 @@ import { Router, Response } from 'express'; +import { validate as validateUUID } from 'uuid'; import { StatsWalesApi } from '../services/stats-wales-api'; import { FileList } from '../dtos2/filelist'; @@ -27,7 +28,7 @@ view.get('/:datasetId', async (req: AuthedRequest, res: Response) => { const page_number: number = Number.parseInt(req.query.page_number as string, 10) || 1; const page_size: number = Number.parseInt(req.query.page_size as string, 10) || 100; - if (!req.params.datasetId) { + if (!req.params.datasetId || !validateUUID(req.params.datasetId)) { const err: ViewErrDTO = { success: false, status: 400, @@ -52,6 +53,7 @@ view.get('/:datasetId', async (req: AuthedRequest, res: Response) => { res.render('data', err); return; } + const datasetId = req.params.datasetId; const file = await statsWalesApi(req).getDatasetView(datasetId, page_number, page_size);