From e81a8c108ece1e45c403699f5fae76773e2ecbd9 Mon Sep 17 00:00:00 2001 From: Gunar Gessner Date: Thu, 25 Aug 2022 11:31:01 -0300 Subject: [PATCH] break: secure file upload and download (public and private) - prevents user uploads from being publicly accesible - makes all S3 routes go through the API - adds a new API Token concept to only allow BoPS to download user files - side-effect: prevents users from downloading their own files --- .env | 2 + api.planx.uk/.env.test | 1 + api.planx.uk/helpers.js | 10 +- api.planx.uk/package.json | 1 + api.planx.uk/pnpm-lock.yaml | 411 ++++++++++++------ api.planx.uk/s3.js | 115 +++-- api.planx.uk/send.js | 25 +- api.planx.uk/server.js | 78 +++- api.planx.uk/server.test.js | 177 ++++++++ api.planx.uk/tests/serverErrorHandler.test.js | 4 - docker-compose.yml | 4 +- .../DrawBoundary/Public/Public.test.tsx | 22 +- .../components/DrawBoundary/Public/Upload.tsx | 19 +- .../components/DrawBoundary/Public/index.tsx | 13 +- .../components/FileUpload/Public.test.tsx | 13 +- .../@planx/components/FileUpload/Public.tsx | 21 +- .../@planx/components/Review/Public.test.tsx | 214 +++++++++ .../Send/bops/__tests__/files.test.ts | 37 +- .../@planx/components/Send/uniform/index.ts | 8 +- .../components/shared/Preview/SummaryList.tsx | 14 +- .../src/@planx/components/shared/hooks.ts | 33 ++ editor.planx.uk/src/api/download.ts | 15 + editor.planx.uk/src/api/upload.ts | 55 ++- .../src/components/ImagePreview.tsx | 8 + editor.planx.uk/src/ui/FileUpload.tsx | 4 +- infrastructure/application/index.ts | 9 + infrastructure/application/package.json | 1 + infrastructure/application/pnpm-lock.yaml | 9 + 28 files changed, 1057 insertions(+), 266 deletions(-) create mode 100644 editor.planx.uk/src/api/download.ts create mode 100644 editor.planx.uk/src/components/ImagePreview.tsx diff --git a/.env b/.env index 17427b5ac3..fda78618dd 100644 --- a/.env +++ b/.env @@ -56,3 +56,5 @@ GOVUK_NOTIFY_SAVE_RETURN_EMAIL_TEMPLATE_ID=428c4dfd-a70b-44d6-9f81-b4f833d80405 GOVUK_NOTIFY_RESUME_EMAIL_TEMPLATE_ID=c7202e07-08cf-468e-a6a4-ac528d60d2f7 GOVUK_NOTIFY_REMINDER_EMAIL_TEMPLATE_ID=43be4c11-a406-4381-b2be-056a1127455d GOVUK_NOTIFY_EXPIRY_EMAIL_TEMPLATE_ID=9619f89d-5d33-4cb0-a365-42c431ea9db3 + +FILE_API_KEY=filekey diff --git a/api.planx.uk/.env.test b/api.planx.uk/.env.test index e6489a3185..93beab445b 100644 --- a/api.planx.uk/.env.test +++ b/api.planx.uk/.env.test @@ -34,3 +34,4 @@ GOVUK_NOTIFY_REMINDER_EMAIL_TEMPLATE_ID=43be4c11-a406-4381-b2be-056a1127455d GOVUK_NOTIFY_EXPIRY_EMAIL_TEMPLATE_ID=9619f89d-5d33-4cb0-a365-42c431ea9db3 HASURA_PLANX_API_KEY=testtesttest +FILE_API_KEY=test \ No newline at end of file diff --git a/api.planx.uk/helpers.js b/api.planx.uk/helpers.js index 111609f0c6..abf15188d0 100644 --- a/api.planx.uk/helpers.js +++ b/api.planx.uk/helpers.js @@ -94,4 +94,12 @@ const dataMerged = async (id, ob = {}) => { return ob; }; -export { getFlowData, getMostRecentPublishedFlow, getPublishedFlowByDate, dataMerged }; +function buildFilePath(fileKey, fileName) { + if (!fileKey || !fileName) { + return null; + } + + return `${fileKey}/${fileName}`; +} + +export { getFlowData, getMostRecentPublishedFlow, getPublishedFlowByDate, dataMerged, buildFilePath }; diff --git a/api.planx.uk/package.json b/api.planx.uk/package.json index 4a521ede36..944eb1b7ee 100644 --- a/api.planx.uk/package.json +++ b/api.planx.uk/package.json @@ -26,6 +26,7 @@ "jsondiffpatch": "^0.4.1", "jsonwebtoken": "^8.5.1", "mime": "^3.0.0", + "multer": "^1.4.4", "nanoid": "^3.3.4", "notifications-node-client": "^5.1.1", "passport": "^0.5.3", diff --git a/api.planx.uk/pnpm-lock.yaml b/api.planx.uk/pnpm-lock.yaml index e9c4e68f79..deb233a70d 100644 --- a/api.planx.uk/pnpm-lock.yaml +++ b/api.planx.uk/pnpm-lock.yaml @@ -38,6 +38,7 @@ specifiers: jsondiffpatch: ^0.4.1 jsonwebtoken: ^8.5.1 mime: ^3.0.0 + multer: ^1.4.4 nanoid: ^3.3.4 nock: ^13.2.9 node-dev: ^7.4.3 @@ -77,6 +78,7 @@ dependencies: jsondiffpatch: 0.4.1 jsonwebtoken: 8.5.1 mime: 3.0.0 + multer: 1.4.4 nanoid: 3.3.4 notifications-node-client: 5.1.1 passport: 0.5.3 @@ -86,21 +88,21 @@ dependencies: devDependencies: '@babel/preset-typescript': 7.18.6 - '@types/jest': 28.1.6 - '@types/node': 16.11.47 + '@types/jest': 28.1.7 + '@types/node': 16.11.53 dotenv: 16.0.1 esbuild: 0.14.49 esbuild-jest: 0.5.0_esbuild@0.14.49 graphql-query-test-mock: 0.12.1_nock@13.2.9 - jest: 28.1.3_@types+node@16.11.47 + jest: 28.1.3_@types+node@16.11.53 json-stringify-pretty-compact: 3.0.0 nock: 13.2.9 node-dev: 7.4.3 prettier: 2.7.1 rimraf: 3.0.2 supertest: 6.2.4 - ts-jest: 28.0.7_e4vlsll2vsrxuwy4jhlvxjbksa - ts-node-dev: 2.0.0_ow5yu25silzxcp7pmv7jv4j54m + ts-jest: 28.0.8_e4vlsll2vsrxuwy4jhlvxjbksa + ts-node-dev: 2.0.0_3ntvslzp3xhg4qw2n7rsqqnjwe typescript: 4.7.4 packages: @@ -181,11 +183,11 @@ packages: source-map: 0.5.7 dev: true - /@babel/generator/7.18.12: - resolution: {integrity: sha512-dfQ8ebCN98SvyL7IxNMCUtZQSq5R7kxgN+r8qYTGDmmSion1hX2C0zq2yo1bsCDhXixokv1SAWTZUMYbO/V5zg==} + /@babel/generator/7.18.13: + resolution: {integrity: sha512-CkPg8ySSPuHTYPJYo7IRALdqyjM9HCbt/3uOBEFbzyGVP6Mn8bwFPB0jX6982JVNBlYzM1nnPkfjuXSOPtQeEQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.18.10 + '@babel/types': 7.18.13 '@jridgewell/gen-mapping': 0.3.2 jsesc: 2.5.2 dev: true @@ -194,7 +196,7 @@ packages: resolution: {integrity: sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.18.10 + '@babel/types': 7.18.13 dev: true /@babel/helper-compilation-targets/7.15.0_@babel+core@7.15.0: @@ -205,13 +207,13 @@ packages: dependencies: '@babel/compat-data': 7.15.0 '@babel/core': 7.15.0 - '@babel/helper-validator-option': 7.18.6 + '@babel/helper-validator-option': 7.14.5 browserslist: 4.16.8 semver: 6.3.0 dev: true - /@babel/helper-create-class-features-plugin/7.18.9: - resolution: {integrity: sha512-WvypNAYaVh23QcjpMR24CwZY2Nz6hqdOcFdPbNpV56hL5H6KiFheO7Xm1aPdlLQ7d5emYZX7VZwPp9x3z+2opw==} + /@babel/helper-create-class-features-plugin/7.18.13: + resolution: {integrity: sha512-hDvXp+QYxSRL+23mpAlSGxHMDyIGChm0/AwTfTAAK5Ufe40nCsyNdaYCGuK91phn/fVu9kqayImRDkvNAgdrsA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -232,40 +234,63 @@ packages: engines: {node: '>=6.9.0'} dev: true + /@babel/helper-function-name/7.14.5: + resolution: {integrity: sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-get-function-arity': 7.14.5 + '@babel/template': 7.14.5 + '@babel/types': 7.15.0 + dev: true + /@babel/helper-function-name/7.18.9: resolution: {integrity: sha512-fJgWlZt7nxGksJS9a0XdSaI4XvpExnNIgRP+rVefWh5U7BL8pPuir6SJUmFKRfjWQ51OtWSzwOxhaH/EBWWc0A==} engines: {node: '>=6.9.0'} dependencies: '@babel/template': 7.18.10 - '@babel/types': 7.18.10 + '@babel/types': 7.18.13 + dev: true + + /@babel/helper-get-function-arity/7.14.5: + resolution: {integrity: sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.15.0 dev: true /@babel/helper-hoist-variables/7.14.5: resolution: {integrity: sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.18.10 + '@babel/types': 7.15.0 dev: true /@babel/helper-hoist-variables/7.18.6: resolution: {integrity: sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.18.10 + '@babel/types': 7.18.13 + dev: true + + /@babel/helper-member-expression-to-functions/7.15.0: + resolution: {integrity: sha512-Jq8H8U2kYiafuj2xMTPQwkTBnEEdGKpT35lJEQsRRjnG0LW3neucsaMWLgKcwu3OHKNeYugfw+Z20BXBSEs2Lg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.15.0 dev: true /@babel/helper-member-expression-to-functions/7.18.9: resolution: {integrity: sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.18.10 + '@babel/types': 7.18.13 dev: true /@babel/helper-module-imports/7.14.5: resolution: {integrity: sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.18.10 + '@babel/types': 7.15.0 dev: true /@babel/helper-module-transforms/7.15.0: @@ -273,9 +298,9 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/helper-module-imports': 7.14.5 - '@babel/helper-replace-supers': 7.18.9 + '@babel/helper-replace-supers': 7.15.0 '@babel/helper-simple-access': 7.14.8 - '@babel/helper-split-export-declaration': 7.18.6 + '@babel/helper-split-export-declaration': 7.14.5 '@babel/helper-validator-identifier': 7.14.9 '@babel/template': 7.14.5 '@babel/traverse': 7.15.0 @@ -284,11 +309,18 @@ packages: - supports-color dev: true + /@babel/helper-optimise-call-expression/7.14.5: + resolution: {integrity: sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.15.0 + dev: true + /@babel/helper-optimise-call-expression/7.18.6: resolution: {integrity: sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.18.10 + '@babel/types': 7.18.13 dev: true /@babel/helper-plugin-utils/7.14.5: @@ -301,6 +333,18 @@ packages: engines: {node: '>=6.9.0'} dev: true + /@babel/helper-replace-supers/7.15.0: + resolution: {integrity: sha512-6O+eWrhx+HEra/uJnifCwhwMd6Bp5+ZfZeJwbqUTuqkhIT6YcRhiZCOOFChRypOIe0cV46kFrRBlm+t5vHCEaA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-member-expression-to-functions': 7.15.0 + '@babel/helper-optimise-call-expression': 7.14.5 + '@babel/traverse': 7.15.0 + '@babel/types': 7.15.0 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/helper-replace-supers/7.18.9: resolution: {integrity: sha512-dNsWibVI4lNT6HiuOIBr1oyxo40HvIVmbwPUm3XZ7wMh4k2WxrxTqZwSqw/eEmXDS9np0ey5M2bz9tBmO9c+YQ==} engines: {node: '>=6.9.0'} @@ -308,8 +352,8 @@ packages: '@babel/helper-environment-visitor': 7.18.9 '@babel/helper-member-expression-to-functions': 7.18.9 '@babel/helper-optimise-call-expression': 7.18.6 - '@babel/traverse': 7.18.11 - '@babel/types': 7.18.10 + '@babel/traverse': 7.18.13 + '@babel/types': 7.18.13 transitivePeerDependencies: - supports-color dev: true @@ -321,11 +365,18 @@ packages: '@babel/types': 7.15.0 dev: true + /@babel/helper-split-export-declaration/7.14.5: + resolution: {integrity: sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.15.0 + dev: true + /@babel/helper-split-export-declaration/7.18.6: resolution: {integrity: sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.18.10 + '@babel/types': 7.18.13 dev: true /@babel/helper-string-parser/7.18.10: @@ -343,6 +394,11 @@ packages: engines: {node: '>=6.9.0'} dev: true + /@babel/helper-validator-option/7.14.5: + resolution: {integrity: sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/helper-validator-option/7.18.6: resolution: {integrity: sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==} engines: {node: '>=6.9.0'} @@ -363,7 +419,7 @@ packages: resolution: {integrity: sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/helper-validator-identifier': 7.18.6 + '@babel/helper-validator-identifier': 7.14.9 chalk: 2.4.2 js-tokens: 4.0.0 dev: true @@ -385,12 +441,12 @@ packages: '@babel/types': 7.15.0 dev: true - /@babel/parser/7.18.11: - resolution: {integrity: sha512-9JKn5vN+hDt0Hdqn1PiJ2guflwP+B6Ga8qbDuoF0PzzVhrzsKIJo8yGqVk6CmMHiMei9w1C1Bp9IMJSIK+HPIQ==} + /@babel/parser/7.18.13: + resolution: {integrity: sha512-dgXcIfMuQ0kgzLB2b9tRZs7TTFFaGM2AbtA4fJgUUYukzGH4jwsS7hzQHEGs67jdehpm22vkgKwvbU+aEflgwg==} engines: {node: '>=6.0.0'} hasBin: true dependencies: - '@babel/types': 7.18.10 + '@babel/types': 7.18.13 dev: true /@babel/plugin-syntax-async-generators/7.8.4_@babel+core@7.15.0: @@ -399,7 +455,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.15.0 - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.14.5 dev: true /@babel/plugin-syntax-bigint/7.8.3_@babel+core@7.15.0: @@ -408,7 +464,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.15.0 - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.14.5 dev: true /@babel/plugin-syntax-class-properties/7.12.13_@babel+core@7.15.0: @@ -417,7 +473,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.15.0 - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.14.5 dev: true /@babel/plugin-syntax-import-meta/7.10.4_@babel+core@7.15.0: @@ -426,7 +482,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.15.0 - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.14.5 dev: true /@babel/plugin-syntax-json-strings/7.8.3_@babel+core@7.15.0: @@ -435,7 +491,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.15.0 - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.14.5 dev: true /@babel/plugin-syntax-logical-assignment-operators/7.10.4_@babel+core@7.15.0: @@ -444,7 +500,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.15.0 - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.14.5 dev: true /@babel/plugin-syntax-nullish-coalescing-operator/7.8.3_@babel+core@7.15.0: @@ -453,7 +509,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.15.0 - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.14.5 dev: true /@babel/plugin-syntax-numeric-separator/7.10.4_@babel+core@7.15.0: @@ -462,7 +518,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.15.0 - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.14.5 dev: true /@babel/plugin-syntax-object-rest-spread/7.8.3_@babel+core@7.15.0: @@ -471,7 +527,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.15.0 - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.14.5 dev: true /@babel/plugin-syntax-optional-catch-binding/7.8.3_@babel+core@7.15.0: @@ -480,7 +536,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.15.0 - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.14.5 dev: true /@babel/plugin-syntax-optional-chaining/7.8.3_@babel+core@7.15.0: @@ -489,7 +545,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.15.0 - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.14.5 dev: true /@babel/plugin-syntax-top-level-await/7.14.5_@babel+core@7.15.0: @@ -499,7 +555,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.15.0 - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.14.5 dev: true /@babel/plugin-syntax-typescript/7.18.6: @@ -542,7 +598,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/helper-create-class-features-plugin': 7.18.9 + '@babel/helper-create-class-features-plugin': 7.18.13 '@babel/helper-plugin-utils': 7.18.9 '@babel/plugin-syntax-typescript': 7.18.6 transitivePeerDependencies: @@ -576,8 +632,8 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/code-frame': 7.18.6 - '@babel/parser': 7.18.11 - '@babel/types': 7.18.10 + '@babel/parser': 7.18.13 + '@babel/types': 7.18.13 dev: true /@babel/traverse/7.15.0: @@ -586,9 +642,9 @@ packages: dependencies: '@babel/code-frame': 7.14.5 '@babel/generator': 7.15.0 - '@babel/helper-function-name': 7.18.9 + '@babel/helper-function-name': 7.14.5 '@babel/helper-hoist-variables': 7.14.5 - '@babel/helper-split-export-declaration': 7.18.6 + '@babel/helper-split-export-declaration': 7.14.5 '@babel/parser': 7.15.3 '@babel/types': 7.15.0 debug: 4.3.4 @@ -597,18 +653,18 @@ packages: - supports-color dev: true - /@babel/traverse/7.18.11: - resolution: {integrity: sha512-TG9PiM2R/cWCAy6BPJKeHzNbu4lPzOSZpeMfeNErskGpTJx6trEvFaVCbDvpcxwy49BKWmEPwiW8mrysNiDvIQ==} + /@babel/traverse/7.18.13: + resolution: {integrity: sha512-N6kt9X1jRMLPxxxPYWi7tgvJRH/rtoU+dbKAPDM44RFHiMH8igdsaSBgFeskhSl/kLWLDUvIh1RXCrTmg0/zvA==} engines: {node: '>=6.9.0'} dependencies: '@babel/code-frame': 7.18.6 - '@babel/generator': 7.18.12 + '@babel/generator': 7.18.13 '@babel/helper-environment-visitor': 7.18.9 '@babel/helper-function-name': 7.18.9 '@babel/helper-hoist-variables': 7.18.6 '@babel/helper-split-export-declaration': 7.18.6 - '@babel/parser': 7.18.11 - '@babel/types': 7.18.10 + '@babel/parser': 7.18.13 + '@babel/types': 7.18.13 debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: @@ -623,8 +679,8 @@ packages: to-fast-properties: 2.0.0 dev: true - /@babel/types/7.18.10: - resolution: {integrity: sha512-MJvnbEiiNkpjo+LknnmRrqbY1GPUUggjv+wQVjetM/AONoupqRALB7I6jGqNUAZsKcRIEu2J6FRFvsczljjsaQ==} + /@babel/types/7.18.13: + resolution: {integrity: sha512-ePqfTihzW0W6XAU+aMw2ykilisStJfDnsejDCXRchCcMJ4O0+8DhPXf2YUbZ6wjBlsEmZwLK/sPweWtu8hcJYQ==} engines: {node: '>=6.9.0'} dependencies: '@babel/helper-string-parser': 7.18.10 @@ -673,7 +729,7 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} dependencies: '@jest/types': 28.1.3 - '@types/node': 16.11.47 + '@types/node': 16.11.53 chalk: 4.1.2 jest-message-util: 28.1.3 jest-util: 28.1.3 @@ -694,14 +750,14 @@ packages: '@jest/test-result': 28.1.3 '@jest/transform': 28.1.3 '@jest/types': 28.1.3 - '@types/node': 16.11.47 + '@types/node': 16.11.53 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.3.2 exit: 0.1.2 graceful-fs: 4.2.10 jest-changed-files: 28.1.3 - jest-config: 28.1.3_@types+node@16.11.47 + jest-config: 28.1.3_@types+node@16.11.53 jest-haste-map: 28.1.3 jest-message-util: 28.1.3 jest-regex-util: 28.0.2 @@ -729,7 +785,7 @@ packages: dependencies: '@jest/fake-timers': 28.1.3 '@jest/types': 28.1.3 - '@types/node': 16.11.47 + '@types/node': 16.11.53 jest-mock: 28.1.3 dev: true @@ -756,7 +812,7 @@ packages: dependencies: '@jest/types': 28.1.3 '@sinonjs/fake-timers': 9.1.2 - '@types/node': 16.11.47 + '@types/node': 16.11.53 jest-message-util: 28.1.3 jest-mock: 28.1.3 jest-util: 28.1.3 @@ -788,7 +844,7 @@ packages: '@jest/transform': 28.1.3 '@jest/types': 28.1.3 '@jridgewell/trace-mapping': 0.3.14 - '@types/node': 16.11.47 + '@types/node': 16.11.53 chalk: 4.1.2 collect-v8-coverage: 1.0.1 exit: 0.1.2 @@ -857,7 +913,7 @@ packages: chalk: 4.1.2 convert-source-map: 1.8.0 fast-json-stable-stringify: 2.1.0 - graceful-fs: 4.2.10 + graceful-fs: 4.2.8 jest-haste-map: 26.6.2 jest-regex-util: 26.0.0 jest-util: 26.6.2 @@ -899,7 +955,7 @@ packages: dependencies: '@types/istanbul-lib-coverage': 2.0.3 '@types/istanbul-reports': 3.0.1 - '@types/node': 16.11.47 + '@types/node': 16.11.53 '@types/yargs': 15.0.14 chalk: 4.1.2 dev: true @@ -911,7 +967,7 @@ packages: '@jest/schemas': 28.1.3 '@types/istanbul-lib-coverage': 2.0.3 '@types/istanbul-reports': 3.0.1 - '@types/node': 16.11.47 + '@types/node': 16.11.53 '@types/yargs': 17.0.10 chalk: 4.1.2 dev: true @@ -998,27 +1054,27 @@ packages: /@types/babel__generator/7.6.3: resolution: {integrity: sha512-/GWCmzJWqV7diQW54smJZzWbSFf4QYtF71WCKhcx6Ru/tFyQIY2eiiITcCAeuPbNSvT9YCGkVMqqvSk2Z0mXiA==} dependencies: - '@babel/types': 7.18.10 + '@babel/types': 7.15.0 dev: true /@types/babel__template/7.4.1: resolution: {integrity: sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==} dependencies: - '@babel/parser': 7.18.11 - '@babel/types': 7.18.10 + '@babel/parser': 7.15.3 + '@babel/types': 7.15.0 dev: true /@types/babel__traverse/7.14.2: resolution: {integrity: sha512-K2waXdXBi2302XUdcHcR1jCeU0LL4TD9HRs/gk0N2Xvrht+G/BfJa4QObBQZfhMdxiCpV3COl5Nfq4uKTeTnJA==} dependencies: - '@babel/types': 7.18.10 + '@babel/types': 7.15.0 dev: true /@types/body-parser/1.19.2: resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==} dependencies: '@types/connect': 3.4.35 - '@types/node': 16.11.47 + '@types/node': 16.11.53 dev: false /@types/caseless/0.12.2: @@ -1028,13 +1084,13 @@ packages: /@types/connect/3.4.35: resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} dependencies: - '@types/node': 16.11.47 + '@types/node': 16.11.53 dev: false /@types/express-serve-static-core/4.17.29: resolution: {integrity: sha512-uMd++6dMKS32EOuw1Uli3e3BPgdLIXmezcfHv7N4c1s3gkhikBplORPpMq3fuWkxncZN1reb16d5n8yhQ80x7Q==} dependencies: - '@types/node': 16.11.47 + '@types/node': 16.11.53 '@types/qs': 6.9.7 '@types/range-parser': 1.2.4 dev: false @@ -1057,13 +1113,13 @@ packages: /@types/graceful-fs/4.1.5: resolution: {integrity: sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==} dependencies: - '@types/node': 16.11.47 + '@types/node': 16.11.53 dev: true /@types/http-proxy/1.17.9: resolution: {integrity: sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw==} dependencies: - '@types/node': 16.11.47 + '@types/node': 16.11.53 dev: false /@types/istanbul-lib-coverage/2.0.3: @@ -1082,25 +1138,25 @@ packages: '@types/istanbul-lib-report': 3.0.0 dev: true - /@types/jest/28.1.6: - resolution: {integrity: sha512-0RbGAFMfcBJKOmqRazM8L98uokwuwD5F8rHrv/ZMbrZBwVOWZUyPG6VFNscjYr/vjM3Vu4fRrCPbOs42AfemaQ==} + /@types/jest/28.1.7: + resolution: {integrity: sha512-acDN4VHD40V24tgu0iC44jchXavRNVFXQ/E6Z5XNsswgoSO/4NgsXoEYmPUGookKldlZQyIpmrEXsHI9cA3ZTA==} dependencies: - jest-matcher-utils: 28.1.3 + expect: 28.1.3 pretty-format: 28.1.3 dev: true /@types/jsonwebtoken/8.5.8: resolution: {integrity: sha512-zm6xBQpFDIDM6o9r6HSgDeIcLy82TKWctCXEPbJJcXb5AKmi5BNNdLXneixK4lplX3PqIVcwLBCGE/kAGnlD4A==} dependencies: - '@types/node': 16.11.47 + '@types/node': 16.11.53 dev: false /@types/mime/1.3.2: resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==} dev: false - /@types/node/16.11.47: - resolution: {integrity: sha512-fpP+jk2zJ4VW66+wAMFoBJlx1bxmBKx4DUFf68UHgdGCOuyUTDlLWqsaNPJh7xhNDykyJ9eIzAygilP/4WoN8g==} + /@types/node/16.11.53: + resolution: {integrity: sha512-3yJerjVV8GlGSWCjEPal2cDymbQEE/1bhUr1NdW5apDPZo6EjBaqHxR7AC4wKmZ24Hzqcz+tgJyAGe9qcGHw7w==} /@types/prettier/2.3.2: resolution: {integrity: sha512-eI5Yrz3Qv4KPUa/nSIAi0h+qX0XyewOliug5F2QAtuRg6Kjg6jfmxe1GIwoIRhZspD1A0RP8ANrPwvEXXtRFog==} @@ -1122,7 +1178,7 @@ packages: resolution: {integrity: sha512-GWP9AZW7foLd4YQxyFZDBepl0lPsWLMEXDZUjQ/c1gqVPDPECrRZyEzuhJdnPWioFCq3Tv0qoGpMD6U+ygd4ZA==} dependencies: '@types/caseless': 0.12.2 - '@types/node': 16.11.47 + '@types/node': 16.11.53 '@types/tough-cookie': 4.0.1 form-data: 2.5.1 dev: false @@ -1131,7 +1187,7 @@ packages: resolution: {integrity: sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==} dependencies: '@types/mime': 1.3.2 - '@types/node': 16.11.47 + '@types/node': 16.11.53 dev: false /@types/stack-utils/2.0.1: @@ -1202,6 +1258,11 @@ packages: engines: {node: '>=4'} dev: true + /ansi-regex/5.0.0: + resolution: {integrity: sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==} + engines: {node: '>=8'} + dev: true + /ansi-regex/5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1242,6 +1303,10 @@ packages: picomatch: 2.3.0 dev: true + /append-field/1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + dev: false + /arg/4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} dev: true @@ -1385,7 +1450,7 @@ packages: resolution: {integrity: sha512-AF55rZXpe7trmEylbaE1Gv54wn6rwU03aptvRoVIGP8YykoSxqdVLV1TfwflBCE/QtHmqtP8SWlTENqbK8GCSQ==} engines: {node: '>=8'} dependencies: - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.14.5 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-instrument: 4.0.3 @@ -1398,7 +1463,7 @@ packages: resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} engines: {node: '>=8'} dependencies: - '@babel/helper-plugin-utils': 7.18.9 + '@babel/helper-plugin-utils': 7.14.5 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-instrument: 5.2.0 @@ -1411,8 +1476,8 @@ packages: resolution: {integrity: sha512-PO9t0697lNTmcEHH69mdtYiOIkkOlj9fySqfO3K1eCcdISevLAE0xY59VLLUj0SoiPiTX/JU2CYFpILydUa5Lw==} engines: {node: '>= 10.14.2'} dependencies: - '@babel/template': 7.18.10 - '@babel/types': 7.18.10 + '@babel/template': 7.14.5 + '@babel/types': 7.15.0 '@types/babel__core': 7.1.15 '@types/babel__traverse': 7.14.2 dev: true @@ -1421,8 +1486,8 @@ packages: resolution: {integrity: sha512-Ys3tUKAmfnkRUpPdpa98eYrAR0nV+sSFUZZEGuQ2EbFd1y4SOLtD5QDNHAq+bb9a+bbXvYQC4b+ID/THIMcU6Q==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} dependencies: - '@babel/template': 7.18.10 - '@babel/types': 7.18.10 + '@babel/template': 7.14.5 + '@babel/types': 7.15.0 '@types/babel__core': 7.1.15 '@types/babel__traverse': 7.14.2 dev: true @@ -1586,7 +1651,6 @@ packages: /buffer-from/1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - dev: true /buffer/4.9.2: resolution: {integrity: sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==} @@ -1596,6 +1660,14 @@ packages: isarray: 1.0.0 dev: false + /busboy/0.2.14: + resolution: {integrity: sha512-InWFDomvlkEj+xWLBfU3AvnbVYqeTWmQopiW0tWWEy5yehYm2YkGEc59sUmw/4ty5Zj/b0WHGs1LgecuBSBGrg==} + engines: {node: '>=0.8.0'} + dependencies: + dicer: 0.2.5 + readable-stream: 1.1.14 + dev: false + /bytes/3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -1744,7 +1816,7 @@ packages: dev: true /color-name/1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + resolution: {integrity: sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=} /color-name/1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -1768,6 +1840,16 @@ packages: resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} dev: true + /concat-stream/1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 2.3.7 + typedarray: 0.0.6 + dev: false + /content-disposition/0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -1835,6 +1917,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /core-util-is/1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + dev: false + /cors/2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -2012,6 +2098,14 @@ packages: wrappy: 1.0.2 dev: true + /dicer/0.2.5: + resolution: {integrity: sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=} + engines: {node: '>=0.8.0'} + dependencies: + readable-stream: 1.1.14 + streamsearch: 0.1.2 + dev: false + /diff-match-patch/1.0.5: resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} dev: false @@ -2058,7 +2152,7 @@ packages: dev: false /ee-first/1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} dev: false /electron-to-chromium/1.3.814: @@ -2861,7 +2955,7 @@ packages: dev: false /has-flag/3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + resolution: {integrity: sha1-tdRU3CGZriJWmfNGfloH87lVuv0=} engines: {node: '>=4'} /has-flag/4.0.0: @@ -3289,8 +3383,12 @@ packages: is-docker: 2.2.1 dev: true + /isarray/0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + dev: false + /isarray/1.0.0: - resolution: {integrity: sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=} + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} /isexe/2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3344,7 +3442,7 @@ packages: engines: {node: '>=8'} dependencies: '@babel/core': 7.15.0 - '@babel/parser': 7.18.11 + '@babel/parser': 7.15.3 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.0 semver: 6.3.0 @@ -3400,7 +3498,7 @@ packages: '@jest/expect': 28.1.3 '@jest/test-result': 28.1.3 '@jest/types': 28.1.3 - '@types/node': 16.11.47 + '@types/node': 16.11.53 chalk: 4.1.2 co: 4.6.0 dedent: 0.7.0 @@ -3419,7 +3517,7 @@ packages: - supports-color dev: true - /jest-cli/28.1.3_@types+node@16.11.47: + /jest-cli/28.1.3_@types+node@16.11.53: resolution: {integrity: sha512-roY3kvrv57Azn1yPgdTebPAXvdR2xfezaKKYzVxZ6It/5NCxzJym6tUI5P1zkdWhfUYkxEI9uZWcQdaFLo8mJQ==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} hasBin: true @@ -3436,7 +3534,7 @@ packages: exit: 0.1.2 graceful-fs: 4.2.10 import-local: 3.0.2 - jest-config: 28.1.3_@types+node@16.11.47 + jest-config: 28.1.3_@types+node@16.11.53 jest-util: 28.1.3 jest-validate: 28.1.3 prompts: 2.4.1 @@ -3447,7 +3545,7 @@ packages: - ts-node dev: true - /jest-config/28.1.3_@types+node@16.11.47: + /jest-config/28.1.3_@types+node@16.11.53: resolution: {integrity: sha512-MG3INjByJ0J4AsNBm7T3hsuxKQqFIiRo/AUqb1q9LRKI5UU6Aar9JHbr9Ivn1TVwfUD9KirRoM/T6u8XlcQPHQ==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} peerDependencies: @@ -3462,7 +3560,7 @@ packages: '@babel/core': 7.15.0 '@jest/test-sequencer': 28.1.3 '@jest/types': 28.1.3 - '@types/node': 16.11.47 + '@types/node': 16.11.53 babel-jest: 28.1.3_@babel+core@7.15.0 chalk: 4.1.2 ci-info: 3.3.2 @@ -3530,7 +3628,7 @@ packages: '@jest/environment': 28.1.3 '@jest/fake-timers': 28.1.3 '@jest/types': 28.1.3 - '@types/node': 16.11.47 + '@types/node': 16.11.53 jest-mock: 28.1.3 jest-util: 28.1.3 dev: true @@ -3550,10 +3648,10 @@ packages: dependencies: '@jest/types': 26.6.2 '@types/graceful-fs': 4.1.5 - '@types/node': 16.11.47 + '@types/node': 16.11.53 anymatch: 3.1.2 fb-watchman: 2.0.1 - graceful-fs: 4.2.10 + graceful-fs: 4.2.8 jest-regex-util: 26.0.0 jest-serializer: 26.6.2 jest-util: 26.6.2 @@ -3573,7 +3671,7 @@ packages: dependencies: '@jest/types': 28.1.3 '@types/graceful-fs': 4.1.5 - '@types/node': 16.11.47 + '@types/node': 16.11.53 anymatch: 3.1.2 fb-watchman: 2.0.1 graceful-fs: 4.2.10 @@ -3624,7 +3722,7 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} dependencies: '@jest/types': 28.1.3 - '@types/node': 16.11.47 + '@types/node': 16.11.53 dev: true /jest-pnp-resolver/1.2.2_jest-resolve@28.1.3: @@ -3683,7 +3781,7 @@ packages: '@jest/test-result': 28.1.3 '@jest/transform': 28.1.3 '@jest/types': 28.1.3 - '@types/node': 16.11.47 + '@types/node': 16.11.53 chalk: 4.1.2 emittery: 0.10.2 graceful-fs: 4.2.10 @@ -3737,8 +3835,8 @@ packages: resolution: {integrity: sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g==} engines: {node: '>= 10.14.2'} dependencies: - '@types/node': 16.11.47 - graceful-fs: 4.2.10 + '@types/node': 16.11.53 + graceful-fs: 4.2.8 dev: true /jest-snapshot/28.1.3: @@ -3777,9 +3875,9 @@ packages: engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 - '@types/node': 16.11.47 + '@types/node': 16.11.53 chalk: 4.1.2 - graceful-fs: 4.2.10 + graceful-fs: 4.2.8 is-ci: 2.0.0 micromatch: 4.0.4 dev: true @@ -3789,7 +3887,7 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} dependencies: '@jest/types': 28.1.3 - '@types/node': 16.11.47 + '@types/node': 16.11.53 chalk: 4.1.2 ci-info: 3.3.2 graceful-fs: 4.2.10 @@ -3814,7 +3912,7 @@ packages: dependencies: '@jest/test-result': 28.1.3 '@jest/types': 28.1.3 - '@types/node': 16.11.47 + '@types/node': 16.11.53 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.10.2 @@ -3826,7 +3924,7 @@ packages: resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==} engines: {node: '>= 10.13.0'} dependencies: - '@types/node': 16.11.47 + '@types/node': 16.11.53 merge-stream: 2.0.0 supports-color: 7.2.0 dev: true @@ -3835,12 +3933,12 @@ packages: resolution: {integrity: sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} dependencies: - '@types/node': 16.11.47 + '@types/node': 16.11.53 merge-stream: 2.0.0 supports-color: 8.1.1 dev: true - /jest/28.1.3_@types+node@16.11.47: + /jest/28.1.3_@types+node@16.11.53: resolution: {integrity: sha512-N4GT5on8UkZgH0O5LUavMRV1EDEhNTL0KEfRmDIeZHSV7p2XgLoY9t9VDUgL6o+yfdgYHVxuz81G8oB9VG5uyA==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} hasBin: true @@ -3853,7 +3951,7 @@ packages: '@jest/core': 28.1.3 '@jest/types': 28.1.3 import-local: 3.0.2 - jest-cli: 28.1.3_@types+node@16.11.47 + jest-cli: 28.1.3_@types+node@16.11.53 transitivePeerDependencies: - '@types/node' - supports-color @@ -4178,7 +4276,6 @@ packages: /minimist/1.2.6: resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} - dev: true /mixin-deep/1.3.2: resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==} @@ -4188,6 +4285,13 @@ packages: is-extendable: 1.0.1 dev: true + /mkdirp/0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + dependencies: + minimist: 1.2.6 + dev: false + /mkdirp/1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -4205,6 +4309,21 @@ packages: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: false + /multer/1.4.4: + resolution: {integrity: sha512-2wY2+xD4udX612aMqMcB8Ws2Voq6NIUPEtD1be6m411T4uDH/VtL9i//xvcyFlTVfRdaBsk7hV5tgrGQqhuBiw==} + engines: {node: '>= 0.10.0'} + deprecated: Multer 1.x is affected by CVE-2022-24434. This is fixed in v1.4.4-lts.1 which drops support for versions of Node.js before 6. Please upgrade to at least Node.js 6 and version 1.4.4-lts.1 of Multer. If you need support for older versions of Node.js, we are open to accepting patches that would fix the CVE on the main 1.x release line, whilst maintaining compatibility with Node.js 0.10. + dependencies: + append-field: 1.0.0 + busboy: 0.2.14 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + on-finished: 2.4.1 + type-is: 1.6.18 + xtend: 4.0.2 + dev: false + /nanoid/3.3.4: resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4347,7 +4466,7 @@ packages: dev: false /object-assign/4.1.1: - resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=} + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} dev: false @@ -4641,6 +4760,10 @@ packages: react-is: 18.2.0 dev: true + /process-nextick-args/2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + dev: false + /process-warning/1.0.0: resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} dev: false @@ -4733,6 +4856,27 @@ packages: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true + /readable-stream/1.1.14: + resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==} + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 0.0.1 + string_decoder: 0.10.31 + dev: false + + /readable-stream/2.3.7: + resolution: {integrity: sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==} + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + dev: false + /readable-stream/3.6.0: resolution: {integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==} engines: {node: '>= 6'} @@ -4849,7 +4993,6 @@ packages: /safe-buffer/5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - dev: true /safe-buffer/5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -5121,6 +5264,11 @@ packages: resolution: {integrity: sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==} dev: false + /streamsearch/0.1.2: + resolution: {integrity: sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=} + engines: {node: '>=0.8.0'} + dev: false + /string-length/4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} @@ -5160,6 +5308,16 @@ packages: es-abstract: 1.20.1 dev: false + /string_decoder/0.10.31: + resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} + dev: false + + /string_decoder/1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + dependencies: + safe-buffer: 5.1.2 + dev: false + /string_decoder/1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} dependencies: @@ -5169,7 +5327,7 @@ packages: resolution: {integrity: sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==} engines: {node: '>=8'} dependencies: - ansi-regex: 5.0.1 + ansi-regex: 5.0.0 dev: true /strip-ansi/6.0.1: @@ -5309,7 +5467,7 @@ packages: dev: true /to-fast-properties/2.0.0: - resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + resolution: {integrity: sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=} engines: {node: '>=4'} dev: true @@ -5358,8 +5516,8 @@ packages: hasBin: true dev: true - /ts-jest/28.0.7_e4vlsll2vsrxuwy4jhlvxjbksa: - resolution: {integrity: sha512-wWXCSmTwBVmdvWrOpYhal79bDpioDy4rTT+0vyUnE3ZzM7LOAAGG9NXwzkEL/a516rQEgnMmS/WKP9jBPCVJyA==} + /ts-jest/28.0.8_e4vlsll2vsrxuwy4jhlvxjbksa: + resolution: {integrity: sha512-5FaG0lXmRPzApix8oFG8RKjAz4ehtm8yMKOTy5HX3fY6W8kmvOrmcY0hKDElW52FJov+clhUbrKAqofnj4mXTg==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} hasBin: true peerDependencies: @@ -5382,7 +5540,7 @@ packages: bs-logger: 0.2.6 esbuild: 0.14.49 fast-json-stable-stringify: 2.1.0 - jest: 28.1.3_@types+node@16.11.47 + jest: 28.1.3_@types+node@16.11.53 jest-util: 28.1.3 json5: 2.2.1 lodash.memoize: 4.1.2 @@ -5392,7 +5550,7 @@ packages: yargs-parser: 21.0.1 dev: true - /ts-node-dev/2.0.0_ow5yu25silzxcp7pmv7jv4j54m: + /ts-node-dev/2.0.0_3ntvslzp3xhg4qw2n7rsqqnjwe: resolution: {integrity: sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==} engines: {node: '>=0.8.0'} hasBin: true @@ -5411,7 +5569,7 @@ packages: rimraf: 2.7.1 source-map-support: 0.5.13 tree-kill: 1.2.2 - ts-node: 10.9.1_ow5yu25silzxcp7pmv7jv4j54m + ts-node: 10.9.1_3ntvslzp3xhg4qw2n7rsqqnjwe tsconfig: 7.0.0 typescript: 4.7.4 transitivePeerDependencies: @@ -5420,7 +5578,7 @@ packages: - '@types/node' dev: true - /ts-node/10.9.1_ow5yu25silzxcp7pmv7jv4j54m: + /ts-node/10.9.1_3ntvslzp3xhg4qw2n7rsqqnjwe: resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true peerDependencies: @@ -5439,7 +5597,7 @@ packages: '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.3 - '@types/node': 16.11.47 + '@types/node': 16.11.53 acorn: 8.8.0 acorn-walk: 8.2.0 arg: 4.1.3 @@ -5480,7 +5638,7 @@ packages: engines: {node: '>= 0.6'} dependencies: media-typer: 0.3.0 - mime-types: 2.1.32 + mime-types: 2.1.35 dev: false /typedarray-to-buffer/3.1.5: @@ -5489,6 +5647,10 @@ packages: is-typedarray: 1.0.0 dev: true + /typedarray/0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + dev: false + /typescript/4.7.4: resolution: {integrity: sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==} engines: {node: '>=4.2.0'} @@ -5553,7 +5715,7 @@ packages: dev: true /util-deprecate/1.0.2: - resolution: {integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=} + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} /util/0.12.4: resolution: {integrity: sha512-bxZ9qtSlGUWSOy9Qa9Xgk11kSslpuZwaxCg4sNIDj6FLucDab2JxnHwyNTCpHMtK1MjoQiWQ6DiUMZYbSrO+Sw==} @@ -5707,7 +5869,6 @@ packages: /xtend/4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} - dev: true /y18n/5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} diff --git a/api.planx.uk/s3.js b/api.planx.uk/s3.js index e08764ee08..d36ca1cec5 100644 --- a/api.planx.uk/s3.js +++ b/api.planx.uk/s3.js @@ -10,36 +10,89 @@ assert(process.env.AWS_S3_REGION); assert(process.env.AWS_ACCESS_KEY); assert(process.env.AWS_SECRET_KEY); -const signS3Upload = (filename) => - new Promise((res, rej) => { - const s3 = new S3({ - // apiVersion: "2006-03-01", - params: { Bucket: process.env.AWS_S3_BUCKET }, - region: process.env.AWS_S3_REGION, - accessKeyId: process.env.AWS_ACCESS_KEY, - secretAccessKey: process.env.AWS_SECRET_KEY, - ...useMinio(), - }); - - const fileType = getType(filename); - const key = `${nanoid()}/${filename}`; - - const params = { - ACL: process.env.AWS_S3_ACL, - Key: key, - // ContentType: fileType, - }; +const uploadPublicFile = async (file, filename) => { + const s3 = s3Factory(); + + const { params, key, fileType } = generateFileParams(file, filename); + + await s3.putObject(params).promise(); + + return { + file_type: fileType, + key, + } +} + +const uploadPrivateFile = async (file, filename) => { + const s3 = s3Factory(); + + const { params, key, fileType } = generateFileParams(file, filename); + + params.Metadata = { + is_private: 'true', + } + + await s3.putObject(params).promise(); + + return { + file_type: fileType, + key, + } +} + +const getFileFromS3 = async (fileId) => { + const s3 = s3Factory(); + + const params = { + Key: fileId, + }; + + const file = await s3.getObject(params).promise(); + + return { + body: file.Body, + isPrivate: file.Metadata?.is_private === 'true', + headers: { + "Content-Type": file.ContentType, + "Content-Length": file.ContentLength, + "Content-Disposition": file.ContentDisposition, + "Content-Encoding": file.ContentEncoding, + "Cache-Control": file.CacheControl, + "Expires": file.Expires, + "Last-Modified": file.LastModified, + "ETag": file.ETag, + } + } +} - s3.getSignedUrl("putObject", params, (err, url) => { - if (err) return rej(err); - return res({ - fileType, - key, - acl: process.env.AWS_S3_ACL, - url, - }); - }); +function s3Factory() { + return new S3({ + params: { Bucket: process.env.AWS_S3_BUCKET }, + region: process.env.AWS_S3_REGION, + accessKeyId: process.env.AWS_ACCESS_KEY, + secretAccessKey: process.env.AWS_SECRET_KEY, + ...useMinio(), }); +} + +function generateFileParams(file, filename) { + const fileType = getType(filename); + const key = `${nanoid()}/${filename}`; + + const params = { + ACL: process.env.AWS_S3_ACL, + Key: key, + Body: file.buffer, + ContentDisposition: `inline;filename="${filename}"`, + ContentType: file.mimetype, + }; + + return { + fileType, + params, + key, + } +} function useMinio() { if (process.env.NODE_ENV === "production") { @@ -55,4 +108,8 @@ function useMinio() { } } -export { signS3Upload }; +export { + getFileFromS3, + uploadPublicFile, + uploadPrivateFile, +}; diff --git a/api.planx.uk/send.js b/api.planx.uk/send.js index f9f61c818a..60661edf2b 100644 --- a/api.planx.uk/send.js +++ b/api.planx.uk/send.js @@ -8,6 +8,7 @@ import str from "string-to-stream"; import stringify from "csv-stringify"; import { GraphQLClient } from "graphql-request"; import { markSessionAsSubmitted } from "./saveAndReturn/utils"; +import { getFileFromS3 } from "./s3"; const client = new GraphQLClient(process.env.HASURA_GRAPHQL_URL, { headers: { @@ -120,7 +121,7 @@ const sendToUniform = async (req, res, next) => { * Creates a zip folder containing the documents required by Uniform * @param {any} stringXml - a string representation of the XML schema, resulting file must be named "proposal.xml" * @param {any} csv - an array of objects representing our custom CSV format - * @param {string[]} files - an array of the S3 URLs for any user-uploaded files + * @param {object[]} files - an array of user-uploaded files * @param {string} sessionId * @returns {Promise} - name of zip */ @@ -141,10 +142,12 @@ async function createZip(stringXml, csv, files, sessionId) { for (let file of files) { // Ensure unique filename by combining original filename and S3 folder name, which is a nanoid // Uniform requires all uploaded files to be present in the zip, even if they are duplicates + const s3SplittedPath = file.split('/').slice(-2); + // Must match unique filename in editor.planx.uk/src/@planx/components/Send/uniform/xml.ts - const uniqueFilename = file.split("/").slice(-2).join("-"); + const uniqueFilename = s3SplittedPath.join("-"); const filePath = path.join(tmpDir, uniqueFilename); - await downloadFile(file, filePath, zip); + await downloadFile(s3SplittedPath.join('/'), filePath, zip); } } @@ -160,7 +163,6 @@ async function createZip(stringXml, csv, files, sessionId) { zip.addLocalFile(csvPath); deleteFile(csvPath); - // build the XML file from a string, write it locally, add it to the zip // must be named "proposal.xml" to be processed by Uniform const xmlPath = "proposal.xml"; @@ -341,19 +343,14 @@ async function retrieveSubmission(token, submissionId) { /** * Helper method to locally download S3 files, add them to the zip, then clean them up * - * @param {string} url - s3 URL + * @param {string} filePath - s3 `path/key` to file * @param {string} path - file name for download * @param {string} folder - AdmZip archive */ -const downloadFile = async (url, path, folder) => { - const res = await fetch(url); - const fileStream = fs.createWriteStream(path); - - res.body.pipe(fileStream); - await new Promise((resolve, reject) => { - fileStream.on("error", reject); - fileStream.on("finish", resolve); - }); +const downloadFile = async (filePath, path, folder) => { + const { body } = await getFileFromS3(filePath) + + fs.writeFileSync(path, body); folder.addLocalFile(path); deleteFile(path); diff --git a/api.planx.uk/server.js b/api.planx.uk/server.js index ba40be397b..61fc4414a5 100644 --- a/api.planx.uk/server.js +++ b/api.planx.uk/server.js @@ -19,8 +19,8 @@ import { fixRequestBody, } from "http-proxy-middleware"; import helmet from 'helmet'; +import multer from 'multer'; -import { signS3Upload } from "./s3"; import { locationSearch } from "./gis/index"; import { diffFlow, publishFlow } from "./publish"; import { findAndReplaceInFlow } from "./findReplace"; @@ -37,6 +37,8 @@ import { markSessionAsSubmitted } from "./saveAndReturn/utils"; import { createReminderEvent, createExpiryEvent } from "./webhooks/lowcalSessionEvents"; import { adminGraphQLClient } from "./hasura"; import { sendEmailLimiter, apiLimiter } from "./rateLimit"; +import { buildFilePath } from "./helpers"; +import { getFileFromS3, uploadPrivateFile, uploadPublicFile } from "./s3"; const router = express.Router(); @@ -245,6 +247,14 @@ const useJWT = expressjwt({ req.query?.token, }); +assert(process.env.FILE_API_KEY, "Missing environment variable 'FILE_API_KEY'"); +const useFilePermission = (req, res, next) => { + if (req.headers['api-key'] !== process.env.FILE_API_KEY) { + return next({ status: 403, message: "forbidden" }); + } + return next(); +} + if (process.env.NODE_ENV !== "test") { app.use( require("express-pino-logger")({ @@ -534,18 +544,63 @@ app.post("/download-application", async (req, res, next) => { } }); -app.post("/sign-s3-upload", async (req, res, next) => { - if (!req.body.filename) next({ status: 422, message: "missing filename" }); +app.post("/private-file-upload", multer().single('file'), async (req, res, next) => { + if (!req.body.filename) return next({ status: 422, message: "missing filename" }); + if (!req.file) return next({ status: 422, message: "missing file" }); try { - const { fileType, url, acl } = await signS3Upload(req.body.filename); + const fileResponse = await uploadPrivateFile(req.file, req.body.filename); - res.json({ - upload_to: url, - public_readonly_url_will_be: url.split("?")[0], - file_type: fileType, - acl, - }); + res.json(fileResponse); + } catch (err) { + next(err); + } +}); + +app.post("/public-file-upload", multer().single('file'), async (req, res, next) => { + if (!req.body.filename) return next({ status: 422, message: "missing filename" }); + if (!req.file) return next({ status: 422, message: "missing file" }); + + try { + const fileResponse = await uploadPublicFile(req.file, req.body.filename); + + res.json(fileResponse); + } catch (err) { + next(err); + } +}); + +app.get("/file/public/:fileKey/:fileName", async (req, res, next) => { + const filePath = buildFilePath(req.params.fileKey, req.params.fileName); + + if (!filePath) { + return next({ status: 404, message: "file not found" }); + } + + try { + const { body, headers, isPrivate } = await getFileFromS3(filePath); + + if (isPrivate) return next({ status: 400, message: "bad request" }); + + res.set(headers); + res.send(body); + } catch (err) { + next(err); + } +}); + +app.get("/file/private/:fileKey/:fileName", useFilePermission, async (req, res, next) => { + const filePath = buildFilePath(req.params.fileKey, req.params.fileName); + + if (!filePath) { + return next({ status: 404, message: "file not found" }); + } + + try { + const { body, headers } = await getFileFromS3(filePath) + + res.set(headers); + res.send(body); } catch (err) { next(err); } @@ -661,8 +716,7 @@ function usePayProxy(options, req) { ...req.headers, Authorization: `Bearer ${process.env[ `GOV_UK_PAY_TOKEN_${req.params.localAuthority}`.toUpperCase() - ] - }`, + ]}`, }, ...options, }); diff --git a/api.planx.uk/server.test.js b/api.planx.uk/server.test.js index c77b51968d..f1ad6520b5 100644 --- a/api.planx.uk/server.test.js +++ b/api.planx.uk/server.test.js @@ -273,3 +273,180 @@ describe.skip("fetching GIS data from Digital Land for supported local authoriti }, 20_000); // 20s request timeout }); }); + +const mockPutObject = jest.fn(() => ({ + promise: () => Promise.resolve() +})) + +let getObjectResponse = {}; + +const mockGetObject = jest.fn(() => ({ + promise: () => Promise.resolve(getObjectResponse) +})) + +const s3Mock = () => { + return { + putObject: mockPutObject, + getObject: mockGetObject, + }; +}; + +jest.mock('aws-sdk/clients/s3', () => { + return jest.fn().mockImplementation(() => { + return s3Mock(); + }) +}); + +describe("File upload", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("private-file-upload - should not upload without filename", async () => { + await supertest(app) + .post("/private-file-upload") + .field("filename", '') + .attach("file", Buffer.from('some data'), 'some_file.txt') + .expect(422) + .then(res => { + expect(mockPutObject).not.toHaveBeenCalled(); + expect(res.body.error).toBe("missing filename") + }) + }); + + it("private-file-upload - should not upload without file", async () => { + await supertest(app) + .post("/private-file-upload") + .field("filename", 'some filename') + .expect(422) + .then(res => { + expect(mockPutObject).not.toHaveBeenCalled(); + expect(res.body.error).toBe("missing file") + }) + }); + + it("private-file-upload - should upload file", async () => { + await supertest(app) + .post("/private-file-upload") + .field("filename", 'some_file.txt') + .attach("file", Buffer.from('some data'), 'some_file.txt') + .then(res => { + expect(res.body).toEqual({ + file_type: 'text/plain', + key: expect.stringContaining('some_file.txt'), + }); + expect(mockPutObject).toHaveBeenCalledTimes(1); + }); + }); + + it("public-file-upload - should not upload without file", async () => { + await supertest(app) + .post("/public-file-upload") + .field("filename", 'some filename') + .expect(422) + .then(res => { + expect(mockPutObject).not.toHaveBeenCalled(); + expect(res.body.error).toBe("missing file") + }) + }); + + it("public-file-upload - should upload file", async () => { + await supertest(app) + .post("/public-file-upload") + .field("filename", 'some_file.txt') + .attach("file", Buffer.from('some data'), 'some_file.txt') + .then(res => { + expect(res.body).toEqual({ + file_type: 'text/plain', + key: expect.stringContaining('some_file.txt'), + }); + expect(mockPutObject).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe("File download", () => { + beforeEach(() => { + getObjectResponse = { + Body: Buffer.from('some data'), + ContentLength: '633', + ContentDisposition: 'inline;filename="some_file.txt"', + ContentEncoding: 'undefined', + CacheControl: 'undefined', + Expires: 'undefined', + LastModified: 'Tue May 31 2022 12:21:37 GMT+0000 (Coordinated Universal Time)', + ETag: 'a4c57ed39e8d869d636ccf5fc34a65a1', + }; + jest.clearAllMocks() + }) + + it("file/public - should not download with incomplete path", async () => { + await supertest(app) + .get("/file/public/somekey") + .expect(404) + }); + + it("file/public - should download", async () => { + await supertest(app) + .get("/file/public/somekey/file_name.txt") + .expect(200) + .then(res => { + expect(mockGetObject).toHaveBeenCalledTimes(1); + }) + }); + + it("file/public - should not download private files", async () => { + const filePath = 'somekey/file_name.txt' + getObjectResponse = { + ...getObjectResponse, + Metadata: { + is_private: 'true' + } + } + + await supertest(app) + .get(`/file/public/${filePath}`) + .expect(400) + .then(res => { + expect(mockGetObject).toHaveBeenCalledTimes(1); + expect(res.body.error).toBe("bad request") + }); + }); + + it("file/private - should not download if file is private", async () => { + const filePath = 'somekey/file_name.txt' + getObjectResponse = { + ...getObjectResponse, + Metadata: { + is_private: 'true' + } + } + + await supertest(app) + .get(`/file/public/${filePath}`) + .expect(400) + .then(res => { + expect(mockGetObject).toHaveBeenCalledTimes(1); + expect(res.body.error).toBe("bad request") + }); + }); + + it("file/private - should download file", async () => { + const filePath = 'somekey/file_name.txt' + + getObjectResponse = { + ...getObjectResponse, + Metadata: { + is_private: 'true' + } + } + + await supertest(app) + .get(`/file/private/${filePath}`) + .set({ 'api-key': 'test' }) + .expect(200) + .then(() => { + expect(mockGetObject).toHaveBeenCalledTimes(1); + }); + }); +}); \ No newline at end of file diff --git a/api.planx.uk/tests/serverErrorHandler.test.js b/api.planx.uk/tests/serverErrorHandler.test.js index 9cb1987b5e..21eb41633d 100644 --- a/api.planx.uk/tests/serverErrorHandler.test.js +++ b/api.planx.uk/tests/serverErrorHandler.test.js @@ -67,8 +67,4 @@ describe("bad requests", () => { test(`app.post("/flows/:flowId/download-schema")`, (done) => { post("/flows/WRONG/download-schema").expect(404, done); }); - - test(`app.post("/sign-s3-upload")`, (done) => { - post("/sign-s3-upload").expect(422, done); - }); }); diff --git a/docker-compose.yml b/docker-compose.yml index 6f43cd50aa..26e8148b58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -104,7 +104,7 @@ services: context: ./scripts/seed-database dockerfile: Dockerfile volumes: - - "./hasura.planx.uk/:/hasura" + - "./hasura.planx.uk/:/hasura" restart: "no" depends_on: hasura-proxy: @@ -170,7 +170,7 @@ services: GOVUK_NOTIFY_REMINDER_EMAIL_TEMPLATE_ID: ${GOVUK_NOTIFY_REMINDER_EMAIL_TEMPLATE_ID} GOVUK_NOTIFY_EXPIRY_EMAIL_TEMPLATE_ID: ${GOVUK_NOTIFY_EXPIRY_EMAIL_TEMPLATE_ID} HASURA_PLANX_API_KEY: ${HASURA_PLANX_API_KEY} - + FILE_API_KEY: ${FILE_API_KEY} sharedb: restart: unless-stopped build: diff --git a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Public.test.tsx b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Public.test.tsx index 3a4589f9fc..49df714c95 100644 --- a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Public.test.tsx @@ -9,16 +9,26 @@ test("recovers previously submitted files when clicking the back button", async const handleSubmit = jest.fn(); const previouslySubmittedData = { "proposal.drawing.locationPlan": - "http://127.0.0.1:9000/planx-temp/tdgg8gvf/file.pdf", + "http://localhost:7002/file/private/slb56xfv/placeholder.png", "property.uploadedFile": { file: { - path: "file.pdf", - type: "application.pdf", + path: "placeholder.png", + size: 6146, }, status: "success", progress: 1, - id: "g5Xy36kAGY2k9xoTxtk_i", - url: "http://127.0.0.1:9000/planx-temp/tdgg8gvf/file.pdf", + id: "43sDL_JNJ6JgYxd_WUYW-", + url: "http://localhost:7002/file/private/slb56xfv/placeholder.png", + }, + cachedFile: { + file: { + path: "placeholder.png", + size: 6146, + }, + status: "success", + progress: 1, + id: "43sDL_JNJ6JgYxd_WUYW-", + url: "http://localhost:7002/file/private/slb56xfv/placeholder.png", }, }; @@ -40,7 +50,7 @@ test("recovers previously submitted files when clicking the back button", async userEvent.click(screen.getByTestId("continue-button")); expect(handleSubmit).toHaveBeenCalledWith({ - data: previouslySubmittedData, + data: expect.objectContaining(previouslySubmittedData), }); }); diff --git a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Upload.tsx b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Upload.tsx index b71d5d2613..24b2d6f279 100644 --- a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Upload.tsx +++ b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/Upload.tsx @@ -6,8 +6,9 @@ import FileIcon from "@material-ui/icons/AttachFile"; import DeleteIcon from "@material-ui/icons/Close"; import CloudUpload from "@material-ui/icons/CloudUpload"; import { visuallyHidden } from "@material-ui/utils"; -import { uploadFile } from "api/upload"; +import { UploadFileResponse, uploadPrivateFile } from "api/upload"; import classNames from "classnames"; +import ImagePreview from "components/ImagePreview"; import { nanoid } from "nanoid"; import React, { useEffect, useState } from "react"; import { FileWithPath, useDropzone } from "react-dropzone"; @@ -143,7 +144,7 @@ export default function FileUpload(props: Props) { multiple: false, onDrop: ([file]: FileWithPath[]) => { // XXX: This is a non-blocking promise chain - uploadFile(file, { + uploadPrivateFile(file, { onProgress: (progress) => { setSlot((_file: any) => ({ ..._file, progress })); }, @@ -191,7 +192,8 @@ export default function FileUpload(props: Props) { aria-valuenow={slot?.progress || 0} /> - {slot?.file.type.includes("image") ? ( + {slot?.file instanceof File && + slot?.file?.type?.includes("image") ? ( ) : ( @@ -250,17 +252,6 @@ export default function FileUpload(props: Props) { ); } -function ImagePreview({ file }: any) { - const { current: url } = React.useRef(URL.createObjectURL(file)); - useEffect(() => { - return () => { - // Cleanup to free up memory - URL.revokeObjectURL(url); - }; - }, [url]); - return ; -} - function formatBytes(a: any, b = 2) { if (0 === a) return "0 Bytes"; const c = 0 > b ? 0 : b, diff --git a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/index.tsx b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/index.tsx index ee7eb66d67..b991d36dbc 100644 --- a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/index.tsx +++ b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/index.tsx @@ -52,8 +52,7 @@ export default function Component(props: Props) { props.previouslySubmittedData?.data?.[props.dataFieldBoundary]; const previousArea = props.previouslySubmittedData?.data?.[props.dataFieldArea]; - const previousFile = - props.previouslySubmittedData?.data?.[PASSPORT_UPLOADED_FILE_KEY]; + const previousFile = props.previouslySubmittedData?.data?.cachedFile; const startPage = previousFile ? "upload" : "draw"; const [page, setPage] = useState<"draw" | "upload">(startPage); const passport = useStore((state) => state.computePassport()); @@ -203,6 +202,16 @@ export default function Component(props: Props) { : undefined, [PASSPORT_UPLOADED_FILE_KEY]: selectedFile && propsDataFieldUrl ? selectedFile : undefined, + cachedFile: selectedFile + ? { + ...selectedFile, + file: { + path: selectedFile.file.path, + size: selectedFile.file.size, + type: selectedFile.file.type, + }, + } + : undefined, }; })(); diff --git a/editor.planx.uk/src/@planx/components/FileUpload/Public.test.tsx b/editor.planx.uk/src/@planx/components/FileUpload/Public.test.tsx index 672952625c..b9a3458775 100644 --- a/editor.planx.uk/src/@planx/components/FileUpload/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/FileUpload/Public.test.tsx @@ -69,17 +69,18 @@ test("recovers previously submitted files when clicking the back button even if test.todo("cannot continue until uploads have finished"); const dummyFile = { - url: "http://127.0.0.1:9000/planx-temp/4oh73out/PXL_20210327_122515714.pdf", - filename: "PXL_20210327_122515714.pdf", + url: "http://localhost:7002/file/private/y2uubi9x/placeholder.png", + filename: "placeholder.png", cachedSlot: { file: { - path: "PXL_20210327_122515714.pdf", - type: "application/pdf", + path: "placeholder.png", + type: "image/png", + size: 6146, }, status: "success", progress: 1, - id: "2vBmuynz-3D_EN-H2gF2E", - url: "http://127.0.0.1:9000/planx-temp/4oh73out/PXL_20210327_122515714.pdf", + id: "oPd5GUV_T-bWZWJb0wGs8", + url: "http://localhost:7002/file/private/y2uubi9x/placeholder.png", }, }; diff --git a/editor.planx.uk/src/@planx/components/FileUpload/Public.tsx b/editor.planx.uk/src/@planx/components/FileUpload/Public.tsx index 7bb7637469..8525641f21 100644 --- a/editor.planx.uk/src/@planx/components/FileUpload/Public.tsx +++ b/editor.planx.uk/src/@planx/components/FileUpload/Public.tsx @@ -9,8 +9,9 @@ import { visuallyHidden } from "@material-ui/utils"; import { MoreInformation } from "@planx/components/shared"; import Card from "@planx/components/shared/Preview/Card"; import QuestionHeader from "@planx/components/shared/Preview/QuestionHeader"; -import { uploadFile } from "api/upload"; +import { uploadPrivateFile } from "api/upload"; import classNames from "classnames"; +import ImagePreview from "components/ImagePreview"; import { nanoid } from "nanoid"; import { Store } from "pages/FlowEditor/lib/store"; import type { handleSubmit } from "pages/Preview/Node"; @@ -242,7 +243,7 @@ function Dropzone(props: any) { ...acceptedFiles.map((file) => { // XXX: This is a non-blocking promise chain // If a file is removed while it's being uploaded, nothing should break because we're using map() - uploadFile(file, { + uploadPrivateFile(file, { onProgress: (progress) => { setSlots((_files: any) => _files.map((_file: any) => @@ -316,7 +317,7 @@ function Dropzone(props: any) { aria-valuenow={progress} /> - {file.type.includes("image") ? ( + {file instanceof File && file.type.includes("image") ? ( ) : ( @@ -376,20 +377,6 @@ function Dropzone(props: any) { ); } -function ImagePreview({ file, url: parentUrl }: any) { - const { current: url } = React.useRef( - file instanceof File ? URL.createObjectURL(file) : parentUrl - ); - - useEffect(() => { - return () => { - // Cleanup to free up memory - URL.revokeObjectURL(url); - }; - }, [url]); - return ; -} - function formatBytes(a: any, b = 2) { if (0 === a) return "0 Bytes"; const c = 0 > b ? 0 : b, diff --git a/editor.planx.uk/src/@planx/components/Review/Public.test.tsx b/editor.planx.uk/src/@planx/components/Review/Public.test.tsx index 94338176ad..2d1963b87f 100644 --- a/editor.planx.uk/src/@planx/components/Review/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/Review/Public.test.tsx @@ -2,9 +2,16 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import axe from "axe-helper"; import React from "react"; +import { act } from "react-dom/test-utils"; +import waitForExpect from "wait-for-expect"; import Review from "./Public/Presentational"; +jest.mock("../../../api/download.ts", () => ({ + __esModule: true, + downloadFile: jest.fn(() => Promise.resolve({})), +})); + test("renders correctly", async () => { const handleSubmit = jest.fn(); @@ -104,3 +111,210 @@ it("should not have any accessibility violations", async () => { const results = await axe(container); expect(results).toHaveNoViolations(); }); + +const mockLink = "my-file.png"; +const uploadedPlanUrl = "http://someurl.com/whjhnh65/plan.png"; + +beforeEach(() => { + global.URL = { + createObjectURL: jest.fn(() => mockLink), + } as any; +}); + +it("should render file upload link", async () => { + const { getByTestId } = render( + {}} + showChangeButton={true} + /> + ); + + const element = getByTestId("file-upload-name"); + + await act(async () => { + await waitForExpect(() => { + expect(element).toHaveTextContent(mockLink); + }); + }); +}); + +it("should render uploaded location plan link", async () => { + const { getByTestId } = render( + {}} + showChangeButton={true} + /> + ); + + const element = getByTestId("uploaded-plan-name"); + + await act(async () => { + await waitForExpect(() => { + expect(element).toBeInTheDocument(); + }); + }); +}); + +const fileUploadBreadcrumbs = { + fileUpload: { + auto: false, + data: { + fileUpload: [ + { + filename: "my-file.png", + }, + ], + }, + }, +}; + +const fileUploadFlow = { + _root: { + edges: ["fileUpload", "review"], + }, + review: { + data: { + title: "Check your answers before sending your application", + }, + type: 600, + }, + fileUpload: { + data: { + color: "#EFEFEF", + }, + type: 140, + }, +}; + +const fileUploadPassport = { + data: { + fileUpload: [ + { + serverFile: { + fileHash: + "8c91f81b213865af9632d2296039984753f0e41cfe234ba1dce65ad58568209b", + fileId: "0702enth/my-file.png", + }, + filename: "my-file.png", + }, + ], + }, +}; + +const uploadedPlansBreadcrumb = { + IHTyNVZqon: { + auto: false, + data: { + _address: { + uprn: "200003453481", + blpu_code: "2", + latitude: 51.4858354, + longitude: -0.0761504, + organisation: null, + pao: "49", + street: "COBOURG ROAD", + town: "LONDON", + postcode: "SE5 0HU", + x: 533676, + y: 178075, + planx_description: "Terrace", + planx_value: "residential.dwelling.house.terrace", + single_line_address: "49, COBOURG ROAD, LONDON, SOUTHWARK, SE5 0HU", + title: "49, COBOURG ROAD, LONDON", + }, + "property.type": ["residential.dwelling.house.terrace"], + "property.localAuthorityDistrict": ["Southwark"], + "property.region": ["London"], + }, + }, + EO6DzPso8o: { + auto: false, + data: { + "proposal.drawing.locationPlan": uploadedPlanUrl, + "property.uploadedFile": { + file: { + path: "fut.email.png", + }, + status: "success", + progress: 1, + id: "u6jFS4xJ-MM9Gsg1o2ZsI", + url: uploadedPlanUrl, + }, + }, + }, +}; + +const uploadedPlansPassport = { + data: { + _address: { + uprn: "200003453481", + blpu_code: "2", + latitude: 51.4858354, + longitude: -0.0761504, + organisation: null, + pao: "49", + street: "COBOURG ROAD", + town: "LONDON", + postcode: "SE5 0HU", + x: 533676, + y: 178075, + planx_description: "Terrace", + planx_value: "residential.dwelling.house.terrace", + single_line_address: "49, COBOURG ROAD, LONDON, SOUTHWARK, SE5 0HU", + title: "49, COBOURG ROAD, LONDON", + }, + "property.type": ["residential.dwelling.house.terrace"], + "property.localAuthorityDistrict": ["Southwark"], + "property.region": ["London"], + "proposal.drawing.locationPlan": { + url: uploadedPlanUrl, + }, + "property.uploadedFile": { + file: { + path: "fut.email.png", + }, + status: "success", + progress: 1, + id: "u6jFS4xJ-MM9Gsg1o2ZsI", + url: uploadedPlanUrl, + }, + }, +}; + +const drawBoundaryFlow = { + _root: { + edges: ["IHTyNVZqon", "EO6DzPso8o", "ZNUl9Kr2ib"], + }, + EO6DzPso8o: { + data: { + title: "Draw the boundary of the property", + dataFieldArea: "property.boundary.area", + hideFileUpload: false, + dataFieldBoundary: "property.boundary.site", + titleForUploading: "Upload a location plan", + }, + type: 10, + }, + IHTyNVZqon: { + data: { + description: "

For example CM7 3YL

", + }, + type: 9, + }, + ZNUl9Kr2ib: { + data: { + title: "Check your answers before sending your application", + }, + type: 600, + }, +}; diff --git a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/files.test.ts b/editor.planx.uk/src/@planx/components/Send/bops/__tests__/files.test.ts index 06f212ab26..9c536d3166 100644 --- a/editor.planx.uk/src/@planx/components/Send/bops/__tests__/files.test.ts +++ b/editor.planx.uk/src/@planx/components/Send/bops/__tests__/files.test.ts @@ -24,8 +24,19 @@ test("makes file object", () => { data: { "property.drawing.elevation": [ { - url: "http://example.com/planning-application-location-plan.jpeg", - filename: "planning-application-location-plan.jpeg", + url: "http://localhost:7002/file/private/y2uubi9x/placeholder.png", + filename: "placeholder.png", + cachedSlot: { + file: { + path: "placeholder.png", + type: "image/png", + size: 6146, + }, + status: "success", + progress: 1, + id: "oPd5GUV_T-bWZWJb0wGs8", + url: "http://localhost:7002/file/private/y2uubi9x/placeholder.png", + }, }, ], }, @@ -35,8 +46,19 @@ test("makes file object", () => { data: { "property.drawing.elevation": [ { - url: "http://example.com/planning-application-location-plan.jpeg", - filename: "planning-application-location-plan.jpeg", + url: "http://localhost:7002/file/private/y2uubi9x/placeholder.png", + filename: "placeholder.png", + cachedSlot: { + file: { + path: "placeholder.png", + type: "image/png", + size: 6146, + }, + status: "success", + progress: 1, + id: "oPd5GUV_T-bWZWJb0wGs8", + url: "http://localhost:7002/file/private/y2uubi9x/placeholder.png", + }, }, ], }, @@ -45,10 +67,9 @@ test("makes file object", () => { const actual = getParams(breadcrumbs, flow, passport, "123").files; const expected = [ - { - filename: "http://example.com/planning-application-location-plan.jpeg", - tags: ["Existing", "Elevation"], - }, + expect.objectContaining({ + filename: "http://localhost:7002/file/private/y2uubi9x/placeholder.png", + }), ]; expect(actual).toEqual(expected); diff --git a/editor.planx.uk/src/@planx/components/Send/uniform/index.ts b/editor.planx.uk/src/@planx/components/Send/uniform/index.ts index e1e120a1b4..c3ac2af2ef 100644 --- a/editor.planx.uk/src/@planx/components/Send/uniform/index.ts +++ b/editor.planx.uk/src/@planx/components/Send/uniform/index.ts @@ -1,3 +1,4 @@ +import { UploadFileResponse } from "api/upload"; import omit from "lodash/omit"; import { Store } from "../../../../pages/FlowEditor/lib/store"; @@ -6,6 +7,11 @@ import { CSVData } from "../model"; import { UniformInstance } from "./applicationType"; import { makeXmlString } from "./xml"; +type UniformFile = { + name: string; + url: string; +}; + export function getUniformParams( breadcrumbs: Store.breadcrumbs, flow: Store.flow, @@ -14,7 +20,7 @@ export function getUniformParams( uniformInstance: UniformInstance ) { // make a list of all S3 URLs & filenames from uploaded files - const files: { url: string; name: string }[] = []; + const files: UniformFile[] = []; Object.entries(passport.data || {}) // add any files uploaded via a FileUpload component .filter(([, v]: any) => v?.[0]?.url) diff --git a/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx b/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx index b96ecfe40f..6483e4b796 100644 --- a/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx +++ b/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx @@ -221,9 +221,7 @@ function FileUpload(props: ComponentProps) {
    {getAnswersByNode(props)?.map((file: any, i: number) => (
  • - - {file.filename} - + {file.filename}
  • ))}
@@ -246,6 +244,8 @@ function DrawBoundary(props: ComponentProps) { const geodata = props.userData?.data?.[props.node.data?.dataFieldBoundary]; const locationPlan = props.userData?.data?.[PASSPORT_UPLOAD_KEY]; + const fileName = locationPlan ? locationPlan.split("/").pop() : ""; + if (!geodata && !locationPlan && !props.node.data?.hideFileUpload) { // XXX: we always expect to have data, this is for temporary debugging console.error(props); @@ -258,10 +258,10 @@ function DrawBoundary(props: ComponentProps) { <>
Site boundary
- {locationPlan && ( - - Your uploaded location plan - + {fileName && ( + + Your uploaded location plan: {fileName} + )} {geodata && ( <> diff --git a/editor.planx.uk/src/@planx/components/shared/hooks.ts b/editor.planx.uk/src/@planx/components/shared/hooks.ts index 460bfcf112..d1dd7a0c75 100644 --- a/editor.planx.uk/src/@planx/components/shared/hooks.ts +++ b/editor.planx.uk/src/@planx/components/shared/hooks.ts @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { useCurrentRoute } from "react-navi"; /** @@ -12,3 +13,35 @@ export const useTeamSlug = () => { const route = useCurrentRoute(); return route?.data?.team; }; + +export type UseFileUrlProps = + | { file: File } + | { url: string } + | { file: File; url: string }; + +/** + * Returns fileUrl for uploaded files, either private or public. + */ +export const useFileUrl = (props: UseFileUrlProps) => { + const [fileUrl, setFileUrl] = useState(""); + + useEffect(() => { + if ("file" in props && props.file instanceof File) { + setFileUrl(URL.createObjectURL(props.file)); + } else if ("url" in props && props.url) { + // XXX: Backwards compatibility to accept files uploaded directly to S3. + setFileUrl(props.url); + } + + return () => { + if (fileUrl) { + // Cleanup to free up memory + URL.revokeObjectURL(fileUrl); + } + }; + }, []); + + return { + fileUrl, + }; +}; diff --git a/editor.planx.uk/src/api/download.ts b/editor.planx.uk/src/api/download.ts new file mode 100644 index 0000000000..c4e8fdb2d6 --- /dev/null +++ b/editor.planx.uk/src/api/download.ts @@ -0,0 +1,15 @@ +export { downloadFile, getPrivateFileURL }; + +function getPrivateFileURL(fileKey: string) { + return `${process.env.REACT_APP_API_URL}/file/private/${fileKey}`; +} + +async function downloadFile(fileKey: string, fileHash: string) { + const res = await fetch(getPrivateFileURL(fileKey), { + method: "GET", + headers: { + "file-hash": fileHash, + }, + }); + return res.blob(); +} diff --git a/editor.planx.uk/src/api/upload.ts b/editor.planx.uk/src/api/upload.ts index 39c72f4ad2..d182f8e00b 100644 --- a/editor.planx.uk/src/api/upload.ts +++ b/editor.planx.uk/src/api/upload.ts @@ -1,25 +1,49 @@ import axios from "axios"; -export { uploadFile }; +export { uploadPrivateFile, uploadPublicFile }; -async function uploadFile( +export type UploadFileResponse = string; + +async function uploadPublicFile( file: any, { onProgress }: { onProgress?: (p: any) => void } = {} ) { - const res = await fetch(`${process.env.REACT_APP_API_URL}/sign-s3-upload`, { - method: "POST", - body: JSON.stringify({ - filename: file.name, - }), - headers: { - "Content-Type": "application/json", - }, - }); - const token = await res.json(); - await axios.put(token.upload_to, file, { + const { data } = await handleUpload(file, { onProgress, path: "public" }); + + return `${process.env.REACT_APP_API_URL}/file/public/${data.key}`; +} + +async function uploadPrivateFile( + file: any, + { onProgress }: { onProgress?: (p: any) => void } = {} +) { + const { data } = await handleUpload(file, { onProgress, path: "private" }); + + return `${process.env.REACT_APP_API_URL}/file/private/${data.key}`; +} + +function handleUpload( + file: any, + { + onProgress, + path: path, + }: { onProgress?: (p: any) => void; path: "public" | "private" } +) { + const formData = new FormData(); + + formData.append("file", file); + formData.append("filename", file.name); + + const paths = { + public: "public-file-upload", + private: "private-file-upload", + }; + + const endpoint = paths[path]; + + return axios.post(`${process.env.REACT_APP_API_URL}/${endpoint}`, formData, { headers: { - "Content-Type": file.type, - "Content-Disposition": `inline;filename="${file.name}"`, + "Content-Type": "multipart/form-data", }, onUploadProgress: ({ loaded, total }) => { if (onProgress) { @@ -27,5 +51,4 @@ async function uploadFile( } }, }); - return token.public_readonly_url_will_be; } diff --git a/editor.planx.uk/src/components/ImagePreview.tsx b/editor.planx.uk/src/components/ImagePreview.tsx new file mode 100644 index 0000000000..3bc54df326 --- /dev/null +++ b/editor.planx.uk/src/components/ImagePreview.tsx @@ -0,0 +1,8 @@ +import { useFileUrl, UseFileUrlProps } from "@planx/components/shared/hooks"; +import React from "react"; + +export default function ImagePreview(props: UseFileUrlProps) { + const { fileUrl } = useFileUrl(props); + + return ; +} diff --git a/editor.planx.uk/src/ui/FileUpload.tsx b/editor.planx.uk/src/ui/FileUpload.tsx index 30f2fa45ab..1b1ad29768 100644 --- a/editor.planx.uk/src/ui/FileUpload.tsx +++ b/editor.planx.uk/src/ui/FileUpload.tsx @@ -4,7 +4,7 @@ import { makeStyles } from "@material-ui/core/styles"; import Tooltip from "@material-ui/core/Tooltip"; import ErrorIcon from "@material-ui/icons/Error"; import Image from "@material-ui/icons/Image"; -import { uploadFile } from "api/upload"; +import { uploadPublicFile } from "api/upload"; import React, { useCallback, useEffect, useState } from "react"; import { useDropzone } from "react-dropzone"; @@ -52,7 +52,7 @@ export default function FileUpload(props: Props): FCReturn { setStatus({ type: "loading", }); - uploadFile(file) + uploadPublicFile(file) .then((res) => { setStatus({ type: "none", diff --git a/infrastructure/application/index.ts b/infrastructure/application/index.ts index b141b6b204..5a6b31e326 100644 --- a/infrastructure/application/index.ts +++ b/infrastructure/application/index.ts @@ -9,6 +9,7 @@ import * as postgres from "@pulumi/postgresql"; import * as mime from "mime"; import * as tldjs from "tldjs"; import * as url from "url"; +import * as random from "@pulumi/random"; import { generateTeamSecrets } from "./utils/generateTeamSecrets"; import { createHasuraService } from "./services/hasura"; @@ -279,6 +280,9 @@ export = async () => { sslPolicy: "ELBSecurityPolicy-TLS-1-2-Ext-2018-06", certificateArn: certificates.requireOutput("certificateArn"), }); + const fileApiKey = new random.RandomPassword("file-api-key", { + length: 44, + }).result; const apiService = new awsx.ecs.FargateService("api", { cluster, subnets: networking.requireOutput("publicSubnetIds"), @@ -301,6 +305,10 @@ export = async () => { value: pulumi.interpolate`${apiBucket.bucket}`, }, { name: "AWS_S3_ACL", value: "public-read" }, + { + name: "FILE_API_KEY", + value: fileApiKey, + }, { name: "GOOGLE_CLIENT_ID", value: config.require("google-client-id"), @@ -708,6 +716,7 @@ export = async () => { return { customDomains, + fileApiKey, }; }; diff --git a/infrastructure/application/package.json b/infrastructure/application/package.json index 5ca08ce47a..f51947a061 100644 --- a/infrastructure/application/package.json +++ b/infrastructure/application/package.json @@ -11,6 +11,7 @@ "@pulumi/docker": "^3.2.0", "@pulumi/postgresql": "^3.4.0", "@pulumi/pulumi": "^3.33.2", + "@pulumi/random": "^4.8.2", "@types/mime": "^2.0.3", "mime": "^3.0.0", "tldjs": "^2.3.1" diff --git a/infrastructure/application/pnpm-lock.yaml b/infrastructure/application/pnpm-lock.yaml index 377971c166..a37af11110 100644 --- a/infrastructure/application/pnpm-lock.yaml +++ b/infrastructure/application/pnpm-lock.yaml @@ -8,6 +8,7 @@ specifiers: '@pulumi/docker': ^3.2.0 '@pulumi/postgresql': ^3.4.0 '@pulumi/pulumi': ^3.33.2 + '@pulumi/random': ^4.8.2 '@types/mime': ^2.0.3 '@types/node': ^17.0.38 '@types/tldjs': ^2.3.1 @@ -22,6 +23,7 @@ dependencies: '@pulumi/docker': 3.2.0 '@pulumi/postgresql': 3.4.0 '@pulumi/pulumi': 3.33.2 + '@pulumi/random': 4.8.2 '@types/mime': 2.0.3 mime: 3.0.0 tldjs: 2.3.1 @@ -181,6 +183,13 @@ packages: resolution: {integrity: sha512-xfo+yLRM2zVjVEA4p23IjQWzyWl1ZhWOGobsBqRpIarzLvwNH/RAGaoehdxlhx4X92302DrpdIFgTICMN4P38w==} dev: false + /@pulumi/random/4.8.2: + resolution: {integrity: sha512-XDQ2OV+VW5Bpn9nxVqsEiKD3DAEeMd8ngZPLpioJWCiHzsBMSrYfP97MkdGDaBsDvS6/NL1Bvk0VQXXooFl3Yw==} + requiresBuild: true + dependencies: + '@pulumi/pulumi': 3.33.2 + dev: false + /@types/aws-lambda/8.10.75: resolution: {integrity: sha512-orOKSsIVUMsAbKgbSX2ST3FwQt9pxinHVCAIAVl4SmmTxmki2Gu+cGqobMD3eYwDV5FV0YNtaXyxnvE9pLrKTw==} dev: false