diff --git a/CHANGELOG.md b/CHANGELOG.md index 4591e0dd0..c23963383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,17 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v1.76.0](https://github.com/opengovsg/GoGovSG/compare/v1.75.0...v1.76.0) + +- feat: admin api v1 - create url [`#2213`](https://github.com/opengovsg/GoGovSG/pull/2213) +- feat: allow zip files and block password-protected files [`#2203`](https://github.com/opengovsg/GoGovSG/pull/2203) +- fix: package.json & package-lock.json to reduce vulnerabilities [`#2207`](https://github.com/opengovsg/GoGovSG/pull/2207) +- [develop] 1.75.0 [`#2201`](https://github.com/opengovsg/GoGovSG/pull/2201) + #### [v1.75.0](https://github.com/opengovsg/GoGovSG/compare/v1.74.0...v1.75.0) +> 13 April 2023 + - chore: redirect user to existing link in directory [`#2181`](https://github.com/opengovsg/GoGovSG/pull/2181) - feat: add verify Message button to headers [`#2199`](https://github.com/opengovsg/GoGovSG/pull/2199) - feat: announcement modal image for memos [`#2198`](https://github.com/opengovsg/GoGovSG/pull/2198) diff --git a/README.md b/README.md index a49a6dfdc..6324d3f1f 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,8 @@ After these have been set up, set the environment variables according to the tab |JOB_POLL_ATTEMPTS|No|Number of attempts for long polling of job status before timeout of 408 is returned. Defaults to 12| |JOB_POLL_INTERVAL|No|Interval of time between attempts for long polling of job status in ms. Defaults to 5000ms (5s)| |API_LINK_RANDOM_STR_LENGTH|No|String length of randomly generated shortUrl in API created links. Defaults to 8| -|FF_EXTERNAL_API|No|Boolean, feature flag for enabling the external API. Defaults to false| -|ADMIN_API_EMAIL|No|Email with admin API access. Defaults to none.| +|FF_EXTERNAL_API|No|Boolean, feature flag for enabling the external and admin API. Defaults to false| +|ADMIN_API_EMAILS|No|Emails with admin API access, separated by commas without spaces. Defaults to none.| #### Serverless functions for link migration diff --git a/docker-compose.yml b/docker-compose.yml index 7e86bc70d..f528987a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,7 +65,7 @@ services: - API_LINK_RANDOM_STR_LENGTH=8 - API_KEY_SALT=$$2b$$10$$9rBKuE4Gb5ravnvP4xjoPu - FF_EXTERNAL_API=true - - ADMIN_API_EMAIL + - ADMIN_API_EMAILS=integration-test-admin@open.gov.sg volumes: - ./public:/usr/src/gogovsg/public - ./src:/usr/src/gogovsg/src diff --git a/package-lock.json b/package-lock.json index d5ec8756f..49c73e355 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,12 @@ { "name": "GoGovSG", - "version": "1.75.0", + "version": "1.76.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "1.75.0", + "name": "GoGovSG", + "version": "1.76.0", "license": "MIT", "dependencies": { "@datadog/browser-rum": "^4.15.0", @@ -18,7 +19,7 @@ "@types/express-rate-limit": "^5.1.3", "@types/papaparse": "^5.3.5", "archiver": "^5.3.1", - "aws-sdk": "^2.1101.0", + "aws-sdk": "^2.1354.0", "babel-polyfill": "^6.26.0", "bcrypt": "^5.1.0", "body-parser": "^1.19.2", @@ -32,7 +33,6 @@ "copy-to-clipboard": "^3.3.1", "core-js": "^3.16.3", "cross-fetch": "^3.1.5", - "csv-parse": "^5.3.6", "datadog-winston": "^1.5.1", "date-fns-tz": "^1.3.4", "dd-trace": "^2.11.0", @@ -141,6 +141,7 @@ "concurrently": "^6.2.0", "copyfiles": "^2.4.1", "coveralls": "^3.1.1", + "csv-parse": "^5.3.6", "cz-conventional-changelog": "^3.3.0", "eslint": "^7.30.0", "eslint-config-airbnb": "^18.2.1", @@ -6996,10 +6997,21 @@ "semver": "bin/semver.js" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/aws-sdk": { - "version": "2.1102.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1102.0.tgz", - "integrity": "sha512-MMOncE8IG3Dop3WPza6ryTAEz413ftn/MtDO7ouessb3ljlg5BfqRkTe/rhPH5svqEqJvlh7qHnK0VjgJwmLTQ==", + "version": "2.1354.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1354.0.tgz", + "integrity": "sha512-3aDxvyuOqMB9DqJguCq6p8momdsz0JR1axwkWOOCzHA7a35+Bw+WLmqt3pWwRjR1tGIwkkZ2CvGJObYHsOuw3w==", "dependencies": { "buffer": "4.9.2", "events": "1.1.1", @@ -7008,20 +7020,20 @@ "querystring": "0.2.0", "sax": "1.2.1", "url": "0.10.3", - "uuid": "3.3.2", - "xml2js": "0.4.19" + "util": "^0.12.4", + "uuid": "8.0.0", + "xml2js": "0.5.0" }, "engines": { "node": ">= 10.0.0" } }, "node_modules/aws-sdk/node_modules/uuid": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", - "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", "bin": { - "uuid": "bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/aws-sign2": { @@ -9662,7 +9674,8 @@ "node_modules/csv-parse": { "version": "5.3.6", "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.3.6.tgz", - "integrity": "sha512-WI330GjCuEioK/ii8HM2YE/eV+ynpeLvU+RXw4R8bRU8R0laK5zO3fDsc4gH8s472e3Ga38rbIjCAiQh+tEHkw==" + "integrity": "sha512-WI330GjCuEioK/ii8HM2YE/eV+ynpeLvU+RXw4R8bRU8R0laK5zO3fDsc4gH8s472e3Ga38rbIjCAiQh+tEHkw==", + "dev": true }, "node_modules/cuint": { "version": "0.2.2", @@ -9854,7 +9867,6 @@ "version": "2.23.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.23.0.tgz", "integrity": "sha512-5ycpauovVyAk0kXNZz6ZoB9AYMZB4DObse7P3BPWmyEjXNORTI8EJ6X0uaSAq4sCHzM1uajzrkr6HnsLQpxGXA==", - "dev": true, "engines": { "node": ">=0.11" }, @@ -12935,6 +12947,14 @@ } } }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dependencies": { + "is-callable": "^1.1.3" + } + }, "node_modules/for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -13120,13 +13140,13 @@ } }, "node_modules/get-intrinsic": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", - "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", + "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", "dependencies": { "function-bind": "^1.1.1", "has": "^1.0.3", - "has-symbols": "^1.0.1" + "has-symbols": "^1.0.3" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -13353,6 +13373,17 @@ "node": ">=0.6.0" } }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -13472,9 +13503,23 @@ } }, "node_modules/has-symbols": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", - "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dependencies": { + "has-symbols": "^1.0.2" + }, "engines": { "node": ">= 0.4" }, @@ -14424,7 +14469,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz", "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -14585,6 +14629,20 @@ "node": ">=6" } }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", @@ -14781,6 +14839,24 @@ "node": ">=0.10.0" } }, + "node_modules/is-typed-array": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", + "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -22089,7 +22165,7 @@ "node_modules/sax": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", - "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" + "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==" }, "node_modules/saxes": { "version": "5.0.1", @@ -25720,6 +25796,18 @@ "integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E=", "dev": true }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -26689,6 +26777,25 @@ "which": "bin/which" } }, + "node_modules/which-typed-array": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", + "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wide-align": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", @@ -26927,18 +27034,21 @@ "dev": true }, "node_modules/xml2js": { - "version": "0.4.19", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", - "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", "dependencies": { "sax": ">=0.6.0", - "xmlbuilder": "~9.0.1" + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" } }, "node_modules/xmlbuilder": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", - "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", "engines": { "node": ">=4.0" } @@ -30264,7 +30374,8 @@ "@material-ui/types": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", - "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==" + "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==", + "requires": {} }, "@material-ui/utils": { "version": "4.11.2", @@ -31851,7 +31962,8 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.0.4.tgz", "integrity": "sha512-cs3XLy+UcxiP6bj0A6u7MLLuwdXJ1c3Dtc0RkKg+wiI1g/Ti1om8+/2hc2A2B60NbBNAbMgyBMHvyymWm/j4wQ==", - "dev": true + "dev": true, + "requires": {} }, "@webpack-cli/info": { "version": "1.3.0", @@ -31866,7 +31978,8 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.5.1.tgz", "integrity": "sha512-4vSVUiOPJLmr45S8rMGy7WDvpWxfFxfP/Qx/cxZFCfvoypTYpPPL1X8VIZMe0WTA+Jr7blUxwUSEZNkjoMTgSw==", - "dev": true + "dev": true, + "requires": {} }, "@xtuc/ieee754": { "version": "1.2.0", @@ -31928,7 +32041,8 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true + "dev": true, + "requires": {} }, "acorn-walk": { "version": "7.2.0", @@ -31969,12 +32083,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz", "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==", - "dev": true + "dev": true, + "requires": {} }, "ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==" + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "requires": {} }, "amdefine": { "version": "1.0.1", @@ -32338,10 +32454,15 @@ } } }, + "available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==" + }, "aws-sdk": { - "version": "2.1102.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1102.0.tgz", - "integrity": "sha512-MMOncE8IG3Dop3WPza6ryTAEz413ftn/MtDO7ouessb3ljlg5BfqRkTe/rhPH5svqEqJvlh7qHnK0VjgJwmLTQ==", + "version": "2.1354.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1354.0.tgz", + "integrity": "sha512-3aDxvyuOqMB9DqJguCq6p8momdsz0JR1axwkWOOCzHA7a35+Bw+WLmqt3pWwRjR1tGIwkkZ2CvGJObYHsOuw3w==", "requires": { "buffer": "4.9.2", "events": "1.1.1", @@ -32350,14 +32471,15 @@ "querystring": "0.2.0", "sax": "1.2.1", "url": "0.10.3", - "uuid": "3.3.2", - "xml2js": "0.4.19" + "util": "^0.12.4", + "uuid": "8.0.0", + "xml2js": "0.5.0" }, "dependencies": { "uuid": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", - "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==" } } }, @@ -34460,7 +34582,8 @@ "csv-parse": { "version": "5.3.6", "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.3.6.tgz", - "integrity": "sha512-WI330GjCuEioK/ii8HM2YE/eV+ynpeLvU+RXw4R8bRU8R0laK5zO3fDsc4gH8s472e3Ga38rbIjCAiQh+tEHkw==" + "integrity": "sha512-WI330GjCuEioK/ii8HM2YE/eV+ynpeLvU+RXw4R8bRU8R0laK5zO3fDsc4gH8s472e3Ga38rbIjCAiQh+tEHkw==", + "dev": true }, "cuint": { "version": "0.2.2", @@ -34637,13 +34760,13 @@ "date-fns": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.23.0.tgz", - "integrity": "sha512-5ycpauovVyAk0kXNZz6ZoB9AYMZB4DObse7P3BPWmyEjXNORTI8EJ6X0uaSAq4sCHzM1uajzrkr6HnsLQpxGXA==", - "dev": true + "integrity": "sha512-5ycpauovVyAk0kXNZz6ZoB9AYMZB4DObse7P3BPWmyEjXNORTI8EJ6X0uaSAq4sCHzM1uajzrkr6HnsLQpxGXA==" }, "date-fns-tz": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-1.3.4.tgz", - "integrity": "sha512-O47vEyz85F2ax/ZdhMBJo187RivZGjH6V0cPjPzpm/yi6YffJg4upD/8ibezO11ezZwP3QYlBHh/t4JhRNx0Ow==" + "integrity": "sha512-O47vEyz85F2ax/ZdhMBJo187RivZGjH6V0cPjPzpm/yi6YffJg4upD/8ibezO11ezZwP3QYlBHh/t4JhRNx0Ow==", + "requires": {} }, "dd-trace": { "version": "2.11.0", @@ -35834,13 +35957,15 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.3.0.tgz", "integrity": "sha512-BgZuLUSeKzvlL/VUjx/Yb787VQ26RU3gGjA3iiFvdsp/2bMfVIWUVP7tjxtjS0e+HP409cPlPvNkQloz8C91ew==", - "dev": true + "dev": true, + "requires": {} }, "eslint-import-resolver-alias": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/eslint-import-resolver-alias/-/eslint-import-resolver-alias-1.1.2.tgz", "integrity": "sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w==", - "dev": true + "dev": true, + "requires": {} }, "eslint-import-resolver-node": { "version": "0.3.4", @@ -36201,7 +36326,8 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", - "dev": true + "dev": true, + "requires": {} }, "eslint-scope": { "version": "5.1.1", @@ -36551,7 +36677,8 @@ "express-joi-validation": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/express-joi-validation/-/express-joi-validation-4.0.3.tgz", - "integrity": "sha512-XnEyhlllurczZDx1vLPWnaohTAQzxlvaP7ifEbvRf2zvYC5C5ZZrgFH75g0/XcL7OuaZ0XlVtB0J0E/R0O1L4A==" + "integrity": "sha512-XnEyhlllurczZDx1vLPWnaohTAQzxlvaP7ifEbvRf2zvYC5C5ZZrgFH75g0/XcL7OuaZ0XlVtB0J0E/R0O1L4A==", + "requires": {} }, "express-rate-limit": { "version": "5.3.0", @@ -36997,6 +37124,14 @@ "integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==", "dev": true }, + "for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "requires": { + "is-callable": "^1.1.3" + } + }, "for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -37135,13 +37270,13 @@ "dev": true }, "get-intrinsic": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", - "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", + "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", "requires": { "function-bind": "^1.1.1", "has": "^1.0.3", - "has-symbols": "^1.0.1" + "has-symbols": "^1.0.3" } }, "get-own-enumerable-property-symbols": { @@ -37310,6 +37445,14 @@ "minimist": "^1.2.5" } }, + "gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "requires": { + "get-intrinsic": "^1.1.3" + } + }, "graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -37401,9 +37544,17 @@ "dev": true }, "has-symbols": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", - "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + }, + "has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "requires": { + "has-symbols": "^1.0.2" + } }, "has-unicode": { "version": "2.0.1", @@ -38160,8 +38311,7 @@ "is-callable": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz", - "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==", - "dev": true + "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==" }, "is-ci": { "version": "2.0.0", @@ -38269,6 +38419,14 @@ "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true }, + "is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "requires": { + "has-tostringtag": "^1.0.0" + } + }, "is-glob": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", @@ -38399,6 +38557,18 @@ "text-extensions": "^1.0.0" } }, + "is-typed-array": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", + "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + } + }, "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -39307,7 +39477,8 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", - "dev": true + "dev": true, + "requires": {} }, "jest-regex-util": { "version": "26.0.0", @@ -42124,7 +42295,8 @@ "pg-pool": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.4.1.tgz", - "integrity": "sha512-TVHxR/gf3MeJRvchgNHxsYsTCHQ+4wm3VIHSS19z8NC0+gioEhq1okDY1sm/TYbfoP6JLFx01s0ShvZ3puP/iQ==" + "integrity": "sha512-TVHxR/gf3MeJRvchgNHxsYsTCHQ+4wm3VIHSS19z8NC0+gioEhq1okDY1sm/TYbfoP6JLFx01s0ShvZ3puP/iQ==", + "requires": {} }, "pg-protocol": { "version": "1.5.0", @@ -42919,7 +43091,8 @@ "react-ga": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/react-ga/-/react-ga-3.3.0.tgz", - "integrity": "sha512-o8RScHj6Lb8cwy3GMrVH6NJvL+y0zpJvKtc0+wmH7Bt23rszJmnqEQxRbyrqUzk9DTJIHoP42bfO5rswC9SWBQ==" + "integrity": "sha512-o8RScHj6Lb8cwy3GMrVH6NJvL+y0zpJvKtc0+wmH7Bt23rszJmnqEQxRbyrqUzk9DTJIHoP42bfO5rswC9SWBQ==", + "requires": {} }, "react-i18next": { "version": "11.11.4", @@ -43301,12 +43474,14 @@ "redux-devtools-extension": { "version": "2.13.9", "resolved": "https://registry.npmjs.org/redux-devtools-extension/-/redux-devtools-extension-2.13.9.tgz", - "integrity": "sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A==" + "integrity": "sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A==", + "requires": {} }, "redux-thunk": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz", - "integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==" + "integrity": "sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==", + "requires": {} }, "reflect-metadata": { "version": "0.1.13", @@ -44001,7 +44176,7 @@ "sax": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", - "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" + "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==" }, "saxes": { "version": "5.0.1", @@ -46829,6 +47004,18 @@ "integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E=", "dev": true }, + "util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "requires": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -47577,6 +47764,19 @@ } } }, + "which-typed-array": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", + "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0", + "is-typed-array": "^1.1.10" + } + }, "wide-align": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", @@ -47755,7 +47955,8 @@ "version": "7.5.3", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz", "integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==", - "dev": true + "dev": true, + "requires": {} }, "xml-name-validator": { "version": "3.0.0", @@ -47764,18 +47965,18 @@ "dev": true }, "xml2js": { - "version": "0.4.19", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", - "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", "requires": { "sax": ">=0.6.0", - "xmlbuilder": "~9.0.1" + "xmlbuilder": "~11.0.0" } }, "xmlbuilder": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", - "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" }, "xmlchars": { "version": "2.2.0", diff --git a/package.json b/package.json index ae53ef09c..3e9a54256 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "GoGovSG", - "version": "1.75.0", + "version": "1.76.0", "description": "Link shortener for Singapore government.", "main": "src/server/index.js", "scripts": { @@ -46,7 +46,7 @@ "@types/express-rate-limit": "^5.1.3", "@types/papaparse": "^5.3.5", "archiver": "^5.3.1", - "aws-sdk": "^2.1101.0", + "aws-sdk": "^2.1354.0", "babel-polyfill": "^6.26.0", "bcrypt": "^5.1.0", "body-parser": "^1.19.2", diff --git a/src/server/api/admin-v1/index.ts b/src/server/api/admin-v1/index.ts new file mode 100644 index 000000000..11d1c776c --- /dev/null +++ b/src/server/api/admin-v1/index.ts @@ -0,0 +1,30 @@ +import Express from 'express' +import { createValidator } from 'express-joi-validation' +import { container } from '../../util/inversify' +import jsonMessage from '../../util/json' +import { DependencyIds } from '../../constants' +import { AdminApiV1Controller } from '../../modules/api/admin-v1' +import { UrlCheckController } from '../../modules/threat' +import { urlSchema } from './validators' + +const adminApiV1Controller = container.get( + DependencyIds.adminApiV1Controller, +) +const urlCheckController = container.get( + DependencyIds.urlCheckController, +) +const validator = createValidator({ passError: true }) +const router = Express.Router() + +router.post( + '/urls', + validator.body(urlSchema), + urlCheckController.singleUrlCheck, + adminApiV1Controller.createUrl, +) + +router.use((_, res) => { + res.status(404).send(jsonMessage('Resource not found.')) +}) + +export = router diff --git a/src/server/api/admin-v1/validators.ts b/src/server/api/admin-v1/validators.ts new file mode 100644 index 000000000..db5a3a89d --- /dev/null +++ b/src/server/api/admin-v1/validators.ts @@ -0,0 +1,55 @@ +import * as Joi from '@hapi/joi' +import { isValidGovEmail } from '../../util/email' +import { + isBlacklisted, + isCircularRedirects, + isHttps, + isValidShortUrl, + isValidUrl, +} from '../../../shared/util/validation' +import { ogHostname } from '../../config' + +export const urlSchema = Joi.object({ + userId: Joi.number().required(), + shortUrl: Joi.string() + .custom((url: string, helpers) => { + if (!isValidShortUrl(url)) { + return helpers.message({ custom: 'Short URL format is invalid.' }) + } + return url + }) + .optional(), + longUrl: Joi.string() + .custom((url: string, helpers) => { + if (!isHttps(url)) { + return helpers.message({ custom: 'Only HTTPS URLs are allowed.' }) + } + if (!isValidUrl(url)) { + return helpers.message({ custom: 'Long URL format is invalid.' }) + } + if (isCircularRedirects(url, ogHostname)) { + return helpers.message({ + custom: 'Circular redirects are not allowed.', + }) + } + if (isBlacklisted(url)) { + return helpers.message({ + custom: 'Creation of URLs to link shortener sites are not allowed.', + }) + } + return url + }) + .required(), + email: Joi.string() + .custom((email: string, helpers) => { + if (!isValidGovEmail(email)) { + return helpers.message({ + custom: 'Invalid email provided. Email domain is not whitelisted.', + }) + } + return email + }) + .required(), +}) + +export default urlSchema diff --git a/src/server/api/index.ts b/src/server/api/index.ts index 103701b74..30da753ce 100644 --- a/src/server/api/index.ts +++ b/src/server/api/index.ts @@ -80,7 +80,7 @@ async function apiKeyAdminAuthMiddleware( const { userId } = req.body const isAdmin = await apiKeyAuthService.isAdmin(userId) if (!isAdmin) { - res.unauthorized('User is unauthorized') + res.unauthorized(jsonMessage('User is unauthorized')) return } next() @@ -117,6 +117,14 @@ router.use( /* Register APIKey protected endpoints */ if (ffExternalApi) { + router.use( + '/v1/admin', + apiKeyAuthMiddleware, + apiKeyAdminAuthMiddleware, + preprocess, + // eslint-disable-next-line global-require + require('./admin-v1'), + ) // eslint-disable-next-line global-require router.use('/v1', apiKeyAuthMiddleware, preprocess, require('./external-v1')) } diff --git a/src/server/config.ts b/src/server/config.ts index 9ed29e62f..6d0cae35c 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -269,4 +269,6 @@ export const apiKeySalt = process.env.API_KEY_SALT as string export const apiLinkRandomStrLength: number = Number(process.env.API_LINK_RANDOM_STR_LENGTH) || 8 export const ffExternalApi: boolean = process.env.FF_EXTERNAL_API === 'true' -export const apiAdmin: string = process.env.ADMIN_API_EMAIL || '' +export const apiAdmins: string[] = process.env.ADMIN_API_EMAILS + ? process.env.ADMIN_API_EMAILS.split(',') + : [] diff --git a/src/server/constants.ts b/src/server/constants.ts index 03d44e70e..27386c928 100644 --- a/src/server/constants.ts +++ b/src/server/constants.ts @@ -69,6 +69,7 @@ export const DependencyIds = { bulkService: Symbol.for('bulkService'), apiKeyAuthService: Symbol.for('apiKeyAuthService'), apiV1Controller: Symbol.for('apiV1Controller'), + adminApiV1Controller: Symbol.for('adminApiV1Controller'), } export const ERROR_404_PATH = '404.error.ejs' diff --git a/src/server/inversify.config.ts b/src/server/inversify.config.ts index 4124a1171..2e6308152 100644 --- a/src/server/inversify.config.ts +++ b/src/server/inversify.config.ts @@ -61,6 +61,7 @@ import { } from './modules/analytics/services' import { LinkStatisticsRepository } from './modules/analytics/repositories/LinkStatisticsRepository' import { ApiV1Controller } from './modules/api/external-v1' +import { AdminApiV1Controller } from './modules/api/admin-v1' import { LinkAuditController } from './modules/audit' import { LinkAuditService } from './modules/audit/services' import { UrlHistoryRepository } from './modules/audit/repositories' @@ -142,6 +143,7 @@ export default () => { bindIfUnbound(DependencyIds.directoryController, DirectoryController) bindIfUnbound(DependencyIds.deviceCheckService, DeviceCheckService) bindIfUnbound(DependencyIds.apiV1Controller, ApiV1Controller) + bindIfUnbound(DependencyIds.adminApiV1Controller, AdminApiV1Controller) container .bind(DependencyIds.allowedFileExtensions) diff --git a/src/server/models/user.ts b/src/server/models/user.ts index 5ff56f0db..498abd702 100644 --- a/src/server/models/user.ts +++ b/src/server/models/user.ts @@ -26,7 +26,10 @@ export const User = sequelize.define( validate: { isEmail: true, isLowercase: true, - is: emailValidator.makeRe(), + is: { + args: emailValidator.makeRe(), + msg: 'Email domain is not whitelisted.', + }, }, set(this: Settable, email: string) { // must save email as lowercase diff --git a/src/server/modules/api/admin-v1/AdminApiV1Controller.ts b/src/server/modules/api/admin-v1/AdminApiV1Controller.ts new file mode 100644 index 000000000..1f9f44b81 --- /dev/null +++ b/src/server/modules/api/admin-v1/AdminApiV1Controller.ts @@ -0,0 +1,87 @@ +import Express from 'express' +import { inject, injectable } from 'inversify' +import Sequelize from 'sequelize' + +import { logger } from '../../../config' +import { DependencyIds } from '../../../constants' +import jsonMessage from '../../../util/json' +import { AlreadyExistsError, NotFoundError } from '../../../util/error' + +import { UrlManagementService } from '../../user/interfaces' +import { MessageType } from '../../../../shared/util/messages' +import { StorableUrlSource } from '../../../repositories/enums' + +import { UrlCreationRequest } from '.' +import { UrlV1Mapper } from '../../../mappers/UrlV1Mapper' +import { UserRepositoryInterface } from '../../../repositories/interfaces/UserRepositoryInterface' + +@injectable() +export class AdminApiV1Controller { + private userRepository: UserRepositoryInterface + + private urlManagementService: UrlManagementService + + private urlV1Mapper: UrlV1Mapper + + public constructor( + @inject(DependencyIds.userRepository) + userRepository: UserRepositoryInterface, + @inject(DependencyIds.urlManagementService) + urlManagementService: UrlManagementService, + @inject(DependencyIds.urlV1Mapper) + urlV1Mapper: UrlV1Mapper, + ) { + this.userRepository = userRepository + this.urlManagementService = urlManagementService + this.urlV1Mapper = urlV1Mapper + } + + public createUrl: ( + req: Express.Request, + res: Express.Response, + ) => Promise = async (req, res) => { + const { userId, shortUrl, longUrl, email }: UrlCreationRequest = req.body + + try { + const targetUser = await this.userRepository.findOrCreateWithEmail(email) + const newUrl = await this.urlManagementService.createUrl( + userId, + StorableUrlSource.Api, + shortUrl, + longUrl, + ) + + if (userId !== targetUser.id) { + const url = await this.urlManagementService.changeOwnership( + userId, + newUrl.shortUrl, + targetUser.email, + ) + const apiUrl = this.urlV1Mapper.persistenceToDto(url) + res.ok(apiUrl) + return + } + const apiUrl = this.urlV1Mapper.persistenceToDto(newUrl) + res.ok(apiUrl) + return + } catch (error) { + if (error instanceof NotFoundError) { + res.notFound(jsonMessage(error.message)) + return + } + if (error instanceof AlreadyExistsError) { + res.badRequest(jsonMessage(error.message, MessageType.ShortUrlError)) + return + } + if (error instanceof Sequelize.ValidationError) { + res.badRequest(jsonMessage(error.message)) + return + } + logger.error(`Error creating short URL:\t${error}`) + res.serverError(jsonMessage('Server error.')) + return + } + } +} + +export default AdminApiV1Controller diff --git a/src/server/modules/api/admin-v1/__tests__/AdminApiV1Controller.test.ts b/src/server/modules/api/admin-v1/__tests__/AdminApiV1Controller.test.ts new file mode 100644 index 000000000..659130999 --- /dev/null +++ b/src/server/modules/api/admin-v1/__tests__/AdminApiV1Controller.test.ts @@ -0,0 +1,250 @@ +import moment from 'moment' +import httpMocks from 'node-mocks-http' +import { ValidationError } from 'sequelize' +import { createRequestWithUser } from '../../../../../../test/server/api/util' +import { UrlV1Mapper } from '../../../../mappers/UrlV1Mapper' +import { AlreadyExistsError, NotFoundError } from '../../../../util/error' +import { AdminApiV1Controller } from '../AdminApiV1Controller' + +const urlManagementService = { + createUrl: jest.fn(), + updateUrl: jest.fn(), + changeOwnership: jest.fn(), + getUrlsWithConditions: jest.fn(), + bulkCreate: jest.fn(), +} + +const userRepository = { + findById: jest.fn(), + findByEmail: jest.fn(), + findOrCreateWithEmail: jest.fn(), + findOneUrlForUser: jest.fn(), + findUserByUrl: jest.fn(), + findUrlsForUser: jest.fn(), + saveApiKeyHash: jest.fn(), + findUserByApiKey: jest.fn(), + hasApiKey: jest.fn(), +} + +const urlV1Mapper = new UrlV1Mapper() + +const controller = new AdminApiV1Controller( + userRepository, + urlManagementService, + urlV1Mapper, +) + +/** + * Unit tests for Admin API v1 controller. + */ +describe('AdminApiV1Controller', () => { + describe('createUrl', () => { + it('create and sanitize link with same owner and target email for admin API', async () => { + const userId = 1 + const shortUrl = 'abcdef' + const longUrl = 'https://www.agency.sg' + const state = 'ACTIVE' + const source = 'API' + const clicks = 0 + const contactEmail = 'person@open.gov.sg' + const description = 'test description' + const tags: string[] = [] + const tagStrings = '' + const createdAt = moment().toISOString() + const updatedAt = moment().toISOString() + const email = 'person@domain.sg' + + const req = httpMocks.createRequest({ + body: { + userId, + shortUrl, + longUrl, + email, + }, + }) + + const res: any = httpMocks.createResponse() + res.ok = jest.fn() + const result = { + shortUrl, + longUrl, + state, + source, + clicks, + contactEmail, + description, + tags, + tagStrings, + createdAt, + updatedAt, + } + urlManagementService.createUrl.mockResolvedValue(result) + userRepository.findOrCreateWithEmail.mockResolvedValue({ + id: userId, + email, + urls: undefined, + }) + + await controller.createUrl(req, res) + expect(userRepository.findOrCreateWithEmail).toHaveBeenCalledWith(email) + expect(urlManagementService.createUrl).toHaveBeenCalledWith( + userId, + source, + shortUrl, + longUrl, + ) + expect(res.ok).toHaveBeenCalledWith({ + shortUrl, + longUrl, + state, + clicks, + createdAt, + updatedAt, + }) + }) + + it('create, sanitize and transfer link to target email for admin API', async () => { + const userId = 1 + const shortUrl = 'abcdef' + const longUrl = 'https://www.agency.sg' + const state = 'ACTIVE' + const source = 'API' + const clicks = 0 + const contactEmail = 'person@open.gov.sg' + const description = 'test description' + const tags: string[] = [] + const tagStrings = '' + const createdAt = moment().toISOString() + const updatedAt = moment().toISOString() + const email = 'person@domain.sg' + + const req = httpMocks.createRequest({ + body: { + userId, + shortUrl, + longUrl, + email, + }, + }) + + const res: any = httpMocks.createResponse() + res.ok = jest.fn() + const result = { + shortUrl, + longUrl, + state, + source, + clicks, + contactEmail, + description, + tags, + tagStrings, + createdAt, + updatedAt, + } + urlManagementService.createUrl.mockResolvedValue(result) + userRepository.findOrCreateWithEmail.mockResolvedValue({ + id: 2, + email, + urls: undefined, + }) + urlManagementService.changeOwnership.mockResolvedValue(result) + + await controller.createUrl(req, res) + expect(userRepository.findOrCreateWithEmail).toHaveBeenCalledWith(email) + expect(urlManagementService.createUrl).toHaveBeenCalledWith( + userId, + source, + shortUrl, + longUrl, + ) + expect(urlManagementService.changeOwnership).toHaveBeenCalledWith( + userId, + shortUrl, + email, + ) + expect(res.ok).toHaveBeenCalledWith({ + shortUrl, + longUrl, + state, + clicks, + createdAt, + updatedAt, + }) + }) + + it('reports server error with user creation', async () => { + const req = createRequestWithUser(undefined) + const res: any = httpMocks.createResponse() + res.serverError = jest.fn() + + userRepository.findOrCreateWithEmail.mockRejectedValue(new Error()) + + await controller.createUrl(req, res) + expect(res.serverError).toHaveBeenCalledWith({ + message: expect.any(String), + }) + }) + + it('reports not found on NotFoundError', async () => { + const req = createRequestWithUser(undefined) + const res: any = httpMocks.createResponse() + res.notFound = jest.fn() + + userRepository.findOrCreateWithEmail.mockResolvedValue({}) + urlManagementService.createUrl.mockRejectedValue(new NotFoundError('')) + + await controller.createUrl(req, res) + expect(res.notFound).toHaveBeenCalledWith({ + message: expect.any(String), + }) + }) + + it('reports bad request on AlreadyExistsError', async () => { + const req = createRequestWithUser(undefined) + const res: any = httpMocks.createResponse() + res.badRequest = jest.fn() + + userRepository.findOrCreateWithEmail.mockResolvedValue({}) + urlManagementService.createUrl.mockRejectedValue( + new AlreadyExistsError(''), + ) + + await controller.createUrl(req, res) + expect(res.badRequest).toHaveBeenCalledWith({ + message: expect.any(String), + type: expect.any(String), + }) + }) + + it('reports bad request on Sequelize.ValidationError', async () => { + const req = createRequestWithUser(undefined) + const res: any = httpMocks.createResponse() + res.badRequest = jest.fn() + + userRepository.findOrCreateWithEmail.mockResolvedValue({}) + urlManagementService.createUrl.mockRejectedValue( + new ValidationError('', []), + ) + + await controller.createUrl(req, res) + expect(res.badRequest).toHaveBeenCalledWith({ + message: expect.any(String), + }) + }) + + it('reports server error on generic Error', async () => { + const req = createRequestWithUser(undefined) + const res: any = httpMocks.createResponse() + res.serverError = jest.fn() + + userRepository.findOrCreateWithEmail.mockResolvedValue({}) + urlManagementService.createUrl.mockRejectedValue(new Error()) + + await controller.createUrl(req, res) + expect(res.serverError).toHaveBeenCalledWith({ + message: expect.any(String), + }) + }) + }) +}) diff --git a/src/server/modules/api/admin-v1/index.ts b/src/server/modules/api/admin-v1/index.ts new file mode 100644 index 000000000..b483cb5d0 --- /dev/null +++ b/src/server/modules/api/admin-v1/index.ts @@ -0,0 +1,29 @@ +import { StorableUrl } from '../../../repositories/types' + +export { AdminApiV1Controller } from './AdminApiV1Controller' + +type LongUrlProperty = { + longUrl: string +} +type UserIdProperty = { + userId: number +} + +type ShortUrlProperty = { + shortUrl: string +} + +type EmailProperty = { + email: string +} + +type ShortUrlOperationProperty = UserIdProperty & ShortUrlProperty + +export type UrlCreationRequest = ShortUrlOperationProperty & + LongUrlProperty & + EmailProperty + +export type UrlV1DTO = Pick< + StorableUrl, + 'shortUrl' | 'longUrl' | 'state' | 'clicks' | 'createdAt' | 'updatedAt' +> diff --git a/src/server/modules/api/external-v1/ApiV1Controller.ts b/src/server/modules/api/external-v1/ApiV1Controller.ts index 31819835b..13df7650c 100644 --- a/src/server/modules/api/external-v1/ApiV1Controller.ts +++ b/src/server/modules/api/external-v1/ApiV1Controller.ts @@ -65,9 +65,10 @@ export class ApiV1Controller { } if (error instanceof Sequelize.ValidationError) { res.badRequest(jsonMessage(error.message)) + return } logger.error(`Error creating short URL:\t${error}`) - res.badRequest(jsonMessage('Server error.')) + res.serverError(jsonMessage('Server error.')) return } } diff --git a/src/server/modules/api/external-v1/__tests__/ApiV1Controller.test.ts b/src/server/modules/api/external-v1/__tests__/ApiV1Controller.test.ts index c482d6e76..44e96da11 100644 --- a/src/server/modules/api/external-v1/__tests__/ApiV1Controller.test.ts +++ b/src/server/modules/api/external-v1/__tests__/ApiV1Controller.test.ts @@ -127,15 +127,15 @@ describe('ApiV1Controller', () => { }) }) - it('reports bad request on generic Error', async () => { + it('reports server error on generic Error', async () => { const req = createRequestWithUser(undefined) const res: any = httpMocks.createResponse() - res.badRequest = jest.fn() + res.serverError = jest.fn() urlManagementService.createUrl.mockRejectedValue(new Error()) await controller.createUrl(req, res) - expect(res.badRequest).toHaveBeenCalledWith({ + expect(res.serverError).toHaveBeenCalledWith({ message: expect.any(String), }) }) diff --git a/src/server/modules/threat/FileCheckController.ts b/src/server/modules/threat/FileCheckController.ts index e4a905340..30a9ab821 100644 --- a/src/server/modules/threat/FileCheckController.ts +++ b/src/server/modules/threat/FileCheckController.ts @@ -67,11 +67,22 @@ export class FileCheckController { next: NextFunction, ) => Promise = async (req, res, next) => { const file = req.files?.file as fileUpload.UploadedFile | undefined + const user = req.session?.user if (file) { try { - const hasVirus = await this.virusScanService.hasVirus(file) + const { hasVirus, isPasswordProtected } = + await this.virusScanService.scanFile(file) + if (isPasswordProtected) { + // Do not support password-protected files as they cannot be scanned for viruses + logger.info( + `User ${ + user?.email || user?.id + } tried to upload a password-protected file ${file.name}`, + ) + res.badRequest(jsonMessage('Cannot upload password-protected files.')) + return + } if (hasVirus) { - const user = req.session?.user logger.warn( `Malicious file attempt: User ${ user?.email || user?.id diff --git a/src/server/modules/threat/__tests__/FileCheckController.test.ts b/src/server/modules/threat/__tests__/FileCheckController.test.ts index ebd9998d1..74de75aa0 100644 --- a/src/server/modules/threat/__tests__/FileCheckController.test.ts +++ b/src/server/modules/threat/__tests__/FileCheckController.test.ts @@ -18,16 +18,20 @@ export function createRequestWithFile(file: any): Request { describe('FileCheckController test', () => { const file = { data: Buffer.from('data'), name: 'file.csv' } + const getExtension = jest.fn() const hasAllowedType = jest.fn() - const hasVirus = jest.fn() + const scanFile = jest.fn() - const controller = new FileCheckController({ hasAllowedType }, { hasVirus }) + const controller = new FileCheckController( + { getExtension, hasAllowedType }, + { scanFile }, + ) const badRequest = jest.fn() beforeEach(() => { hasAllowedType.mockClear() - hasVirus.mockClear() + scanFile.mockClear() badRequest.mockClear() }) @@ -42,7 +46,7 @@ describe('FileCheckController test', () => { await controller.fileVirusCheck(req, res, afterFileVirusCheck) expect(hasAllowedType).not.toHaveBeenCalled() - expect(hasVirus).not.toHaveBeenCalled() + expect(scanFile).not.toHaveBeenCalled() expect(afterSingleFileCheck).toHaveBeenCalled() expect(afterFileExtensionCheck).toHaveBeenCalled() expect(afterFileVirusCheck).toHaveBeenCalled() @@ -83,12 +87,12 @@ describe('FileCheckController test', () => { const res = httpMocks.createResponse() as any const afterFileVirusCheck = jest.fn() - hasVirus.mockRejectedValue(false) + scanFile.mockRejectedValue(new Error()) res.badRequest = badRequest await controller.fileVirusCheck(req, res, afterFileVirusCheck) - expect(hasVirus).toHaveBeenCalled() + expect(scanFile).toHaveBeenCalled() expect(res.badRequest).toHaveBeenCalled() expect(afterFileVirusCheck).not.toHaveBeenCalled() }) @@ -98,12 +102,12 @@ describe('FileCheckController test', () => { const res = httpMocks.createResponse() as any const afterFileVirusCheck = jest.fn() - hasVirus.mockResolvedValue(true) + scanFile.mockResolvedValue({ hasVirus: true, isPasswordProtected: false }) res.badRequest = badRequest await controller.fileVirusCheck(req, res, afterFileVirusCheck) - expect(hasVirus).toHaveBeenCalled() + expect(scanFile).toHaveBeenCalled() expect(res.badRequest).toHaveBeenCalled() expect(afterFileVirusCheck).not.toHaveBeenCalled() }) @@ -117,7 +121,7 @@ describe('FileCheckController test', () => { const afterFileVirusCheck = jest.fn() hasAllowedType.mockResolvedValue(true) - hasVirus.mockResolvedValue(false) + scanFile.mockResolvedValue(false) res.badRequest = badRequest res.serverError = badRequest @@ -129,7 +133,7 @@ describe('FileCheckController test', () => { await controller.fileVirusCheck(req, res, afterFileVirusCheck) expect(hasAllowedType).toHaveBeenCalled() - expect(hasVirus).toHaveBeenCalled() + expect(scanFile).toHaveBeenCalled() expect(res.badRequest).not.toHaveBeenCalled() expect(res.serverError).not.toHaveBeenCalled() diff --git a/src/server/modules/threat/interfaces/FileTypeFilterService.ts b/src/server/modules/threat/interfaces/FileTypeFilterService.ts index 515e9292c..8750ed0ac 100644 --- a/src/server/modules/threat/interfaces/FileTypeFilterService.ts +++ b/src/server/modules/threat/interfaces/FileTypeFilterService.ts @@ -1,4 +1,9 @@ export interface FileTypeFilterService { + getExtension: (file: { + name: string + data: Buffer + }) => Promise + hasAllowedType: ( file: { name: string diff --git a/src/server/modules/threat/interfaces/VirusScanService.ts b/src/server/modules/threat/interfaces/VirusScanService.ts index 1d83b9b44..fd83fc78d 100644 --- a/src/server/modules/threat/interfaces/VirusScanService.ts +++ b/src/server/modules/threat/interfaces/VirusScanService.ts @@ -1,5 +1,7 @@ import fileUpload from 'express-fileupload' export interface VirusScanService { - hasVirus(file: fileUpload.UploadedFile): Promise + scanFile( + file: fileUpload.UploadedFile, + ): Promise<{ hasVirus: boolean; isPasswordProtected: boolean }> } diff --git a/src/server/modules/threat/services/CloudmersiveScanService.ts b/src/server/modules/threat/services/CloudmersiveScanService.ts index fc0e378b3..4e98975f8 100644 --- a/src/server/modules/threat/services/CloudmersiveScanService.ts +++ b/src/server/modules/threat/services/CloudmersiveScanService.ts @@ -20,14 +20,26 @@ export class CloudmersiveScanService implements VirusScanService { this.api = api } - private scanFilePromise: (file: Buffer) => Promise = (file) => + private scanFilePromise: ( + file: Buffer, + ) => Promise<{ hasVirus: boolean; isPasswordProtected: boolean }> = (file) => new Promise((res, rej) => { - this.api.scanFile(file, (err, data) => { + const options = { + allowExecutables: false, // default value + allowInvalidFiles: false, // default value + allowScripts: false, // default value + restrictFileTypes: '', // default value, disabled + } + this.api.scanFileAdvanced(file, options, (err, data) => { if (err) { logger.error(`Error when scanning file via Cloudmersive: ${err}`) return rej(err) } - return res(!data.CleanResult) + return res({ + // @types/cloudmersive-virus-api-client is outdated so `data` doesn't have the `ContainsPasswordProtectedFile` property + isPasswordProtected: (data as any).ContainsPasswordProtectedFile, + hasVirus: data.FoundViruses !== null, + }) }) }) @@ -43,16 +55,20 @@ export class CloudmersiveScanService implements VirusScanService { }) }) - public hasVirus: (file: { data: Buffer; name: string }) => Promise = - async (file) => { - if (!this.cloudmersiveKey) { - logger.warn( - `No Cloudmersive API key provided. Not scanning file: ${file.name}`, - ) - return false - } - return this.scanFilePromise(file.data) + public scanFile: (file: { + data: Buffer + name: string + }) => Promise<{ hasVirus: boolean; isPasswordProtected: boolean }> = async ( + file, + ) => { + if (!this.cloudmersiveKey) { + logger.warn( + `No Cloudmersive API key provided. Not scanning file: ${file.name}`, + ) + return { hasVirus: false, isPasswordProtected: false } } + return this.scanFilePromise(file.data) + } // Note: This function is no longer used as we now use Google Safe Browsing // to scan URLs instead. We can consider removing this functionality. diff --git a/src/server/modules/threat/services/FileTypeFilterService.ts b/src/server/modules/threat/services/FileTypeFilterService.ts index 55215bdbd..3b78a2036 100644 --- a/src/server/modules/threat/services/FileTypeFilterService.ts +++ b/src/server/modules/threat/services/FileTypeFilterService.ts @@ -25,6 +25,7 @@ export const DEFAULT_ALLOWED_FILE_EXTENSIONS = [ 'tiff', 'txt', 'xlsx', + 'zip', ] @injectable() @@ -38,15 +39,22 @@ export class FileTypeFilterService implements interfaces.FileTypeFilterService { this.allowedFileExtensions = allowedFileExtensions } + getExtension: (file: { + name: string + data: Buffer + }) => Promise = async ({ name, data }) => { + const fileType = await FileType.fromBuffer(data) + return fileType?.ext || name.split('.').pop() + } + hasAllowedType: ( file: { name: string data: Buffer }, allowedExtensions?: string[], - ) => Promise = async ({ name, data }, allowedExtensions) => { - const fileType = await FileType.fromBuffer(data) - const extension = fileType?.ext || name.split('.').pop() + ) => Promise = async (file, allowedExtensions) => { + const extension = await this.getExtension(file) if (!extension) return false if (allowedExtensions && allowedExtensions.length > 0) { diff --git a/src/server/modules/threat/services/__tests__/CloudmersiveScanService.test.ts b/src/server/modules/threat/services/__tests__/CloudmersiveScanService.test.ts index d90f1e0a1..05397742d 100644 --- a/src/server/modules/threat/services/__tests__/CloudmersiveScanService.test.ts +++ b/src/server/modules/threat/services/__tests__/CloudmersiveScanService.test.ts @@ -1,9 +1,8 @@ import { CloudmersiveScanService } from '..' -const scanFile = jest.fn() +const scanFile = () => {} const scanWebsite = jest.fn() - -const scanFileAdvanced = () => {} +const scanFileAdvanced = jest.fn() const api = { scanFile, scanWebsite, scanFileAdvanced } const file = { data: Buffer.from(''), name: '' } @@ -14,8 +13,11 @@ describe('CloudmersiveScanService', () => { const service = new CloudmersiveScanService('', api) it('does not trigger file scans', async () => { - await expect(service.hasVirus(file)).resolves.toBeFalsy() - expect(scanFile).not.toHaveBeenCalled() + await expect(service.scanFile(file)).resolves.toEqual({ + hasVirus: false, + isPasswordProtected: false, + }) + expect(scanFileAdvanced).not.toHaveBeenCalled() }) it('does not trigger url scans', async () => { @@ -28,30 +30,40 @@ describe('CloudmersiveScanService', () => { const service = new CloudmersiveScanService('key', api) describe('hasVirus', () => { - beforeEach(() => scanFile.mockClear()) + beforeEach(() => scanFileAdvanced.mockClear()) it('returns false on clean file', async () => { - scanFile.mockImplementation((_ignored, callback) => - callback(null, { CleanResult: true }), + scanFileAdvanced.mockImplementation((_ignored, _ignored2, callback) => + callback(null, { + FoundViruses: null, + ContainsPasswordProtectedFile: false, + }), ) - await expect(service.hasVirus(file)).resolves.toBeFalsy() - expect(scanFile).toHaveBeenCalled() + await expect(service.scanFile(file)).resolves.toEqual({ + hasVirus: false, + isPasswordProtected: false, + }) + expect(scanFileAdvanced).toHaveBeenCalled() }) it('returns true on dirty file', async () => { - scanFile.mockImplementation((_ignored, callback) => - callback(null, { CleanResult: false }), + scanFileAdvanced.mockImplementation((_ignored, _ignored2, callback) => + callback(null, { + FoundViruses: [ + { FileName: 'stream', VirusName: ' Eicar-Signature' }, + ], + }), ) - await expect(service.hasVirus(file)).resolves.toBeTruthy() - expect(scanFile).toHaveBeenCalled() + await expect(service.scanFile(file)).resolves.toBeTruthy() + expect(scanFileAdvanced).toHaveBeenCalled() }) it('throws on scan error', async () => { const error = new Error() - scanFile.mockImplementation((_ignored, callback) => + scanFileAdvanced.mockImplementation((_ignored, _ignored2, callback) => callback(error, null), ) - await expect(service.hasVirus(file)).rejects.toStrictEqual(error) - expect(scanFile).toHaveBeenCalled() + await expect(service.scanFile(file)).rejects.toStrictEqual(error) + expect(scanFileAdvanced).toHaveBeenCalled() }) }) diff --git a/src/server/modules/user/services/ApiKeyAuthService.ts b/src/server/modules/user/services/ApiKeyAuthService.ts index 40da196be..32080eca9 100644 --- a/src/server/modules/user/services/ApiKeyAuthService.ts +++ b/src/server/modules/user/services/ApiKeyAuthService.ts @@ -9,7 +9,7 @@ import dogstatsd, { import { UserRepositoryInterface } from '../../../repositories/interfaces/UserRepositoryInterface' import { API_KEY_SEPARATOR, DependencyIds } from '../../../constants' import { StorableUser } from '../../../repositories/types' -import { apiAdmin, apiEnv, apiKeySalt, apiKeyVersion } from '../../../config' +import { apiAdmins, apiEnv, apiKeySalt, apiKeyVersion } from '../../../config' const BASE64_ENCODING = 'base64' @injectable() @@ -46,7 +46,7 @@ class ApiKeyAuthService implements ApiKeyAuthServiceInterface { isAdmin: (userId: number) => Promise = async (userId: number) => { const user = await this.userRepository.findById(userId) if (!user) return false - return apiAdmin === user.email + return apiAdmins.includes(user.email) } hasApiKey: (userId: number) => Promise = async (userId: number) => { diff --git a/test/integration/api/admin-v1/Urls.test.ts b/test/integration/api/admin-v1/Urls.test.ts new file mode 100644 index 000000000..f4c3acc86 --- /dev/null +++ b/test/integration/api/admin-v1/Urls.test.ts @@ -0,0 +1,209 @@ +import { API_ADMIN_V1_URLS } from '../../config' +import { + DATETIME_REGEX, + createIntegrationTestAdminUser, + createIntegrationTestUser, + deleteIntegrationTestUser, + generateRandomString, +} from '../../util/helpers' +import { postJson } from '../../util/requests' + +async function createLinkUrl( + link: { + shortUrl?: string + longUrl?: string + email?: string + }, + apiKey: string, +) { + const res = await postJson(API_ADMIN_V1_URLS, link, undefined, apiKey) + return res +} + +/** + * Integration tests for Admin API v1. + */ +describe('Admin API v1 Integration Tests', () => { + let email: string + let apiKey: string + const longUrl = 'https://example.com' + const validEmail = 'integration-test-user@test.gov.sg' + + beforeAll(async () => { + ;({ email, apiKey } = await createIntegrationTestAdminUser()) + }) + + afterAll(async () => { + await deleteIntegrationTestUser(email) + }) + + it('should not be able to create urls without API key header', async () => { + const res = await postJson( + API_ADMIN_V1_URLS, + { longUrl }, + undefined, + undefined, + ) + expect(res.status).toBe(401) + const json = await res.json() + expect(json).toBeTruthy() + expect(json).toEqual({ + message: 'Authorization header is missing', + }) + }) + + it('should not be able to create urls with invalid API key', async () => { + const res = await postJson( + API_ADMIN_V1_URLS, + { longUrl }, + undefined, + 'this-is-an-invalid-api-key', + ) + expect(res.status).toBe(401) + const json = await res.json() + expect(json).toBeTruthy() + expect(json).toEqual({ + message: 'Invalid API Key', + }) + }) + + it('should not be able to create urls with unauthorized API key', async () => { + const testUser = await createIntegrationTestUser() + const res = await postJson( + API_ADMIN_V1_URLS, + { longUrl }, + undefined, + testUser.apiKey, + ) + expect(res.status).toBe(401) + const json = await res.json() + expect(json).toBeTruthy() + expect(json).toEqual({ + message: `User is unauthorized`, + }) + await deleteIntegrationTestUser(testUser.email) + }) + + it('should be able to create link url with longUrl, shortUrl, and validEmail', async () => { + const shortUrl = await generateRandomString(8) + const res = await createLinkUrl( + { shortUrl, longUrl, email: validEmail }, + apiKey, + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ + shortUrl: expect.stringMatching(/^[a-z0-9]{8}$/), + longUrl, + clicks: 0, + state: 'ACTIVE', + createdAt: expect.stringMatching(DATETIME_REGEX), + updatedAt: expect.stringMatching(DATETIME_REGEX), + }) + }) + + it('should not be able to create link url with longUrl and shortUrl, without validEmail', async () => { + const shortUrl = await generateRandomString(8) + const res = await createLinkUrl({ shortUrl, longUrl }, apiKey) + expect(res.status).toBe(400) + const json = await res.json() + expect(json).toBeTruthy() + expect(json).toEqual({ + message: 'ValidationError: "email" is required', + }) + }) + + it('should not be able to create link url with longUrl and shortUrl, with invalid email', async () => { + const shortUrl = await generateRandomString(8) + const invalidEmail = 'integration-test-user@nongov.sg' + const res = await createLinkUrl( + { shortUrl, longUrl, email: invalidEmail }, + apiKey, + ) + expect(res.status).toBe(400) + const json = await res.json() + expect(json).toBeTruthy() + expect(json).toEqual({ + message: + 'ValidationError: Invalid email provided. Email domain is not whitelisted.', + }) + }) + + it('should be able to create link url with longUrl and validEmail, without shortUrl', async () => { + const res = await createLinkUrl({ longUrl, email: validEmail }, apiKey) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ + shortUrl: expect.stringMatching(/^[a-z0-9]{8}$/), + longUrl, + clicks: 0, + state: 'ACTIVE', + createdAt: expect.stringMatching(DATETIME_REGEX), + updatedAt: expect.stringMatching(DATETIME_REGEX), + }) + }) + + it('should not be able to create link url with longUrl, without shortUrl and validEmail', async () => { + const res = await createLinkUrl({ longUrl }, apiKey) + expect(res.status).toBe(400) + const json = await res.json() + expect(json).toBeTruthy() + expect(json).toEqual({ + message: 'ValidationError: "email" is required', + }) + }) + + it('should not be able to create link url with invalid email', async () => { + const res = await createLinkUrl( + { longUrl, email: 'invalid-email-value' }, + apiKey, + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body).toEqual({ + message: + 'ValidationError: Invalid email provided. Email domain is not whitelisted.', + }) + }) + + it('should not be able to create link url without longUrl, shortUrl, and email', async () => { + const res = await createLinkUrl({}, apiKey) + expect(res.status).toBe(400) + const body = await res.json() + expect(body).toEqual({ + message: 'ValidationError: "longUrl" is required. "email" is required', + }) + }) + + it('should not be able to create link url with invalid longUrl', async () => { + const invalidLongUrl = 'this-is-an-invalid-url' + const res = await createLinkUrl( + { longUrl: invalidLongUrl, email: validEmail }, + apiKey, + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body).toEqual({ + message: 'ValidationError: Only HTTPS URLs are allowed.', + }) + }) + + it('should not be able to create link url with invalid shortUrl', async () => { + const invalidShortUrl = 'foo%bar' + const res = await createLinkUrl( + { shortUrl: invalidShortUrl, longUrl, email: validEmail }, + apiKey, + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body).toEqual({ + message: 'ValidationError: Short URL format is invalid.', + }) + }) + + it('should be able to create link url with longUrl, shortUrl, and same email as admin', async () => { + const shortUrl = await generateRandomString(8) + const res = await createLinkUrl({ shortUrl, longUrl, email }, apiKey) + expect(res.status).toBe(200) + }) +}) diff --git a/test/integration/config.ts b/test/integration/config.ts index d33e389b4..488ee7e6b 100644 --- a/test/integration/config.ts +++ b/test/integration/config.ts @@ -8,6 +8,7 @@ export const IMAGE_FILE_PATH = './test/integration/assets/go-logo.png' export const API_USER_URL = 'http://localhost:8080/api/user/url' export const API_EXTERNAL_V1_URLS = 'http://localhost:8080/api/v1/urls' +export const API_ADMIN_V1_URLS = 'http://localhost:8080/api/v1/admin/urls' export const API_LOGIN_OTP = 'http://localhost:8080/api/login/otp' export const API_LOGIN_VERIFY = 'http://localhost:8080/api/login/verify' diff --git a/test/integration/util/helpers.ts b/test/integration/util/helpers.ts index 92c6aa34b..80195d462 100644 --- a/test/integration/util/helpers.ts +++ b/test/integration/util/helpers.ts @@ -37,6 +37,21 @@ export const createIntegrationTestUser: () => Promise<{ return { email: integrationTestEmail, apiKey } } +export const createIntegrationTestAdminUser: () => Promise<{ + email: string + apiKey: string +}> = async () => { + const testAdminEmail = 'integration-test-admin@open.gov.sg' + + const randomApiString = crypto.randomBytes(32).toString('base64') + const hash = await bcrypt.hash(randomApiString, API_KEY_SALT) + const apiKey = `test_v1_${randomApiString}` + const apiKeyHash = `test_v1_${hash.replace(API_KEY_SALT, '')}` + + await createDbUser(testAdminEmail, apiKeyHash) + return { email: testAdminEmail, apiKey } +} + export const deleteIntegrationTestUser: (email: string) => Promise = async (email) => { await deleteDbUser(email)