diff --git a/.dockerignore b/.dockerignore index b262d6484..7fcbca9cc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -33,6 +33,7 @@ yarn-debug.log* yarn-error.log* # local env files +.env .env.local .env.development.local .env.test.local diff --git a/.env.example b/.env.example index 02c1ed2ae..81840436d 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,14 @@ # DOCKER +DOCKER_MINIO_FORWARD_PORT="9000" +DOCKER_MINIO_FORWARD_CONSOLE_PORT="9090" +DOCKER_MINIO_USERNAME="startui" +DOCKER_MINIO_PASSWORD="password" DOCKER_DATABASE_PORT="5432" DOCKER_DATABASE_NAME="startui" DOCKER_DATABASE_USERNAME="startui" DOCKER_DATABASE_PASSWORD="startui" + # PUBLIC CONFIG NEXT_PUBLIC_BASE_URL="http://localhost:3000" # Use the following environment variables to show the environment name. @@ -38,3 +43,10 @@ EMAIL_FROM="Start UI " # LOGGER LOGGER_LEVEL="info" LOGGER_PRETTY="true" + +# S3 +S3_ENDPOINT="http://127.0.0.1:${DOCKER_MINIO_FORWARD_PORT}" +S3_BUCKET_NAME="start-ui-bucket" +S3_BUCKET_PUBLIC_URL="http://127.0.0.1:${DOCKER_MINIO_FORWARD_PORT}/${S3_BUCKET_NAME}" +S3_ACCESS_KEY_ID="miniodevuser" +S3_SECRET_ACCESS_KEY="miniodevuserpassword" diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index dd70ca15b..4bcf8ffea 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -29,7 +29,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 8 run_install: false - name: Get pnpm store directory diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index d9026ad85..3b7a9373e 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -26,6 +26,12 @@ jobs: AUTH_SECRET: Replace me with `openssl rand -base64 32` generated secret EMAIL_SERVER: smtp://username:password@localhost:1025 EMAIL_FROM: Start UI + S3_ENDPOINT: http://127.0.0.1:9000 + S3_BUCKET_NAME: start-ui-bucket + S3_BUCKET_PUBLIC_URL: http://127.0.0.1:9000/start-ui-bucket + S3_ACCESS_KEY_ID: miniotestuser + S3_SECRET_ACCESS_KEY: miniotestuserpassword + services: postgres: image: postgres @@ -50,16 +56,33 @@ jobs: with: node-version: 20 + # - name: Setup minio + # env: + # MINIO_ACCESS_KEY: minioadmin + # MINIO_SECRET_KEY: minioadmin + # run: | + # docker run -d -p 9000:9000 --name minio + # -e MINIO_ACCESS_KEY=startui \ + # -e MINIO_SECRET_KEY=password \ + # -v /tmp/data:/data \ + # -v /tmp/config:/root/.minio \ + # minio/minio server /data + + # export S3_ACCESS_KEY_ID=miniotestuser + # export S3_SECRET_ACCESS_KEY=miniotestuserpassword + # export AWS_EC2_METADATA_DISABLED=true + + # aws --endpoint-url http://127.0.0.1:9000/start-ui-bucket s3 mb s3://testbucket + - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9 run_install: false - name: Get pnpm store directory shell: bash run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + echo STORE_PATH=$(pnpm store path --silent) >> $GITHUB_ENV - name: Cache node modules uses: actions/cache@v4 diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 6a837a649..4c6de36c3 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,6 +3,7 @@ import React, { useEffect } from 'react'; import { Box, useColorMode } from '@chakra-ui/react'; import { Preview } from '@storybook/react'; import { themes } from '@storybook/theming'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useDarkMode } from 'storybook-dark-mode'; @@ -12,10 +13,8 @@ import { AVAILABLE_LANGUAGES, DEFAULT_LANGUAGE_KEY, } from '../src/lib/i18n/constants'; -// @ts-ignore don't want to implement a d.ts declaration for storybook only -import logoReversed from './logo-reversed.svg'; -// @ts-ignore don't want to implement a d.ts declaration for storybook only -import logo from './logo.svg'; + +const queryClient = new QueryClient(); const DocumentationWrapper = ({ children, context, isDarkMode }) => { const { i18n } = useTranslation(); @@ -86,12 +85,14 @@ const preview: Preview = { const isDarkMode = useDarkMode(); return ( - - {/* Calling as a function to avoid errors. Learn more at: - * https://github.com/storybookjs/storybook/issues/15223#issuecomment-1092837912 - */} - {story(context)} - + + + {/* Calling as a function to avoid errors. Learn more at: + * https://github.com/storybookjs/storybook/issues/15223#issuecomment-1092837912 + */} + {story(context)} + + ); }, diff --git a/README.md b/README.md index 776f1137e..f69dab6f8 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ A live read-only demonstration of what you will have when starting a project wit - [NodeJS](https://nodejs.org/) >=20 - [Pnpm](https://pnpm.io/) -- [Docker](https://www.docker.com/) (or a [PostgreSQL](https://www.postgresql.org/) database) +- [Docker](https://www.docker.com/) (or a [PostgreSQL](https://www.postgresql.org/) database and an [S3 compatible](https://aws.amazon.com/s3/) service) ## Getting Started @@ -53,14 +53,17 @@ cp .env.example .env pnpm install ``` -3. Setup and start the db with docker +3. Setup and start the services (database and S3) with docker ```bash pnpm dk:init ``` + > [!NOTE] > **Don't want to use docker?** > > Setup a PostgreSQL database (locally or online) and replace the **DATABASE_URL** environment variable. Then you can run `pnpm db:push` to update your database schema and then run `pnpm db:seed` to seed your database. +> For S3, Start UI [web] comes with a Minio service. You can use any online S3 compatible services and update the +> environment variables accordingly. ## Development diff --git a/docker-compose.yml b/docker-compose.yml index 0132ece7b..aed0c2d93 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,68 @@ services: postgres: image: postgres:16.1 + env_file: + - .env ports: - '${DOCKER_DATABASE_PORT:-5432}:5432' environment: POSTGRES_DB: $DOCKER_DATABASE_NAME POSTGRES_USER: $DOCKER_DATABASE_USERNAME POSTGRES_PASSWORD: $DOCKER_DATABASE_PASSWORD + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U $DOCKER_DATABASE_NAME'] + interval: 10s + timeout: 5s + retries: 5 + + initializedatabase: + build: + context: . + dockerfile: docker/initialize-database.dockerfile + depends_on: + postgres: + condition: service_healthy + environment: + DATABASE_URL: 'postgres://${DOCKER_DATABASE_USERNAME}:${DOCKER_DATABASE_PASSWORD}@postgres:5432/${DOCKER_DATABASE_NAME}' + + minio: + image: 'minio/minio:RELEASE.2024-07-16T23-46-41Z-cpuv1' + ports: + - '${DOCKER_FORWARD_MINIO_PORT:-9000}:9000' + - '${DOCKER_FORWARD_MINIO_CONSOLE_PORT:-9090}:9090' + environment: + MINIO_ROOT_USER: $DOCKER_MINIO_USERNAME + MINIO_ROOT_PASSWORD: $DOCKER_MINIO_PASSWORD + volumes: + - 'minio:/data/minio' + command: minio server /data/minio --console-address ":${FORWARD_MINIO_CONSOLE_PORT:-9090}" + healthcheck: + test: ['CMD', 'mc', 'ready', 'local'] + interval: 5s + timeout: 5s + retries: 5 + + createbuckets: + image: minio/mc + depends_on: + minio: + condition: service_healthy + # Set an alias, meaning myminio is an alias for the host, the username and the password, avoiding to type it again in the next commands + # As an admin, add a new user to scope minio access to this new user, avoiding to use the admin credentials in the application + # Set the policy readwrite to the user we previously created on the previously created alias + # Create the bucket on the previously created alias + # Set the public access policy to download, meaning readonly. + entrypoint: > + /bin/sh -c " + /usr/bin/mc alias set myminio http://minio:${DOCKER_FORWARD_MINIO_PORT:-9000} $DOCKER_MINIO_USERNAME $DOCKER_MINIO_PASSWORD; + /usr/bin/mc admin user add myminio $S3_ACCESS_KEY_ID $S3_SECRET_ACCESS_KEY; + /usr/bin/mc admin policy attach myminio readwrite --user $S3_ACCESS_KEY_ID; + /usr/bin/mc mb myminio/$S3_BUCKET_NAME; + /usr/bin/mc anonymous set download myminio/$S3_BUCKET_NAME; + exit 0; + " + +# use docker compose down --volumes to remove volumes declared in this file +volumes: + minio: + driver: local diff --git a/docker/initialize-database.dockerfile b/docker/initialize-database.dockerfile new file mode 100644 index 000000000..1c62cafc4 --- /dev/null +++ b/docker/initialize-database.dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /usr/src/app + +COPY . . + +RUN apk upgrade --update-cache --available && \ + apk add openssl && \ + rm -rf /var/cache/apk/* +RUN npm install -g pnpm@latest-9 +RUN pnpm install + +CMD [ "pnpm", "db:init" ] diff --git a/e2e/avatar-upload.spec.ts b/e2e/avatar-upload.spec.ts new file mode 100644 index 000000000..9e226b4d9 --- /dev/null +++ b/e2e/avatar-upload.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test'; +import { pageUtils } from 'e2e/utils/pageUtils'; +import { USER_EMAIL } from 'e2e/utils/users'; + +import { env } from '@/env.mjs'; +import { ROUTES_ACCOUNT } from '@/features/account/routes'; +import { ROUTES_APP } from '@/features/app/routes'; +import locales from '@/locales'; + +test.beforeEach('Login to the app', async ({ page }) => { + const utils = pageUtils(page); + + await utils.loginApp({ email: USER_EMAIL }); + await page.waitForURL( + `${env.NEXT_PUBLIC_BASE_URL}${ROUTES_APP.root() || '/'}**` + ); + await expect(page.getByTestId('app-layout')).toBeVisible(); +}); +test.describe('Avatar upload flow', () => { + test('Upload an avatar', async ({ page }) => { + await expect(page.getByTestId('avatar-account')).toBeVisible(); + + await page.getByTestId('avatar-account').click(); + + await page.waitForURL(`**${ROUTES_ACCOUNT.app.root()}`); + await expect( + page.getByText(locales.en.account.data.avatar.placeholder) + ).toBeVisible(); + + const fileChooserPromise = page.waitForEvent('filechooser'); + + await page.getByText(locales.en.account.data.avatar.placeholder).click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles('./public/android-chrome-192x192.png'); + + await page + .getByRole('button', { name: locales.en.account.profile.actions.update }) + .first() + .click(); + + await expect( + page.locator('a[data-testid="avatar-account"] > img') + ).toHaveAttribute('src'); + }); +}); diff --git a/package.json b/package.json index 9abe63022..f5b7f373e 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "version": "2.0.0", "description": "Opinionated UI starter with ⚛️ React, ⚡️ Chakra UI, ⚛️ React Query & 📋 React Hook Form — From the 🐻 BearStudio Team", + "packageManager": "pnpm@9.15.0", "engines": { "node": ">=20" }, @@ -29,7 +30,7 @@ "test:ui": "vitest --ui", "e2e": "dotenv -- cross-var playwright test", "e2e:ui": "dotenv -- cross-var playwright test --ui", - "dk:init": "docker compose up -d && sleep 10 && pnpm db:init", + "dk:init": "docker compose up -d", "dk:start": "docker compose start", "dk:stop": "docker compose stop", "dk:clear": "docker compose down --volumes", @@ -42,6 +43,8 @@ "*.{ts,tsx,js,jsx,json}": "prettier --write" }, "dependencies": { + "@aws-sdk/client-s3": "3.435.0", + "@aws-sdk/s3-request-presigner": "3.435.0", "@chakra-ui/anatomy": "2.2.2", "@chakra-ui/next-js": "2.2.0", "@chakra-ui/react": "2.8.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02c5db7b1..92dd0fcf3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@aws-sdk/client-s3': + specifier: 3.435.0 + version: 3.435.0 + '@aws-sdk/s3-request-presigner': + specifier: 3.435.0 + version: 3.435.0 '@chakra-ui/anatomy': specifier: 2.2.2 version: 2.2.2 @@ -358,6 +364,169 @@ packages: resolution: {integrity: sha512-Xk1sIhyNC/esHGGVjL/niHLowM0csl/kFO5uawBy4IrWwy0o1G8LGt3jP6nmWGz+USxeeqbihAmp/oVZju6wug==} hasBin: true + '@aws-crypto/crc32@3.0.0': + resolution: {integrity: sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==} + + '@aws-crypto/crc32c@3.0.0': + resolution: {integrity: sha512-ENNPPManmnVJ4BTXlOjAgD7URidbAznURqD0KvfREyc4o20DPYdEldU1f5cQ7Jbj0CJJSPaMIk/9ZshdB3210w==} + + '@aws-crypto/ie11-detection@3.0.0': + resolution: {integrity: sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==} + + '@aws-crypto/sha1-browser@3.0.0': + resolution: {integrity: sha512-NJth5c997GLHs6nOYTzFKTbYdMNA6/1XlKVgnZoaZcQ7z7UJlOgj2JdbHE8tiYLS3fzXNCguct77SPGat2raSw==} + + '@aws-crypto/sha256-browser@3.0.0': + resolution: {integrity: sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==} + + '@aws-crypto/sha256-js@3.0.0': + resolution: {integrity: sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==} + + '@aws-crypto/supports-web-crypto@3.0.0': + resolution: {integrity: sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==} + + '@aws-crypto/util@3.0.0': + resolution: {integrity: sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==} + + '@aws-sdk/client-s3@3.435.0': + resolution: {integrity: sha512-jyuv0SLLwc7Wa0s0eWHs1G4V0EJB2+4Nl/yn/LhEUrcDPrCI2FHd/lLudSmrEW+s7Rty0KTx5ZzeTn6YZ6ohTQ==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/client-sso@3.435.0': + resolution: {integrity: sha512-tT2bpwFZ3RStgyaS+JzFF4Yj+l4JRXP5+4ZRrIX5DFimzCUT8koeP4t2Gb6lvVD3DJL0nwGU5MODI1YbHTqZSQ==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/client-sts@3.435.0': + resolution: {integrity: sha512-xenshHn87b4cv45ntRgTQqeGk3H7Rrs7Br63cejFG+6ZJw7JRiz1g8EL+pIUEYyWHPYwDG0493ylxwf7p8XqaQ==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/credential-provider-env@3.433.0': + resolution: {integrity: sha512-Vl7Qz5qYyxBurMn6hfSiNJeUHSqfVUlMt0C1Bds3tCkl3IzecRWwyBOlxtxO3VCrgVeW3HqswLzCvhAFzPH6nQ==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/credential-provider-ini@3.435.0': + resolution: {integrity: sha512-YHXftGxQ2UDaIyJ2F4ZbyU52MWyWZ9dFG9oKlnA0qMPF7AIH+GtH3X+oFGC0lCAi4zx4Zd26gFlkoqupVy1HbA==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/credential-provider-node@3.435.0': + resolution: {integrity: sha512-58sOsgBzkmhyGAvTRkI/OPe+hhwsbbO1iuoyFPzFcfbU90S9NSN4BkRnvcgphbckBwKy+BIF0wP2fk/gF0CdEA==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/credential-provider-process@3.433.0': + resolution: {integrity: sha512-W7FcGlQjio9Y/PepcZGRyl5Bpwb0uWU7qIUCh+u4+q2mW4D5ZngXg8V/opL9/I/p4tUH9VXZLyLGwyBSkdhL+A==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/credential-provider-sso@3.435.0': + resolution: {integrity: sha512-WPt/7efTM0lvHsCh+OzRp79wIatkCTnCoYcp4kCHIR+aq9Z9vXICPIhmSO4okGkHnlxd/7UuNdld1BoZkT9oRA==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.433.0': + resolution: {integrity: sha512-RlwjP1I5wO+aPpwyCp23Mk8nmRbRL33hqRASy73c4JA2z2YiRua+ryt6MalIxehhwQU6xvXUKulJnPG9VaMFZg==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/middleware-bucket-endpoint@3.433.0': + resolution: {integrity: sha512-Lk1xIu2tWTRa1zDw5hCF1RrpWQYSodUhrS/q3oKz8IAoFqEy+lNaD5jx+fycuZb5EkE4IzWysT+8wVkd0mAnOg==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/middleware-expect-continue@3.433.0': + resolution: {integrity: sha512-Uq2rPIsjz0CR2sulM/HyYr5WiqiefrSRLdwUZuA7opxFSfE808w5DBWSprHxbH3rbDSQR4nFiOiVYIH8Eth7nA==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.433.0': + resolution: {integrity: sha512-Ptssx373+I7EzFUWjp/i/YiNFt6I6sDuRHz6DOUR9nmmRTlHHqmdcBXlJL2d9wwFxoBRCN8/PXGsTc/DJ4c95Q==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/middleware-host-header@3.433.0': + resolution: {integrity: sha512-mBTq3UWv1UzeHG+OfUQ2MB/5GEkt5LTKFaUqzL7ESwzW8XtpBgXnjZvIwu3Vcd3sEetMwijwaGiJhY0ae/YyaA==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/middleware-location-constraint@3.433.0': + resolution: {integrity: sha512-2YD860TGntwZifIUbxm+lFnNJJhByR/RB/+fV1I8oGKg+XX2rZU+94pRfHXRywoZKlCA0L+LGDA1I56jxrB9sw==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/middleware-logger@3.433.0': + resolution: {integrity: sha512-We346Fb5xGonTGVZC9Nvqtnqy74VJzYuTLLiuuftA5sbNzftBDy/22QCfvYSTOAl3bvif+dkDUzQY2ihc5PwOQ==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.433.0': + resolution: {integrity: sha512-HEvYC9PQlWY/ccUYtLvAlwwf1iCif2TSAmLNr3YTBRVa98x6jKL0hlCrHWYklFeqOGSKy6XhE+NGJMUII0/HaQ==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.433.0': + resolution: {integrity: sha512-mkn3DiSuMVh4NTLsduC42Av+ApcOor52LMoQY0Wc6M5Mx7Xd05U+G1j8sjI9n/1bs5cZ/PoeRYJ/9bL1Xxznnw==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/middleware-sdk-sts@3.433.0': + resolution: {integrity: sha512-ORYbJnBejUyonFl5FwIqhvI3Cq6sAp9j+JpkKZtFNma9tFPdrhmYgfCeNH32H/wGTQV/tUoQ3luh0gA4cuk6DA==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/middleware-signing@3.433.0': + resolution: {integrity: sha512-jxPvt59NZo/epMNLNTu47ikmP8v0q217I6bQFGJG7JVFnfl36zDktMwGw+0xZR80qiK47/2BWrNpta61Zd2FxQ==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/middleware-ssec@3.433.0': + resolution: {integrity: sha512-2AMaPx0kYfCiekxoL7aqFqSSoA9du+yI4zefpQNLr+1cZOerYiDxdsZ4mbqStR1CVFaX6U6hrYokXzjInsvETw==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/middleware-user-agent@3.433.0': + resolution: {integrity: sha512-jMgA1jHfisBK4oSjMKrtKEZf0sl2vzADivkFmyZFzORpSZxBnF6hC21RjaI+70LJLcc9rSCzLgcoz5lHb9LLDg==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/region-config-resolver@3.433.0': + resolution: {integrity: sha512-xpjRjCZW+CDFdcMmmhIYg81ST5UAnJh61IHziQEk0FXONrg4kjyYPZAOjEdzXQ+HxJQuGQLKPhRdzxmQnbX7pg==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/s3-request-presigner@3.435.0': + resolution: {integrity: sha512-1vNsy2YVT0gvX6q3GLI42v5hLqzQDqlvU5NkKv2/Oa426c5c7eIaC2DafUfrdMgR9hBsey93MxYdCCcWvSInmw==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.433.0': + resolution: {integrity: sha512-wl2j1dos4VOKFawbapPm/0CNa3cIgpJXbEx+sp+DI3G8tSuP3c5UGtm0pXjM85egxZulhHVK1RVde0iD8j63pQ==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/token-providers@3.435.0': + resolution: {integrity: sha512-JZKqsuoK321ozp2ufGmjfpbAqtK1tYnLn0PaePWjvDL48B5A5jGNqFyP3/tg7LFP7vTp9O3pJ7ln0QLh8FpsjQ==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/types@3.433.0': + resolution: {integrity: sha512-0jEE2mSrNDd8VGFjTc1otYrwYPIkzZJEIK90ZxisKvQ/EURGBhNzWn7ejWB9XCMFT6XumYLBR0V9qq5UPisWtA==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/util-arn-parser@3.310.0': + resolution: {integrity: sha512-jL8509owp/xB9+Or0pvn3Fe+b94qfklc2yPowZZIFAkFcCSIdkIglz18cPDWnYAcy9JGewpMS1COXKIUhZkJsA==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/util-endpoints@3.433.0': + resolution: {integrity: sha512-LFNUh9FH7RMtYjSjPGz9lAJQMzmJ3RcXISzc5X5k2R/9mNwMK7y1k2VAfvx+RbuDbll6xwsXlgv6QHcxVdF2zw==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/util-format-url@3.433.0': + resolution: {integrity: sha512-Z6T7I4hELoQ4eeIuKIKx+52B9bc3SCPhjgMcFAFQeesjmHAr0drHyoGNJIat6ckvgI6zzFaeaBZTvWDA2hyDkA==} + engines: {node: '>=14.0.0'} + + '@aws-sdk/util-locate-window@3.568.0': + resolution: {integrity: sha512-3nh4TINkXYr+H41QaPelCceEB2FXP3fxp93YZXB/kqJvX0U9j0N0Uk45gvsjmEPzG8XxkPEeLIfT2I1M7A6Lig==} + engines: {node: '>=16.0.0'} + + '@aws-sdk/util-user-agent-browser@3.433.0': + resolution: {integrity: sha512-2Cf/Lwvxbt5RXvWFXrFr49vXv0IddiUwrZoAiwhDYxvsh+BMnh+NUFot+ZQaTrk/8IPZVDeLPWZRdVy00iaVXQ==} + + '@aws-sdk/util-user-agent-node@3.433.0': + resolution: {integrity: sha512-yT1tO4MbbsUBLl5+S+jVv8wxiAtP5TKjKib9B2KQ2x0OtWWTrIf2o+IZK8va+zQqdV4MVMjezdxdE20hOdB4yQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/util-utf8-browser@3.259.0': + resolution: {integrity: sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==} + + '@aws-sdk/xml-builder@3.310.0': + resolution: {integrity: sha512-TqELu4mOuSIKQCqj63fGVs86Yh+vBx5nHRpWKNUNhB2nPTpfbziTs5c1X358be3peVWA4wPxW7Nt53KIg1tnNw==} + engines: {node: '>=14.0.0'} + '@babel/code-frame@7.23.5': resolution: {integrity: sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==} engines: {node: '>=6.9.0'} @@ -3248,6 +3417,189 @@ packages: resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} + '@smithy/abort-controller@2.2.0': + resolution: {integrity: sha512-wRlta7GuLWpTqtFfGo+nZyOO1vEvewdNR1R4rTxpC8XU6vG/NDyrFBhwLZsqg1NUoR1noVaXJPC/7ZK47QCySw==} + engines: {node: '>=14.0.0'} + + '@smithy/chunked-blob-reader-native@2.2.0': + resolution: {integrity: sha512-VNB5+1oCgX3Fzs072yuRsUoC2N4Zg/LJ11DTxX3+Qu+Paa6AmbIF0E9sc2wthz9Psrk/zcOlTCyuposlIhPjZQ==} + + '@smithy/chunked-blob-reader@2.2.0': + resolution: {integrity: sha512-3GJNvRwXBGdkDZZOGiziVYzDpn4j6zfyULHMDKAGIUo72yHALpE9CbhfQp/XcLNVoc1byfMpn6uW5H2BqPjgaQ==} + + '@smithy/config-resolver@2.2.0': + resolution: {integrity: sha512-fsiMgd8toyUba6n1WRmr+qACzXltpdDkPTAaDqc8QqPBUzO+/JKwL6bUBseHVi8tu9l+3JOK+tSf7cay+4B3LA==} + engines: {node: '>=14.0.0'} + + '@smithy/credential-provider-imds@2.3.0': + resolution: {integrity: sha512-BWB9mIukO1wjEOo1Ojgl6LrG4avcaC7T/ZP6ptmAaW4xluhSIPZhY+/PI5YKzlk+jsm+4sQZB45Bt1OfMeQa3w==} + engines: {node: '>=14.0.0'} + + '@smithy/eventstream-codec@2.2.0': + resolution: {integrity: sha512-8janZoJw85nJmQZc4L8TuePp2pk1nxLgkxIR0TUjKJ5Dkj5oelB9WtiSSGXCQvNsJl0VSTvK/2ueMXxvpa9GVw==} + + '@smithy/eventstream-serde-browser@2.2.0': + resolution: {integrity: sha512-UaPf8jKbcP71BGiO0CdeLmlg+RhWnlN8ipsMSdwvqBFigl5nil3rHOI/5GE3tfiuX8LvY5Z9N0meuU7Rab7jWw==} + engines: {node: '>=14.0.0'} + + '@smithy/eventstream-serde-config-resolver@2.2.0': + resolution: {integrity: sha512-RHhbTw/JW3+r8QQH7PrganjNCiuiEZmpi6fYUAetFfPLfZ6EkiA08uN3EFfcyKubXQxOwTeJRZSQmDDCdUshaA==} + engines: {node: '>=14.0.0'} + + '@smithy/eventstream-serde-node@2.2.0': + resolution: {integrity: sha512-zpQMtJVqCUMn+pCSFcl9K/RPNtQE0NuMh8sKpCdEHafhwRsjP50Oq/4kMmvxSRy6d8Jslqd8BLvDngrUtmN9iA==} + engines: {node: '>=14.0.0'} + + '@smithy/eventstream-serde-universal@2.2.0': + resolution: {integrity: sha512-pvoe/vvJY0mOpuF84BEtyZoYfbehiFj8KKWk1ds2AT0mTLYFVs+7sBJZmioOFdBXKd48lfrx1vumdPdmGlCLxA==} + engines: {node: '>=14.0.0'} + + '@smithy/fetch-http-handler@2.5.0': + resolution: {integrity: sha512-BOWEBeppWhLn/no/JxUL/ghTfANTjT7kg3Ww2rPqTUY9R4yHPXxJ9JhMe3Z03LN3aPwiwlpDIUcVw1xDyHqEhw==} + + '@smithy/hash-blob-browser@2.2.0': + resolution: {integrity: sha512-SGPoVH8mdXBqrkVCJ1Hd1X7vh1zDXojNN1yZyZTZsCno99hVue9+IYzWDjq/EQDDXxmITB0gBmuyPh8oAZSTcg==} + + '@smithy/hash-node@2.2.0': + resolution: {integrity: sha512-zLWaC/5aWpMrHKpoDF6nqpNtBhlAYKF/7+9yMN7GpdR8CzohnWfGtMznPybnwSS8saaXBMxIGwJqR4HmRp6b3g==} + engines: {node: '>=14.0.0'} + + '@smithy/hash-stream-node@2.2.0': + resolution: {integrity: sha512-aT+HCATOSRMGpPI7bi7NSsTNVZE/La9IaxLXWoVAYMxHT5hGO3ZOGEMZQg8A6nNL+pdFGtZQtND1eoY084HgHQ==} + engines: {node: '>=14.0.0'} + + '@smithy/invalid-dependency@2.2.0': + resolution: {integrity: sha512-nEDASdbKFKPXN2O6lOlTgrEEOO9NHIeO+HVvZnkqc8h5U9g3BIhWsvzFo+UcUbliMHvKNPD/zVxDrkP1Sbgp8Q==} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/md5-js@2.2.0': + resolution: {integrity: sha512-M26XTtt9IIusVMOWEAhIvFIr9jYj4ISPPGJROqw6vXngO3IYJCnVVSMFn4Tx1rUTG5BiKJNg9u2nxmBiZC5IlQ==} + + '@smithy/middleware-content-length@2.2.0': + resolution: {integrity: sha512-5bl2LG1Ah/7E5cMSC+q+h3IpVHMeOkG0yLRyQT1p2aMJkSrZG7RlXHPuAgb7EyaFeidKEnnd/fNaLLaKlHGzDQ==} + engines: {node: '>=14.0.0'} + + '@smithy/middleware-endpoint@2.5.1': + resolution: {integrity: sha512-1/8kFp6Fl4OsSIVTWHnNjLnTL8IqpIb/D3sTSczrKFnrE9VMNWxnrRKNvpUHOJ6zpGD5f62TPm7+17ilTJpiCQ==} + engines: {node: '>=14.0.0'} + + '@smithy/middleware-retry@2.3.1': + resolution: {integrity: sha512-P2bGufFpFdYcWvqpyqqmalRtwFUNUA8vHjJR5iGqbfR6mp65qKOLcUd6lTr4S9Gn/enynSrSf3p3FVgVAf6bXA==} + engines: {node: '>=14.0.0'} + + '@smithy/middleware-serde@2.3.0': + resolution: {integrity: sha512-sIADe7ojwqTyvEQBe1nc/GXB9wdHhi9UwyX0lTyttmUWDJLP655ZYE1WngnNyXREme8I27KCaUhyhZWRXL0q7Q==} + engines: {node: '>=14.0.0'} + + '@smithy/middleware-stack@2.2.0': + resolution: {integrity: sha512-Qntc3jrtwwrsAC+X8wms8zhrTr0sFXnyEGhZd9sLtsJ/6gGQKFzNB+wWbOcpJd7BR8ThNCoKt76BuQahfMvpeA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-config-provider@2.3.0': + resolution: {integrity: sha512-0elK5/03a1JPWMDPaS726Iw6LpQg80gFut1tNpPfxFuChEEklo2yL823V94SpTZTxmKlXFtFgsP55uh3dErnIg==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@2.5.0': + resolution: {integrity: sha512-mVGyPBzkkGQsPoxQUbxlEfRjrj6FPyA3u3u2VXGr9hT8wilsoQdZdvKpMBFMB8Crfhv5dNkKHIW0Yyuc7eABqA==} + engines: {node: '>=14.0.0'} + + '@smithy/property-provider@2.2.0': + resolution: {integrity: sha512-+xiil2lFhtTRzXkx8F053AV46QnIw6e7MV8od5Mi68E1ICOjCeCHw2XfLnDEUHnT9WGUIkwcqavXjfwuJbGlpg==} + engines: {node: '>=14.0.0'} + + '@smithy/protocol-http@3.3.0': + resolution: {integrity: sha512-Xy5XK1AFWW2nlY/biWZXu6/krgbaf2dg0q492D8M5qthsnU2H+UgFeZLbM76FnH7s6RO/xhQRkj+T6KBO3JzgQ==} + engines: {node: '>=14.0.0'} + + '@smithy/querystring-builder@2.2.0': + resolution: {integrity: sha512-L1kSeviUWL+emq3CUVSgdogoM/D9QMFaqxL/dd0X7PCNWmPXqt+ExtrBjqT0V7HLN03Vs9SuiLrG3zy3JGnE5A==} + engines: {node: '>=14.0.0'} + + '@smithy/querystring-parser@2.2.0': + resolution: {integrity: sha512-BvHCDrKfbG5Yhbpj4vsbuPV2GgcpHiAkLeIlcA1LtfpMz3jrqizP1+OguSNSj1MwBHEiN+jwNisXLGdajGDQJA==} + engines: {node: '>=14.0.0'} + + '@smithy/service-error-classification@2.1.5': + resolution: {integrity: sha512-uBDTIBBEdAQryvHdc5W8sS5YX7RQzF683XrHePVdFmAgKiMofU15FLSM0/HU03hKTnazdNRFa0YHS7+ArwoUSQ==} + engines: {node: '>=14.0.0'} + + '@smithy/shared-ini-file-loader@2.4.0': + resolution: {integrity: sha512-WyujUJL8e1B6Z4PBfAqC/aGY1+C7T0w20Gih3yrvJSk97gpiVfB+y7c46T4Nunk+ZngLq0rOIdeVeIklk0R3OA==} + engines: {node: '>=14.0.0'} + + '@smithy/signature-v4@2.3.0': + resolution: {integrity: sha512-ui/NlpILU+6HAQBfJX8BBsDXuKSNrjTSuOYArRblcrErwKFutjrCNb/OExfVRyj9+26F9J+ZmfWT+fKWuDrH3Q==} + engines: {node: '>=14.0.0'} + + '@smithy/smithy-client@2.5.1': + resolution: {integrity: sha512-jrbSQrYCho0yDaaf92qWgd+7nAeap5LtHTI51KXqmpIFCceKU3K9+vIVTUH72bOJngBMqa4kyu1VJhRcSrk/CQ==} + engines: {node: '>=14.0.0'} + + '@smithy/types@2.12.0': + resolution: {integrity: sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==} + engines: {node: '>=14.0.0'} + + '@smithy/url-parser@2.2.0': + resolution: {integrity: sha512-hoA4zm61q1mNTpksiSWp2nEl1dt3j726HdRhiNgVJQMj7mLp7dprtF57mOB6JvEk/x9d2bsuL5hlqZbBuHQylQ==} + + '@smithy/util-base64@2.3.0': + resolution: {integrity: sha512-s3+eVwNeJuXUwuMbusncZNViuhv2LjVJ1nMwTqSA0XAC7gjKhqqxRdJPhR8+YrkoZ9IiIbFk/yK6ACe/xlF+hw==} + engines: {node: '>=14.0.0'} + + '@smithy/util-body-length-browser@2.2.0': + resolution: {integrity: sha512-dtpw9uQP7W+n3vOtx0CfBD5EWd7EPdIdsQnWTDoFf77e3VUf05uA7R7TGipIo8e4WL2kuPdnsr3hMQn9ziYj5w==} + + '@smithy/util-body-length-node@2.3.0': + resolution: {integrity: sha512-ITWT1Wqjubf2CJthb0BuT9+bpzBfXeMokH/AAa5EJQgbv9aPMVfnM76iFIZVFf50hYXGbtiV71BHAthNWd6+dw==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-config-provider@2.3.0': + resolution: {integrity: sha512-HZkzrRcuFN1k70RLqlNK4FnPXKOpkik1+4JaBoHNJn+RnJGYqaa3c5/+XtLOXhlKzlRgNvyaLieHTW2VwGN0VQ==} + engines: {node: '>=14.0.0'} + + '@smithy/util-defaults-mode-browser@2.2.1': + resolution: {integrity: sha512-RtKW+8j8skk17SYowucwRUjeh4mCtnm5odCL0Lm2NtHQBsYKrNW0od9Rhopu9wF1gHMfHeWF7i90NwBz/U22Kw==} + engines: {node: '>= 10.0.0'} + + '@smithy/util-defaults-mode-node@2.3.1': + resolution: {integrity: sha512-vkMXHQ0BcLFysBMWgSBLSk3+leMpFSyyFj8zQtv5ZyUBx8/owVh1/pPEkzmW/DR/Gy/5c8vjLDD9gZjXNKbrpA==} + engines: {node: '>= 10.0.0'} + + '@smithy/util-hex-encoding@2.2.0': + resolution: {integrity: sha512-7iKXR+/4TpLK194pVjKiasIyqMtTYJsgKgM242Y9uzt5dhHnUDvMNb+3xIhRJ9QhvqGii/5cRUt4fJn3dtXNHQ==} + engines: {node: '>=14.0.0'} + + '@smithy/util-middleware@2.2.0': + resolution: {integrity: sha512-L1qpleXf9QD6LwLCJ5jddGkgWyuSvWBkJwWAZ6kFkdifdso+sk3L3O1HdmPvCdnCK3IS4qWyPxev01QMnfHSBw==} + engines: {node: '>=14.0.0'} + + '@smithy/util-retry@2.2.0': + resolution: {integrity: sha512-q9+pAFPTfftHXRytmZ7GzLFFrEGavqapFc06XxzZFcSIGERXMerXxCitjOG1prVDR9QdjqotF40SWvbqcCpf8g==} + engines: {node: '>= 14.0.0'} + + '@smithy/util-stream@2.2.0': + resolution: {integrity: sha512-17faEXbYWIRst1aU9SvPZyMdWmqIrduZjVOqCPMIsWFNxs5yQQgFrJL6b2SdiCzyW9mJoDjFtgi53xx7EH+BXA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-uri-escape@2.2.0': + resolution: {integrity: sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-waiter@2.2.0': + resolution: {integrity: sha512-IHk53BVw6MPMi2Gsn+hCng8rFA3ZmR3Rk7GllxDUW9qFJl/hiSvskn7XldkECapQVkIg/1dHpMAxI9xSTaLLSA==} + engines: {node: '>=14.0.0'} + '@socket.io/component-emitter@3.1.0': resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==} @@ -4881,6 +5233,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.11.0: + resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==} + bplist-parser@0.2.0: resolution: {integrity: sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==} engines: {node: '>= 5.10.0'} @@ -6056,6 +6411,10 @@ packages: fast-shallow-equal@1.0.0: resolution: {integrity: sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==} + fast-xml-parser@4.2.5: + resolution: {integrity: sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g==} + hasBin: true + fastest-stable-stringify@2.0.2: resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==} @@ -9140,6 +9499,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@1.0.5: + resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} + style-loader@3.3.3: resolution: {integrity: sha512-53BiGLXAcll9maCYtZi2RCQZKa8NQQai5C4horqKyRmHj9H7QmcUyucrH+4KW/gBQbXM2AsB0axoEcFZPlfPcw==} engines: {node: '>= 12.13.0'} @@ -10203,6 +10565,475 @@ snapshots: dependencies: default-browser-id: 3.0.0 + '@aws-crypto/crc32@3.0.0': + dependencies: + '@aws-crypto/util': 3.0.0 + '@aws-sdk/types': 3.433.0 + tslib: 1.14.1 + + '@aws-crypto/crc32c@3.0.0': + dependencies: + '@aws-crypto/util': 3.0.0 + '@aws-sdk/types': 3.433.0 + tslib: 1.14.1 + + '@aws-crypto/ie11-detection@3.0.0': + dependencies: + tslib: 1.14.1 + + '@aws-crypto/sha1-browser@3.0.0': + dependencies: + '@aws-crypto/ie11-detection': 3.0.0 + '@aws-crypto/supports-web-crypto': 3.0.0 + '@aws-crypto/util': 3.0.0 + '@aws-sdk/types': 3.433.0 + '@aws-sdk/util-locate-window': 3.568.0 + '@aws-sdk/util-utf8-browser': 3.259.0 + tslib: 1.14.1 + + '@aws-crypto/sha256-browser@3.0.0': + dependencies: + '@aws-crypto/ie11-detection': 3.0.0 + '@aws-crypto/sha256-js': 3.0.0 + '@aws-crypto/supports-web-crypto': 3.0.0 + '@aws-crypto/util': 3.0.0 + '@aws-sdk/types': 3.433.0 + '@aws-sdk/util-locate-window': 3.568.0 + '@aws-sdk/util-utf8-browser': 3.259.0 + tslib: 1.14.1 + + '@aws-crypto/sha256-js@3.0.0': + dependencies: + '@aws-crypto/util': 3.0.0 + '@aws-sdk/types': 3.433.0 + tslib: 1.14.1 + + '@aws-crypto/supports-web-crypto@3.0.0': + dependencies: + tslib: 1.14.1 + + '@aws-crypto/util@3.0.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@aws-sdk/util-utf8-browser': 3.259.0 + tslib: 1.14.1 + + '@aws-sdk/client-s3@3.435.0': + dependencies: + '@aws-crypto/sha1-browser': 3.0.0 + '@aws-crypto/sha256-browser': 3.0.0 + '@aws-crypto/sha256-js': 3.0.0 + '@aws-sdk/client-sts': 3.435.0 + '@aws-sdk/credential-provider-node': 3.435.0 + '@aws-sdk/middleware-bucket-endpoint': 3.433.0 + '@aws-sdk/middleware-expect-continue': 3.433.0 + '@aws-sdk/middleware-flexible-checksums': 3.433.0 + '@aws-sdk/middleware-host-header': 3.433.0 + '@aws-sdk/middleware-location-constraint': 3.433.0 + '@aws-sdk/middleware-logger': 3.433.0 + '@aws-sdk/middleware-recursion-detection': 3.433.0 + '@aws-sdk/middleware-sdk-s3': 3.433.0 + '@aws-sdk/middleware-signing': 3.433.0 + '@aws-sdk/middleware-ssec': 3.433.0 + '@aws-sdk/middleware-user-agent': 3.433.0 + '@aws-sdk/region-config-resolver': 3.433.0 + '@aws-sdk/signature-v4-multi-region': 3.433.0 + '@aws-sdk/types': 3.433.0 + '@aws-sdk/util-endpoints': 3.433.0 + '@aws-sdk/util-user-agent-browser': 3.433.0 + '@aws-sdk/util-user-agent-node': 3.433.0 + '@aws-sdk/xml-builder': 3.310.0 + '@smithy/config-resolver': 2.2.0 + '@smithy/eventstream-serde-browser': 2.2.0 + '@smithy/eventstream-serde-config-resolver': 2.2.0 + '@smithy/eventstream-serde-node': 2.2.0 + '@smithy/fetch-http-handler': 2.5.0 + '@smithy/hash-blob-browser': 2.2.0 + '@smithy/hash-node': 2.2.0 + '@smithy/hash-stream-node': 2.2.0 + '@smithy/invalid-dependency': 2.2.0 + '@smithy/md5-js': 2.2.0 + '@smithy/middleware-content-length': 2.2.0 + '@smithy/middleware-endpoint': 2.5.1 + '@smithy/middleware-retry': 2.3.1 + '@smithy/middleware-serde': 2.3.0 + '@smithy/middleware-stack': 2.2.0 + '@smithy/node-config-provider': 2.3.0 + '@smithy/node-http-handler': 2.5.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/smithy-client': 2.5.1 + '@smithy/types': 2.12.0 + '@smithy/url-parser': 2.2.0 + '@smithy/util-base64': 2.3.0 + '@smithy/util-body-length-browser': 2.2.0 + '@smithy/util-body-length-node': 2.3.0 + '@smithy/util-defaults-mode-browser': 2.2.1 + '@smithy/util-defaults-mode-node': 2.3.1 + '@smithy/util-retry': 2.2.0 + '@smithy/util-stream': 2.2.0 + '@smithy/util-utf8': 2.3.0 + '@smithy/util-waiter': 2.2.0 + fast-xml-parser: 4.2.5 + tslib: 2.6.2 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.435.0': + dependencies: + '@aws-crypto/sha256-browser': 3.0.0 + '@aws-crypto/sha256-js': 3.0.0 + '@aws-sdk/middleware-host-header': 3.433.0 + '@aws-sdk/middleware-logger': 3.433.0 + '@aws-sdk/middleware-recursion-detection': 3.433.0 + '@aws-sdk/middleware-user-agent': 3.433.0 + '@aws-sdk/region-config-resolver': 3.433.0 + '@aws-sdk/types': 3.433.0 + '@aws-sdk/util-endpoints': 3.433.0 + '@aws-sdk/util-user-agent-browser': 3.433.0 + '@aws-sdk/util-user-agent-node': 3.433.0 + '@smithy/config-resolver': 2.2.0 + '@smithy/fetch-http-handler': 2.5.0 + '@smithy/hash-node': 2.2.0 + '@smithy/invalid-dependency': 2.2.0 + '@smithy/middleware-content-length': 2.2.0 + '@smithy/middleware-endpoint': 2.5.1 + '@smithy/middleware-retry': 2.3.1 + '@smithy/middleware-serde': 2.3.0 + '@smithy/middleware-stack': 2.2.0 + '@smithy/node-config-provider': 2.3.0 + '@smithy/node-http-handler': 2.5.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/smithy-client': 2.5.1 + '@smithy/types': 2.12.0 + '@smithy/url-parser': 2.2.0 + '@smithy/util-base64': 2.3.0 + '@smithy/util-body-length-browser': 2.2.0 + '@smithy/util-body-length-node': 2.3.0 + '@smithy/util-defaults-mode-browser': 2.2.1 + '@smithy/util-defaults-mode-node': 2.3.1 + '@smithy/util-retry': 2.2.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.6.2 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sts@3.435.0': + dependencies: + '@aws-crypto/sha256-browser': 3.0.0 + '@aws-crypto/sha256-js': 3.0.0 + '@aws-sdk/credential-provider-node': 3.435.0 + '@aws-sdk/middleware-host-header': 3.433.0 + '@aws-sdk/middleware-logger': 3.433.0 + '@aws-sdk/middleware-recursion-detection': 3.433.0 + '@aws-sdk/middleware-sdk-sts': 3.433.0 + '@aws-sdk/middleware-signing': 3.433.0 + '@aws-sdk/middleware-user-agent': 3.433.0 + '@aws-sdk/region-config-resolver': 3.433.0 + '@aws-sdk/types': 3.433.0 + '@aws-sdk/util-endpoints': 3.433.0 + '@aws-sdk/util-user-agent-browser': 3.433.0 + '@aws-sdk/util-user-agent-node': 3.433.0 + '@smithy/config-resolver': 2.2.0 + '@smithy/fetch-http-handler': 2.5.0 + '@smithy/hash-node': 2.2.0 + '@smithy/invalid-dependency': 2.2.0 + '@smithy/middleware-content-length': 2.2.0 + '@smithy/middleware-endpoint': 2.5.1 + '@smithy/middleware-retry': 2.3.1 + '@smithy/middleware-serde': 2.3.0 + '@smithy/middleware-stack': 2.2.0 + '@smithy/node-config-provider': 2.3.0 + '@smithy/node-http-handler': 2.5.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/smithy-client': 2.5.1 + '@smithy/types': 2.12.0 + '@smithy/url-parser': 2.2.0 + '@smithy/util-base64': 2.3.0 + '@smithy/util-body-length-browser': 2.2.0 + '@smithy/util-body-length-node': 2.3.0 + '@smithy/util-defaults-mode-browser': 2.2.1 + '@smithy/util-defaults-mode-node': 2.3.1 + '@smithy/util-retry': 2.2.0 + '@smithy/util-utf8': 2.3.0 + fast-xml-parser: 4.2.5 + tslib: 2.6.2 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-env@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@smithy/property-provider': 2.2.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/credential-provider-ini@3.435.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.433.0 + '@aws-sdk/credential-provider-process': 3.433.0 + '@aws-sdk/credential-provider-sso': 3.435.0 + '@aws-sdk/credential-provider-web-identity': 3.433.0 + '@aws-sdk/types': 3.433.0 + '@smithy/credential-provider-imds': 2.3.0 + '@smithy/property-provider': 2.2.0 + '@smithy/shared-ini-file-loader': 2.4.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.435.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.433.0 + '@aws-sdk/credential-provider-ini': 3.435.0 + '@aws-sdk/credential-provider-process': 3.433.0 + '@aws-sdk/credential-provider-sso': 3.435.0 + '@aws-sdk/credential-provider-web-identity': 3.433.0 + '@aws-sdk/types': 3.433.0 + '@smithy/credential-provider-imds': 2.3.0 + '@smithy/property-provider': 2.2.0 + '@smithy/shared-ini-file-loader': 2.4.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@smithy/property-provider': 2.2.0 + '@smithy/shared-ini-file-loader': 2.4.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/credential-provider-sso@3.435.0': + dependencies: + '@aws-sdk/client-sso': 3.435.0 + '@aws-sdk/token-providers': 3.435.0 + '@aws-sdk/types': 3.433.0 + '@smithy/property-provider': 2.2.0 + '@smithy/shared-ini-file-loader': 2.4.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@smithy/property-provider': 2.2.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/middleware-bucket-endpoint@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@aws-sdk/util-arn-parser': 3.310.0 + '@smithy/node-config-provider': 2.3.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/types': 2.12.0 + '@smithy/util-config-provider': 2.3.0 + tslib: 2.6.2 + + '@aws-sdk/middleware-expect-continue@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/middleware-flexible-checksums@3.433.0': + dependencies: + '@aws-crypto/crc32': 3.0.0 + '@aws-crypto/crc32c': 3.0.0 + '@aws-sdk/types': 3.433.0 + '@smithy/is-array-buffer': 2.2.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/types': 2.12.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.6.2 + + '@aws-sdk/middleware-host-header@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/middleware-location-constraint@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/middleware-logger@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/middleware-recursion-detection@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/middleware-sdk-s3@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@aws-sdk/util-arn-parser': 3.310.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/smithy-client': 2.5.1 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/middleware-sdk-sts@3.433.0': + dependencies: + '@aws-sdk/middleware-signing': 3.433.0 + '@aws-sdk/types': 3.433.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/middleware-signing@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@smithy/property-provider': 2.2.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/signature-v4': 2.3.0 + '@smithy/types': 2.12.0 + '@smithy/util-middleware': 2.2.0 + tslib: 2.6.2 + + '@aws-sdk/middleware-ssec@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/middleware-user-agent@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@aws-sdk/util-endpoints': 3.433.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/region-config-resolver@3.433.0': + dependencies: + '@smithy/node-config-provider': 2.3.0 + '@smithy/types': 2.12.0 + '@smithy/util-config-provider': 2.3.0 + '@smithy/util-middleware': 2.2.0 + tslib: 2.6.2 + + '@aws-sdk/s3-request-presigner@3.435.0': + dependencies: + '@aws-sdk/signature-v4-multi-region': 3.433.0 + '@aws-sdk/types': 3.433.0 + '@aws-sdk/util-format-url': 3.433.0 + '@smithy/middleware-endpoint': 2.5.1 + '@smithy/protocol-http': 3.3.0 + '@smithy/smithy-client': 2.5.1 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/signature-v4-multi-region@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/signature-v4': 2.3.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/token-providers@3.435.0': + dependencies: + '@aws-crypto/sha256-browser': 3.0.0 + '@aws-crypto/sha256-js': 3.0.0 + '@aws-sdk/middleware-host-header': 3.433.0 + '@aws-sdk/middleware-logger': 3.433.0 + '@aws-sdk/middleware-recursion-detection': 3.433.0 + '@aws-sdk/middleware-user-agent': 3.433.0 + '@aws-sdk/region-config-resolver': 3.433.0 + '@aws-sdk/types': 3.433.0 + '@aws-sdk/util-endpoints': 3.433.0 + '@aws-sdk/util-user-agent-browser': 3.433.0 + '@aws-sdk/util-user-agent-node': 3.433.0 + '@smithy/config-resolver': 2.2.0 + '@smithy/fetch-http-handler': 2.5.0 + '@smithy/hash-node': 2.2.0 + '@smithy/invalid-dependency': 2.2.0 + '@smithy/middleware-content-length': 2.2.0 + '@smithy/middleware-endpoint': 2.5.1 + '@smithy/middleware-retry': 2.3.1 + '@smithy/middleware-serde': 2.3.0 + '@smithy/middleware-stack': 2.2.0 + '@smithy/node-config-provider': 2.3.0 + '@smithy/node-http-handler': 2.5.0 + '@smithy/property-provider': 2.2.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/shared-ini-file-loader': 2.4.0 + '@smithy/smithy-client': 2.5.1 + '@smithy/types': 2.12.0 + '@smithy/url-parser': 2.2.0 + '@smithy/util-base64': 2.3.0 + '@smithy/util-body-length-browser': 2.2.0 + '@smithy/util-body-length-node': 2.3.0 + '@smithy/util-defaults-mode-browser': 2.2.1 + '@smithy/util-defaults-mode-node': 2.3.1 + '@smithy/util-retry': 2.2.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.6.2 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.433.0': + dependencies: + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/util-arn-parser@3.310.0': + dependencies: + tslib: 2.6.2 + + '@aws-sdk/util-endpoints@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + tslib: 2.6.2 + + '@aws-sdk/util-format-url@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@smithy/querystring-builder': 2.2.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/util-locate-window@3.568.0': + dependencies: + tslib: 2.6.2 + + '@aws-sdk/util-user-agent-browser@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@smithy/types': 2.12.0 + bowser: 2.11.0 + tslib: 2.6.2 + + '@aws-sdk/util-user-agent-node@3.433.0': + dependencies: + '@aws-sdk/types': 3.433.0 + '@smithy/node-config-provider': 2.3.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@aws-sdk/util-utf8-browser@3.259.0': + dependencies: + tslib: 2.6.2 + + '@aws-sdk/xml-builder@3.310.0': + dependencies: + tslib: 2.6.2 + '@babel/code-frame@7.23.5': dependencies: '@babel/highlight': 7.23.4 @@ -10768,9 +11599,11 @@ snapshots: dependencies: '@babel/core': 7.24.7 '@babel/helper-hoist-variables': 7.22.5 - '@babel/helper-module-transforms': 7.24.5(@babel/core@7.24.7) + '@babel/helper-module-transforms': 7.24.7(@babel/core@7.24.7) '@babel/helper-plugin-utils': 7.24.5 '@babel/helper-validator-identifier': 7.24.5 + transitivePeerDependencies: + - supports-color '@babel/plugin-transform-modules-umd@7.24.1(@babel/core@7.24.7)': dependencies: @@ -13200,6 +14033,303 @@ snapshots: '@sindresorhus/merge-streams@2.3.0': {} + '@smithy/abort-controller@2.2.0': + dependencies: + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/chunked-blob-reader-native@2.2.0': + dependencies: + '@smithy/util-base64': 2.3.0 + tslib: 2.6.2 + + '@smithy/chunked-blob-reader@2.2.0': + dependencies: + tslib: 2.6.2 + + '@smithy/config-resolver@2.2.0': + dependencies: + '@smithy/node-config-provider': 2.3.0 + '@smithy/types': 2.12.0 + '@smithy/util-config-provider': 2.3.0 + '@smithy/util-middleware': 2.2.0 + tslib: 2.6.2 + + '@smithy/credential-provider-imds@2.3.0': + dependencies: + '@smithy/node-config-provider': 2.3.0 + '@smithy/property-provider': 2.2.0 + '@smithy/types': 2.12.0 + '@smithy/url-parser': 2.2.0 + tslib: 2.6.2 + + '@smithy/eventstream-codec@2.2.0': + dependencies: + '@aws-crypto/crc32': 3.0.0 + '@smithy/types': 2.12.0 + '@smithy/util-hex-encoding': 2.2.0 + tslib: 2.6.2 + + '@smithy/eventstream-serde-browser@2.2.0': + dependencies: + '@smithy/eventstream-serde-universal': 2.2.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/eventstream-serde-config-resolver@2.2.0': + dependencies: + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/eventstream-serde-node@2.2.0': + dependencies: + '@smithy/eventstream-serde-universal': 2.2.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/eventstream-serde-universal@2.2.0': + dependencies: + '@smithy/eventstream-codec': 2.2.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/fetch-http-handler@2.5.0': + dependencies: + '@smithy/protocol-http': 3.3.0 + '@smithy/querystring-builder': 2.2.0 + '@smithy/types': 2.12.0 + '@smithy/util-base64': 2.3.0 + tslib: 2.6.2 + + '@smithy/hash-blob-browser@2.2.0': + dependencies: + '@smithy/chunked-blob-reader': 2.2.0 + '@smithy/chunked-blob-reader-native': 2.2.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/hash-node@2.2.0': + dependencies: + '@smithy/types': 2.12.0 + '@smithy/util-buffer-from': 2.2.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.6.2 + + '@smithy/hash-stream-node@2.2.0': + dependencies: + '@smithy/types': 2.12.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.6.2 + + '@smithy/invalid-dependency@2.2.0': + dependencies: + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.6.2 + + '@smithy/md5-js@2.2.0': + dependencies: + '@smithy/types': 2.12.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.6.2 + + '@smithy/middleware-content-length@2.2.0': + dependencies: + '@smithy/protocol-http': 3.3.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/middleware-endpoint@2.5.1': + dependencies: + '@smithy/middleware-serde': 2.3.0 + '@smithy/node-config-provider': 2.3.0 + '@smithy/shared-ini-file-loader': 2.4.0 + '@smithy/types': 2.12.0 + '@smithy/url-parser': 2.2.0 + '@smithy/util-middleware': 2.2.0 + tslib: 2.6.2 + + '@smithy/middleware-retry@2.3.1': + dependencies: + '@smithy/node-config-provider': 2.3.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/service-error-classification': 2.1.5 + '@smithy/smithy-client': 2.5.1 + '@smithy/types': 2.12.0 + '@smithy/util-middleware': 2.2.0 + '@smithy/util-retry': 2.2.0 + tslib: 2.6.2 + uuid: 9.0.1 + + '@smithy/middleware-serde@2.3.0': + dependencies: + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/middleware-stack@2.2.0': + dependencies: + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/node-config-provider@2.3.0': + dependencies: + '@smithy/property-provider': 2.2.0 + '@smithy/shared-ini-file-loader': 2.4.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/node-http-handler@2.5.0': + dependencies: + '@smithy/abort-controller': 2.2.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/querystring-builder': 2.2.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/property-provider@2.2.0': + dependencies: + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/protocol-http@3.3.0': + dependencies: + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/querystring-builder@2.2.0': + dependencies: + '@smithy/types': 2.12.0 + '@smithy/util-uri-escape': 2.2.0 + tslib: 2.6.2 + + '@smithy/querystring-parser@2.2.0': + dependencies: + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/service-error-classification@2.1.5': + dependencies: + '@smithy/types': 2.12.0 + + '@smithy/shared-ini-file-loader@2.4.0': + dependencies: + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/signature-v4@2.3.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + '@smithy/types': 2.12.0 + '@smithy/util-hex-encoding': 2.2.0 + '@smithy/util-middleware': 2.2.0 + '@smithy/util-uri-escape': 2.2.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.6.2 + + '@smithy/smithy-client@2.5.1': + dependencies: + '@smithy/middleware-endpoint': 2.5.1 + '@smithy/middleware-stack': 2.2.0 + '@smithy/protocol-http': 3.3.0 + '@smithy/types': 2.12.0 + '@smithy/util-stream': 2.2.0 + tslib: 2.6.2 + + '@smithy/types@2.12.0': + dependencies: + tslib: 2.6.2 + + '@smithy/url-parser@2.2.0': + dependencies: + '@smithy/querystring-parser': 2.2.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/util-base64@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.6.2 + + '@smithy/util-body-length-browser@2.2.0': + dependencies: + tslib: 2.6.2 + + '@smithy/util-body-length-node@2.3.0': + dependencies: + tslib: 2.6.2 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.6.2 + + '@smithy/util-config-provider@2.3.0': + dependencies: + tslib: 2.6.2 + + '@smithy/util-defaults-mode-browser@2.2.1': + dependencies: + '@smithy/property-provider': 2.2.0 + '@smithy/smithy-client': 2.5.1 + '@smithy/types': 2.12.0 + bowser: 2.11.0 + tslib: 2.6.2 + + '@smithy/util-defaults-mode-node@2.3.1': + dependencies: + '@smithy/config-resolver': 2.2.0 + '@smithy/credential-provider-imds': 2.3.0 + '@smithy/node-config-provider': 2.3.0 + '@smithy/property-provider': 2.2.0 + '@smithy/smithy-client': 2.5.1 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/util-hex-encoding@2.2.0': + dependencies: + tslib: 2.6.2 + + '@smithy/util-middleware@2.2.0': + dependencies: + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/util-retry@2.2.0': + dependencies: + '@smithy/service-error-classification': 2.1.5 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + + '@smithy/util-stream@2.2.0': + dependencies: + '@smithy/fetch-http-handler': 2.5.0 + '@smithy/node-http-handler': 2.5.0 + '@smithy/types': 2.12.0 + '@smithy/util-base64': 2.3.0 + '@smithy/util-buffer-from': 2.2.0 + '@smithy/util-hex-encoding': 2.2.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.6.2 + + '@smithy/util-uri-escape@2.2.0': + dependencies: + tslib: 2.6.2 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.6.2 + + '@smithy/util-waiter@2.2.0': + dependencies: + '@smithy/abort-controller': 2.2.0 + '@smithy/types': 2.12.0 + tslib: 2.6.2 + '@socket.io/component-emitter@3.1.0': {} '@stoplight/elements-core@8.3.3(@babel/core@7.24.7)(@babel/template@7.24.7)(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -16099,6 +17229,8 @@ snapshots: boolbase@1.0.0: {} + bowser@2.11.0: {} + bplist-parser@0.2.0: dependencies: big-integer: 1.6.52 @@ -17573,6 +18705,10 @@ snapshots: fast-shallow-equal@1.0.0: {} + fast-xml-parser@4.2.5: + dependencies: + strnum: 1.0.5 + fastest-stable-stringify@2.0.2: {} fastestsmallesttextencoderdecoder@1.0.22: {} @@ -20174,7 +21310,7 @@ snapshots: react-select@5.8.0(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.24.5 '@emotion/cache': 11.11.0 '@emotion/react': 11.11.4(@types/react@18.3.3)(react@18.3.1) '@floating-ui/dom': 1.5.3 @@ -21003,6 +22139,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@1.0.5: {} + style-loader@3.3.3(webpack@5.89.0(esbuild@0.20.2)): dependencies: webpack: 5.89.0(esbuild@0.20.2) diff --git a/src/components/Form/FieldUpload/FieldUpload.spec.tsx b/src/components/Form/FieldUpload/FieldUpload.spec.tsx new file mode 100644 index 000000000..a8df34ad1 --- /dev/null +++ b/src/components/Form/FieldUpload/FieldUpload.spec.tsx @@ -0,0 +1,87 @@ +import { expect, test, vi } from 'vitest'; +import { z } from 'zod'; + +import { FormFieldController } from '@/components/Form/FormFieldController'; +import { FormFieldLabel } from '@/components/Form/FormFieldLabel'; +import { FieldUploadValue, zFieldUploadValue } from '@/lib/s3/schemas'; +import { render, screen, setupUser } from '@/tests/utils'; + +import { FormField } from '../FormField'; +import { FormMocked } from '../form-test-utils'; + +const mockFileRaw = new File(['mock-contet'], 'FileTest', { + type: 'image/png', +}); + +const mockFile: FieldUploadValue = { + file: mockFileRaw, + lastModified: mockFileRaw.lastModified, + lastModifiedDate: new Date(mockFileRaw.lastModified), + size: mockFileRaw.size, + type: mockFileRaw.type, + name: mockFileRaw.name ?? '', +}; + +test('update value', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + File + + + )} + + ); + + const input = screen.getByLabelText('Upload'); + await user.upload(input, mockFile.file ?? []); + expect(input.files ? input.files[0] : []).toBe(mockFile.file); + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ file: mockFile }); +}); + +test('default value', async () => { + const user = setupUser(); + const mockedSubmit = vi.fn(); + + render( + + {({ form }) => ( + + File + + + )} + + ); + + const input = screen.getByLabelText(mockFile.name ?? ''); + expect(input.files ? input.files[0] : []).toBe(undefined); + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(mockedSubmit).toHaveBeenCalledWith({ file: mockFile }); +}); diff --git a/src/components/Form/FieldUpload/FieldUploadPreview.tsx b/src/components/Form/FieldUpload/FieldUploadPreview.tsx new file mode 100644 index 000000000..d32b11274 --- /dev/null +++ b/src/components/Form/FieldUpload/FieldUploadPreview.tsx @@ -0,0 +1,108 @@ +import { FC, PropsWithChildren, useCallback, useEffect, useState } from 'react'; + +import { Box, Flex, type FlexProps, IconButton } from '@chakra-ui/react'; +import { useFormContext } from 'react-hook-form'; +import { LuX } from 'react-icons/lu'; + +const ImagePreview = ({ + image, + onClick, +}: { + image: string; + onClick: React.MouseEventHandler; +}) => { + return ( + + + } + aria-label="Remove" + rounded="full" + minWidth="6" + minHeight="6" + width="6" + height="6" + onClick={onClick} + /> + + ); +}; + +export type FieldUploadPreviewProps = FlexProps & { + uploaderName: string; +}; +export const FieldUploadPreview: FC< + PropsWithChildren +> = ({ uploaderName, ...rest }) => { + const { watch, setValue } = useFormContext(); + + const [fileToPreview, setFileToPreview] = useState(); + + const value = watch(uploaderName); + const previewFile = useCallback(async () => { + if (!value || (!value.fileUrl && !value.file)) { + setFileToPreview(undefined); + return; + } + + const hasUserUploadedAFile = !!value.file; + const hasDefaultFileSet = !!value.fileUrl && !hasUserUploadedAFile; + + if (hasDefaultFileSet) { + setFileToPreview(value.fileUrl); + return; + } + + const uploadedFileToPreview = await new Promise( + (resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result?.toString() ?? ''); + reader.onerror = reject; + if (value.file) { + reader.readAsDataURL(value.file); + } + } + ); + + setFileToPreview(uploadedFileToPreview); + }, [value]); + + useEffect(() => { + previewFile(); + }, [previewFile]); + + return ( + fileToPreview && ( + + { + setValue(uploaderName, undefined); + }} + /> + + ) + ); +}; diff --git a/src/components/Form/FieldUpload/docs.stories.tsx b/src/components/Form/FieldUpload/docs.stories.tsx new file mode 100644 index 000000000..ce8ba91af --- /dev/null +++ b/src/components/Form/FieldUpload/docs.stories.tsx @@ -0,0 +1,121 @@ +import { Box, Button, Stack } from '@chakra-ui/react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { zFieldUploadValue } from '@/lib/s3/schemas'; + +import { Form, FormField, FormFieldController, FormFieldLabel } from '../'; +import { FieldUploadPreview } from './FieldUploadPreview'; +import { useFieldUploadFileFromUrl } from './utils'; + +export default { + title: 'Form/FieldUpload', +}; + +type FormSchema = z.infer>; +const zFormSchema = () => + z.object({ + file: zFieldUploadValue('avatar').optional(), + }); + +const formOptions = { + mode: 'onBlur', + resolver: zodResolver(zFormSchema()), +} as const; + +export const Default = () => { + const form = useForm({ + defaultValues: { + file: undefined, + }, + ...formOptions, + }); + + return ( +
console.log(values)}> + + + Name + + + + + + +
+ ); +}; + +export const WithDefaultValue = () => { + const initialFile = useFieldUploadFileFromUrl( + 'https://plus.unsplash.com/premium_photo-1674593231084-d8b27596b134?auto=format&fit=crop&q=60&w=800&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxlZGl0b3JpYWwtZmVlZHwyfHx8ZW58MHx8fHx8' + ); + + const form = useForm({ + values: { + file: initialFile.data, + }, + ...formOptions, + }); + + return ( +
console.log(values)}> + + + Name + + + + + + +
+ ); +}; + +export const WithPreview = () => { + const initialFile = useFieldUploadFileFromUrl( + 'https://plus.unsplash.com/premium_photo-1674593231084-d8b27596b134?auto=format&fit=crop&q=60&w=800&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxlZGl0b3JpYWwtZmVlZHwyfHx8ZW58MHx8fHx8' + ); + + const form = useForm({ + values: { + file: initialFile.data, + }, + ...formOptions, + }); + + return ( +
console.log(values)}> + + + Name + + + + + + + +
+ ); +}; diff --git a/src/components/Form/FieldUpload/index.tsx b/src/components/Form/FieldUpload/index.tsx new file mode 100644 index 000000000..814c88da9 --- /dev/null +++ b/src/components/Form/FieldUpload/index.tsx @@ -0,0 +1,94 @@ +import { ChangeEvent } from 'react'; + +import { Icon, Input, InputProps, Spinner, chakra } from '@chakra-ui/react'; +import { Controller, FieldPath, FieldValues } from 'react-hook-form'; +import { FiPaperclip } from 'react-icons/fi'; + +import { FieldCommonProps } from '@/components/Form/FormFieldController'; +import { FormFieldError } from '@/components/Form/FormFieldError'; + +type InputRootProps = Pick; + +export type FieldUploadProps< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + type: 'upload'; + isLoading?: boolean; + accept?: string; +} & InputRootProps & + FieldCommonProps; + +export const FieldUpload = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>( + props: FieldUploadProps +) => { + return ( + { + const { value, onChange, ...fieldProps } = field; + + const handleChange = ({ target }: ChangeEvent) => { + const file = target.files?.[0]; + + if (!file) { + onChange(null); + return; + } + + onChange({ + name: file.name, + size: file.size.toString(), + type: file.type, + lastModified: file.lastModified, + lastModifiedDate: new Date(file.lastModified), + file, + }); + }; + + const isFieldUploadDisabled = props.isDisabled || props.isLoading; + + return ( + <> + + + {props.isLoading ? ( + + ) : ( + + )} + {!props.isLoading && ( + + {!value ? props.placeholder ?? '...' : value.name} + + )} + + + + + ); + }} + /> + ); +}; diff --git a/src/components/Form/FieldUpload/utils.ts b/src/components/Form/FieldUpload/utils.ts new file mode 100644 index 000000000..2dc6264fd --- /dev/null +++ b/src/components/Form/FieldUpload/utils.ts @@ -0,0 +1,27 @@ +import { useId } from 'react'; + +import { useQuery } from '@tanstack/react-query'; + +export const useFieldUploadFileFromUrl = (url: string) => { + const id = useId(); + return useQuery({ + queryKey: ['filesFromUrls', id], + queryFn: () => { + return fetch(url).then((res) => { + return res.arrayBuffer().then((buf) => { + const urlArray = url.split('/'); + const fileName = + (urlArray[urlArray.length - 1] ?? '').split('?')[0] ?? ''; + return { + file: new File([buf], fileName, { + type: res.headers.get('Content-Type') ?? undefined, + }), + name: fileName, + type: res.headers.get('Content-Type') ?? undefined, + }; + }); + }); + }, + staleTime: Infinity, + }); +}; diff --git a/src/components/Form/FormFieldController.tsx b/src/components/Form/FormFieldController.tsx index 03d15cedb..340191be2 100644 --- a/src/components/Form/FormFieldController.tsx +++ b/src/components/Form/FormFieldController.tsx @@ -21,6 +21,7 @@ import { FieldSelect, FieldSelectProps } from './FieldSelect'; import { FieldSwitch, FieldSwitchProps } from './FieldSwitch'; import { FieldText, FieldTextProps } from './FieldText'; import { FieldTextarea, FieldTextareaProps } from './FieldTextarea'; +import { FieldUpload, FieldUploadProps } from './FieldUpload'; type FormFieldSize = 'sm' | 'md' | 'lg'; @@ -49,6 +50,7 @@ export type FormFieldControllerProps< // -- ADD NEW FIELD PROPS TYPE HERE -- | FieldCheckboxProps | FieldSwitchProps + | FieldUploadProps | FieldTextProps | FieldTextareaProps | FieldSelectProps @@ -115,6 +117,8 @@ export const FormFieldController = < case 'switch': return ; + case 'upload': + return ; // -- ADD NEW FIELD COMPONENT HERE -- } diff --git a/src/components/ImageUpload/docs.stories.tsx b/src/components/ImageUpload/docs.stories.tsx new file mode 100644 index 000000000..fa3ada017 --- /dev/null +++ b/src/components/ImageUpload/docs.stories.tsx @@ -0,0 +1,60 @@ +import { ChangeEvent } from 'react'; + +import { Box, Flex, Spinner } from '@chakra-ui/react'; +import { useMutation } from '@tanstack/react-query'; +import { LuImage } from 'react-icons/lu'; + +import { Icon } from '@/components/Icons'; + +import { ImageUpload } from '.'; + +export default { + title: 'Components/ImageUpload', +}; + +const uploadFileMock = async (file: File) => { + return new Promise((resolve, reject) => { + setTimeout(() => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result?.toString() ?? ''); + reader.onerror = reject; + reader.readAsDataURL(file); + }, 1000); + }); +}; + +export const Default = () => { + const updateImage = useMutation({ + mutationFn: async (file: File) => { + return await uploadFileMock(file); + }, + }); + + const handleChange = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) { + return; + } + + updateImage.mutate(file); + }; + + return ( + + + {!updateImage.isLoading && updateImage.data && ( + + )} + {!updateImage.isLoading && !updateImage.data && } + {updateImage.isLoading && } + + + ); +}; diff --git a/src/components/ImageUpload/index.tsx b/src/components/ImageUpload/index.tsx new file mode 100644 index 000000000..6565e4e36 --- /dev/null +++ b/src/components/ImageUpload/index.tsx @@ -0,0 +1,40 @@ +import { InputHTMLAttributes, ReactNode } from 'react'; + +import { Box, ChakraProps, chakra } from '@chakra-ui/react'; + +export type ImageUploadProps = ChakraProps & { + onChange: InputHTMLAttributes['onChange']; + children: ReactNode; + accept?: string; +}; + +export const ImageUpload = ({ + children, + onChange, + accept, + ...props +}: ImageUploadProps) => { + return ( + + {children} + + + ); +}; diff --git a/src/env.mjs b/src/env.mjs index 50570d044..bcd88db83 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -24,6 +24,7 @@ export const env = createEnv({ EMAIL_SERVER: z.string().url(), EMAIL_FROM: z.string(), + LOGGER_LEVEL: z .enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']) .default(process.env.NODE_ENV === 'production' ? 'error' : 'info'), @@ -31,6 +32,11 @@ export const env = createEnv({ .enum(['true', 'false']) .default(process.env.NODE_ENV === 'production' ? 'false' : 'true') .transform((value) => value === 'true'), + + S3_ENDPOINT: z.string().url(), + S3_BUCKET_NAME: z.string(), + S3_ACCESS_KEY_ID: z.string(), + S3_SECRET_ACCESS_KEY: z.string(), }, /** @@ -70,6 +76,7 @@ export const env = createEnv({ (process.env.NODE_ENV === 'development' ? 'warning' : 'success') ), NEXT_PUBLIC_NODE_ENV: zNodeEnv(), + NEXT_PUBLIC_S3_BUCKET_PUBLIC_URL: z.string().url(), }, /** @@ -84,6 +91,12 @@ export const env = createEnv({ EMAIL_SERVER: process.env.EMAIL_SERVER, LOGGER_LEVEL: process.env.LOGGER_LEVEL, LOGGER_PRETTY: process.env.LOGGER_PRETTY, + S3_ENDPOINT: process.env.S3_ENDPOINT, + S3_BUCKET_NAME: process.env.S3_BUCKET_NAME, + NEXT_PUBLIC_S3_BUCKET_PUBLIC_URL: + process.env.NEXT_PUBLIC_S3_BUCKET_PUBLIC_URL, + S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID, + S3_SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY, GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, diff --git a/src/features/account/AccountProfileForm.tsx b/src/features/account/AccountProfileForm.tsx index 2b89a6566..0ec0dfc99 100644 --- a/src/features/account/AccountProfileForm.tsx +++ b/src/features/account/AccountProfileForm.tsx @@ -2,7 +2,8 @@ import React from 'react'; import { Button, ButtonGroup, Stack } from '@chakra-ui/react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { SubmitHandler, useForm } from 'react-hook-form'; +import { useMutation } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { ErrorPage } from '@/components/ErrorPage'; @@ -22,6 +23,8 @@ import { AVAILABLE_LANGUAGES, DEFAULT_LANGUAGE_KEY, } from '@/lib/i18n/constants'; +import { uploadFile } from '@/lib/s3/client'; +import { FILES_COLLECTIONS_CONFIG } from '@/lib/s3/config'; import { trpc } from '@/lib/trpc/client'; export const AccountProfileForm = () => { @@ -31,7 +34,24 @@ export const AccountProfileForm = () => { staleTime: Infinity, }); - const updateAccount = trpc.account.update.useMutation({ + const updateAccount = useMutation({ + mutationFn: async ({ image, ...values }: FormFieldsAccountProfile) => { + return await trpcUtils.client.account.update.mutate({ + ...values, + image: image?.file + ? await uploadFile({ + trpcClient: trpcUtils.client, + collection: 'avatar', + file: image.file, + onError: () => { + form.setError('image', { + message: t('account:profile.feedbacks.uploadError.title'), + }); + }, + }) + : account.data?.image, + }); + }, onSuccess: async () => { await trpcUtils.account.invalidate(); toastCustom({ @@ -53,21 +73,38 @@ export const AccountProfileForm = () => { values: { name: account.data?.name ?? '', language: account.data?.language ?? DEFAULT_LANGUAGE_KEY, + image: account.data?.imageMetadata ?? null, }, }); - const onSubmit: SubmitHandler = (values) => { - updateAccount.mutate(values); - }; - return ( <> {account.isLoading && } {account.isError && } {account.isSuccess && ( -
+ { + updateAccount.mutate(values); + }} + > + + + {t('account:data.avatar.label')} + + + + {t('account:data.name.label')} >; export const zUserAccount = () => @@ -8,6 +9,8 @@ export const zUserAccount = () => id: true, name: true, email: true, + image: true, + imageMetadata: true, isEmailVerified: true, authorizations: true, language: true, @@ -34,5 +37,13 @@ export const zFormFieldsAccountEmail = () => export type FormFieldsAccountProfile = z.infer< ReturnType >; + export const zFormFieldsAccountProfile = () => - zUserAccount().pick({ name: true, language: true }).required(); + zUser() + .pick({ + name: true, + language: true, + }) + .extend({ + image: zFieldUploadValue('avatar').nullish(), + }); diff --git a/src/features/admin/AdminNavBar.tsx b/src/features/admin/AdminNavBar.tsx index b6a9c5712..bab27a948 100644 --- a/src/features/admin/AdminNavBar.tsx +++ b/src/features/admin/AdminNavBar.tsx @@ -55,6 +55,7 @@ import { ROUTES_DOCS } from '@/features/docs/routes'; import { ROUTES_MANAGEMENT } from '@/features/management/routes'; import { ROUTES_REPOSITORIES } from '@/features/repositories/routes'; import { useRtl } from '@/hooks/useRtl'; +import { getFilePublicUrl } from '@/lib/s3/client'; import { trpc } from '@/lib/trpc/client'; import buildInfo from '../../../scripts/.build-info.json'; @@ -89,11 +90,10 @@ const AdminNavBarAccountMenu = ({ ...rest }: Omit) => { } + src={getFilePublicUrl(account.data?.image)} + icon={account.isLoading ? : undefined} name={account.data?.name ?? account.data?.email ?? ''} - > - {account.isLoading && } - + /> { @@ -52,11 +53,13 @@ export const AppNavBarDesktop = (props: BoxProps) => { } + src={getFilePublicUrl(account.data?.image)} name={account.data?.name ?? account.data?.email ?? ''} + icon={account.isLoading ? : undefined} {...(isAccountActive ? { ring: '2px', @@ -69,9 +72,7 @@ export const AppNavBarDesktop = (props: BoxProps) => { }, } : {})} - > - {account.isLoading && } - + /> diff --git a/src/features/users/PageAdminUsers.tsx b/src/features/users/PageAdminUsers.tsx index 66622c505..b23efa845 100644 --- a/src/features/users/PageAdminUsers.tsx +++ b/src/features/users/PageAdminUsers.tsx @@ -107,7 +107,11 @@ export default function PageAdminUsers() { .map((user) => ( - + diff --git a/src/features/users/schemas.ts b/src/features/users/schemas.ts index a0e918115..e7675d3e7 100644 --- a/src/features/users/schemas.ts +++ b/src/features/users/schemas.ts @@ -2,6 +2,7 @@ import { t } from 'i18next'; import { z } from 'zod'; import { DEFAULT_LANGUAGE_KEY } from '@/lib/i18n/constants'; +import { zFieldMetadata } from '@/lib/s3/schemas'; import { zu } from '@/lib/zod/zod-utils'; export const USER_AUTHORIZATIONS = ['APP', 'ADMIN'] as const; @@ -33,6 +34,8 @@ export const zUser = () => required_error: t('users:data.email.required'), invalid_type_error: t('users:data.email.invalid'), }), + image: z.string().nullish(), + imageMetadata: zFieldMetadata().nullish(), isEmailVerified: z.boolean(), authorizations: zu.array .nonEmpty( diff --git a/src/lib/s3/client.ts b/src/lib/s3/client.ts new file mode 100644 index 000000000..4ed2416a1 --- /dev/null +++ b/src/lib/s3/client.ts @@ -0,0 +1,44 @@ +import { env } from '@/env.mjs'; +import { trpc } from '@/lib/trpc/client'; +import { RouterInputs } from '@/lib/trpc/types'; + +export const uploadFile = async (params: { + file: File; + trpcClient: ReturnType['client']; + collection: RouterInputs['files']['uploadPresignedUrl']['collection']; + metadata?: Record; + onError?: (file: File, error: unknown) => void; +}) => { + try { + const presignedUrlOutput = + await params.trpcClient.files.uploadPresignedUrl.mutate({ + metadata: params.metadata, + collection: params.collection, + type: params.file.type, + size: params.file.size, + name: params.file.name, + }); + + const response = await fetch(presignedUrlOutput.signedUrl, { + method: 'PUT', + headers: { 'Content-Type': params.file.type }, + body: params.file, + }); + + if (!response.ok) { + throw new Error('Failed to upload file'); + } + + return presignedUrlOutput.key; + } catch (error) { + params.onError?.(params.file, error); + throw error; + } +}; + +export const getFilePublicUrl = (key: string | null | undefined) => { + if (!key) { + return undefined; + } + return `${env.NEXT_PUBLIC_S3_BUCKET_PUBLIC_URL}/${key}`; +}; diff --git a/src/lib/s3/config.ts b/src/lib/s3/config.ts new file mode 100644 index 000000000..13c5a84b8 --- /dev/null +++ b/src/lib/s3/config.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +import { User } from '@/features/users/schemas'; + +export type FilesCollectionName = z.infer< + ReturnType +>; +export const zFilesCollectionName = () => z.enum(['avatar']); + +export const FILES_COLLECTIONS_CONFIG = { + avatar: { + getKey: ({ user }) => `avatars/${user.id}`, + allowedTypes: ['image/png', 'image/jpg', 'image/jpeg'], + maxSize: 5 * 1024 * 1024, // 5MB in bytes, + }, +} satisfies Record; + +export type FilesCollectionConfig = { + getKey: (params: { user: User }) => string; + allowedTypes?: Array; + maxSize?: number; +}; diff --git a/src/lib/s3/schemas.ts b/src/lib/s3/schemas.ts new file mode 100644 index 000000000..0e035b391 --- /dev/null +++ b/src/lib/s3/schemas.ts @@ -0,0 +1,61 @@ +import { t } from 'i18next'; +import { z } from 'zod'; + +import { + FILES_COLLECTIONS_CONFIG, + FilesCollectionName, + zFilesCollectionName, +} from '@/lib/s3/config'; +import { validateFile } from '@/lib/s3/utils'; +import { zu } from '@/lib/zod/zod-utils'; + +export type FieldMetadata = z.infer>; +export const zFieldMetadata = () => + z.object({ + lastModifiedDate: z.date().optional(), + name: zu.string.nonEmptyNullish(z.string()), + size: z.coerce.number().nullish(), + type: zu.string.nonEmptyNullish(z.string()), + }); + +export type FieldUploadValue = z.infer>; +export const zFieldUploadValue = (collection: FilesCollectionName) => + zFieldMetadata() + .extend({ + file: z.instanceof(File).optional(), + lastModified: z.number().optional(), + }) + .superRefine((input, ctx) => { + const config = FILES_COLLECTIONS_CONFIG[collection]; + const validateFileReturn = validateFile({ input, config }); + + if (!validateFileReturn.success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t(`files.errors.${validateFileReturn.error.key}`), + }); + } + }); + +export type UploadSignedUrlInput = z.infer< + ReturnType +>; +export const zUploadSignedUrlInput = () => + z.object({ + metadata: z.record(z.string(), z.string()).optional(), + name: z + .string() + .max(255) + .regex(/^[^/\\]*$/), // Prevent path traversal (Coderabbitai) + type: z.string(), + size: z.number(), + collection: zFilesCollectionName(), + }); +export type UploadSignedUrlOutput = z.infer< + ReturnType +>; +export const zUploadSignedUrlOutput = () => + z.object({ + key: z.string(), + signedUrl: z.string(), + }); diff --git a/src/lib/s3/utils.ts b/src/lib/s3/utils.ts new file mode 100644 index 000000000..1a08f973f --- /dev/null +++ b/src/lib/s3/utils.ts @@ -0,0 +1,44 @@ +import { FilesCollectionConfig } from '@/lib/s3/config'; +import { FieldMetadata } from '@/lib/s3/schemas'; + +type ValidateReturn = + | { success: true } + | { + success: false; + error: { + message: string; + key: 'tooLarge' | 'typeNotAllowed'; + }; + }; + +export const validateFile = (params: { + input: FieldMetadata; + config: FilesCollectionConfig; +}): ValidateReturn => { + if ( + params.config.maxSize && + (params.input.size ?? 0) > params.config.maxSize + ) { + return { + error: { + key: 'tooLarge', + message: `File size is too big ${params.input.size}/${params.config.maxSize}`, + }, + success: false, + }; + } + + if ( + params.config.allowedTypes && + !params.config.allowedTypes.includes(params.input.type?.toLowerCase() ?? '') + ) { + return { + error: { + key: 'typeNotAllowed', + message: `Incorrect file type ${params.input.type} (authorized: ${params.config.allowedTypes.join(',')})`, + }, + success: false, + }; + } + return { success: true }; +}; diff --git a/src/locales/ar/common.json b/src/locales/ar/common.json index d5b15389f..3fb7ae126 100644 --- a/src/locales/ar/common.json +++ b/src/locales/ar/common.json @@ -22,5 +22,11 @@ }, "filter": "منقي", "clear": "واضح", - "submit": "يُقدِّم" + "submit": "يُقدِّم", + "files": { + "errors": { + "tooLarge": "ملف كبير جدا", + "typeNotAllowed": "نوع الملف غير مسموح به" + } + } } diff --git a/src/locales/en/account.json b/src/locales/en/account.json index 9a82d80d7..ec008c516 100644 --- a/src/locales/en/account.json +++ b/src/locales/en/account.json @@ -17,6 +17,9 @@ }, "updateError": { "title": "Update failed" + }, + "uploadError": { + "title": "Upload failed" } }, "actions": { @@ -69,6 +72,11 @@ } }, "data": { + "avatar": { + "label": "Avatar", + "placeholder": "Update avatar...", + "required": "Avatar is required" + }, "name": { "label": "Name" }, diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 51da7cf2d..225a92c8b 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -20,6 +20,12 @@ "confirmText": "Leave without saving", "cancelText": "Stay on the page" }, + "files": { + "errors": { + "tooLarge": "File too large", + "typeNotAllowed": "File type not allowed" + } + }, "filter": "Filter", "clear": "Clear", "submit": "Submit" diff --git a/src/locales/fr/account.json b/src/locales/fr/account.json index 8069618af..f81c04f40 100644 --- a/src/locales/fr/account.json +++ b/src/locales/fr/account.json @@ -11,6 +11,9 @@ }, "updateError": { "title": "Échec de la mise à jour" + }, + "uploadError": { + "title": "Échec de l'import" } }, "actions": { @@ -19,6 +22,11 @@ "title": "Informations du profil" }, "data": { + "avatar": { + "label": "Avatar", + "placeholder": "Modifier l'avatar...", + "required": "L'avatar est requis" + }, "name": { "label": "Nom" }, diff --git a/src/locales/fr/common.json b/src/locales/fr/common.json index ee59c9497..524a73ae9 100644 --- a/src/locales/fr/common.json +++ b/src/locales/fr/common.json @@ -22,5 +22,11 @@ }, "filter": "Filtrer", "clear": "Effacer", - "submit": "Soumettre" + "submit": "Soumettre", + "files": { + "errors": { + "tooLarge": "Fichier trop lourd", + "typeNotAllowed": "Type de fichier non autorisé" + } + } } diff --git a/src/locales/sw/common.json b/src/locales/sw/common.json index a3a9cb9b4..74858cb82 100644 --- a/src/locales/sw/common.json +++ b/src/locales/sw/common.json @@ -22,5 +22,11 @@ }, "filter": "Chuja", "clear": "Wazi", - "submit": "Wasilisha" + "submit": "Wasilisha", + "files": { + "errors": { + "tooLarge": "Faili kubwa sana", + "typeNotAllowed": "Aina ya faili hairuhusiwi" + } + } } diff --git a/src/server/config/s3.ts b/src/server/config/s3.ts new file mode 100644 index 000000000..86290c8d9 --- /dev/null +++ b/src/server/config/s3.ts @@ -0,0 +1,74 @@ +import { + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +import { env } from '@/env.mjs'; +import { FieldMetadata, UploadSignedUrlOutput } from '@/lib/s3/schemas'; + +const SIGNED_URL_EXPIRATION_TIME_SECONDS = 60; // 1 minute + +const S3 = new S3Client({ + region: 'auto', + endpoint: env.S3_ENDPOINT, + credentials: { + accessKeyId: env.S3_ACCESS_KEY_ID, + secretAccessKey: env.S3_SECRET_ACCESS_KEY, + }, +}); + +type UploadSignedUrlOptions = { + /** The tree structure of the file in S3 */ + key: string; + metadata?: Record; +}; + +export const getS3UploadSignedUrl = async ( + options: UploadSignedUrlOptions +): Promise => { + const signedUrl = await getSignedUrl( + S3, + new PutObjectCommand({ + Bucket: env.S3_BUCKET_NAME, + Key: options.key, + Metadata: options.metadata, + }), + { expiresIn: SIGNED_URL_EXPIRATION_TIME_SECONDS } + ); + + return { + signedUrl, + key: options.key, + }; +}; + +export const fetchFileMetadata = async ( + key: string +): Promise<{ success: true; data: FieldMetadata } | { success: false }> => { + const s3key = key.split('?')[0]; // Remove the ?timestamp + try { + const command = new HeadObjectCommand({ + Bucket: env.S3_BUCKET_NAME, + Key: s3key, + }); + const fileResponse = await S3.send(command); + + return { + success: true, + data: { + size: fileResponse.ContentLength, + type: fileResponse.ContentType, + lastModifiedDate: fileResponse.LastModified + ? new Date(fileResponse.LastModified) + : undefined, + name: fileResponse.Metadata?.name, + }, + }; + } catch { + return { + success: false, + }; + } +}; diff --git a/src/server/router.ts b/src/server/router.ts index 3ce18c905..3b560f373 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -1,6 +1,7 @@ import { createTRPCRouter } from '@/server/config/trpc'; import { accountRouter } from '@/server/routers/account'; import { authRouter } from '@/server/routers/auth'; +import { filesRouter } from '@/server/routers/files'; import { oauthRouter } from '@/server/routers/oauth'; import { repositoriesRouter } from '@/server/routers/repositories'; import { usersRouter } from '@/server/routers/users'; @@ -16,6 +17,7 @@ export const appRouter = createTRPCRouter({ oauth: oauthRouter, repositories: repositoriesRouter, users: usersRouter, + files: filesRouter, }); // export type definition of API diff --git a/src/server/routers/account.tsx b/src/server/routers/account.tsx index 3434fe5b3..d9a08e313 100644 --- a/src/server/routers/account.tsx +++ b/src/server/routers/account.tsx @@ -20,6 +20,7 @@ import { } from '@/server/config/auth'; import { sendEmail } from '@/server/config/email'; import { ExtendedTRPCError } from '@/server/config/errors'; +import { fetchFileMetadata } from '@/server/config/s3'; import { createTRPCRouter, protectedProcedure } from '@/server/config/trpc'; export const accountRouter = createTRPCRouter({ @@ -36,7 +37,17 @@ export const accountRouter = createTRPCRouter({ .output(zUserAccount()) .query(async ({ ctx }) => { ctx.logger.info('Return the current user'); - return ctx.user; + + const imageMetadataResponse = ctx.user.image + ? await fetchFileMetadata(ctx.user.image) + : undefined; + + return { + ...ctx.user, + imageMetadata: imageMetadataResponse?.success + ? imageMetadataResponse.data + : undefined, + }; }), update: protectedProcedure() @@ -49,7 +60,8 @@ export const accountRouter = createTRPCRouter({ }, }) .input( - zUserAccount().required().pick({ + zUserAccount().pick({ + image: true, name: true, language: true, }) @@ -58,9 +70,15 @@ export const accountRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { try { ctx.logger.info('Updating the user'); + return await ctx.db.user.update({ where: { id: ctx.user.id }, - data: input, + data: { + ...input, + image: input.image + ? `${input.image}?${Date.now()}` // Allows to update the cache when the user changes his account + : null, + }, }); } catch (e) { ctx.logger.warn('An error occured while updating the user'); diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts new file mode 100644 index 000000000..92cabafcf --- /dev/null +++ b/src/server/routers/files.ts @@ -0,0 +1,50 @@ +import { TRPCError } from '@trpc/server'; + +import { FILES_COLLECTIONS_CONFIG } from '@/lib/s3/config'; +import { + zUploadSignedUrlInput, + zUploadSignedUrlOutput, +} from '@/lib/s3/schemas'; +import { validateFile } from '@/lib/s3/utils'; +import { getS3UploadSignedUrl } from '@/server/config/s3'; +import { createTRPCRouter, protectedProcedure } from '@/server/config/trpc'; + +export const filesRouter = createTRPCRouter({ + uploadPresignedUrl: protectedProcedure() + .meta({ + openapi: { + method: 'POST', + path: '/files/upload-presigned-url', + tags: ['files'], + protect: true, + }, + }) + .input(zUploadSignedUrlInput()) + .output(zUploadSignedUrlOutput()) + .mutation(async ({ input, ctx }) => { + const config = FILES_COLLECTIONS_CONFIG[input.collection]; + + if (!config) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `No collection ${input.collection}`, + }); + } + + const validateFileResult = validateFile({ input, config }); + + if (!validateFileResult.success) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: validateFileResult.error.message, + }); + } + + return await getS3UploadSignedUrl({ + key: config.getKey({ user: ctx.user }), + metadata: input.metadata + ? { name: input.name, ...input.metadata } + : undefined, + }); + }), +});