diff --git a/api.planx.uk/.env.test.example b/api.planx.uk/.env.test.example index 5c208c4cf4..a8332ce35c 100644 --- a/api.planx.uk/.env.test.example +++ b/api.planx.uk/.env.test.example @@ -39,3 +39,4 @@ UNIFORM_TOKEN_URL=👻 UNIFORM_SUBMISSION_URL=👻 SLACK_WEBHOOK_URL=👻 +FILE_API_KEY=👻 \ No newline at end of file diff --git a/api.planx.uk/Dockerfile b/api.planx.uk/Dockerfile index 7b0b994ae2..1dc8159463 100644 --- a/api.planx.uk/Dockerfile +++ b/api.planx.uk/Dockerfile @@ -32,7 +32,7 @@ ENV NODE_ENV development ADD . . RUN pnpm fetch -RUN pnpm install --recursive --offline +RUN pnpm install --recursive --offline -f COPY . . CMD ["pnpm", "dev"] diff --git a/api.planx.uk/package.json b/api.planx.uk/package.json index 5518a0e00e..115fba4144 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", @@ -54,6 +55,7 @@ "@types/jsonwebtoken": "^8.5.9", "@types/mime": "^3.0.1", "@types/node": "^16.18.2", + "@types/multer": "^1.4.7", "@types/passport": "^1.0.11", "@types/passport-google-oauth20": "^2.0.11", "@types/supertest": "^2.0.12", diff --git a/api.planx.uk/pnpm-lock.yaml b/api.planx.uk/pnpm-lock.yaml index 70cb6e4d80..3fc587be46 100644 --- a/api.planx.uk/pnpm-lock.yaml +++ b/api.planx.uk/pnpm-lock.yaml @@ -18,6 +18,7 @@ specifiers: '@types/jest': ^28.1.8 '@types/jsonwebtoken': ^8.5.9 '@types/mime': ^3.0.1 + '@types/multer': ^1.4.7 '@types/node': ^16.18.2 '@types/passport': ^1.0.11 '@types/passport-google-oauth20': ^2.0.11 @@ -50,6 +51,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 @@ -90,6 +92,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 @@ -109,6 +112,7 @@ devDependencies: '@types/jest': 28.1.8 '@types/jsonwebtoken': 8.5.9 '@types/mime': 3.0.1 + '@types/multer': 1.4.7 '@types/node': 16.18.2 '@types/passport': 1.0.11 '@types/passport-google-oauth20': 2.0.11 @@ -1249,6 +1253,12 @@ packages: resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==} dev: true + /@types/multer/1.4.7: + resolution: {integrity: sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==} + dependencies: + '@types/express': 4.17.14 + dev: true + /@types/node/16.18.2: resolution: {integrity: sha512-KIGQJyya+opDCFvDSZMNNS899ov5jlNdtN7PypgHWeb8e+5vWISdwTRo/ClsNVlmDihzOGqFyNBDamUs7TQQCA==} @@ -1430,6 +1440,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 @@ -1773,7 +1787,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==} @@ -1783,6 +1796,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'} @@ -1951,6 +1972,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'} @@ -2018,6 +2049,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'} @@ -2195,6 +2230,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 @@ -2241,7 +2284,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.4.284: @@ -3473,6 +3516,10 @@ 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=} @@ -4354,7 +4401,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==} @@ -4364,6 +4410,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'} @@ -4381,6 +4434,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} @@ -4523,7 +4591,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 @@ -4821,6 +4889,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 @@ -4913,6 +4985,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'} @@ -5029,7 +5122,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==} @@ -5314,6 +5406,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'} @@ -5353,6 +5450,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: @@ -5683,6 +5790,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'} @@ -5912,7 +6023,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.ts b/api.planx.uk/s3.ts deleted file mode 100644 index b0adc38a2e..0000000000 --- a/api.planx.uk/s3.ts +++ /dev/null @@ -1,62 +0,0 @@ -import assert from "assert"; -import S3 from "aws-sdk/clients/s3"; -import { customAlphabet } from "nanoid"; -import { getType } from "mime"; - -const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8); - -assert(process.env.AWS_S3_BUCKET); -assert(process.env.AWS_S3_REGION); -assert(process.env.AWS_ACCESS_KEY); -assert(process.env.AWS_SECRET_KEY); - -interface SignedFile { - fileType: string | null; - key: string; - acl?: string; - url: string; -} - -const signS3Upload = async (filename: string): Promise => { - 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 url = await s3.getSignedUrlPromise("putObject", params); - return { - fileType, - key, - acl: process.env.AWS_S3_ACL, - url, - }; -}; - -function useMinio() { - if (process.env.NODE_ENV === "production") { - // Points to AWS - return {}; - } else { - // Points to Minio - return { - endpoint: "http://127.0.0.1:9000", - s3ForcePathStyle: true, - signatureVersion: "v4", - }; - } -} - -export { signS3Upload }; diff --git a/api.planx.uk/s3/controller.ts b/api.planx.uk/s3/controller.ts new file mode 100644 index 0000000000..dd613d6a12 --- /dev/null +++ b/api.planx.uk/s3/controller.ts @@ -0,0 +1,90 @@ +import assert from "assert"; +import { uploadPrivateFile, uploadPublicFile } from "./uploadFile"; +import { getFileFromS3 } from "./getFile"; +import { NextFunction, Request, Response } from "express"; +import { buildFilePath } from "./utils"; + +assert(process.env.AWS_S3_BUCKET); +assert(process.env.AWS_S3_REGION); +assert(process.env.AWS_ACCESS_KEY); +assert(process.env.AWS_SECRET_KEY); + +export const privateUploadController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + 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 uploadPrivateFile(req.file, req.body.filename); + + res.json(fileResponse); + } catch (err) { + next(err); + } +}; + +export const publicUploadController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + 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); + } +}; + +export const publicDownloadController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + 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); + } +}; + +export const privateDownloadController = async ( + req: Request, + res: Response, + next: NextFunction +) => { + 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); + } +}; diff --git a/api.planx.uk/s3/getFile.ts b/api.planx.uk/s3/getFile.ts new file mode 100644 index 0000000000..a2ad6efe33 --- /dev/null +++ b/api.planx.uk/s3/getFile.ts @@ -0,0 +1,27 @@ +import S3 from "aws-sdk/clients/s3"; +import { s3Factory } from "./utils"; + +export const getFileFromS3 = async (fileId: string) => { + const s3 = s3Factory(); + + const params = { + Key: fileId, + } as S3.PutObjectRequest; + + 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, + }, + }; +}; diff --git a/api.planx.uk/s3/index.ts b/api.planx.uk/s3/index.ts new file mode 100644 index 0000000000..b3b4e67951 --- /dev/null +++ b/api.planx.uk/s3/index.ts @@ -0,0 +1,13 @@ +import { + privateUploadController, + publicUploadController, + publicDownloadController, + privateDownloadController, +} from "./controller"; + +export { + privateUploadController, + publicUploadController, + publicDownloadController, + privateDownloadController, +}; diff --git a/api.planx.uk/s3/uploadFile.ts b/api.planx.uk/s3/uploadFile.ts new file mode 100644 index 0000000000..ff6170b4c4 --- /dev/null +++ b/api.planx.uk/s3/uploadFile.ts @@ -0,0 +1,67 @@ +import S3 from "aws-sdk/clients/s3"; +import { customAlphabet } from "nanoid"; +import { getType } from "mime"; +import { s3Factory } from "./utils"; +const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8); + +export const uploadPublicFile = async ( + file: Express.Multer.File, + filename: string +) => { + const s3 = s3Factory(); + + const { params, key, fileType } = generateFileParams(file, filename); + + await s3.putObject(params).promise(); + + return { + file_type: fileType, + key, + }; +}; + +export const uploadPrivateFile = async ( + file: Express.Multer.File, + filename: string +) => { + 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, + }; +}; + +export function generateFileParams( + file: Express.Multer.File, + filename: string +): { + params: S3.PutObjectRequest; + fileType: string | null; + key: string; +} { + 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, + } as S3.PutObjectRequest; + + return { + fileType, + params, + key, + }; +} diff --git a/api.planx.uk/s3/utils.ts b/api.planx.uk/s3/utils.ts new file mode 100644 index 0000000000..32b48c60f4 --- /dev/null +++ b/api.planx.uk/s3/utils.ts @@ -0,0 +1,36 @@ +import S3 from "aws-sdk/clients/s3"; + +export 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 useMinio() { + if (process.env.NODE_ENV === "production") { + // Points to AWS + return {}; + } else { + // Points to Minio + return { + endpoint: "http://127.0.0.1:9000", + s3ForcePathStyle: true, + signatureVersion: "v4", + }; + } +} + +export function buildFilePath( + fileKey: string, + fileName: string +): string | null { + if (!fileKey || !fileName) { + return null; + } + + return `${fileKey}/${fileName}`; +} diff --git a/api.planx.uk/send/uniform.js b/api.planx.uk/send/uniform.js index 1b0d464921..3daff37bd9 100644 --- a/api.planx.uk/send/uniform.js +++ b/api.planx.uk/send/uniform.js @@ -5,7 +5,8 @@ import FormData from "form-data"; import fs from "fs"; import AdmZip from "adm-zip"; import str from "string-to-stream"; -import { stringify } from "csv-stringify"; +import stringify from "csv-stringify"; +import { getFileFromS3 } from "../s3"; import { adminGraphQLClient } from "../hasura"; import { markSessionAsSubmitted } from "../saveAndReturn/utils"; import { gql } from "graphql-request"; @@ -14,7 +15,7 @@ const client = adminGraphQLClient; /** * Submits application data to Uniform - * + * * first, create a zip folder containing an XML (Idox's schema), CSV (our format), and any user-uploaded files * then, make requests to Uniform's "Submission API" to authenticate, create a submission, and attach the zip to the submission * finally, insert a record into uniform_applications for future auditing @@ -23,16 +24,16 @@ const sendToUniform = async (req, res, next) => { if (!getUniformClient(req.params.localAuthority)) { return next({ status: 400, - message: "Idox/Uniform connector is not enabled for this local authority" + message: "Idox/Uniform connector is not enabled for this local authority", }); } - + // `/uniform/:localAuthority` is only called via Hasura's scheduled event webhook now, so body is wrapped in a "payload" key const { payload } = req.body; if (!payload?.xml || !payload?.sessionId) { return next({ status: 400, - message: "Missing application data to send to Uniform" + message: "Missing application data to send to Uniform", }); } @@ -47,24 +48,40 @@ const sendToUniform = async (req, res, next) => { } try { - const { clientId, clientSecret } = getUniformClient(req.params.localAuthority); + const { clientId, clientSecret } = getUniformClient( + req.params.localAuthority + ); // Setup - Create the zip folder - const zipPath = await createZip(payload?.xml, payload?.csv, payload?.files, payload?.sessionId); + const zipPath = await createZip( + payload?.xml, + payload?.csv, + payload?.files, + payload?.sessionId + ); // Request 1/3 - Authenticate const { access_token: token, "organisation-name": organisation, - "organisation-id": organisationId + "organisation-id": organisationId, } = await authenticate(clientId, clientSecret); // 2/3 - Create a submission if (token) { - const idoxSubmissionId = await createSubmission(token, organisation, organisationId, payload?.sessionId); + const idoxSubmissionId = await createSubmission( + token, + organisation, + organisationId, + payload?.sessionId + ); // 3/3 - Attach the zip & create an audit entry if (idoxSubmissionId) { - const attachmentAdded = await attachArchive(token, idoxSubmissionId, zipPath); + const attachmentAdded = await attachArchive( + token, + idoxSubmissionId, + zipPath + ); if (attachmentAdded) { deleteFile(zipPath); } @@ -131,7 +148,7 @@ const sendToUniform = async (req, res, next) => { /** * Query the Uniform audit table to see if we already have an application for this session - * @param {string} sessionId + * @param {string} sessionId * @returns {object|undefined} most recent uniform_applications.response */ async function checkUniformAuditTable(sessionId) { @@ -151,18 +168,18 @@ async function checkUniformAuditTable(sessionId) { } `, { - submission_reference: sessionId + submission_reference: sessionId, } ); return application?.uniform_applications[0]?.response; -}; +} /** * 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 */ @@ -183,10 +200,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); } } @@ -194,7 +213,10 @@ async function createZip(stringXml, csv, files, sessionId) { const csvPath = path.join(tmpDir, "application.csv"); const csvFile = fs.createWriteStream(csvPath); - const csvStream = stringify(csv, { columns: ["question", "responses", "metadata"], header: true }).pipe(csvFile); + const csvStream = stringify(csv, { + columns: ["question", "responses", "metadata"], + header: true, + }).pipe(csvFile); await new Promise((resolve, reject) => { csvStream.on("error", reject); csvStream.on("finish", resolve); @@ -202,7 +224,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"; @@ -230,12 +251,12 @@ async function createZip(stringXml, csv, files, sessionId) { } catch (err) { throw err; } -}; +} /** * Logs in to the Idox Submission API using a username/password * and returns an access token - * + * * @param {string} clientId - idox-generated client ID * @param {string} clientSecret - idox-generated client secret * @returns {Promise} - access token @@ -246,7 +267,9 @@ async function authenticate(clientId, clientSecret) { const authOptions = { method: "POST", headers: new Headers({ - "Authorization": 'Basic ' + Buffer.from(clientId + ":" + clientSecret).toString("base64"), + Authorization: + "Basic " + + Buffer.from(clientId + ":" + clientSecret).toString("base64"), "Content-type": "application/x-www-form-urlencoded", }), body: new URLSearchParams({ @@ -258,75 +281,89 @@ async function authenticate(clientId, clientSecret) { }; try { - return await fetch(authEndpoint, authOptions) - .then(response => response.json()); + return await fetch(authEndpoint, authOptions).then((response) => + response.json() + ); } catch (err) { throw err; } -}; +} /** * Creates a submission (submissionReference is unique value provided by RIPA & must match XML ) * and returns a submissionId parsed from the resource link - * + * * @param {string} token - access token retrieved from idox authentication * @param {string} organisation - idox-generated organisation name * @param {number} organisationId - idox-generated organisation id * @param {string} sessionId - ripa-generated sessionId * @returns {Promise} - idox-generated submissionId */ -async function createSubmission(token, organisation, organisationId, sessionId = "TEST") { - const createSubmissionEndpoint = process.env.UNIFORM_SUBMISSION_URL + "/secure/submission"; +async function createSubmission( + token, + organisation, + organisationId, + sessionId = "TEST" +) { + const createSubmissionEndpoint = + process.env.UNIFORM_SUBMISSION_URL + "/secure/submission"; const isStaging = process.env.UNIFORM_SUBMISSION_URL.includes("staging"); const createSubmissionOptions = { method: "POST", headers: new Headers({ - "Authorization": `Bearer ${token}`, + Authorization: `Bearer ${token}`, "Content-type": "application/json", }), body: JSON.stringify({ - "entity": "dc", - "module": "dc", - "organisation": organisation, - "organisationId": organisationId, - "submissionReference": sessionId, - "description": isStaging ? "Staging submission from PlanX" : "Production submission from PlanX", - "submissionProcessorType": "API" + entity: "dc", + module: "dc", + organisation: organisation, + organisationId: organisationId, + submissionReference: sessionId, + description: isStaging + ? "Staging submission from PlanX" + : "Production submission from PlanX", + submissionProcessorType: "API", }), redirect: "follow", }; try { - return await fetch(createSubmissionEndpoint, createSubmissionOptions) - .then(response => { + return await fetch(createSubmissionEndpoint, createSubmissionOptions).then( + (response) => { // successful submission returns 201 Created without body if (response.status === 201) { // parse & return the submissionId const resourceLink = response.headers.get("location"); return resourceLink.split("/").pop(); } - }); + } + ); } catch (err) { throw err; } -}; +} /** * Uploads and attaches a zip folder to an existing submission - * - * @param {string} token - access token retrieved from idox authentication - * @param {string} submissionId - idox-generated UUID returned in the resource link of the submission + * + * @param {string} token - access token retrieved from idox authentication + * @param {string} submissionId - idox-generated UUID returned in the resource link of the submission * @param {string} zipPath - file path to an existing zip folder (2GB limit) * @returns {Promise} - "zipAttached" boolean for our audit record because retrieveSubmission response will include archive.href regardless */ async function attachArchive(token, submissionId, zipPath) { if (!fs.existsSync(zipPath)) { - console.log(`Zip does not exist, cannot attach to idox_submission_id ${submissionId}`); + console.log( + `Zip does not exist, cannot attach to idox_submission_id ${submissionId}` + ); return false; } - const attachArchiveEndpoint = process.env.UNIFORM_SUBMISSION_URL + `/secure/submission/${submissionId}/archive`; + const attachArchiveEndpoint = + process.env.UNIFORM_SUBMISSION_URL + + `/secure/submission/${submissionId}/archive`; const formData = new FormData(); formData.append("file", fs.createReadStream(zipPath)); @@ -334,69 +371,67 @@ async function attachArchive(token, submissionId, zipPath) { const attachArchiveOptions = { method: "POST", headers: new Headers({ - "Authorization": `Bearer ${token}`, + Authorization: `Bearer ${token}`, }), body: formData, redirect: "follow", }; try { - return await fetch(attachArchiveEndpoint, attachArchiveOptions) - .then(response => { + return await fetch(attachArchiveEndpoint, attachArchiveOptions).then( + (response) => { // successful upload returns 204 No Content without body if (response.status === 204) { return true; } - }); + } + ); } catch (err) { throw err; } -}; - +} /** * Gets details about an existing submission to store for auditing purposes * since neither createSubmission nor attachArchive requests return a meaningful response body - * + * * @param {string} token - access token retrieved from idox authentication * @param {string} submissionId - idox-generated UUID returned in the resource link of the submission * @returns {Promise} */ async function retrieveSubmission(token, submissionId) { - const getSubmissionEndpoint = process.env.UNIFORM_SUBMISSION_URL + `/secure/submission/${submissionId}` + const getSubmissionEndpoint = + process.env.UNIFORM_SUBMISSION_URL + `/secure/submission/${submissionId}`; const getSubmissionOptions = { method: "GET", headers: new Headers({ - "Authorization": `Bearer ${token}`, + Authorization: `Bearer ${token}`, }), redirect: "follow", }; try { - return await fetch(getSubmissionEndpoint, getSubmissionOptions) - .then(response => response.json()); + return await fetch( + getSubmissionEndpoint, + getSubmissionOptions + ).then((response) => response.json()); } catch (err) { throw err; } -}; +} /** * 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); @@ -404,7 +439,7 @@ const downloadFile = async (url, path, folder) => { /** * Helper method to clean up files temporarily stored locally - * + * * @param {string} path - file name */ const deleteFile = (path) => { @@ -417,14 +452,17 @@ const deleteFile = (path) => { /** * Get id and secret of Uniform client which matches the provided Local Authority - * @param {string} localAuthority + * @param {string} localAuthority * @returns {object} */ const getUniformClient = (localAuthority) => { // Greedily match any non-word characters // XXX: Matches regex used in IAC (getCustomerSecrets.ts) - const regex = new RegExp(/\W+/g) - const client = process.env["UNIFORM_CLIENT_" + localAuthority.replace(regex, "_").toUpperCase()]; + const regex = new RegExp(/\W+/g); + const client = + process.env[ + "UNIFORM_CLIENT_" + localAuthority.replace(regex, "_").toUpperCase() + ]; // If we can't find secrets, return undefined to trigger a 400 error using next() in sendToUniform() if (!client) return undefined; diff --git a/api.planx.uk/server.test.js b/api.planx.uk/server.test.js index 1694d5fe37..3a047bbf76 100644 --- a/api.planx.uk/server.test.js +++ b/api.planx.uk/server.test.js @@ -207,3 +207,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); + }); + }); +}); diff --git a/api.planx.uk/server.ts b/api.planx.uk/server.ts index 46feeefa0a..853fedcbf9 100644 --- a/api.planx.uk/server.ts +++ b/api.planx.uk/server.ts @@ -5,7 +5,12 @@ import cookieParser from "cookie-parser"; import cookieSession from "cookie-session"; import cors from "cors"; import { stringify } from "csv-stringify"; -import express, { CookieOptions, ErrorRequestHandler, Response } from "express"; +import express, { + CookieOptions, + ErrorRequestHandler, + NextFunction, + Response, +} from "express"; import { expressjwt, Request } from "express-jwt"; import noir from "pino-noir"; import { URL } from "url"; @@ -24,9 +29,9 @@ import { Options, } from "http-proxy-middleware"; import helmet from "helmet"; +import multer from "multer"; import SlackNotify from "slack-notify"; -import { signS3Upload } from "./s3"; import { locationSearch } from "./gis/index"; import { diffFlow, publishFlow } from "./editor/publish"; import { findAndReplaceInFlow } from "./editor/findReplace"; @@ -49,6 +54,12 @@ import { } from "./webhooks/lowcalSessionEvents"; import { adminGraphQLClient } from "./hasura"; import { sendEmailLimiter, apiLimiter } from "./rateLimit"; +import { + privateDownloadController, + privateUploadController, + publicDownloadController, + publicUploadController, +} from "./s3"; import { sendToBOPS } from "./send/bops"; import { createSendEvents } from "./send/createSendEvents"; import { sendToUniform } from "./send/uniform"; @@ -258,6 +269,14 @@ const useJWT = expressjwt({ req.query?.token, }); +assert(process.env.FILE_API_KEY, "Missing environment variable 'FILE_API_KEY'"); +const useFilePermission = (req: Request, res: Response, next: NextFunction) => { + 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")({ @@ -495,22 +514,25 @@ 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"), + privateUploadController +); - try { - const { fileType, url, acl } = await signS3Upload(req.body.filename); +app.post( + "/public-file-upload", + multer().single("file"), + publicUploadController +); - res.json({ - upload_to: url, - public_readonly_url_will_be: url.split("?")[0], - file_type: fileType, - acl, - }); - } catch (err) { - next(err); - } -}); +app.get("/file/public/:fileKey/:fileName", publicDownloadController); + +app.get( + "/file/private/:fileKey/:fileName", + useFilePermission, + privateDownloadController +); const trackAnalyticsLogExit = async (id: number, isUserExit: boolean) => { try { diff --git a/api.planx.uk/tests/serverErrorHandler.test.js b/api.planx.uk/tests/serverErrorHandler.test.js index 98c717dff8..4d36b258ea 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 dafa38b85f..dc0863ae24 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,6 +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} SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL} sharedb: diff --git a/editor.planx.uk/.husky/pre-push b/editor.planx.uk/.husky/pre-push new file mode 100755 index 0000000000..e19b53211f --- /dev/null +++ b/editor.planx.uk/.husky/pre-push @@ -0,0 +1,4 @@ +echo "Checking for git author…" +git log -1 --pretty=format:%ae origin/main...HEAD | grep --silent "harles" && echo "Missing running command: gauthor" && exit 1 +git log -1 --pretty=format:%an origin/main...HEAD | grep --silent "harles" && echo "Missing running command: gauthor" && exit 1 +exit 0 diff --git a/editor.planx.uk/package.json b/editor.planx.uk/package.json index 87bbdbd671..72168f61c6 100644 --- a/editor.planx.uk/package.json +++ b/editor.planx.uk/package.json @@ -123,7 +123,8 @@ "storybook-addon-material-ui": "^0.9.0-alpha.24", "stream-browserify": "^3.0.0", "tsconfig-paths-webpack-plugin": "^4.0.0", - "typescript": "^4.7.4" + "typescript": "^4.7.4", + "wait-for-expect": "^3.0.2" }, "scripts": { "start": "craco start", diff --git a/editor.planx.uk/pnpm-lock.yaml b/editor.planx.uk/pnpm-lock.yaml index 25b37a0dd5..fee5a2995b 100644 --- a/editor.planx.uk/pnpm-lock.yaml +++ b/editor.planx.uk/pnpm-lock.yaml @@ -159,6 +159,7 @@ specifiers: tsconfig-paths-webpack-plugin: ^4.0.0 typescript: ^4.7.4 uuid: ^8.3.2 + wait-for-expect: ^3.0.2 wkt: ^0.1.1 yup: ^0.32.11 zustand: ^4.1.1 @@ -220,7 +221,7 @@ dependencies: react-markdown: 8.0.3_7v64pk2mkrohwh22gx7lrz5ive react-navi: 0.15.0_navi@0.15.0+react@18.2.0 react-navi-helmet-async: 0.15.0_e4zufcmuewgygmkt4h2ll6yape - react-scripts: 5.0.1_kwg5n3kie4k7un3jwvoet3477e + react-scripts: 5.0.1_vh4wxar7p2tjvamau2jk4jtkke react-toastify: 9.0.8_biqbaboplfbrettd7655fr4n2y react-use: 17.4.0_biqbaboplfbrettd7655fr4n2y reconnecting-websocket: 4.4.0 @@ -239,16 +240,16 @@ devDependencies: '@craco/craco': 6.4.5_hsv26antxuqwg6pblgtk7klj5y '@react-theming/storybook-addon': 1.1.7_bkjzr6gtllhi6b56bzdkrz5vbm '@storybook/addon-actions': 6.5.10_biqbaboplfbrettd7655fr4n2y - '@storybook/addon-essentials': 6.5.10_ocy7z5q5teoszq65sdejqtktbq + '@storybook/addon-essentials': 6.5.10_anwffrbdcjg26xymtuekq365vi '@storybook/addon-links': 6.5.10_biqbaboplfbrettd7655fr4n2y - '@storybook/builder-webpack5': 6.5.10_rseclsx2333fcdnm5fz7qiks3e - '@storybook/manager-webpack5': 6.5.10_rseclsx2333fcdnm5fz7qiks3e + '@storybook/builder-webpack5': 6.5.10_tvujanbhwtieorb7hwimcecxam + '@storybook/manager-webpack5': 6.5.10_tvujanbhwtieorb7hwimcecxam '@storybook/node-logger': 6.5.10 - '@storybook/react': 6.5.10_nf4awcmtiuy64v5yaetfcv572e + '@storybook/react': 6.5.10_on3g637n2rrj5gvsz4ayjuwwj4 '@storybook/theming': 6.5.10_biqbaboplfbrettd7655fr4n2y '@testing-library/jest-dom': 5.16.5 '@testing-library/react': 13.3.0_biqbaboplfbrettd7655fr4n2y - '@testing-library/user-event': 14.4.3_zqdhmevb64vvgf27bikfwyob6q + '@testing-library/user-event': 14.4.3 '@turf/helpers': 6.5.0 '@types/draft-js': 0.11.9 '@types/jest': 29.0.0 @@ -264,13 +265,13 @@ devDependencies: '@types/sharedb': 3.0.0 '@types/testing-library__jest-dom': 5.14.5 '@types/uuid': 8.3.4 - craco-esbuild: 0.5.1_vjkpjf7ap7ec2tebnthfajwmgi - css-loader: 6.7.1_webpack@5.65.0 + craco-esbuild: 0.5.1_grp3hrkxu6lyjfltr3tvd52hx4 + css-loader: 6.7.1 esbuild: 0.14.54 esbuild-jest: 0.5.0_esbuild@0.14.54 - eslint-plugin-jsx-a11y: 6.6.1_eslint@8.4.1 - eslint-plugin-simple-import-sort: 7.0.0_eslint@8.4.1 - eslint-plugin-testing-library: 5.6.0_nqjrzu6d3irzy6zot23swntkzq + eslint-plugin-jsx-a11y: 6.6.1 + eslint-plugin-simple-import-sort: 7.0.0 + eslint-plugin-testing-library: 5.6.0_typescript@4.7.4 husky: 8.0.1 identity-obj-proxy: 3.0.0 jest-axe: 6.0.0 @@ -280,11 +281,12 @@ devDependencies: react-app-rewired: 2.2.1_react-scripts@5.0.1 react-refresh: 0.14.0 sass: 1.54.3 - sass-loader: 13.0.2_sass@1.54.3+webpack@5.65.0 - storybook-addon-material-ui: 0.9.0-alpha.24_3r362pksqry6kq3xywweucnmge + sass-loader: 13.0.2_sass@1.54.3 + storybook-addon-material-ui: 0.9.0-alpha.24_y42yuahqiqc7d7qqdirq6d4n3q stream-browserify: 3.0.0 tsconfig-paths-webpack-plugin: 4.0.0 typescript: 4.7.4 + wait-for-expect: 3.0.2 packages: @@ -2012,7 +2014,7 @@ packages: cosmiconfig-typescript-loader: 1.0.3_x2utdhayajzrh747hktprshhby cross-spawn: 7.0.3 lodash: 4.17.21 - react-scripts: 5.0.1_kwg5n3kie4k7un3jwvoet3477e + react-scripts: 5.0.1_vh4wxar7p2tjvamau2jk4jtkke semver: 7.3.5 webpack-merge: 4.2.2 transitivePeerDependencies: @@ -3347,7 +3349,7 @@ packages: '@react-theming/theme-name': 1.0.3 '@react-theming/theme-swatch': 1.0.0_react@18.2.0 '@storybook/addon-devkit': 1.4.2_hfd7r5j37aor7drp5l7dumshdq - '@storybook/react': 6.5.10_nf4awcmtiuy64v5yaetfcv572e + '@storybook/react': 6.5.10_on3g637n2rrj5gvsz4ayjuwwj4 '@storybook/theming': 6.5.10_biqbaboplfbrettd7655fr4n2y '@usulpro/react-json-view': 2.0.1_biqbaboplfbrettd7655fr4n2y color-string: 1.9.1 @@ -3501,7 +3503,7 @@ packages: util-deprecate: 1.0.2 dev: true - /@storybook/addon-controls/6.5.10_tmgq24astrn4zautp4tmc3em6e: + /@storybook/addon-controls/6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q: resolution: {integrity: sha512-lC2y3XcolmQAJwFurIyGrynAHPWmfNtTCdu3rQBTVGwyxCoNwdOOeC2jV0BRqX2+CW6OHzJr9frNWXPSaZ8c4w==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -3516,7 +3518,7 @@ packages: '@storybook/api': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/client-logger': 6.5.10 '@storybook/components': 6.5.10_biqbaboplfbrettd7655fr4n2y - '@storybook/core-common': 6.5.10_tmgq24astrn4zautp4tmc3em6e + '@storybook/core-common': 6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q '@storybook/csf': 0.0.2--canary.4566f4d.1 '@storybook/node-logger': 6.5.10 '@storybook/store': 6.5.10_biqbaboplfbrettd7655fr4n2y @@ -3545,7 +3547,7 @@ packages: '@reach/rect': 0.2.1_v2m5e27vhdewzwhryxwfaorcca '@storybook/addons': 6.4.10_biqbaboplfbrettd7655fr4n2y '@storybook/core-events': 6.4.10 - '@storybook/react': 6.5.10_nf4awcmtiuy64v5yaetfcv572e + '@storybook/react': 6.5.10_on3g637n2rrj5gvsz4ayjuwwj4 '@storybook/theming': 6.5.10_biqbaboplfbrettd7655fr4n2y deep-equal: 2.0.4 prop-types: 15.8.1 @@ -3553,7 +3555,7 @@ packages: react-dom: 18.2.0_react@18.2.0 dev: true - /@storybook/addon-docs/6.5.10_mbxbtfi3a7kjfxcvx2yqmv2kzu: + /@storybook/addon-docs/6.5.10_udtckcufepcwxjivbvq4bpw3te: resolution: {integrity: sha512-1kgjo3f0vL6GN8fTwLL05M/q/kDdzvuqwhxPY/v5hubFb3aQZGr2yk9pRBaLAbs4bez0yG0ASXcwhYnrEZUppg==} peerDependencies: '@storybook/mdx2-csf': ^0.0.3 @@ -3574,7 +3576,7 @@ packages: '@storybook/addons': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/api': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/components': 6.5.10_biqbaboplfbrettd7655fr4n2y - '@storybook/core-common': 6.5.10_tmgq24astrn4zautp4tmc3em6e + '@storybook/core-common': 6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q '@storybook/core-events': 6.5.10 '@storybook/csf': 0.0.2--canary.4566f4d.1 '@storybook/docs-tools': 6.5.10_biqbaboplfbrettd7655fr4n2y @@ -3585,7 +3587,7 @@ packages: '@storybook/source-loader': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/store': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/theming': 6.5.10_biqbaboplfbrettd7655fr4n2y - babel-loader: 8.2.3_eysr5kyjcyvslisuofpotdl4lq + babel-loader: 8.2.3_@babel+core@7.16.5 core-js: 3.24.1 fast-deep-equal: 3.1.3 global: 4.4.0 @@ -3608,7 +3610,7 @@ packages: - webpack-command dev: true - /@storybook/addon-essentials/6.5.10_ocy7z5q5teoszq65sdejqtktbq: + /@storybook/addon-essentials/6.5.10_anwffrbdcjg26xymtuekq365vi: resolution: {integrity: sha512-PT2aiR4vgAyB0pl3HNBUa4/a7NDRxASxAazz7zt9ZDirkipDKfxwdcLeRoJzwSngVDWEhuz5/paN5x4eNp4Hww==} peerDependencies: '@babel/core': ^7.9.6 @@ -3668,23 +3670,22 @@ packages: '@babel/core': 7.16.5 '@storybook/addon-actions': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/addon-backgrounds': 6.5.10_biqbaboplfbrettd7655fr4n2y - '@storybook/addon-controls': 6.5.10_tmgq24astrn4zautp4tmc3em6e - '@storybook/addon-docs': 6.5.10_mbxbtfi3a7kjfxcvx2yqmv2kzu + '@storybook/addon-controls': 6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q + '@storybook/addon-docs': 6.5.10_udtckcufepcwxjivbvq4bpw3te '@storybook/addon-measure': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/addon-outline': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/addon-toolbars': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/addon-viewport': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/addons': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/api': 6.5.10_biqbaboplfbrettd7655fr4n2y - '@storybook/builder-webpack5': 6.5.10_rseclsx2333fcdnm5fz7qiks3e - '@storybook/core-common': 6.5.10_tmgq24astrn4zautp4tmc3em6e + '@storybook/builder-webpack5': 6.5.10_tvujanbhwtieorb7hwimcecxam + '@storybook/core-common': 6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q '@storybook/node-logger': 6.5.10 core-js: 3.24.1 react: 18.2.0 react-dom: 18.2.0_react@18.2.0 regenerator-runtime: 0.13.9 ts-dedent: 2.0.0 - webpack: 5.65.0_esbuild@0.14.54 transitivePeerDependencies: - '@storybook/mdx2-csf' - eslint @@ -3914,7 +3915,7 @@ packages: util-deprecate: 1.0.2 dev: true - /@storybook/builder-webpack4/6.5.10_tmgq24astrn4zautp4tmc3em6e: + /@storybook/builder-webpack4/6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q: resolution: {integrity: sha512-AoKjsCNoQQoZXYwBDxO8s+yVEd5FjBJAaysEuUTHq2fb81jwLrGcEOo6hjw4jqfugZQIzYUEjPazlvubS78zpw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -3932,7 +3933,7 @@ packages: '@storybook/client-api': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/client-logger': 6.5.10 '@storybook/components': 6.5.10_biqbaboplfbrettd7655fr4n2y - '@storybook/core-common': 6.5.10_tmgq24astrn4zautp4tmc3em6e + '@storybook/core-common': 6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q '@storybook/core-events': 6.5.10 '@storybook/node-logger': 6.5.10 '@storybook/preview-web': 6.5.10_biqbaboplfbrettd7655fr4n2y @@ -3950,7 +3951,7 @@ packages: css-loader: 3.6.0_webpack@4.44.2 file-loader: 6.2.0_webpack@4.44.2 find-up: 5.0.0 - fork-ts-checker-webpack-plugin: 4.1.6_7sg4umprdujt67wwjlx44oqo7u + fork-ts-checker-webpack-plugin: 4.1.6_7db7cfvs5qrp3bnhg77xjtpjse glob: 7.2.0 glob-promise: 3.4.0_glob@7.2.0 global: 4.4.0 @@ -3983,7 +3984,7 @@ packages: - webpack-command dev: true - /@storybook/builder-webpack5/6.5.10_rseclsx2333fcdnm5fz7qiks3e: + /@storybook/builder-webpack5/6.5.10_tvujanbhwtieorb7hwimcecxam: resolution: {integrity: sha512-Hcsm/TzGRXHndgQCftt+pzI7GQJRqAv8A8ie5b3aFcodhJfK0qzZsQD4Y4ZWxXh1I/xe5t74Kl2qUJ40PX+geA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -4001,7 +4002,7 @@ packages: '@storybook/client-api': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/client-logger': 6.5.10 '@storybook/components': 6.5.10_biqbaboplfbrettd7655fr4n2y - '@storybook/core-common': 6.5.10_tmgq24astrn4zautp4tmc3em6e + '@storybook/core-common': 6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q '@storybook/core-events': 6.5.10 '@storybook/node-logger': 6.5.10 '@storybook/preview-web': 6.5.10_biqbaboplfbrettd7655fr4n2y @@ -4016,7 +4017,7 @@ packages: case-sensitive-paths-webpack-plugin: 2.4.0 core-js: 3.24.1 css-loader: 5.2.7_webpack@5.65.0 - fork-ts-checker-webpack-plugin: 6.5.0_zjtdjsalfozryr2ra6wl2cbklu + fork-ts-checker-webpack-plugin: 6.5.0_enkpkivpbeyi6fbcejat26nywe glob: 7.2.0 glob-promise: 3.4.0_glob@7.2.0 html-webpack-plugin: 5.5.0_webpack@5.65.0 @@ -4219,7 +4220,7 @@ packages: webpack: 5.65.0_esbuild@0.14.54 dev: true - /@storybook/core-common/6.5.10_tmgq24astrn4zautp4tmc3em6e: + /@storybook/core-common/6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q: resolution: {integrity: sha512-Bx+VKkfWdrAmD8T51Sjq/mMhRaiapBHcpG4cU5bc3DMbg+LF2/yrgqv/cjVu+m5gHAzYCac5D7gqzBgvG7Myww==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -4263,7 +4264,7 @@ packages: express: 4.17.1 file-system-cache: 1.0.5 find-up: 5.0.0 - fork-ts-checker-webpack-plugin: 6.5.0_7sg4umprdujt67wwjlx44oqo7u + fork-ts-checker-webpack-plugin: 6.5.0_7db7cfvs5qrp3bnhg77xjtpjse fs-extra: 9.1.0 glob: 7.2.0 handlebars: 4.7.7 @@ -4302,7 +4303,7 @@ packages: core-js: 3.24.1 dev: true - /@storybook/core-server/6.5.10_izfvehiaisgptbakyp2hqi356i: + /@storybook/core-server/6.5.10_ajjfvqyodcjssivd4g2ocuubty: resolution: {integrity: sha512-jqwpA0ccA8X5ck4esWBid04+cEIVqirdAcqJeNb9IZAD+bRreO4Im8ilzr7jc5AmQ9fkqHs2NByFKh9TITp8NQ==} peerDependencies: '@storybook/builder-webpack5': '*' @@ -4319,19 +4320,19 @@ packages: optional: true dependencies: '@discoveryjs/json-ext': 0.5.6 - '@storybook/builder-webpack4': 6.5.10_tmgq24astrn4zautp4tmc3em6e - '@storybook/builder-webpack5': 6.5.10_rseclsx2333fcdnm5fz7qiks3e + '@storybook/builder-webpack4': 6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q + '@storybook/builder-webpack5': 6.5.10_tvujanbhwtieorb7hwimcecxam '@storybook/core-client': 6.5.10_eyyjs2ch5sgjxsoozxupplmh5m - '@storybook/core-common': 6.5.10_tmgq24astrn4zautp4tmc3em6e + '@storybook/core-common': 6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q '@storybook/core-events': 6.5.10 '@storybook/csf': 0.0.2--canary.4566f4d.1 '@storybook/csf-tools': 6.5.10 - '@storybook/manager-webpack4': 6.5.10_tmgq24astrn4zautp4tmc3em6e - '@storybook/manager-webpack5': 6.5.10_rseclsx2333fcdnm5fz7qiks3e + '@storybook/manager-webpack4': 6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q + '@storybook/manager-webpack5': 6.5.10_tvujanbhwtieorb7hwimcecxam '@storybook/node-logger': 6.5.10 '@storybook/semver': 7.3.2 '@storybook/store': 6.5.10_biqbaboplfbrettd7655fr4n2y - '@storybook/telemetry': 6.5.10_tmgq24astrn4zautp4tmc3em6e + '@storybook/telemetry': 6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q '@types/node': 14.18.0 '@types/node-fetch': 2.5.7 '@types/pretty-hrtime': 1.0.1 @@ -4381,7 +4382,7 @@ packages: - webpack-command dev: true - /@storybook/core/6.5.10_g757nn5qelsvwsczsmad772toq: + /@storybook/core/6.5.10_5nk7hlsdamccj43b5dnuucsyiq: resolution: {integrity: sha512-K86yYa0tYlMxADlwQTculYvPROokQau09SCVqpsLg3wJCTvYFL4+SIqcYoyBSbFmHOdnYbJgPydjN33MYLiOZQ==} peerDependencies: '@storybook/builder-webpack5': '*' @@ -4398,10 +4399,10 @@ packages: typescript: optional: true dependencies: - '@storybook/builder-webpack5': 6.5.10_rseclsx2333fcdnm5fz7qiks3e + '@storybook/builder-webpack5': 6.5.10_tvujanbhwtieorb7hwimcecxam '@storybook/core-client': 6.5.10_njcv4xf3lbkruwkwwyhxulwnn4 - '@storybook/core-server': 6.5.10_izfvehiaisgptbakyp2hqi356i - '@storybook/manager-webpack5': 6.5.10_rseclsx2333fcdnm5fz7qiks3e + '@storybook/core-server': 6.5.10_ajjfvqyodcjssivd4g2ocuubty + '@storybook/manager-webpack5': 6.5.10_tvujanbhwtieorb7hwimcecxam react: 18.2.0 react-dom: 18.2.0_react@18.2.0 typescript: 4.7.4 @@ -4473,7 +4474,7 @@ packages: - supports-color dev: true - /@storybook/manager-webpack4/6.5.10_tmgq24astrn4zautp4tmc3em6e: + /@storybook/manager-webpack4/6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q: resolution: {integrity: sha512-N/TlNDhuhARuFipR/ZJ/xEVESz23iIbCsZ4VNehLHm8PpiGlQUehk+jMjWmz5XV0bJItwjRclY+CU3GjZKblfQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -4488,7 +4489,7 @@ packages: '@babel/preset-react': 7.16.5_@babel+core@7.16.5 '@storybook/addons': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/core-client': 6.5.10_eyyjs2ch5sgjxsoozxupplmh5m - '@storybook/core-common': 6.5.10_tmgq24astrn4zautp4tmc3em6e + '@storybook/core-common': 6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q '@storybook/node-logger': 6.5.10 '@storybook/theming': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/ui': 6.5.10_biqbaboplfbrettd7655fr4n2y @@ -4531,7 +4532,7 @@ packages: - webpack-command dev: true - /@storybook/manager-webpack5/6.5.10_rseclsx2333fcdnm5fz7qiks3e: + /@storybook/manager-webpack5/6.5.10_tvujanbhwtieorb7hwimcecxam: resolution: {integrity: sha512-uRo+6e5MiVOtyFVMYIKVqvpDveCjHyzXBfetSYR7rKEZoaDMEnLLiuF7DIH12lzxwmzCJ1gIc4lf5HFiTMNkgw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -4546,7 +4547,7 @@ packages: '@babel/preset-react': 7.16.5_@babel+core@7.16.5 '@storybook/addons': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/core-client': 6.5.10_njcv4xf3lbkruwkwwyhxulwnn4 - '@storybook/core-common': 6.5.10_tmgq24astrn4zautp4tmc3em6e + '@storybook/core-common': 6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q '@storybook/node-logger': 6.5.10 '@storybook/theming': 6.5.10_biqbaboplfbrettd7655fr4n2y '@storybook/ui': 6.5.10_biqbaboplfbrettd7655fr4n2y @@ -4668,7 +4669,7 @@ packages: - supports-color dev: true - /@storybook/react/6.5.10_nf4awcmtiuy64v5yaetfcv572e: + /@storybook/react/6.5.10_on3g637n2rrj5gvsz4ayjuwwj4: resolution: {integrity: sha512-m8S1qQrwA7pDGwdKEvL6LV3YKvSzVUY297Fq+xcTU3irnAy4sHDuFoLqV6Mi1510mErK1r8+rf+0R5rEXB219g==} engines: {node: '>=10.13.0'} hasBin: true @@ -4701,13 +4702,13 @@ packages: '@babel/preset-react': 7.16.5_@babel+core@7.16.5 '@pmmmwh/react-refresh-webpack-plugin': 0.5.3_lv7vju7f4kmldqnhvouz2eiyt4 '@storybook/addons': 6.5.10_biqbaboplfbrettd7655fr4n2y - '@storybook/builder-webpack5': 6.5.10_rseclsx2333fcdnm5fz7qiks3e + '@storybook/builder-webpack5': 6.5.10_tvujanbhwtieorb7hwimcecxam '@storybook/client-logger': 6.5.10 - '@storybook/core': 6.5.10_g757nn5qelsvwsczsmad772toq - '@storybook/core-common': 6.5.10_tmgq24astrn4zautp4tmc3em6e + '@storybook/core': 6.5.10_5nk7hlsdamccj43b5dnuucsyiq + '@storybook/core-common': 6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q '@storybook/csf': 0.0.2--canary.4566f4d.1 '@storybook/docs-tools': 6.5.10_biqbaboplfbrettd7655fr4n2y - '@storybook/manager-webpack5': 6.5.10_rseclsx2333fcdnm5fz7qiks3e + '@storybook/manager-webpack5': 6.5.10_tvujanbhwtieorb7hwimcecxam '@storybook/node-logger': 6.5.10 '@storybook/react-docgen-typescript-plugin': 1.0.2-canary.6.9d540b91e815f8fc2f8829189deb00553559ff63.0_enkpkivpbeyi6fbcejat26nywe '@storybook/semver': 7.3.2 @@ -4733,7 +4734,6 @@ packages: react-refresh: 0.11.0 read-pkg-up: 7.0.1 regenerator-runtime: 0.13.9 - require-from-string: 2.0.2 ts-dedent: 2.0.0 typescript: 4.7.4 util-deprecate: 1.0.2 @@ -4850,11 +4850,11 @@ packages: util-deprecate: 1.0.2 dev: true - /@storybook/telemetry/6.5.10_tmgq24astrn4zautp4tmc3em6e: + /@storybook/telemetry/6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q: resolution: {integrity: sha512-+M5HILDFS8nDumLxeSeAwi1MTzIuV6UWzV4yB2wcsEXOBTdplcl9oYqFKtlst78oOIdGtpPYxYfivDlqxC2K4g==} dependencies: '@storybook/client-logger': 6.5.10 - '@storybook/core-common': 6.5.10_tmgq24astrn4zautp4tmc3em6e + '@storybook/core-common': 6.5.10_xrxvbtylmve4l2tr3vmmqgfp7q chalk: 4.1.2 core-js: 3.24.1 detect-package-manager: 2.0.1 @@ -5083,13 +5083,11 @@ packages: react-dom: 18.2.0_react@18.2.0 dev: true - /@testing-library/user-event/14.4.3_zqdhmevb64vvgf27bikfwyob6q: + /@testing-library/user-event/14.4.3: resolution: {integrity: sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' - dependencies: - '@testing-library/dom': 8.11.1 dev: true /@tootallnate/once/1.1.2: @@ -5677,6 +5675,23 @@ packages: - supports-color - typescript + /@typescript-eslint/utils/5.36.1_typescript@4.7.4: + resolution: {integrity: sha512-lNj4FtTiXm5c+u0pUehozaUWhh7UYKnwryku0nxJlYUEWetyG92uw2pr+2Iy4M/u0ONMKzfrx7AsGBTCzORmIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + '@types/json-schema': 7.0.9 + '@typescript-eslint/scope-manager': 5.36.1 + '@typescript-eslint/types': 5.36.1 + '@typescript-eslint/typescript-estree': 5.36.1_typescript@4.7.4 + eslint-scope: 5.1.1 + eslint-utils: 3.0.0 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + /@typescript-eslint/visitor-keys/5.36.1: resolution: {integrity: sha512-ojB9aRyRFzVMN3b5joSYni6FAS10BBSCAfKJhjJAV08t/a95aM6tAhz+O1jF+EtgxktuSO3wJysp2R+Def/IWQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -6452,21 +6467,6 @@ packages: engines: {node: '>=4'} dev: false - /autoprefixer/10.4.0_postcss@8.4.16: - resolution: {integrity: sha512-7FdJ1ONtwzV1G43GDD0kpVMn/qbiNqyOPMFTX5nRffI+7vgWoFEc6DcXOxHJxrWNDXrZh18eDsZjvZGUljSRGA==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - dependencies: - browserslist: 4.19.1 - caniuse-lite: 1.0.30001287 - fraction.js: 4.2.0 - normalize-range: 0.1.2 - picocolors: 1.0.0 - postcss: 8.4.16 - postcss-value-parser: 4.2.0 - /autoprefixer/10.4.0_postcss@8.4.5: resolution: {integrity: sha512-7FdJ1ONtwzV1G43GDD0kpVMn/qbiNqyOPMFTX5nRffI+7vgWoFEc6DcXOxHJxrWNDXrZh18eDsZjvZGUljSRGA==} engines: {node: ^10 || ^12 || >=14} @@ -6565,6 +6565,20 @@ packages: transitivePeerDependencies: - supports-color + /babel-loader/8.2.3_@babel+core@7.16.5: + resolution: {integrity: sha512-n4Zeta8NC3QAsuyiizu0GkmRcQ6clkV9WFUnUf1iXP//IeSKbWjofW3UHyZVwlOB4y039YQKefawyTn64Zwbuw==} + engines: {node: '>= 8.9'} + peerDependencies: + '@babel/core': ^7.0.0 + webpack: '>=2' + dependencies: + '@babel/core': 7.16.5 + find-cache-dir: 3.3.1 + loader-utils: 1.4.0 + make-dir: 3.1.0 + schema-utils: 2.7.1 + dev: true + /babel-loader/8.2.3_eysr5kyjcyvslisuofpotdl4lq: resolution: {integrity: sha512-n4Zeta8NC3QAsuyiizu0GkmRcQ6clkV9WFUnUf1iXP//IeSKbWjofW3UHyZVwlOB4y039YQKefawyTn64Zwbuw==} engines: {node: '>= 8.9'} @@ -7840,7 +7854,7 @@ packages: - supports-color dev: true - /craco-esbuild/0.5.1_vjkpjf7ap7ec2tebnthfajwmgi: + /craco-esbuild/0.5.1_grp3hrkxu6lyjfltr3tvd52hx4: resolution: {integrity: sha512-cV10lImVPctQeNAUXi+Gzy/UXXCTHe9Q8lAEJu3t+JVZt+D2uBvZFFqY7sw0dzZq5t+VcKFaVVPX8w5scL7RRw==} peerDependencies: '@craco/craco': ^6.0.0 || ^7.0.0 @@ -7848,8 +7862,8 @@ packages: dependencies: '@craco/craco': 6.4.5_hsv26antxuqwg6pblgtk7klj5y esbuild-jest: 0.5.0_esbuild@0.14.54 - esbuild-loader: 2.18.0_webpack@5.65.0 - react-scripts: 5.0.1_kwg5n3kie4k7un3jwvoet3477e + esbuild-loader: 2.18.0 + react-scripts: 5.0.1_vh4wxar7p2tjvamau2jk4jtkke transitivePeerDependencies: - esbuild - supports-color @@ -8023,6 +8037,22 @@ packages: webpack: 5.65.0_esbuild@0.14.54 dev: true + /css-loader/6.7.1: + resolution: {integrity: sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^5.0.0 + dependencies: + icss-utils: 5.1.0_postcss@8.4.16 + postcss: 8.4.16 + postcss-modules-extract-imports: 3.0.0_postcss@8.4.16 + postcss-modules-local-by-default: 4.0.0_postcss@8.4.16 + postcss-modules-scope: 3.0.0_postcss@8.4.16 + postcss-modules-values: 4.0.0_postcss@8.4.16 + postcss-value-parser: 4.2.0 + semver: 7.3.5 + dev: true + /css-loader/6.7.1_webpack@5.65.0: resolution: {integrity: sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==} engines: {node: '>= 12.13.0'} @@ -9072,7 +9102,7 @@ packages: requiresBuild: true optional: true - /esbuild-loader/2.18.0_webpack@5.65.0: + /esbuild-loader/2.18.0: resolution: {integrity: sha512-AKqxM3bI+gvGPV8o6NAhR+cBxVO8+dh+O0OXBHIXXwuSGumckbPWHzZ17subjBGI2YEGyJ1STH7Haj8aCrwL/w==} peerDependencies: webpack: ^4.40.0 || ^5.0.0 @@ -9082,7 +9112,6 @@ packages: json5: 2.2.0 loader-utils: 2.0.2 tapable: 2.2.1 - webpack: 5.65.0_esbuild@0.14.54 webpack-sources: 2.2.0 dev: true @@ -9197,7 +9226,7 @@ packages: optionalDependencies: source-map: 0.6.1 - /eslint-config-react-app/7.0.1_ppp3kdc37dtqify2fotljjeqq4: + /eslint-config-react-app/7.0.1_73cf7v7u32itaug2h6ddw2exmm: resolution: {integrity: sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==} engines: {node: '>=14.0.0'} peerDependencies: @@ -9215,7 +9244,7 @@ packages: babel-preset-react-app: 10.0.1 confusing-browser-globals: 1.0.11 eslint: 8.4.1 - eslint-plugin-flowtype: 8.0.3_pex26l7yflrhbgizudxlm47cze + eslint-plugin-flowtype: 8.0.3_eslint@8.4.1 eslint-plugin-import: 2.25.3_6tb2nqnsmdxwl2qbj5bbu73xbi eslint-plugin-jest: 25.3.0_x66p4u67uztt5lrqm3mwwccvga eslint-plugin-jsx-a11y: 6.6.1_eslint@8.4.1 @@ -9265,7 +9294,7 @@ packages: transitivePeerDependencies: - supports-color - /eslint-plugin-flowtype/8.0.3_pex26l7yflrhbgizudxlm47cze: + /eslint-plugin-flowtype/8.0.3_eslint@8.4.1: resolution: {integrity: sha512-dX8l6qUL6O+fYPtpNRideCFSpmWOUVx5QcaGLVqe/vlDiBSe4vYljDWDETwnyFzpl7By/WVIu6rcrniCgH9BqQ==} engines: {node: '>=12.0.0'} peerDependencies: @@ -9273,8 +9302,6 @@ packages: '@babel/plugin-transform-react-jsx': ^7.14.9 eslint: ^8.1.0 dependencies: - '@babel/plugin-syntax-flow': 7.16.5_@babel+core@7.16.5 - '@babel/plugin-transform-react-jsx': 7.16.5_@babel+core@7.16.5 eslint: 8.4.1 lodash: 4.17.21 string-natural-compare: 3.0.1 @@ -9330,6 +9357,27 @@ packages: - supports-color - typescript + /eslint-plugin-jsx-a11y/6.6.1: + resolution: {integrity: sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + dependencies: + '@babel/runtime': 7.18.9 + aria-query: 4.2.2 + array-includes: 3.1.5 + ast-types-flow: 0.0.7 + axe-core: 4.4.3 + axobject-query: 2.2.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + has: 1.0.3 + jsx-ast-utils: 3.3.3 + language-tags: 1.0.5 + minimatch: 3.1.2 + semver: 6.3.0 + dev: true + /eslint-plugin-jsx-a11y/6.6.1_eslint@8.4.1: resolution: {integrity: sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==} engines: {node: '>=4.0'} @@ -9381,12 +9429,10 @@ packages: semver: 6.3.0 string.prototype.matchall: 4.0.6 - /eslint-plugin-simple-import-sort/7.0.0_eslint@8.4.1: + /eslint-plugin-simple-import-sort/7.0.0: resolution: {integrity: sha512-U3vEDB5zhYPNfxT5TYR7u01dboFZp+HNpnGhkDB2g/2E4wZ/g1Q9Ton8UwCLfRV9yAKyYqDh62oHOamvkFxsvw==} peerDependencies: eslint: '>=5.0.0' - dependencies: - eslint: 8.4.1 dev: true /eslint-plugin-testing-library/5.6.0_nqjrzu6d3irzy6zot23swntkzq: @@ -9401,6 +9447,18 @@ packages: - supports-color - typescript + /eslint-plugin-testing-library/5.6.0_typescript@4.7.4: + resolution: {integrity: sha512-y63TRzPhGCMNsnUwMGJU1MFWc/3GvYw+nzobp9QiyNTTKsgAt5RKAOT1I34+XqVBpX1lC8bScoOjCkP7iRv0Mw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0, npm: '>=6'} + peerDependencies: + eslint: ^7.5.0 || ^8.0.0 + dependencies: + '@typescript-eslint/utils': 5.36.1_typescript@4.7.4 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + /eslint-scope/4.0.3: resolution: {integrity: sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==} engines: {node: '>=4.0.0'} @@ -9423,6 +9481,15 @@ packages: esrecurse: 4.3.0 estraverse: 5.3.0 + /eslint-utils/3.0.0: + resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==} + engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0} + peerDependencies: + eslint: '>=5' + dependencies: + eslint-visitor-keys: 2.1.0 + dev: true + /eslint-utils/3.0.0_eslint@8.4.1: resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==} engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0} @@ -10030,7 +10097,7 @@ packages: resolution: {integrity: sha512-ZBbtRiapkZYLsqoPyZOR+uPfto0GRMNQN1GwzZtZt7iZvPPbDDQV0JF5Hx4o/QFQ5c0vyuoZ98T8RSBbopzWtA==} dev: true - /fork-ts-checker-webpack-plugin/4.1.6_7sg4umprdujt67wwjlx44oqo7u: + /fork-ts-checker-webpack-plugin/4.1.6_7db7cfvs5qrp3bnhg77xjtpjse: resolution: {integrity: sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw==} engines: {node: '>=6.11.5', yarn: '>=1.0.0'} peerDependencies: @@ -10046,7 +10113,6 @@ packages: dependencies: '@babel/code-frame': 7.16.0 chalk: 2.4.2 - eslint: 8.4.1 micromatch: 3.1.10 minimatch: 3.1.2 semver: 5.7.1 @@ -10058,7 +10124,7 @@ packages: - supports-color dev: true - /fork-ts-checker-webpack-plugin/6.5.0_7sg4umprdujt67wwjlx44oqo7u: + /fork-ts-checker-webpack-plugin/6.5.0_7db7cfvs5qrp3bnhg77xjtpjse: resolution: {integrity: sha512-cS178Y+xxtIjEUorcHddKS7yCMlrDPV31mt47blKKRfMd70Kxu5xruAFE2o9sDY6wVC5deuob/u/alD04YYHnw==} engines: {node: '>=10', yarn: '>=1.0.0'} peerDependencies: @@ -10078,7 +10144,6 @@ packages: chokidar: 3.5.3 cosmiconfig: 6.0.0 deepmerge: 4.2.2 - eslint: 8.4.1 fs-extra: 9.1.0 glob: 7.2.0 memfs: 3.4.0 @@ -10090,6 +10155,37 @@ packages: webpack: 4.44.2 dev: true + /fork-ts-checker-webpack-plugin/6.5.0_enkpkivpbeyi6fbcejat26nywe: + resolution: {integrity: sha512-cS178Y+xxtIjEUorcHddKS7yCMlrDPV31mt47blKKRfMd70Kxu5xruAFE2o9sDY6wVC5deuob/u/alD04YYHnw==} + engines: {node: '>=10', yarn: '>=1.0.0'} + peerDependencies: + eslint: '>= 6' + typescript: '>= 2.7' + vue-template-compiler: '*' + webpack: '>= 4' + peerDependenciesMeta: + eslint: + optional: true + vue-template-compiler: + optional: true + dependencies: + '@babel/code-frame': 7.16.0 + '@types/json-schema': 7.0.9 + chalk: 4.1.2 + chokidar: 3.5.3 + cosmiconfig: 6.0.0 + deepmerge: 4.2.2 + fs-extra: 9.1.0 + glob: 7.2.0 + memfs: 3.4.0 + minimatch: 3.1.2 + schema-utils: 2.7.0 + semver: 7.3.5 + tapable: 1.1.3 + typescript: 4.7.4 + webpack: 5.65.0_esbuild@0.14.54 + dev: true + /fork-ts-checker-webpack-plugin/6.5.0_zjtdjsalfozryr2ra6wl2cbklu: resolution: {integrity: sha512-cS178Y+xxtIjEUorcHddKS7yCMlrDPV31mt47blKKRfMd70Kxu5xruAFE2o9sDY6wVC5deuob/u/alD04YYHnw==} engines: {node: '>=10', yarn: '>=1.0.0'} @@ -15465,7 +15561,7 @@ packages: peerDependencies: react-scripts: '>=2.1.3' dependencies: - react-scripts: 5.0.1_kwg5n3kie4k7un3jwvoet3477e + react-scripts: 5.0.1_vh4wxar7p2tjvamau2jk4jtkke semver: 5.7.1 dev: true @@ -15514,6 +15610,12 @@ packages: /react-dev-utils/12.0.1_zjtdjsalfozryr2ra6wl2cbklu: resolution: {integrity: sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==} engines: {node: '>=14'} + peerDependencies: + typescript: '>=2.7' + webpack: '>=4' + peerDependenciesMeta: + typescript: + optional: true dependencies: '@babel/code-frame': 7.16.0 address: 1.1.2 @@ -15539,12 +15641,12 @@ packages: shell-quote: 1.7.3 strip-ansi: 6.0.1 text-table: 0.2.0 + typescript: 4.7.4 + webpack: 5.65.0_esbuild@0.14.54 transitivePeerDependencies: - eslint - supports-color - - typescript - vue-template-compiler - - webpack /react-dnd-html5-backend/16.0.1: resolution: {integrity: sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==} @@ -15832,7 +15934,7 @@ packages: react: 18.2.0 dev: true - /react-scripts/5.0.1_kwg5n3kie4k7un3jwvoet3477e: + /react-scripts/5.0.1_vh4wxar7p2tjvamau2jk4jtkke: resolution: {integrity: sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==} engines: {node: '>=14.0.0'} hasBin: true @@ -15859,7 +15961,7 @@ packages: dotenv: 10.0.0 dotenv-expand: 5.1.0 eslint: 8.4.1 - eslint-config-react-app: 7.0.1_ppp3kdc37dtqify2fotljjeqq4 + eslint-config-react-app: 7.0.1_73cf7v7u32itaug2h6ddw2exmm eslint-webpack-plugin: 3.1.1_arz5zidbbfkszie7dgv4uxhmli file-loader: 6.2.0_webpack@5.65.0 fs-extra: 10.0.0 @@ -15885,7 +15987,7 @@ packages: semver: 7.3.5 source-map-loader: 3.0.0_webpack@5.65.0 style-loader: 3.3.1_webpack@5.65.0 - tailwindcss: 3.0.5_c2rjb5wq4nyxx5wsmzzdjlv5ga + tailwindcss: 3.0.5_postcss@8.4.5 terser-webpack-plugin: 5.3.0_tuzcy5jahpcom7uiiwokibsx5a typescript: 4.7.4 webpack: 5.65.0_esbuild@0.14.54 @@ -16593,7 +16695,7 @@ packages: sass: 1.54.3 webpack: 5.65.0_esbuild@0.14.54 - /sass-loader/13.0.2_sass@1.54.3+webpack@5.65.0: + /sass-loader/13.0.2_sass@1.54.3: resolution: {integrity: sha512-BbiqbVmbfJaWVeOOAu2o7DhYWtcNmTfvroVgFXa6k2hHheMxNAeDHLNoDy/Q5aoaVlz0LH+MbMktKwm9vN/j8Q==} engines: {node: '>= 14.15.0'} peerDependencies: @@ -16615,7 +16717,6 @@ packages: klona: 2.0.5 neo-async: 2.6.2 sass: 1.54.3 - webpack: 5.65.0_esbuild@0.14.54 dev: true /sass/1.54.3: @@ -17207,7 +17308,7 @@ packages: resolution: {integrity: sha512-7t+/wpKLanLzSnQPX8WAcuLCCeuSHoWdQuh9SB3xD0kNOM38DNf+0Oa+wmvxmYueRzkmh6IcdKFtvTa+ecgPDw==} dev: true - /storybook-addon-material-ui/0.9.0-alpha.24_3r362pksqry6kq3xywweucnmge: + /storybook-addon-material-ui/0.9.0-alpha.24_y42yuahqiqc7d7qqdirq6d4n3q: resolution: {integrity: sha512-Z9S06K/x2lppPofINl/ZM6a1TzeGdy8NZfWwjzyQRXzVf4/ABanhv6Zib2i6ptCxa5AWahZ1HxBqOSQZS4YIHg==} peerDependencies: '@material-ui/core': ^1.0.0 || ^3.0.0 || ^4.0.0 @@ -17220,12 +17321,10 @@ packages: '@emotion/core': 10.1.1_react@18.2.0 '@emotion/styled': 10.0.27_gj3zb24ilqt4m4c2b65lgbcbsq '@material-ui/core': 4.11.0_63dfw6rfgd64rl77ptfma4cvt4 - '@storybook/addons': 6.5.10_biqbaboplfbrettd7655fr4n2y - '@storybook/react': 6.5.10_nf4awcmtiuy64v5yaetfcv572e + '@storybook/react': 6.5.10_on3g637n2rrj5gvsz4ayjuwwj4 '@usulpro/color-picker': 1.1.4_react@18.2.0 global: 4.4.0 js-beautify: 1.13.0 - prop-types: 15.8.1 react: 18.2.0 react-dom: 18.2.0_react@18.2.0 react-inspector: 2.3.1_react@18.2.0 @@ -17630,7 +17729,7 @@ packages: resolution: {integrity: sha512-k8uzYIkIVwmT+TcglpdN50pS2y1BDcUnBPK9iJeGu0Pl1lOI8pD6wtzgw91Pjpe+RxtTncw32tLxs/R0yNL2Mg==} dev: true - /tailwindcss/3.0.5_c2rjb5wq4nyxx5wsmzzdjlv5ga: + /tailwindcss/3.0.5_postcss@8.4.5: resolution: {integrity: sha512-59pNgzx2o+wkAk7IZGIH7H9eNS53gzZGrO3+NPyOEWHDbquHgiLL/c993T5t1vPSAeBxox4X5OgZwNuRvXVf+g==} engines: {node: '>=12.13.0'} hasBin: true @@ -17639,7 +17738,6 @@ packages: postcss: ^8.0.9 dependencies: arg: 5.0.1 - autoprefixer: 10.4.0_postcss@8.4.16 chalk: 4.1.2 chokidar: 3.5.3 color-name: 1.1.4 @@ -18258,6 +18356,7 @@ packages: /unified/9.2.0: resolution: {integrity: sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg==} dependencies: + '@types/unist': 2.0.3 bail: 1.0.5 extend: 3.0.2 is-buffer: 2.0.5 @@ -18631,6 +18730,10 @@ packages: dependencies: xml-name-validator: 3.0.0 + /wait-for-expect/3.0.2: + resolution: {integrity: sha512-cfS1+DZxuav1aBYbaO/kE06EOS8yRw7qOFoD3XtjTkYvCvh3zUvNST8DXK/nPaeqIzIv3P3kL3lRJn8iwOiSag==} + dev: true + /walker/1.0.7: resolution: {integrity: sha512-cF4je9Fgt6sj1PKfuFt9jpQPeHosM+Ryma/hfY9U7uXGKM7pJCsF0v2r55o+Il54+i77SyYWetB4tD1dEygRkw==} dependencies: 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 c3bffa8968..7eaf702ba2 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 @@ -8,16 +8,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", }, }; @@ -39,7 +49,7 @@ test("recovers previously submitted files when clicking the back button", async await user.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 92e999126c..5d851f01b9 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 ButtonBase from "@mui/material/ButtonBase"; import IconButton from "@mui/material/IconButton"; import makeStyles from "@mui/styles/makeStyles"; import { visuallyHidden } from "@mui/utils"; -import { uploadFile } from "api/upload"; +import { 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 72f90ed518..89673e3389 100644 --- a/editor.planx.uk/src/@planx/components/DrawBoundary/Public/index.tsx +++ b/editor.planx.uk/src/@planx/components/DrawBoundary/Public/index.tsx @@ -70,8 +70,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()); @@ -222,6 +221,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 f2cc443583..5b2aa5827a 100644 --- a/editor.planx.uk/src/@planx/components/FileUpload/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/FileUpload/Public.test.tsx @@ -64,17 +64,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 996c0ffc95..2b23c1825b 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 "@mui/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 1bfccff3cb..ecf6d9054b 100644 --- a/editor.planx.uk/src/@planx/components/Review/Public.test.tsx +++ b/editor.planx.uk/src/@planx/components/Review/Public.test.tsx @@ -1,9 +1,16 @@ import { screen } from "@testing-library/react"; import React from "react"; +import { act } from "react-dom/test-utils"; import { axe, setup } from "testUtils"; +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(); @@ -103,3 +110,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 filename", async () => { + const { getByTestId } = setup( + {}} + 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 } = setup( + {}} + 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 fd4753d275..4ecd83dd4e 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 = getBOPSParams(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 92926854c8..a774032e16 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"; @@ -5,6 +6,11 @@ import { getBOPSParams } from "../bops"; import { CSVData } from "../model"; import { makeXmlString } from "./xml"; +type UniformFile = { + name: string; + url: string; +}; + export function getUniformParams( breadcrumbs: Store.breadcrumbs, flow: Store.flow, @@ -12,7 +18,7 @@ export function getUniformParams( sessionId: string ) { // 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 772097dcc0..ef1c535900 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..eb5c163bd5 --- /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 {`Preview; +} diff --git a/editor.planx.uk/src/ui/FileUpload.tsx b/editor.planx.uk/src/ui/FileUpload.tsx index 582c738ffa..e12f39df64 100644 --- a/editor.planx.uk/src/ui/FileUpload.tsx +++ b/editor.planx.uk/src/ui/FileUpload.tsx @@ -4,7 +4,7 @@ import ButtonBase from "@mui/material/ButtonBase"; import CircularProgress from "@mui/material/CircularProgress"; import Tooltip from "@mui/material/Tooltip"; import makeStyles from "@mui/styles/makeStyles"; -import { uploadFile } from "api/upload"; +import { uploadPublicFile } from "api/upload"; import React, { useCallback, useEffect, useState } from "react"; import { FileWithPath, 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 21e0c387f5..a35fb05ba3 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"; @@ -287,6 +288,10 @@ export = async () => { sslPolicy: "ELBSecurityPolicy-TLS-1-2-Ext-2018-06", certificateArn: certificates.requireOutput("certificateArn"), }); + // How to rotate this secret: https://github.com/pulumi/pulumi-random/issues/234 + const fileApiKey = new random.RandomPassword("file-api-key", { + length: 44, + }).result; const apiService = new awsx.ecs.FargateService("api", { cluster, subnets: networking.requireOutput("publicSubnetIds"), @@ -313,6 +318,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"), @@ -736,6 +745,7 @@ export = async () => { return { customDomains, + fileApiKey, }; }; diff --git a/infrastructure/application/package.json b/infrastructure/application/package.json index d0a232c6d6..9120d3f991 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 1b687b4f5e..45e2a49da2 100644 --- a/infrastructure/application/pnpm-lock.yaml +++ b/infrastructure/application/pnpm-lock.yaml @@ -13,6 +13,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 @@ -27,6 +28,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 @@ -186,6 +188,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