From 3b3bcb4eeb16aa20b4a55a2eae35b8aa36d2a178 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Wed, 25 Oct 2023 12:20:34 +0200 Subject: [PATCH 01/27] wip: Upload file wip: docker compose for minio # Conflicts: # docker-compose.yml wip: add postgres to docker compose wip: improve env var and docker compose tested with R2 from Cloudflare # Conflicts: # .env.example wip: configurable key wip: add image direct upload component add healthcheck to minio making sure it is up to create bucket wip: improve rights on the minio setup feat: Add FieldUploadPreview component on AccountProfileForm feat: add optional user metadata on signedUrl S3 calls remove ACL, implement getSignedUrl, minio env variables feat: improve file uploads + useAvatarUpload and useFileUpload hooks fix: change POST http word by GET to get presigned urls feat: fetch avatar in front-end to get metadata wip: FieldUploadPreview example in Storybook fix: docker version minio and example order refactor(file-upload): Remove hook abstraction layer and regroup feature's files fix(file-upload): Remove unused code to simplify fix(file-upload): Feedback + more simplifcation wip: refactor(file-upload): with react-hook-form fix(file-upload): clean zod schema + file type validation docs(file-upload): Update docs.stories for RHF FieldUpload test(file-upload): Vitest update and default value for FieldUpload refactor(file-upload): move avatar file check before call to mutation feat(file-upload): FieldUploadPreview with RHF fix(file-upload): Delete previous Formiz FieldUpload fix(file-upload): remove control prop and defaultValues fix(file-upload): Apply feedbacks fix: sonar cloud code smells fix(file-upload): Upload input schema can't be a record wip: tests(file-upload): Test e2e and update yml CI for minio feat: update after rebase feat: full init using docker compose --- .dockerignore | 1 + .env.example | 12 + .github/workflows/e2e-tests.yml | 27 +- .storybook/preview.tsx | 21 +- README.md | 7 +- docker-compose.yml | 59 + docker/initialize-database.dockerfile | 11 + e2e/avatar-upload.spec.ts | 46 + package.json | 4 +- pnpm-lock.yaml | 1142 ++++++++++++++++- .../Form/FieldUpload/FieldUpload.spec.tsx | 87 ++ .../Form/FieldUpload/FieldUploadPreview.tsx | 108 ++ .../Form/FieldUpload/docs.stories.tsx | 121 ++ src/components/Form/FieldUpload/index.tsx | 89 ++ src/components/Form/FieldUpload/utils.ts | 27 + src/components/Form/FormFieldController.tsx | 4 + src/components/ImageUpload/docs.stories.tsx | 60 + src/components/ImageUpload/index.tsx | 37 + src/env.mjs | 12 + src/features/account/AccountProfileForm.tsx | 39 +- src/features/account/schemas.ts | 14 +- src/features/account/service.ts | 20 + src/features/admin/AdminNavBar.tsx | 2 +- src/features/app/AppNavBarDesktop.tsx | 3 +- src/features/users/schemas.ts | 1 + src/files/schemas.ts | 59 + src/files/utils.ts | 96 ++ src/locales/en/account.json | 8 + src/locales/en/common.json | 3 + src/locales/fr/account.json | 8 + src/locales/fr/common.json | 3 + src/server/config/s3.ts | 45 + src/server/router.ts | 2 + src/server/routers/account.tsx | 6 +- src/server/routers/files.ts | 28 + 35 files changed, 2190 insertions(+), 22 deletions(-) create mode 100644 docker/initialize-database.dockerfile create mode 100644 e2e/avatar-upload.spec.ts create mode 100644 src/components/Form/FieldUpload/FieldUpload.spec.tsx create mode 100644 src/components/Form/FieldUpload/FieldUploadPreview.tsx create mode 100644 src/components/Form/FieldUpload/docs.stories.tsx create mode 100644 src/components/Form/FieldUpload/index.tsx create mode 100644 src/components/Form/FieldUpload/utils.ts create mode 100644 src/components/ImageUpload/docs.stories.tsx create mode 100644 src/components/ImageUpload/index.tsx create mode 100644 src/features/account/service.ts create mode 100644 src/files/schemas.ts create mode 100644 src/files/utils.ts create mode 100644 src/server/config/s3.ts create mode 100644 src/server/routers/files.ts 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/e2e-tests.yml b/.github/workflows/e2e-tests.yml index d9026ad85..9c8979b65 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,6 +56,25 @@ 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: @@ -59,7 +84,7 @@ jobs: - 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..4332e2edc 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:${DOCKER_DATABASE_PORT}/${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..ddf5300d2 --- /dev/null +++ b/docker/initialize-database.dockerfile @@ -0,0 +1,11 @@ +FROM node:20-slim + +WORKDIR /usr/src/app + +COPY . . + +RUN apt-get update -y && apt-get install -y openssl && apt-get clean +RUN corepack enable +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..e242714b2 --- /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.inputText) + ).toBeVisible(); + + const fileChooserPromise = page.waitForEvent('filechooser'); + + await page.getByText(locales.en.account.data.avatar.inputText).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..5889ce8a0 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,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 +42,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..9b17ed876 --- /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 '@/files/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.toString(), + 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..cc18e38a5 --- /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 '@/files/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().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..55d42e49f --- /dev/null +++ b/src/components/Form/FieldUpload/index.tsx @@ -0,0 +1,89 @@ +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'; + inputText?: string; + isLoading?: boolean; +} & 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.inputText : 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..0671d56cd --- /dev/null +++ b/src/components/ImageUpload/index.tsx @@ -0,0 +1,37 @@ +import { InputHTMLAttributes, ReactNode } from 'react'; + +import { Box, ChakraProps, chakra } from '@chakra-ui/react'; + +export type ImageUploadProps = ChakraProps & { + onChange: InputHTMLAttributes['onChange']; + children: ReactNode; +}; + +export const ImageUpload = ({ + children, + onChange, + ...props +}: ImageUploadProps) => { + return ( + + {children} + + + ); +}; diff --git a/src/env.mjs b/src/env.mjs index 50570d044..b19f35616 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,12 @@ 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_BUCKET_PUBLIC_URL: z.string().url(), + S3_ACCESS_KEY_ID: z.string(), + S3_SECRET_ACCESS_KEY: z.string(), }, /** @@ -84,6 +91,11 @@ 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, + S3_BUCKET_PUBLIC_URL: process.env.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..cc957fa05 100644 --- a/src/features/account/AccountProfileForm.tsx +++ b/src/features/account/AccountProfileForm.tsx @@ -18,6 +18,7 @@ import { FormFieldsAccountProfile, zFormFieldsAccountProfile, } from '@/features/account/schemas'; +import { useAvatarFetch, useAvatarUpload } from '@/features/account/service'; import { AVAILABLE_LANGUAGES, DEFAULT_LANGUAGE_KEY, @@ -31,6 +32,13 @@ export const AccountProfileForm = () => { staleTime: Infinity, }); + const accountAvatar = useAvatarFetch(account.data?.image || ''); + + const toastSuccess = useToastSuccess(); + const toastError = useToastError(); + + const uploadFile = useAvatarUpload(); + const updateAccount = trpc.account.update.useMutation({ onSuccess: async () => { await trpcUtils.account.invalidate(); @@ -53,11 +61,26 @@ export const AccountProfileForm = () => { values: { name: account.data?.name ?? '', language: account.data?.language ?? DEFAULT_LANGUAGE_KEY, + image: accountAvatar.data ?? undefined, }, }); - const onSubmit: SubmitHandler = (values) => { - updateAccount.mutate(values); + const onSubmit: SubmitHandler = async ({ + image, + ...values + }) => { + let fileUrl = account.data?.image; + try { + if (image?.file) { + const uploadResponse = await uploadFile.mutateAsync(image?.file); + fileUrl = uploadResponse.fileUrl; + } + updateAccount.mutate({ ...values, image: fileUrl }); + } catch (e) { + form.setError('image', { + message: t('account:profile.feedbacks.uploadError.title'), + }); + } }; return ( @@ -68,6 +91,18 @@ export const AccountProfileForm = () => {
+ + + {t('account:data.avatar.label')} + + + + {t('account:data.name.label')} >; export const zUserAccount = () => @@ -8,6 +9,7 @@ export const zUserAccount = () => id: true, name: true, email: true, + image: true, isEmailVerified: true, authorizations: true, language: true, @@ -34,5 +36,15 @@ 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, + }) + .merge( + z.object({ + image: zFieldUploadValue(['image']).optional(), + }) + ); diff --git a/src/features/account/service.ts b/src/features/account/service.ts new file mode 100644 index 000000000..b38b8459a --- /dev/null +++ b/src/features/account/service.ts @@ -0,0 +1,20 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { trpc } from '@/lib/trpc/client'; + +import { fetchFile, uploadFile } from '../../files/utils'; + +export const useAvatarFetch = (url: string) => { + return useQuery({ + queryKey: ['account', url], + queryFn: () => fetchFile(url, ['name']), + enabled: !!url, + }); +}; + +export const useAvatarUpload = () => { + const getPresignedUrl = trpc.account.uploadAvatarPresignedUrl.useMutation(); + return useMutation({ + mutationFn: (file: File) => uploadFile(getPresignedUrl.mutateAsync, file), + }); +}; diff --git a/src/features/admin/AdminNavBar.tsx b/src/features/admin/AdminNavBar.tsx index b6a9c5712..1a2676b28 100644 --- a/src/features/admin/AdminNavBar.tsx +++ b/src/features/admin/AdminNavBar.tsx @@ -89,7 +89,7 @@ const AdminNavBarAccountMenu = ({ ...rest }: Omit) => { } + src={account.data?.image ?? undefined} name={account.data?.name ?? account.data?.email ?? ''} > {account.isLoading && } diff --git a/src/features/app/AppNavBarDesktop.tsx b/src/features/app/AppNavBarDesktop.tsx index 9fe11f15e..b12bdd384 100644 --- a/src/features/app/AppNavBarDesktop.tsx +++ b/src/features/app/AppNavBarDesktop.tsx @@ -52,10 +52,11 @@ export const AppNavBarDesktop = (props: BoxProps) => { } + src={account.data?.image ?? undefined} name={account.data?.name ?? account.data?.email ?? ''} {...(isAccountActive ? { diff --git a/src/features/users/schemas.ts b/src/features/users/schemas.ts index a0e918115..7134312dd 100644 --- a/src/features/users/schemas.ts +++ b/src/features/users/schemas.ts @@ -33,6 +33,7 @@ export const zUser = () => required_error: t('users:data.email.required'), invalid_type_error: t('users:data.email.invalid'), }), + image: z.string().url().nullish(), isEmailVerified: z.boolean(), authorizations: zu.array .nonEmpty( diff --git a/src/files/schemas.ts b/src/files/schemas.ts new file mode 100644 index 000000000..9e40d5e4e --- /dev/null +++ b/src/files/schemas.ts @@ -0,0 +1,59 @@ +import { t } from 'i18next'; +import { z } from 'zod'; + +import { zu } from '@/lib/zod/zod-utils'; + +export type UploadFileType = z.infer; +export const zUploadFileType = z.enum([ + 'image', + 'video', + 'audio', + 'blob', + 'pdf', + 'text', +]); + +export type FieldUploadValue = z.infer>; +export const zFieldUploadValue = (acceptedTypes?: UploadFileType[]) => + z + .object({ + fileUrl: zu.string.nonEmptyNullish(z.string()), + file: z.instanceof(File).optional(), + lastModified: z.number().optional(), + lastModifiedDate: z.date().optional(), + name: zu.string.nonEmptyNullish(z.string()), + size: zu.string.nonEmptyNullish(z.string()), + type: zu.string.nonEmptyNullish(z.string()), + }) + .refine( + (file) => { + if (!acceptedTypes || acceptedTypes.length === 0) { + return true; + } + + return acceptedTypes.some((type) => file.type?.startsWith(type)); + }, + { + message: t('common:files.invalid', { + acceptedTypes: acceptedTypes?.join(', '), + }), + } + ); + +export type UploadSignedUrlInput = z.infer< + ReturnType +>; +export const zUploadSignedUrlInput = () => + z.object({ + metadata: z.string().optional(), + }); + +export type UploadSignedUrlOutput = z.infer< + ReturnType +>; +export const zUploadSignedUrlOutput = () => + z.object({ + futureFileUrl: z.string(), + key: z.string(), + signedUrl: z.string(), + }); diff --git a/src/files/utils.ts b/src/files/utils.ts new file mode 100644 index 000000000..a6c2857d9 --- /dev/null +++ b/src/files/utils.ts @@ -0,0 +1,96 @@ +import { UseMutateAsyncFunction } from '@tanstack/react-query'; +import { stringify } from 'superjson'; + +import { UploadSignedUrlInput } from './schemas'; + +/** + * Fetches a file from the specified URL and returns file information. + * Designed to be used as a `queryFn` in a `useQuery`. + * + * @param url The URL from which the file should be fetched. + * @param [metadata] The metadata of the file you want to retrieve. + * @returns A Promise that resolves to an object containing information about the file. + * + * @example + * // Usage with Tanstack Query's useQuery: + * const fileQuery = useQuery({ + queryKey: ['fileKey', url], + queryFn: () => fetchFile(url, ['name']), + enabled: !!url, + }); + */ +export const fetchFile = async (url: string, metadata?: string[]) => { + const fileResponse = await fetch(url, { + cache: 'no-cache', + }); + if (!fileResponse.ok) { + throw new Error('Could not fetch the file'); + } + + const lastModifiedDateHeader = fileResponse.headers.get('Last-Modified'); + const defaultFileData = { + fileUrl: url, + size: fileResponse.headers.get('Content-Length') ?? undefined, + type: fileResponse.headers.get('Content-Type') ?? undefined, + lastModifiedDate: lastModifiedDateHeader + ? new Date(lastModifiedDateHeader) + : new Date(), + }; + + if (!metadata) { + return defaultFileData; + } + + return metadata.reduce((file, metadataKey) => { + return { + ...file, + [metadataKey]: fileResponse.headers.get(`x-amz-meta-${metadataKey}`), + }; + }, defaultFileData); +}; + +/** + * Asynchronously uploads a file to a server using a presigned URL. + * Designed to be used as a `mutationFn` in a `useMutation`. + * + * @param getPresignedUrl + * - An asyncMutation that is used to obtain the presigned URL and the future URL where the file will be accessible. + * + * @param file - The file object to upload. + * @param metadata - Optional metadata for the file, which will be sent to the server when generating the presigned URL. + * + * @returns A promise that resolves to an object containing the URL of the uploaded file, + * + * @example + * // Usage with Tanstack Query's useMutation: + * const getPresignedUrl = trpc.routeToGetPresignedUrl.useMutation(); + const fileUpload = useMutation({ + mutationFn: (file: File) => uploadFile(getPresignedUrl.mutateAsync, file), + }); + */ +export const uploadFile = async ( + getPresignedUrl: UseMutateAsyncFunction< + { signedUrl: string; futureFileUrl: string }, + unknown, + UploadSignedUrlInput + >, + file: File, + metadata: Record = {} +) => { + const { signedUrl, futureFileUrl } = await getPresignedUrl({ + metadata: stringify({ + name: file.name, + ...metadata, + }), + }); + + await fetch(signedUrl, { + method: 'PUT', + headers: { 'Content-Type': file.type }, + body: file, + }); + + return { + fileUrl: futureFileUrl, + } as const; +}; diff --git a/src/locales/en/account.json b/src/locales/en/account.json index 9a82d80d7..1c7c381f3 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", + "inputText": "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..60fbcabfc 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -20,6 +20,9 @@ "confirmText": "Leave without saving", "cancelText": "Stay on the page" }, + "files": { + "invalid": "Provided file must be of type : {{acceptedTypes}}" + }, "filter": "Filter", "clear": "Clear", "submit": "Submit" diff --git a/src/locales/fr/account.json b/src/locales/fr/account.json index 8069618af..a03ba1ae5 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", + "inputText": "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..ddda086f0 100644 --- a/src/locales/fr/common.json +++ b/src/locales/fr/common.json @@ -20,6 +20,9 @@ "message": "Vous êtes sur le point de quitter la page sans sauvegarder vos modifications.", "title": "Quitter la page ?" }, + "files": { + "invalid": "Le fichier doit être de type : {{acceptedTypes}}" + }, "filter": "Filtrer", "clear": "Effacer", "submit": "Soumettre" diff --git a/src/server/config/s3.ts b/src/server/config/s3.ts new file mode 100644 index 000000000..7653639ad --- /dev/null +++ b/src/server/config/s3.ts @@ -0,0 +1,45 @@ +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +import { env } from '@/env.mjs'; +import { UploadFileType, UploadSignedUrlOutput } from '@/files/schemas'; + +const SIGNED_URL_EXPIRATION_TIME_SECONDS = 3600; // 1 hour + +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 = { + allowedFileTypes?: UploadFileType[]; + expiresIn?: number; + /** The tree structure of the file in S3 */ + key: string; + host?: 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: options.expiresIn ?? SIGNED_URL_EXPIRATION_TIME_SECONDS } + ); + + return { + signedUrl, + key: options.key, + futureFileUrl: (options.host ? `${options.host}/` : '') + options.key, + }; +}; 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..c3cf24ef8 100644 --- a/src/server/routers/account.tsx +++ b/src/server/routers/account.tsx @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import dayjs from 'dayjs'; import { randomUUID } from 'node:crypto'; +import { parse } from 'superjson'; import { z } from 'zod'; import EmailDeleteAccountCode from '@/emails/templates/delete-account-code'; @@ -12,6 +13,7 @@ import { } from '@/features/account/schemas'; import { zVerificationCodeValidate } from '@/features/auth/schemas'; import { VALIDATION_TOKEN_EXPIRATION_IN_MINUTES } from '@/features/auth/utils'; +import { zUploadSignedUrlInput, zUploadSignedUrlOutput } from '@/files/schemas'; import i18n from '@/lib/i18n/server'; import { deleteUsedCode, @@ -20,6 +22,7 @@ import { } from '@/server/config/auth'; import { sendEmail } from '@/server/config/email'; import { ExtendedTRPCError } from '@/server/config/errors'; +import { getS3UploadSignedUrl } from '@/server/config/s3'; import { createTRPCRouter, protectedProcedure } from '@/server/config/trpc'; export const accountRouter = createTRPCRouter({ @@ -49,7 +52,8 @@ export const accountRouter = createTRPCRouter({ }, }) .input( - zUserAccount().required().pick({ + zUserAccount().pick({ + image: true, name: true, language: true, }) diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts new file mode 100644 index 000000000..bda8f6085 --- /dev/null +++ b/src/server/routers/files.ts @@ -0,0 +1,28 @@ +import { randomUUID } from 'crypto'; +import { parse } from 'superjson'; + +import { env } from '@/env.mjs'; +import { zUploadSignedUrlInput, zUploadSignedUrlOutput } from '@/files/schemas'; +import { getS3UploadSignedUrl } from '@/server/config/s3'; +import { createTRPCRouter, protectedProcedure } from '@/server/config/trpc'; + +export const filesRouter = createTRPCRouter({ + uploadPresignedUrl: protectedProcedure() + .meta({ + openapi: { + method: 'GET', + path: '/files/upload-presigned-url', + tags: ['files'], + protect: true, + }, + }) + .input(zUploadSignedUrlInput()) + .output(zUploadSignedUrlOutput()) + .mutation(async ({ input }) => { + return await getS3UploadSignedUrl({ + key: randomUUID(), + host: env.S3_BUCKET_PUBLIC_URL, + metadata: parse(input?.metadata ?? ''), + }); + }), +}); From 36f89f2e279b6ef205b7afef5bbd48deab3a8732 Mon Sep 17 00:00:00 2001 From: Yoann Fleury Date: Wed, 24 Jul 2024 10:20:21 +0200 Subject: [PATCH 02/27] WIP Working --- .github/workflows/e2e-tests.yml | 41 +++++++++++++-------------- docker/initialize-database.dockerfile | 6 ++-- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9c8979b65..895e03843 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -27,10 +27,10 @@ jobs: 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_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 + S3_ACCESS_KEY_ID: miniotestuser + S3_SECRET_ACCESS_KEY: miniotestuserpassword services: postgres: @@ -56,24 +56,23 @@ 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 + # - 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 diff --git a/docker/initialize-database.dockerfile b/docker/initialize-database.dockerfile index ddf5300d2..6d2c57e4a 100644 --- a/docker/initialize-database.dockerfile +++ b/docker/initialize-database.dockerfile @@ -1,10 +1,12 @@ -FROM node:20-slim +FROM node:20-alpine WORKDIR /usr/src/app COPY . . -RUN apt-get update -y && apt-get install -y openssl && apt-get clean +RUN apk upgrade --update-cache --available && \ + apk add openssl && \ + rm -rf /var/cache/apk/* RUN corepack enable RUN pnpm install From 7b44b851b1214cccb05bce4f6999f13e56f1b0e0 Mon Sep 17 00:00:00 2001 From: Yoann Fleury Date: Mon, 17 Feb 2025 11:24:14 +0100 Subject: [PATCH 03/27] fix: after squash and master rebase --- docker/initialize-database.dockerfile | 2 +- package.json | 1 + src/features/account/AccountProfileForm.tsx | 7 ++----- src/server/routers/account.tsx | 19 +++++++++++++++++++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docker/initialize-database.dockerfile b/docker/initialize-database.dockerfile index 6d2c57e4a..1c62cafc4 100644 --- a/docker/initialize-database.dockerfile +++ b/docker/initialize-database.dockerfile @@ -7,7 +7,7 @@ COPY . . RUN apk upgrade --update-cache --available && \ apk add openssl && \ rm -rf /var/cache/apk/* -RUN corepack enable +RUN npm install -g pnpm@latest-9 RUN pnpm install CMD [ "pnpm", "db:init" ] diff --git a/package.json b/package.json index 5889ce8a0..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" }, diff --git a/src/features/account/AccountProfileForm.tsx b/src/features/account/AccountProfileForm.tsx index cc957fa05..a5c531d5b 100644 --- a/src/features/account/AccountProfileForm.tsx +++ b/src/features/account/AccountProfileForm.tsx @@ -32,10 +32,7 @@ export const AccountProfileForm = () => { staleTime: Infinity, }); - const accountAvatar = useAvatarFetch(account.data?.image || ''); - - const toastSuccess = useToastSuccess(); - const toastError = useToastError(); + const accountAvatar = useAvatarFetch(account.data?.image ?? ''); const uploadFile = useAvatarUpload(); @@ -76,7 +73,7 @@ export const AccountProfileForm = () => { fileUrl = uploadResponse.fileUrl; } updateAccount.mutate({ ...values, image: fileUrl }); - } catch (e) { + } catch { form.setError('image', { message: t('account:profile.feedbacks.uploadError.title'), }); diff --git a/src/server/routers/account.tsx b/src/server/routers/account.tsx index c3cf24ef8..d26acc397 100644 --- a/src/server/routers/account.tsx +++ b/src/server/routers/account.tsx @@ -7,6 +7,7 @@ import { z } from 'zod'; import EmailDeleteAccountCode from '@/emails/templates/delete-account-code'; import EmailUpdateAlreadyUsed from '@/emails/templates/email-update-already-used'; import EmailUpdateCode from '@/emails/templates/email-update-code'; +import { env } from '@/env.mjs'; import { zUserAccount, zUserAccountWithEmail, @@ -290,4 +291,22 @@ export const accountRouter = createTRPCRouter({ return user; }), + uploadAvatarPresignedUrl: protectedProcedure() + .meta({ + openapi: { + method: 'GET', + path: '/accounts/avatar-upload-presigned-url', + tags: ['accounts', 'files'], + protect: true, + }, + }) + .input(zUploadSignedUrlInput()) + .output(zUploadSignedUrlOutput()) + .mutation(async ({ ctx, input }) => { + return await getS3UploadSignedUrl({ + key: ctx.user.id, + host: env.S3_BUCKET_PUBLIC_URL, + metadata: parse(input?.metadata ?? ''), + }); + }), }); From 1daf3c933aea2364611875ba97e1cc02915b72b8 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 17 Feb 2025 11:27:35 +0100 Subject: [PATCH 04/27] fix: avatar data --- src/features/app/AppNavBarDesktop.tsx | 5 ++--- src/server/routers/account.tsx | 5 ++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/features/app/AppNavBarDesktop.tsx b/src/features/app/AppNavBarDesktop.tsx index b12bdd384..2f048d137 100644 --- a/src/features/app/AppNavBarDesktop.tsx +++ b/src/features/app/AppNavBarDesktop.tsx @@ -58,6 +58,7 @@ export const AppNavBarDesktop = (props: BoxProps) => { size="sm" src={account.data?.image ?? undefined} name={account.data?.name ?? account.data?.email ?? ''} + icon={account.isLoading ? : undefined} {...(isAccountActive ? { ring: '2px', @@ -70,9 +71,7 @@ export const AppNavBarDesktop = (props: BoxProps) => { }, } : {})} - > - {account.isLoading && } - + /> diff --git a/src/server/routers/account.tsx b/src/server/routers/account.tsx index d26acc397..2709fec32 100644 --- a/src/server/routers/account.tsx +++ b/src/server/routers/account.tsx @@ -65,7 +65,10 @@ export const accountRouter = createTRPCRouter({ 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()}` : null, + }, }); } catch (e) { ctx.logger.warn('An error occured while updating the user'); From 592dacb08c29dcd338a25b2650e92d8c67e6d1ab Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 17 Feb 2025 12:10:23 +0100 Subject: [PATCH 05/27] fix: check bucket url and remove files router --- src/features/account/AccountProfileForm.tsx | 1 - src/files/schemas.ts | 9 +------ src/files/utils.ts | 8 +++++- src/server/router.ts | 2 -- src/server/routers/account.tsx | 14 ++++++++++- src/server/routers/files.ts | 28 --------------------- 6 files changed, 21 insertions(+), 41 deletions(-) delete mode 100644 src/server/routers/files.ts diff --git a/src/features/account/AccountProfileForm.tsx b/src/features/account/AccountProfileForm.tsx index a5c531d5b..4289fe154 100644 --- a/src/features/account/AccountProfileForm.tsx +++ b/src/features/account/AccountProfileForm.tsx @@ -33,7 +33,6 @@ export const AccountProfileForm = () => { }); const accountAvatar = useAvatarFetch(account.data?.image ?? ''); - const uploadFile = useAvatarUpload(); const updateAccount = trpc.account.update.useMutation({ diff --git a/src/files/schemas.ts b/src/files/schemas.ts index 9e40d5e4e..53a4bb068 100644 --- a/src/files/schemas.ts +++ b/src/files/schemas.ts @@ -4,14 +4,7 @@ import { z } from 'zod'; import { zu } from '@/lib/zod/zod-utils'; export type UploadFileType = z.infer; -export const zUploadFileType = z.enum([ - 'image', - 'video', - 'audio', - 'blob', - 'pdf', - 'text', -]); +export const zUploadFileType = z.enum(['image', 'application/pdf']); export type FieldUploadValue = z.infer>; export const zFieldUploadValue = (acceptedTypes?: UploadFileType[]) => diff --git a/src/files/utils.ts b/src/files/utils.ts index a6c2857d9..3cfcd4854 100644 --- a/src/files/utils.ts +++ b/src/files/utils.ts @@ -1,6 +1,8 @@ import { UseMutateAsyncFunction } from '@tanstack/react-query'; import { stringify } from 'superjson'; +import { env } from '@/env.mjs'; + import { UploadSignedUrlInput } from './schemas'; /** @@ -51,7 +53,7 @@ export const fetchFile = async (url: string, metadata?: string[]) => { /** * Asynchronously uploads a file to a server using a presigned URL. - * Designed to be used as a `mutationFn` in a `useMutation`. + * Designed to be used as a `mutationFn` in a `useMutation`. * * @param getPresignedUrl * - An asyncMutation that is used to obtain the presigned URL and the future URL where the file will be accessible. @@ -94,3 +96,7 @@ export const uploadFile = async ( fileUrl: futureFileUrl, } as const; }; + +export const isFileUrlValidBucket = async (url: string) => { + return url.startsWith(env.S3_BUCKET_PUBLIC_URL); +}; diff --git a/src/server/router.ts b/src/server/router.ts index 3b560f373..3ce18c905 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -1,7 +1,6 @@ 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'; @@ -17,7 +16,6 @@ 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 2709fec32..7caaf02fd 100644 --- a/src/server/routers/account.tsx +++ b/src/server/routers/account.tsx @@ -15,6 +15,7 @@ import { import { zVerificationCodeValidate } from '@/features/auth/schemas'; import { VALIDATION_TOKEN_EXPIRATION_IN_MINUTES } from '@/features/auth/utils'; import { zUploadSignedUrlInput, zUploadSignedUrlOutput } from '@/files/schemas'; +import { isFileUrlValidBucket } from '@/files/utils'; import i18n from '@/lib/i18n/server'; import { deleteUsedCode, @@ -63,11 +64,22 @@ export const accountRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { try { ctx.logger.info('Updating the user'); + + if (input.image && !isFileUrlValidBucket(input.image)) { + ctx.logger.error('Avatar URL do not match S3 bucket URL'); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Avatar URL do not match S3 bucket URL', + }); + } + return await ctx.db.user.update({ where: { id: ctx.user.id }, data: { ...input, - image: input.image ? `${input.image}?${Date.now()}` : null, + image: input.image + ? `${input.image}?${Date.now()}` // Allows to update the cache when the user changes his account + : null, }, }); } catch (e) { diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts deleted file mode 100644 index bda8f6085..000000000 --- a/src/server/routers/files.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { randomUUID } from 'crypto'; -import { parse } from 'superjson'; - -import { env } from '@/env.mjs'; -import { zUploadSignedUrlInput, zUploadSignedUrlOutput } from '@/files/schemas'; -import { getS3UploadSignedUrl } from '@/server/config/s3'; -import { createTRPCRouter, protectedProcedure } from '@/server/config/trpc'; - -export const filesRouter = createTRPCRouter({ - uploadPresignedUrl: protectedProcedure() - .meta({ - openapi: { - method: 'GET', - path: '/files/upload-presigned-url', - tags: ['files'], - protect: true, - }, - }) - .input(zUploadSignedUrlInput()) - .output(zUploadSignedUrlOutput()) - .mutation(async ({ input }) => { - return await getS3UploadSignedUrl({ - key: randomUUID(), - host: env.S3_BUCKET_PUBLIC_URL, - metadata: parse(input?.metadata ?? ''), - }); - }), -}); From ac22b8b74ce308f32b0f7360522edd22313cb4ad Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 17 Feb 2025 13:16:26 +0100 Subject: [PATCH 06/27] fix: ligther code --- src/features/account/AccountProfileForm.tsx | 21 ++--- src/features/account/schemas.ts | 8 +- src/features/account/service.ts | 20 ----- src/files/client.ts | 82 +++++++++++++++++ src/files/schemas.ts | 9 -- src/files/utils.ts | 97 --------------------- src/server/router.ts | 2 + src/server/routers/account.tsx | 22 ----- src/server/routers/files.ts | 33 +++++++ 9 files changed, 131 insertions(+), 163 deletions(-) delete mode 100644 src/features/account/service.ts create mode 100644 src/files/client.ts create mode 100644 src/server/routers/files.ts diff --git a/src/features/account/AccountProfileForm.tsx b/src/features/account/AccountProfileForm.tsx index 4289fe154..625d8ec18 100644 --- a/src/features/account/AccountProfileForm.tsx +++ b/src/features/account/AccountProfileForm.tsx @@ -18,7 +18,7 @@ import { FormFieldsAccountProfile, zFormFieldsAccountProfile, } from '@/features/account/schemas'; -import { useAvatarFetch, useAvatarUpload } from '@/features/account/service'; +import { useFetchFile, useUploadFileMutation } from '@/files/client'; import { AVAILABLE_LANGUAGES, DEFAULT_LANGUAGE_KEY, @@ -32,8 +32,9 @@ export const AccountProfileForm = () => { staleTime: Infinity, }); - const accountAvatar = useAvatarFetch(account.data?.image ?? ''); - const uploadFile = useAvatarUpload(); + const accountAvatar = useFetchFile(account.data?.image); + + const uploadAvatar = useUploadFileMutation(); const updateAccount = trpc.account.update.useMutation({ onSuccess: async () => { @@ -65,13 +66,13 @@ export const AccountProfileForm = () => { image, ...values }) => { - let fileUrl = account.data?.image; try { - if (image?.file) { - const uploadResponse = await uploadFile.mutateAsync(image?.file); - fileUrl = uploadResponse.fileUrl; - } - updateAccount.mutate({ ...values, image: fileUrl }); + updateAccount.mutate({ + ...values, + image: image?.file + ? await uploadAvatar.mutateAsync(image.file) + : account.data?.image, + }); } catch { form.setError('image', { message: t('account:profile.feedbacks.uploadError.title'), @@ -126,7 +127,7 @@ export const AccountProfileForm = () => { diff --git a/src/features/account/schemas.ts b/src/features/account/schemas.ts index c1cdc0cfa..1d343a22a 100644 --- a/src/features/account/schemas.ts +++ b/src/features/account/schemas.ts @@ -43,8 +43,6 @@ export const zFormFieldsAccountProfile = () => name: true, language: true, }) - .merge( - z.object({ - image: zFieldUploadValue(['image']).optional(), - }) - ); + .extend({ + image: zFieldUploadValue(['image']).optional(), + }); diff --git a/src/features/account/service.ts b/src/features/account/service.ts deleted file mode 100644 index b38b8459a..000000000 --- a/src/features/account/service.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; - -import { trpc } from '@/lib/trpc/client'; - -import { fetchFile, uploadFile } from '../../files/utils'; - -export const useAvatarFetch = (url: string) => { - return useQuery({ - queryKey: ['account', url], - queryFn: () => fetchFile(url, ['name']), - enabled: !!url, - }); -}; - -export const useAvatarUpload = () => { - const getPresignedUrl = trpc.account.uploadAvatarPresignedUrl.useMutation(); - return useMutation({ - mutationFn: (file: File) => uploadFile(getPresignedUrl.mutateAsync, file), - }); -}; diff --git a/src/files/client.ts b/src/files/client.ts new file mode 100644 index 000000000..eba8d0d16 --- /dev/null +++ b/src/files/client.ts @@ -0,0 +1,82 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { trpc } from '@/lib/trpc/client'; + +/** + * Fetches a file from the specified URL and returns file information. + * Designed to be used as a `queryFn` in a `useQuery`. + * + * @param url The URL from which the file should be fetched. + * @param [metadata] The metadata of the file you want to retrieve. + * @returns A Promise that resolves to an object containing information about the file. + * + * @example + * // Usage with Tanstack Query's useQuery: + * const fileQuery = useQuery({ + queryKey: ['fileKey', url], + queryFn: () => fetchFile(url, ['name']), + enabled: !!url, + }); + */ +export const fetchFile = async (url: string, metadata?: string[]) => { + const fileResponse = await fetch(url, { + cache: 'no-cache', + }); + if (!fileResponse.ok) { + throw new Error('Could not fetch the file'); + } + + const lastModifiedDateHeader = fileResponse.headers.get('Last-Modified'); + const defaultFileData = { + fileUrl: url, + size: fileResponse.headers.get('Content-Length') ?? undefined, + type: fileResponse.headers.get('Content-Type') ?? undefined, + lastModifiedDate: lastModifiedDateHeader + ? new Date(lastModifiedDateHeader) + : new Date(), + }; + + if (!metadata) { + return defaultFileData; + } + + return metadata.reduce((file, metadataKey) => { + return { + ...file, + [metadataKey]: fileResponse.headers.get(`x-amz-meta-${metadataKey}`), + }; + }, defaultFileData); +}; + +export const useUploadFileMutation = ( + params: { + getMetadata?: (file: File) => Record; + } = {} +) => { + const uploadPresignedUrl = trpc.files.uploadPresignedUrl.useMutation(); + return useMutation({ + mutationFn: async (file: File) => { + const presignedUrlOutput = await uploadPresignedUrl.mutateAsync({ + metadata: { + name: file.name, + ...params.getMetadata?.(file), + }, + }); + await fetch(presignedUrlOutput.signedUrl, { + method: 'PUT', + headers: { 'Content-Type': file.type }, + body: file, + }); + // TODO ERRORS + return presignedUrlOutput.futureFileUrl; + }, + }); +}; + +export const useFetchFile = (url?: string | null) => { + return useQuery({ + queryKey: ['file', url], + queryFn: () => (url ? fetchFile(url, ['name']) : undefined), + enabled: !!url, + }); +}; diff --git a/src/files/schemas.ts b/src/files/schemas.ts index 53a4bb068..f9ae90012 100644 --- a/src/files/schemas.ts +++ b/src/files/schemas.ts @@ -32,15 +32,6 @@ export const zFieldUploadValue = (acceptedTypes?: UploadFileType[]) => }), } ); - -export type UploadSignedUrlInput = z.infer< - ReturnType ->; -export const zUploadSignedUrlInput = () => - z.object({ - metadata: z.string().optional(), - }); - export type UploadSignedUrlOutput = z.infer< ReturnType >; diff --git a/src/files/utils.ts b/src/files/utils.ts index 3cfcd4854..b6fccb26f 100644 --- a/src/files/utils.ts +++ b/src/files/utils.ts @@ -1,102 +1,5 @@ -import { UseMutateAsyncFunction } from '@tanstack/react-query'; -import { stringify } from 'superjson'; - import { env } from '@/env.mjs'; -import { UploadSignedUrlInput } from './schemas'; - -/** - * Fetches a file from the specified URL and returns file information. - * Designed to be used as a `queryFn` in a `useQuery`. - * - * @param url The URL from which the file should be fetched. - * @param [metadata] The metadata of the file you want to retrieve. - * @returns A Promise that resolves to an object containing information about the file. - * - * @example - * // Usage with Tanstack Query's useQuery: - * const fileQuery = useQuery({ - queryKey: ['fileKey', url], - queryFn: () => fetchFile(url, ['name']), - enabled: !!url, - }); - */ -export const fetchFile = async (url: string, metadata?: string[]) => { - const fileResponse = await fetch(url, { - cache: 'no-cache', - }); - if (!fileResponse.ok) { - throw new Error('Could not fetch the file'); - } - - const lastModifiedDateHeader = fileResponse.headers.get('Last-Modified'); - const defaultFileData = { - fileUrl: url, - size: fileResponse.headers.get('Content-Length') ?? undefined, - type: fileResponse.headers.get('Content-Type') ?? undefined, - lastModifiedDate: lastModifiedDateHeader - ? new Date(lastModifiedDateHeader) - : new Date(), - }; - - if (!metadata) { - return defaultFileData; - } - - return metadata.reduce((file, metadataKey) => { - return { - ...file, - [metadataKey]: fileResponse.headers.get(`x-amz-meta-${metadataKey}`), - }; - }, defaultFileData); -}; - -/** - * Asynchronously uploads a file to a server using a presigned URL. - * Designed to be used as a `mutationFn` in a `useMutation`. - * - * @param getPresignedUrl - * - An asyncMutation that is used to obtain the presigned URL and the future URL where the file will be accessible. - * - * @param file - The file object to upload. - * @param metadata - Optional metadata for the file, which will be sent to the server when generating the presigned URL. - * - * @returns A promise that resolves to an object containing the URL of the uploaded file, - * - * @example - * // Usage with Tanstack Query's useMutation: - * const getPresignedUrl = trpc.routeToGetPresignedUrl.useMutation(); - const fileUpload = useMutation({ - mutationFn: (file: File) => uploadFile(getPresignedUrl.mutateAsync, file), - }); - */ -export const uploadFile = async ( - getPresignedUrl: UseMutateAsyncFunction< - { signedUrl: string; futureFileUrl: string }, - unknown, - UploadSignedUrlInput - >, - file: File, - metadata: Record = {} -) => { - const { signedUrl, futureFileUrl } = await getPresignedUrl({ - metadata: stringify({ - name: file.name, - ...metadata, - }), - }); - - await fetch(signedUrl, { - method: 'PUT', - headers: { 'Content-Type': file.type }, - body: file, - }); - - return { - fileUrl: futureFileUrl, - } as const; -}; - export const isFileUrlValidBucket = async (url: string) => { return url.startsWith(env.S3_BUCKET_PUBLIC_URL); }; 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 7caaf02fd..1e90554cf 100644 --- a/src/server/routers/account.tsx +++ b/src/server/routers/account.tsx @@ -1,20 +1,17 @@ import { TRPCError } from '@trpc/server'; import dayjs from 'dayjs'; import { randomUUID } from 'node:crypto'; -import { parse } from 'superjson'; import { z } from 'zod'; import EmailDeleteAccountCode from '@/emails/templates/delete-account-code'; import EmailUpdateAlreadyUsed from '@/emails/templates/email-update-already-used'; import EmailUpdateCode from '@/emails/templates/email-update-code'; -import { env } from '@/env.mjs'; import { zUserAccount, zUserAccountWithEmail, } from '@/features/account/schemas'; import { zVerificationCodeValidate } from '@/features/auth/schemas'; import { VALIDATION_TOKEN_EXPIRATION_IN_MINUTES } from '@/features/auth/utils'; -import { zUploadSignedUrlInput, zUploadSignedUrlOutput } from '@/files/schemas'; import { isFileUrlValidBucket } from '@/files/utils'; import i18n from '@/lib/i18n/server'; import { @@ -24,7 +21,6 @@ import { } from '@/server/config/auth'; import { sendEmail } from '@/server/config/email'; import { ExtendedTRPCError } from '@/server/config/errors'; -import { getS3UploadSignedUrl } from '@/server/config/s3'; import { createTRPCRouter, protectedProcedure } from '@/server/config/trpc'; export const accountRouter = createTRPCRouter({ @@ -306,22 +302,4 @@ export const accountRouter = createTRPCRouter({ return user; }), - uploadAvatarPresignedUrl: protectedProcedure() - .meta({ - openapi: { - method: 'GET', - path: '/accounts/avatar-upload-presigned-url', - tags: ['accounts', 'files'], - protect: true, - }, - }) - .input(zUploadSignedUrlInput()) - .output(zUploadSignedUrlOutput()) - .mutation(async ({ ctx, input }) => { - return await getS3UploadSignedUrl({ - key: ctx.user.id, - host: env.S3_BUCKET_PUBLIC_URL, - metadata: parse(input?.metadata ?? ''), - }); - }), }); diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts new file mode 100644 index 000000000..5845067b3 --- /dev/null +++ b/src/server/routers/files.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { env } from '@/env.mjs'; +import { zUploadSignedUrlOutput } from '@/files/schemas'; +import { getS3UploadSignedUrl } from '@/server/config/s3'; +import { createTRPCRouter, protectedProcedure } from '@/server/config/trpc'; + +export const filesRouter = createTRPCRouter({ + uploadPresignedUrl: protectedProcedure() + .meta({ + openapi: { + method: 'GET', + path: '/files/upload-presigned-url', + tags: ['files'], + protect: true, + }, + }) + .input( + z + .object({ + metadata: z.record(z.string(), z.string()), + }) + .optional() + ) + .output(zUploadSignedUrlOutput()) + .mutation(async ({ input, ctx }) => { + return await getS3UploadSignedUrl({ + key: ctx.user.id, // FIX ME + host: env.S3_BUCKET_PUBLIC_URL, + metadata: input?.metadata, + }); + }), +}); From b8ebc5d5d6aa65e368655ef8bd169c135018f9d9 Mon Sep 17 00:00:00 2001 From: Yoann Fleury Date: Mon, 17 Feb 2025 14:44:10 +0100 Subject: [PATCH 07/27] fix: serializable metadata and file type --- src/files/client.ts | 29 +++++++++++++++++++++-------- src/server/routers/files.ts | 21 ++++++++++++++------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/files/client.ts b/src/files/client.ts index eba8d0d16..eea417f7e 100644 --- a/src/files/client.ts +++ b/src/files/client.ts @@ -1,4 +1,6 @@ import { useMutation, useQuery } from '@tanstack/react-query'; +import { TRPCError } from '@trpc/server'; +import { stringify } from 'superjson'; import { trpc } from '@/lib/trpc/client'; @@ -57,17 +59,28 @@ export const useUploadFileMutation = ( return useMutation({ mutationFn: async (file: File) => { const presignedUrlOutput = await uploadPresignedUrl.mutateAsync({ - metadata: { + // Metadata is a Record but should be serialized for trpc-openapi + metadata: stringify({ name: file.name, ...params.getMetadata?.(file), - }, + }), + type: 'avatar', }); - await fetch(presignedUrlOutput.signedUrl, { - method: 'PUT', - headers: { 'Content-Type': file.type }, - body: file, - }); - // TODO ERRORS + + try { + await fetch(presignedUrlOutput.signedUrl, { + method: 'PUT', + headers: { 'Content-Type': file.type }, + body: file, + }); + } catch (e) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Unable to upload the file', + cause: e, + }); + } + return presignedUrlOutput.futureFileUrl; }, }); diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts index 5845067b3..997d21783 100644 --- a/src/server/routers/files.ts +++ b/src/server/routers/files.ts @@ -1,3 +1,6 @@ +import { TRPCError } from '@trpc/server'; +import { parse } from 'superjson'; +import { match } from 'ts-pattern'; import { z } from 'zod'; import { env } from '@/env.mjs'; @@ -16,18 +19,22 @@ export const filesRouter = createTRPCRouter({ }, }) .input( - z - .object({ - metadata: z.record(z.string(), z.string()), - }) - .optional() + z.object({ + /** + * Must be a string as trpc-openapi requires that attributes must be serialized + */ + metadata: z.string().optional(), + type: z.enum(['avatar']), + }) ) .output(zUploadSignedUrlOutput()) .mutation(async ({ input, ctx }) => { return await getS3UploadSignedUrl({ - key: ctx.user.id, // FIX ME + key: match(input.type) + .with('avatar', () => ctx.user.id) + .exhaustive(), host: env.S3_BUCKET_PUBLIC_URL, - metadata: input?.metadata, + metadata: input.metadata ? parse(input.metadata) : undefined, }); }), }); From 1244dc10814f490df7fc1171e01fc494a7e3a077 Mon Sep 17 00:00:00 2001 From: Yoann Fleury Date: Mon, 17 Feb 2025 16:21:02 +0100 Subject: [PATCH 08/27] feat: some improvement to function names --- src/features/users/PageAdminUsers.tsx | 6 +++++- src/files/client.ts | 1 + src/files/utils.ts | 7 ++++++- src/server/routers/account.tsx | 4 ++-- 4 files changed, 14 insertions(+), 4 deletions(-) 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/files/client.ts b/src/files/client.ts index eea417f7e..7d4a3464f 100644 --- a/src/files/client.ts +++ b/src/files/client.ts @@ -24,6 +24,7 @@ export const fetchFile = async (url: string, metadata?: string[]) => { const fileResponse = await fetch(url, { cache: 'no-cache', }); + if (!fileResponse.ok) { throw new Error('Could not fetch the file'); } diff --git a/src/files/utils.ts b/src/files/utils.ts index b6fccb26f..09ac82e06 100644 --- a/src/files/utils.ts +++ b/src/files/utils.ts @@ -1,5 +1,10 @@ import { env } from '@/env.mjs'; -export const isFileUrlValidBucket = async (url: string) => { +/** + * Check if the provided string starts with the bucket public URL + * @param url The URL to check + * @returns true if the provided `url` matches the bucket public URL + */ +export const doesFileUrlMatchesBucket = async (url: string) => { return url.startsWith(env.S3_BUCKET_PUBLIC_URL); }; diff --git a/src/server/routers/account.tsx b/src/server/routers/account.tsx index 1e90554cf..265db69e9 100644 --- a/src/server/routers/account.tsx +++ b/src/server/routers/account.tsx @@ -12,7 +12,7 @@ import { } from '@/features/account/schemas'; import { zVerificationCodeValidate } from '@/features/auth/schemas'; import { VALIDATION_TOKEN_EXPIRATION_IN_MINUTES } from '@/features/auth/utils'; -import { isFileUrlValidBucket } from '@/files/utils'; +import { doesFileUrlMatchesBucket } from '@/files/utils'; import i18n from '@/lib/i18n/server'; import { deleteUsedCode, @@ -61,7 +61,7 @@ export const accountRouter = createTRPCRouter({ try { ctx.logger.info('Updating the user'); - if (input.image && !isFileUrlValidBucket(input.image)) { + if (input.image && !doesFileUrlMatchesBucket(input.image)) { ctx.logger.error('Avatar URL do not match S3 bucket URL'); throw new TRPCError({ code: 'BAD_REQUEST', From 95dffdb9923ec9cd2ac097fcf807bde1c8c1ced0 Mon Sep 17 00:00:00 2001 From: Yoann Fleury Date: Mon, 17 Feb 2025 16:23:50 +0100 Subject: [PATCH 09/27] ci: upload artifact version and pnpm --- .github/workflows/code-quality.yml | 1 - .github/workflows/e2e-tests.yml | 1 - 2 files changed, 2 deletions(-) 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 895e03843..3b7a9373e 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -77,7 +77,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9 run_install: false - name: Get pnpm store directory From 7080193ce9ab7f5eb0d536a7bedc3f702482dec1 Mon Sep 17 00:00:00 2001 From: Yoann Fleury Date: Mon, 17 Feb 2025 16:45:08 +0100 Subject: [PATCH 10/27] feat: type in implementation not in helper --- src/features/account/AccountProfileForm.tsx | 2 +- src/files/client.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/features/account/AccountProfileForm.tsx b/src/features/account/AccountProfileForm.tsx index 625d8ec18..223ef913b 100644 --- a/src/features/account/AccountProfileForm.tsx +++ b/src/features/account/AccountProfileForm.tsx @@ -34,7 +34,7 @@ export const AccountProfileForm = () => { const accountAvatar = useFetchFile(account.data?.image); - const uploadAvatar = useUploadFileMutation(); + const uploadAvatar = useUploadFileMutation({ type: 'avatar' }); const updateAccount = trpc.account.update.useMutation({ onSuccess: async () => { diff --git a/src/files/client.ts b/src/files/client.ts index 7d4a3464f..fca52ff01 100644 --- a/src/files/client.ts +++ b/src/files/client.ts @@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server'; import { stringify } from 'superjson'; import { trpc } from '@/lib/trpc/client'; +import { RouterInputs } from '@/lib/trpc/types'; /** * Fetches a file from the specified URL and returns file information. @@ -51,11 +52,10 @@ export const fetchFile = async (url: string, metadata?: string[]) => { }, defaultFileData); }; -export const useUploadFileMutation = ( - params: { - getMetadata?: (file: File) => Record; - } = {} -) => { +export const useUploadFileMutation = (params: { + getMetadata?: (file: File) => Record; + type: RouterInputs['files']['uploadPresignedUrl']['type']; +}) => { const uploadPresignedUrl = trpc.files.uploadPresignedUrl.useMutation(); return useMutation({ mutationFn: async (file: File) => { @@ -63,13 +63,13 @@ export const useUploadFileMutation = ( // Metadata is a Record but should be serialized for trpc-openapi metadata: stringify({ name: file.name, - ...params.getMetadata?.(file), + ...params?.getMetadata?.(file), }), - type: 'avatar', + type: params.type, }); try { - await fetch(presignedUrlOutput.signedUrl, { + const response = await fetch(presignedUrlOutput.signedUrl, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file, From a5f6e73c5ddd82a51950913ae887bb6b7d3be5bd Mon Sep 17 00:00:00 2001 From: Yoann Fleury Date: Mon, 17 Feb 2025 18:11:01 +0100 Subject: [PATCH 11/27] feat: file size and type --- src/components/ImageUpload/index.tsx | 3 ++ src/features/account/AccountProfileForm.tsx | 2 +- src/files/client.ts | 20 +++++++----- src/server/routers/files.ts | 35 ++++++++++++++++++--- 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/components/ImageUpload/index.tsx b/src/components/ImageUpload/index.tsx index 0671d56cd..6565e4e36 100644 --- a/src/components/ImageUpload/index.tsx +++ b/src/components/ImageUpload/index.tsx @@ -5,11 +5,13 @@ 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 ( @@ -30,6 +32,7 @@ export const ImageUpload = ({ left={0} width={0} type="file" + accept={accept} onChange={onChange} /> diff --git a/src/features/account/AccountProfileForm.tsx b/src/features/account/AccountProfileForm.tsx index 223ef913b..85ccb1238 100644 --- a/src/features/account/AccountProfileForm.tsx +++ b/src/features/account/AccountProfileForm.tsx @@ -34,7 +34,7 @@ export const AccountProfileForm = () => { const accountAvatar = useFetchFile(account.data?.image); - const uploadAvatar = useUploadFileMutation({ type: 'avatar' }); + const uploadAvatar = useUploadFileMutation('avatar'); const updateAccount = trpc.account.update.useMutation({ onSuccess: async () => { diff --git a/src/files/client.ts b/src/files/client.ts index fca52ff01..b97a291aa 100644 --- a/src/files/client.ts +++ b/src/files/client.ts @@ -52,24 +52,28 @@ export const fetchFile = async (url: string, metadata?: string[]) => { }, defaultFileData); }; -export const useUploadFileMutation = (params: { - getMetadata?: (file: File) => Record; - type: RouterInputs['files']['uploadPresignedUrl']['type']; -}) => { +export const useUploadFileMutation = ( + collection: RouterInputs['files']['uploadPresignedUrl']['collection'], + params: { + getMetadata?: (file: File) => Record; + } = {} +) => { const uploadPresignedUrl = trpc.files.uploadPresignedUrl.useMutation(); return useMutation({ mutationFn: async (file: File) => { const presignedUrlOutput = await uploadPresignedUrl.mutateAsync({ // Metadata is a Record but should be serialized for trpc-openapi metadata: stringify({ - name: file.name, - ...params?.getMetadata?.(file), + ...params.getMetadata?.(file), }), - type: params.type, + collection, + fileType: file.type, + size: file.size, + name: file.name, }); try { - const response = await fetch(presignedUrlOutput.signedUrl, { + await fetch(presignedUrlOutput.signedUrl, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file, diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts index 997d21783..62f603d37 100644 --- a/src/server/routers/files.ts +++ b/src/server/routers/files.ts @@ -24,17 +24,42 @@ export const filesRouter = createTRPCRouter({ * Must be a string as trpc-openapi requires that attributes must be serialized */ metadata: z.string().optional(), - type: z.enum(['avatar']), + name: z.string(), + fileType: z.string(), + size: z.number(), + collection: z.enum(['avatar']), }) ) .output(zUploadSignedUrlOutput()) .mutation(async ({ input, ctx }) => { + const config = match(input) + .with({ collection: 'avatar' }, () => ({ + key: `avatars/${ctx.user.id}`, + fileTypes: ['image/png', 'image/jpg'], + maxSize: 10 * 1024 * 1024, + })) + .exhaustive(); + + if (input.size >= config.maxSize) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `File size is too big ${input.size}/${config.maxSize}`, + }); + } + + if (!config.fileTypes.includes(input.fileType)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Incorrect file type ${input.fileType} (authorized: ${config.fileTypes.join(',')})`, + }); + } + return await getS3UploadSignedUrl({ - key: match(input.type) - .with('avatar', () => ctx.user.id) - .exhaustive(), + key: config.key, host: env.S3_BUCKET_PUBLIC_URL, - metadata: input.metadata ? parse(input.metadata) : undefined, + metadata: input.metadata + ? { name: input.name, ...parse(input.metadata) } + : undefined, }); }), }); From bff986f972bfc524cd8ff41e399fc80963af7f18 Mon Sep 17 00:00:00 2001 From: Yoann Fleury Date: Mon, 17 Feb 2025 21:45:35 +0100 Subject: [PATCH 12/27] feat: extract configuration and validation --- src/files/schemas.ts | 15 ++++++++ src/server/routers/files.ts | 77 ++++++++++++++++++++----------------- 2 files changed, 57 insertions(+), 35 deletions(-) diff --git a/src/files/schemas.ts b/src/files/schemas.ts index f9ae90012..1540f5824 100644 --- a/src/files/schemas.ts +++ b/src/files/schemas.ts @@ -32,6 +32,21 @@ export const zFieldUploadValue = (acceptedTypes?: UploadFileType[]) => }), } ); + +export type UploadSignedUrlInput = z.infer< + ReturnType +>; +export const zUploadSignedUrlInput = () => + z.object({ + /** + * Must be a string as trpc-openapi requires that attributes must be serialized + */ + metadata: z.string().optional(), + name: z.string(), + fileType: z.string(), + size: z.number(), + collection: z.enum(['avatar']), + }); export type UploadSignedUrlOutput = z.infer< ReturnType >; diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts index 62f603d37..c66789597 100644 --- a/src/server/routers/files.ts +++ b/src/server/routers/files.ts @@ -1,12 +1,48 @@ import { TRPCError } from '@trpc/server'; import { parse } from 'superjson'; import { match } from 'ts-pattern'; -import { z } from 'zod'; import { env } from '@/env.mjs'; -import { zUploadSignedUrlOutput } from '@/files/schemas'; +import { + UploadSignedUrlInput, + zUploadSignedUrlInput, + zUploadSignedUrlOutput, +} from '@/files/schemas'; import { getS3UploadSignedUrl } from '@/server/config/s3'; -import { createTRPCRouter, protectedProcedure } from '@/server/config/trpc'; +import { + AppContext, + createTRPCRouter, + protectedProcedure, +} from '@/server/config/trpc'; + +const getConfiguration = (input: UploadSignedUrlInput, ctx: AppContext) => { + return match(input) + .with({ collection: 'avatar' }, () => ({ + key: `avatars/${ctx.user?.id}`, + fileTypes: ['image/png', 'image/jpg'], + maxSize: 10 * 1024 * 1024, + })) + .exhaustive(); +}; + +const validateOrThrowFromConfig = ( + input: UploadSignedUrlInput, + configuration: ReturnType +) => { + if (input.size >= configuration.maxSize) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `File size is too big ${input.size}/${configuration.maxSize}`, + }); + } + + if (!configuration.fileTypes.includes(input.fileType)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Incorrect file type ${input.fileType} (authorized: ${configuration.fileTypes.join(',')})`, + }); + } +}; export const filesRouter = createTRPCRouter({ uploadPresignedUrl: protectedProcedure() @@ -18,41 +54,12 @@ export const filesRouter = createTRPCRouter({ protect: true, }, }) - .input( - z.object({ - /** - * Must be a string as trpc-openapi requires that attributes must be serialized - */ - metadata: z.string().optional(), - name: z.string(), - fileType: z.string(), - size: z.number(), - collection: z.enum(['avatar']), - }) - ) + .input(zUploadSignedUrlInput()) .output(zUploadSignedUrlOutput()) .mutation(async ({ input, ctx }) => { - const config = match(input) - .with({ collection: 'avatar' }, () => ({ - key: `avatars/${ctx.user.id}`, - fileTypes: ['image/png', 'image/jpg'], - maxSize: 10 * 1024 * 1024, - })) - .exhaustive(); - - if (input.size >= config.maxSize) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `File size is too big ${input.size}/${config.maxSize}`, - }); - } + const config = getConfiguration(input, ctx); - if (!config.fileTypes.includes(input.fileType)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `Incorrect file type ${input.fileType} (authorized: ${config.fileTypes.join(',')})`, - }); - } + validateOrThrowFromConfig(input, config); return await getS3UploadSignedUrl({ key: config.key, From 0673b4f3d74f780dfa4e09ec12167479f6a9c761 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Tue, 18 Feb 2025 13:07:45 +0100 Subject: [PATCH 13/27] fix: use POST for presigned url --- src/server/routers/files.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts index c66789597..861774405 100644 --- a/src/server/routers/files.ts +++ b/src/server/routers/files.ts @@ -48,7 +48,7 @@ export const filesRouter = createTRPCRouter({ uploadPresignedUrl: protectedProcedure() .meta({ openapi: { - method: 'GET', + method: 'POST', path: '/files/upload-presigned-url', tags: ['files'], protect: true, From ffe42ef194a0672a327308af4d43fb6ec9ee54db Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Tue, 18 Feb 2025 13:34:32 +0100 Subject: [PATCH 14/27] fix: size and time --- src/server/config/s3.ts | 2 +- src/server/routers/files.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/config/s3.ts b/src/server/config/s3.ts index 7653639ad..77a71149a 100644 --- a/src/server/config/s3.ts +++ b/src/server/config/s3.ts @@ -4,7 +4,7 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { env } from '@/env.mjs'; import { UploadFileType, UploadSignedUrlOutput } from '@/files/schemas'; -const SIGNED_URL_EXPIRATION_TIME_SECONDS = 3600; // 1 hour +const SIGNED_URL_EXPIRATION_TIME_SECONDS = 60; // 1 minute const S3 = new S3Client({ region: 'auto', diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts index 861774405..471cd28e3 100644 --- a/src/server/routers/files.ts +++ b/src/server/routers/files.ts @@ -20,7 +20,7 @@ const getConfiguration = (input: UploadSignedUrlInput, ctx: AppContext) => { .with({ collection: 'avatar' }, () => ({ key: `avatars/${ctx.user?.id}`, fileTypes: ['image/png', 'image/jpg'], - maxSize: 10 * 1024 * 1024, + maxSize: 5 * 1024 * 1024, // 5MB in bytes, })) .exhaustive(); }; From ed6728b3e503e0e41a67397b7f72e5f3c196bed4 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Wed, 19 Feb 2025 12:14:15 +0100 Subject: [PATCH 15/27] wip: fetch metadata on server and store only key instead of full url --- src/env.mjs | 5 +- src/features/account/AccountProfileForm.tsx | 6 +- src/features/account/schemas.ts | 3 +- src/features/admin/AdminNavBar.tsx | 8 +-- src/features/app/AppNavBarDesktop.tsx | 3 +- src/features/users/schemas.ts | 4 +- src/files/client.ts | 63 +++------------------ src/files/schemas.ts | 20 ++++--- src/files/utils.ts | 10 ---- src/server/config/s3.ts | 47 +++++++++++++-- src/server/routers/account.tsx | 18 +++--- src/server/routers/files.ts | 4 +- 12 files changed, 88 insertions(+), 103 deletions(-) delete mode 100644 src/files/utils.ts diff --git a/src/env.mjs b/src/env.mjs index b19f35616..bcd88db83 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -35,7 +35,6 @@ export const env = createEnv({ S3_ENDPOINT: z.string().url(), S3_BUCKET_NAME: z.string(), - S3_BUCKET_PUBLIC_URL: z.string().url(), S3_ACCESS_KEY_ID: z.string(), S3_SECRET_ACCESS_KEY: z.string(), }, @@ -77,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(), }, /** @@ -93,7 +93,8 @@ export const env = createEnv({ LOGGER_PRETTY: process.env.LOGGER_PRETTY, S3_ENDPOINT: process.env.S3_ENDPOINT, S3_BUCKET_NAME: process.env.S3_BUCKET_NAME, - S3_BUCKET_PUBLIC_URL: process.env.S3_BUCKET_PUBLIC_URL, + 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, diff --git a/src/features/account/AccountProfileForm.tsx b/src/features/account/AccountProfileForm.tsx index 85ccb1238..d75a04741 100644 --- a/src/features/account/AccountProfileForm.tsx +++ b/src/features/account/AccountProfileForm.tsx @@ -18,7 +18,7 @@ import { FormFieldsAccountProfile, zFormFieldsAccountProfile, } from '@/features/account/schemas'; -import { useFetchFile, useUploadFileMutation } from '@/files/client'; +import { useUploadFileMutation } from '@/files/client'; import { AVAILABLE_LANGUAGES, DEFAULT_LANGUAGE_KEY, @@ -32,8 +32,6 @@ export const AccountProfileForm = () => { staleTime: Infinity, }); - const accountAvatar = useFetchFile(account.data?.image); - const uploadAvatar = useUploadFileMutation('avatar'); const updateAccount = trpc.account.update.useMutation({ @@ -58,7 +56,7 @@ export const AccountProfileForm = () => { values: { name: account.data?.name ?? '', language: account.data?.language ?? DEFAULT_LANGUAGE_KEY, - image: accountAvatar.data ?? undefined, + image: account.data?.imageMetadata ?? null, }, }); diff --git a/src/features/account/schemas.ts b/src/features/account/schemas.ts index 1d343a22a..d599a5593 100644 --- a/src/features/account/schemas.ts +++ b/src/features/account/schemas.ts @@ -10,6 +10,7 @@ export const zUserAccount = () => name: true, email: true, image: true, + imageMetadata: true, isEmailVerified: true, authorizations: true, language: true, @@ -44,5 +45,5 @@ export const zFormFieldsAccountProfile = () => language: true, }) .extend({ - image: zFieldUploadValue(['image']).optional(), + image: zFieldUploadValue(['image']).nullish(), }); diff --git a/src/features/admin/AdminNavBar.tsx b/src/features/admin/AdminNavBar.tsx index 1a2676b28..af01a06c1 100644 --- a/src/features/admin/AdminNavBar.tsx +++ b/src/features/admin/AdminNavBar.tsx @@ -54,6 +54,7 @@ import { ROUTES_AUTH } from '@/features/auth/routes'; import { ROUTES_DOCS } from '@/features/docs/routes'; import { ROUTES_MANAGEMENT } from '@/features/management/routes'; import { ROUTES_REPOSITORIES } from '@/features/repositories/routes'; +import { getFilePublicUrl } from '@/files/client'; import { useRtl } from '@/hooks/useRtl'; import { trpc } from '@/lib/trpc/client'; @@ -89,11 +90,10 @@ const AdminNavBarAccountMenu = ({ ...rest }: Omit) => { : undefined} name={account.data?.name ?? account.data?.email ?? ''} - > - {account.isLoading && } - + /> { @@ -56,7 +57,7 @@ export const AppNavBarDesktop = (props: BoxProps) => { as={Link} href={ROUTES_ACCOUNT.app.root()} size="sm" - src={account.data?.image ?? undefined} + src={getFilePublicUrl(account.data?.image)} name={account.data?.name ?? account.data?.email ?? ''} icon={account.isLoading ? : undefined} {...(isAccountActive diff --git a/src/features/users/schemas.ts b/src/features/users/schemas.ts index 7134312dd..5ad390d4c 100644 --- a/src/features/users/schemas.ts +++ b/src/features/users/schemas.ts @@ -1,6 +1,7 @@ import { t } from 'i18next'; import { z } from 'zod'; +import { zFieldMetadata } from '@/files/schemas'; import { DEFAULT_LANGUAGE_KEY } from '@/lib/i18n/constants'; import { zu } from '@/lib/zod/zod-utils'; @@ -33,7 +34,8 @@ export const zUser = () => required_error: t('users:data.email.required'), invalid_type_error: t('users:data.email.invalid'), }), - image: z.string().url().nullish(), + image: z.string().nullish(), + imageMetadata: zFieldMetadata().nullish(), isEmailVerified: z.boolean(), authorizations: zu.array .nonEmpty( diff --git a/src/files/client.ts b/src/files/client.ts index b97a291aa..e68d34824 100644 --- a/src/files/client.ts +++ b/src/files/client.ts @@ -1,57 +1,11 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { TRPCError } from '@trpc/server'; import { stringify } from 'superjson'; +import { env } from '@/env.mjs'; import { trpc } from '@/lib/trpc/client'; import { RouterInputs } from '@/lib/trpc/types'; -/** - * Fetches a file from the specified URL and returns file information. - * Designed to be used as a `queryFn` in a `useQuery`. - * - * @param url The URL from which the file should be fetched. - * @param [metadata] The metadata of the file you want to retrieve. - * @returns A Promise that resolves to an object containing information about the file. - * - * @example - * // Usage with Tanstack Query's useQuery: - * const fileQuery = useQuery({ - queryKey: ['fileKey', url], - queryFn: () => fetchFile(url, ['name']), - enabled: !!url, - }); - */ -export const fetchFile = async (url: string, metadata?: string[]) => { - const fileResponse = await fetch(url, { - cache: 'no-cache', - }); - - if (!fileResponse.ok) { - throw new Error('Could not fetch the file'); - } - - const lastModifiedDateHeader = fileResponse.headers.get('Last-Modified'); - const defaultFileData = { - fileUrl: url, - size: fileResponse.headers.get('Content-Length') ?? undefined, - type: fileResponse.headers.get('Content-Type') ?? undefined, - lastModifiedDate: lastModifiedDateHeader - ? new Date(lastModifiedDateHeader) - : new Date(), - }; - - if (!metadata) { - return defaultFileData; - } - - return metadata.reduce((file, metadataKey) => { - return { - ...file, - [metadataKey]: fileResponse.headers.get(`x-amz-meta-${metadataKey}`), - }; - }, defaultFileData); -}; - export const useUploadFileMutation = ( collection: RouterInputs['files']['uploadPresignedUrl']['collection'], params: { @@ -86,15 +40,14 @@ export const useUploadFileMutation = ( }); } - return presignedUrlOutput.futureFileUrl; + return presignedUrlOutput.key; }, }); }; -export const useFetchFile = (url?: string | null) => { - return useQuery({ - queryKey: ['file', url], - queryFn: () => (url ? fetchFile(url, ['name']) : undefined), - enabled: !!url, - }); +export const getFilePublicUrl = (key: string | null | undefined) => { + if (!key) { + return undefined; + } + return `${env.NEXT_PUBLIC_S3_BUCKET_PUBLIC_URL}/${key}`; }; diff --git a/src/files/schemas.ts b/src/files/schemas.ts index 1540f5824..f1a814b0d 100644 --- a/src/files/schemas.ts +++ b/src/files/schemas.ts @@ -6,17 +6,22 @@ import { zu } from '@/lib/zod/zod-utils'; export type UploadFileType = z.infer; export const zUploadFileType = z.enum(['image', 'application/pdf']); +export type FieldMetadata = z.infer>; +export const zFieldMetadata = () => + z.object({ + fileUrl: zu.string.nonEmptyNullish(z.string()), + lastModifiedDate: z.date().optional(), + name: zu.string.nonEmptyNullish(z.string()), + size: zu.string.nonEmptyNullish(z.string()), + type: zu.string.nonEmptyNullish(z.string()), + }); + export type FieldUploadValue = z.infer>; export const zFieldUploadValue = (acceptedTypes?: UploadFileType[]) => - z - .object({ - fileUrl: zu.string.nonEmptyNullish(z.string()), + zFieldMetadata() + .extend({ file: z.instanceof(File).optional(), lastModified: z.number().optional(), - lastModifiedDate: z.date().optional(), - name: zu.string.nonEmptyNullish(z.string()), - size: zu.string.nonEmptyNullish(z.string()), - type: zu.string.nonEmptyNullish(z.string()), }) .refine( (file) => { @@ -52,7 +57,6 @@ export type UploadSignedUrlOutput = z.infer< >; export const zUploadSignedUrlOutput = () => z.object({ - futureFileUrl: z.string(), key: z.string(), signedUrl: z.string(), }); diff --git a/src/files/utils.ts b/src/files/utils.ts deleted file mode 100644 index 09ac82e06..000000000 --- a/src/files/utils.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { env } from '@/env.mjs'; - -/** - * Check if the provided string starts with the bucket public URL - * @param url The URL to check - * @returns true if the provided `url` matches the bucket public URL - */ -export const doesFileUrlMatchesBucket = async (url: string) => { - return url.startsWith(env.S3_BUCKET_PUBLIC_URL); -}; diff --git a/src/server/config/s3.ts b/src/server/config/s3.ts index 77a71149a..daeb94d70 100644 --- a/src/server/config/s3.ts +++ b/src/server/config/s3.ts @@ -1,8 +1,16 @@ -import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { env } from '@/env.mjs'; -import { UploadFileType, UploadSignedUrlOutput } from '@/files/schemas'; +import { + FieldMetadata, + UploadFileType, + UploadSignedUrlOutput, +} from '@/files/schemas'; const SIGNED_URL_EXPIRATION_TIME_SECONDS = 60; // 1 minute @@ -20,7 +28,6 @@ type UploadSignedUrlOptions = { expiresIn?: number; /** The tree structure of the file in S3 */ key: string; - host?: string; metadata?: Record; }; @@ -40,6 +47,38 @@ export const getS3UploadSignedUrl = async ( return { signedUrl, key: options.key, - futureFileUrl: (options.host ? `${options.host}/` : '') + options.key, }; }; + +export const fetchFileMetadata = async (key: string) => { + const s3key = key.split('?')[0]; // Remove the ?timestamp + const fileUrl = `${env.NEXT_PUBLIC_S3_BUCKET_PUBLIC_URL}/${s3key}`; + try { + console.log({ key }); + const command = new HeadObjectCommand({ + Bucket: env.S3_BUCKET_NAME, + Key: s3key, + }); + const fileResponse = await S3.send(command); + + return { + fileUrl, + size: fileResponse.ContentLength?.toString(), + type: fileResponse.ContentType, + lastModifiedDate: fileResponse.LastModified + ? new Date(fileResponse.LastModified) + : undefined, + name: fileResponse.Metadata?.name, + } satisfies FieldMetadata; + } catch (e) { + // TODO Better error handle + console.error('------- ERROR ------', e); + return { + fileUrl, + size: undefined, + type: undefined, + lastModifiedDate: undefined, + name: undefined, + } satisfies FieldMetadata; + } +}; diff --git a/src/server/routers/account.tsx b/src/server/routers/account.tsx index 265db69e9..aa0b733c4 100644 --- a/src/server/routers/account.tsx +++ b/src/server/routers/account.tsx @@ -12,7 +12,6 @@ import { } from '@/features/account/schemas'; import { zVerificationCodeValidate } from '@/features/auth/schemas'; import { VALIDATION_TOKEN_EXPIRATION_IN_MINUTES } from '@/features/auth/utils'; -import { doesFileUrlMatchesBucket } from '@/files/utils'; import i18n from '@/lib/i18n/server'; import { deleteUsedCode, @@ -21,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({ @@ -37,7 +37,13 @@ export const accountRouter = createTRPCRouter({ .output(zUserAccount()) .query(async ({ ctx }) => { ctx.logger.info('Return the current user'); - return ctx.user; + + return { + ...ctx.user, + imageMetadata: ctx.user.image + ? await fetchFileMetadata(ctx.user.image) + : undefined, + }; }), update: protectedProcedure() @@ -61,14 +67,6 @@ export const accountRouter = createTRPCRouter({ try { ctx.logger.info('Updating the user'); - if (input.image && !doesFileUrlMatchesBucket(input.image)) { - ctx.logger.error('Avatar URL do not match S3 bucket URL'); - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'Avatar URL do not match S3 bucket URL', - }); - } - return await ctx.db.user.update({ where: { id: ctx.user.id }, data: { diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts index 471cd28e3..3ece6644e 100644 --- a/src/server/routers/files.ts +++ b/src/server/routers/files.ts @@ -2,7 +2,6 @@ import { TRPCError } from '@trpc/server'; import { parse } from 'superjson'; import { match } from 'ts-pattern'; -import { env } from '@/env.mjs'; import { UploadSignedUrlInput, zUploadSignedUrlInput, @@ -19,7 +18,7 @@ const getConfiguration = (input: UploadSignedUrlInput, ctx: AppContext) => { return match(input) .with({ collection: 'avatar' }, () => ({ key: `avatars/${ctx.user?.id}`, - fileTypes: ['image/png', 'image/jpg'], + fileTypes: ['image/png', 'image/jpg', 'image/jpeg'], maxSize: 5 * 1024 * 1024, // 5MB in bytes, })) .exhaustive(); @@ -63,7 +62,6 @@ export const filesRouter = createTRPCRouter({ return await getS3UploadSignedUrl({ key: config.key, - host: env.S3_BUCKET_PUBLIC_URL, metadata: input.metadata ? { name: input.name, ...parse(input.metadata) } : undefined, From 0d0b373c4c9b24719121827f0f7c949277087a79 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Wed, 19 Feb 2025 12:17:27 +0100 Subject: [PATCH 16/27] fix: move /files into /lib/s3 --- src/components/Form/FieldUpload/FieldUpload.spec.tsx | 2 +- src/components/Form/FieldUpload/docs.stories.tsx | 2 +- src/features/account/AccountProfileForm.tsx | 2 +- src/features/account/schemas.ts | 2 +- src/features/admin/AdminNavBar.tsx | 2 +- src/features/app/AppNavBarDesktop.tsx | 2 +- src/features/users/schemas.ts | 2 +- src/{files => lib/s3}/client.ts | 0 src/{files => lib/s3}/schemas.ts | 0 src/server/config/s3.ts | 2 +- src/server/routers/files.ts | 2 +- 11 files changed, 9 insertions(+), 9 deletions(-) rename src/{files => lib/s3}/client.ts (100%) rename src/{files => lib/s3}/schemas.ts (100%) diff --git a/src/components/Form/FieldUpload/FieldUpload.spec.tsx b/src/components/Form/FieldUpload/FieldUpload.spec.tsx index 9b17ed876..2ade79426 100644 --- a/src/components/Form/FieldUpload/FieldUpload.spec.tsx +++ b/src/components/Form/FieldUpload/FieldUpload.spec.tsx @@ -3,7 +3,7 @@ import { z } from 'zod'; import { FormFieldController } from '@/components/Form/FormFieldController'; import { FormFieldLabel } from '@/components/Form/FormFieldLabel'; -import { FieldUploadValue, zFieldUploadValue } from '@/files/schemas'; +import { FieldUploadValue, zFieldUploadValue } from '@/lib/s3/schemas'; import { render, screen, setupUser } from '@/tests/utils'; import { FormField } from '../FormField'; diff --git a/src/components/Form/FieldUpload/docs.stories.tsx b/src/components/Form/FieldUpload/docs.stories.tsx index cc18e38a5..5cf020aff 100644 --- a/src/components/Form/FieldUpload/docs.stories.tsx +++ b/src/components/Form/FieldUpload/docs.stories.tsx @@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { zFieldUploadValue } from '@/files/schemas'; +import { zFieldUploadValue } from '@/lib/s3/schemas'; import { Form, FormField, FormFieldController, FormFieldLabel } from '../'; import { FieldUploadPreview } from './FieldUploadPreview'; diff --git a/src/features/account/AccountProfileForm.tsx b/src/features/account/AccountProfileForm.tsx index d75a04741..1c4524c55 100644 --- a/src/features/account/AccountProfileForm.tsx +++ b/src/features/account/AccountProfileForm.tsx @@ -18,11 +18,11 @@ import { FormFieldsAccountProfile, zFormFieldsAccountProfile, } from '@/features/account/schemas'; -import { useUploadFileMutation } from '@/files/client'; import { AVAILABLE_LANGUAGES, DEFAULT_LANGUAGE_KEY, } from '@/lib/i18n/constants'; +import { useUploadFileMutation } from '@/lib/s3/client'; import { trpc } from '@/lib/trpc/client'; export const AccountProfileForm = () => { diff --git a/src/features/account/schemas.ts b/src/features/account/schemas.ts index d599a5593..46af94de6 100644 --- a/src/features/account/schemas.ts +++ b/src/features/account/schemas.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { zUser, zUserWithEmail } from '@/features/users/schemas'; -import { zFieldUploadValue } from '@/files/schemas'; +import { zFieldUploadValue } from '@/lib/s3/schemas'; export type UserAccount = z.infer>; export const zUserAccount = () => diff --git a/src/features/admin/AdminNavBar.tsx b/src/features/admin/AdminNavBar.tsx index af01a06c1..bab27a948 100644 --- a/src/features/admin/AdminNavBar.tsx +++ b/src/features/admin/AdminNavBar.tsx @@ -54,8 +54,8 @@ import { ROUTES_AUTH } from '@/features/auth/routes'; import { ROUTES_DOCS } from '@/features/docs/routes'; import { ROUTES_MANAGEMENT } from '@/features/management/routes'; import { ROUTES_REPOSITORIES } from '@/features/repositories/routes'; -import { getFilePublicUrl } from '@/files/client'; import { useRtl } from '@/hooks/useRtl'; +import { getFilePublicUrl } from '@/lib/s3/client'; import { trpc } from '@/lib/trpc/client'; import buildInfo from '../../../scripts/.build-info.json'; diff --git a/src/features/app/AppNavBarDesktop.tsx b/src/features/app/AppNavBarDesktop.tsx index e9ad7d9ac..6f2c94b30 100644 --- a/src/features/app/AppNavBarDesktop.tsx +++ b/src/features/app/AppNavBarDesktop.tsx @@ -19,7 +19,7 @@ import { Logo } from '@/components/Logo'; import { ROUTES_ACCOUNT } from '@/features/account/routes'; import { ROUTES_APP } from '@/features/app/routes'; import { ROUTES_REPOSITORIES } from '@/features/repositories/routes'; -import { getFilePublicUrl } from '@/files/client'; +import { getFilePublicUrl } from '@/lib/s3/client'; import { trpc } from '@/lib/trpc/client'; export const AppNavBarDesktop = (props: BoxProps) => { diff --git a/src/features/users/schemas.ts b/src/features/users/schemas.ts index 5ad390d4c..e7675d3e7 100644 --- a/src/features/users/schemas.ts +++ b/src/features/users/schemas.ts @@ -1,8 +1,8 @@ import { t } from 'i18next'; import { z } from 'zod'; -import { zFieldMetadata } from '@/files/schemas'; 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; diff --git a/src/files/client.ts b/src/lib/s3/client.ts similarity index 100% rename from src/files/client.ts rename to src/lib/s3/client.ts diff --git a/src/files/schemas.ts b/src/lib/s3/schemas.ts similarity index 100% rename from src/files/schemas.ts rename to src/lib/s3/schemas.ts diff --git a/src/server/config/s3.ts b/src/server/config/s3.ts index daeb94d70..4e65c70de 100644 --- a/src/server/config/s3.ts +++ b/src/server/config/s3.ts @@ -10,7 +10,7 @@ import { FieldMetadata, UploadFileType, UploadSignedUrlOutput, -} from '@/files/schemas'; +} from '@/lib/s3/schemas'; const SIGNED_URL_EXPIRATION_TIME_SECONDS = 60; // 1 minute diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts index 3ece6644e..a591b230b 100644 --- a/src/server/routers/files.ts +++ b/src/server/routers/files.ts @@ -6,7 +6,7 @@ import { UploadSignedUrlInput, zUploadSignedUrlInput, zUploadSignedUrlOutput, -} from '@/files/schemas'; +} from '@/lib/s3/schemas'; import { getS3UploadSignedUrl } from '@/server/config/s3'; import { AppContext, From 08e8337a738772912d60b2a7d18bf0f45b49b057 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Wed, 19 Feb 2025 12:42:18 +0100 Subject: [PATCH 17/27] wip: refacto files config --- src/lib/s3/config.ts | 22 +++++++++++++ src/lib/s3/schemas.ts | 3 +- src/server/config/s3.ts | 1 - src/server/routers/files.ts | 63 ++++++++++++++----------------------- 4 files changed, 48 insertions(+), 41 deletions(-) create mode 100644 src/lib/s3/config.ts diff --git a/src/lib/s3/config.ts b/src/lib/s3/config.ts new file mode 100644 index 000000000..cf33ab8e0 --- /dev/null +++ b/src/lib/s3/config.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +import { User } from '@/features/users/schemas'; + +export type FilesCollection = z.infer>; +export const zFilesCollection = () => z.enum(['avatar']); + +// TODO Also use this config in form validation +export const FILES_COLLECTIONS_CONFIG = { + avatar: { + getKey: ({ user }) => `avatars/${user.id}`, + fileTypes: ['image/png', 'image/jpg', 'image/jpeg'], + maxSize: 1 * 1024 * 1024, // 5MB in bytes, + }, +} satisfies Record< + FilesCollection, + { + getKey: (params: { user: User }) => string; + fileTypes?: Array; + maxSize?: number; + } +>; diff --git a/src/lib/s3/schemas.ts b/src/lib/s3/schemas.ts index f1a814b0d..df2128c1b 100644 --- a/src/lib/s3/schemas.ts +++ b/src/lib/s3/schemas.ts @@ -1,6 +1,7 @@ import { t } from 'i18next'; import { z } from 'zod'; +import { zFilesCollection } from '@/lib/s3/config'; import { zu } from '@/lib/zod/zod-utils'; export type UploadFileType = z.infer; @@ -50,7 +51,7 @@ export const zUploadSignedUrlInput = () => name: z.string(), fileType: z.string(), size: z.number(), - collection: z.enum(['avatar']), + collection: zFilesCollection(), }); export type UploadSignedUrlOutput = z.infer< ReturnType diff --git a/src/server/config/s3.ts b/src/server/config/s3.ts index 4e65c70de..f8d8d0fcc 100644 --- a/src/server/config/s3.ts +++ b/src/server/config/s3.ts @@ -54,7 +54,6 @@ export const fetchFileMetadata = async (key: string) => { const s3key = key.split('?')[0]; // Remove the ?timestamp const fileUrl = `${env.NEXT_PUBLIC_S3_BUCKET_PUBLIC_URL}/${s3key}`; try { - console.log({ key }); const command = new HeadObjectCommand({ Bucket: env.S3_BUCKET_NAME, Key: s3key, diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts index a591b230b..b4ca39992 100644 --- a/src/server/routers/files.ts +++ b/src/server/routers/files.ts @@ -1,47 +1,13 @@ import { TRPCError } from '@trpc/server'; import { parse } from 'superjson'; -import { match } from 'ts-pattern'; +import { FILES_COLLECTIONS_CONFIG } from '@/lib/s3/config'; import { - UploadSignedUrlInput, zUploadSignedUrlInput, zUploadSignedUrlOutput, } from '@/lib/s3/schemas'; import { getS3UploadSignedUrl } from '@/server/config/s3'; -import { - AppContext, - createTRPCRouter, - protectedProcedure, -} from '@/server/config/trpc'; - -const getConfiguration = (input: UploadSignedUrlInput, ctx: AppContext) => { - return match(input) - .with({ collection: 'avatar' }, () => ({ - key: `avatars/${ctx.user?.id}`, - fileTypes: ['image/png', 'image/jpg', 'image/jpeg'], - maxSize: 5 * 1024 * 1024, // 5MB in bytes, - })) - .exhaustive(); -}; - -const validateOrThrowFromConfig = ( - input: UploadSignedUrlInput, - configuration: ReturnType -) => { - if (input.size >= configuration.maxSize) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `File size is too big ${input.size}/${configuration.maxSize}`, - }); - } - - if (!configuration.fileTypes.includes(input.fileType)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `Incorrect file type ${input.fileType} (authorized: ${configuration.fileTypes.join(',')})`, - }); - } -}; +import { createTRPCRouter, protectedProcedure } from '@/server/config/trpc'; export const filesRouter = createTRPCRouter({ uploadPresignedUrl: protectedProcedure() @@ -56,12 +22,31 @@ export const filesRouter = createTRPCRouter({ .input(zUploadSignedUrlInput()) .output(zUploadSignedUrlOutput()) .mutation(async ({ input, ctx }) => { - const config = getConfiguration(input, ctx); + const config = FILES_COLLECTIONS_CONFIG[input.collection]; + + if (!config) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `No collection ${input.collection}`, + }); + } + + if (config.maxSize && input.size >= config.maxSize) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `File size is too big ${input.size}/${config.maxSize}`, + }); + } - validateOrThrowFromConfig(input, config); + if (config.fileTypes && !config.fileTypes.includes(input.fileType)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Incorrect file type ${input.fileType} (authorized: ${config.fileTypes.join(',')})`, + }); + } return await getS3UploadSignedUrl({ - key: config.key, + key: config.getKey({ user: ctx.user }), metadata: input.metadata ? { name: input.name, ...parse(input.metadata) } : undefined, From 833eab908cf81f69fbf573913b44651ea2549381 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Wed, 19 Feb 2025 14:30:14 +0100 Subject: [PATCH 18/27] fix: use same validation in server and client --- .../Form/FieldUpload/FieldUpload.spec.tsx | 6 +-- .../Form/FieldUpload/docs.stories.tsx | 2 +- src/features/account/schemas.ts | 2 +- src/lib/s3/client.ts | 2 +- src/lib/s3/config.ts | 23 +++++----- src/lib/s3/schemas.ts | 40 ++++++++--------- src/lib/s3/utils.ts | 44 +++++++++++++++++++ src/locales/ar/common.json | 8 +++- src/locales/en/common.json | 5 ++- src/locales/fr/common.json | 11 +++-- src/locales/sw/common.json | 8 +++- src/server/config/s3.ts | 12 ++--- src/server/routers/files.ts | 13 +++--- 13 files changed, 115 insertions(+), 61 deletions(-) create mode 100644 src/lib/s3/utils.ts diff --git a/src/components/Form/FieldUpload/FieldUpload.spec.tsx b/src/components/Form/FieldUpload/FieldUpload.spec.tsx index 2ade79426..d9529357d 100644 --- a/src/components/Form/FieldUpload/FieldUpload.spec.tsx +++ b/src/components/Form/FieldUpload/FieldUpload.spec.tsx @@ -17,7 +17,7 @@ const mockFile: FieldUploadValue = { file: mockFileRaw, lastModified: mockFileRaw.lastModified, lastModifiedDate: new Date(mockFileRaw.lastModified), - size: mockFileRaw.size.toString(), + size: mockFileRaw.size, type: mockFileRaw.type, name: mockFileRaw.name ?? '', }; @@ -28,7 +28,7 @@ test('update value', async () => { render( @@ -59,7 +59,7 @@ test('default value', async () => { render( >; const zFormSchema = () => z.object({ - file: zFieldUploadValue().optional(), + file: zFieldUploadValue('avatar').optional(), }); const formOptions = { diff --git a/src/features/account/schemas.ts b/src/features/account/schemas.ts index 46af94de6..8d4c0994b 100644 --- a/src/features/account/schemas.ts +++ b/src/features/account/schemas.ts @@ -45,5 +45,5 @@ export const zFormFieldsAccountProfile = () => language: true, }) .extend({ - image: zFieldUploadValue(['image']).nullish(), + image: zFieldUploadValue('avatar').nullish(), }); diff --git a/src/lib/s3/client.ts b/src/lib/s3/client.ts index e68d34824..54a2f0996 100644 --- a/src/lib/s3/client.ts +++ b/src/lib/s3/client.ts @@ -21,7 +21,7 @@ export const useUploadFileMutation = ( ...params.getMetadata?.(file), }), collection, - fileType: file.type, + type: file.type, size: file.size, name: file.name, }); diff --git a/src/lib/s3/config.ts b/src/lib/s3/config.ts index cf33ab8e0..ad6e8f636 100644 --- a/src/lib/s3/config.ts +++ b/src/lib/s3/config.ts @@ -2,21 +2,22 @@ import { z } from 'zod'; import { User } from '@/features/users/schemas'; -export type FilesCollection = z.infer>; -export const zFilesCollection = () => z.enum(['avatar']); +export type FilesCollectionName = z.infer< + ReturnType +>; +export const zFilesCollectionName = () => z.enum(['avatar']); // TODO Also use this config in form validation export const FILES_COLLECTIONS_CONFIG = { avatar: { getKey: ({ user }) => `avatars/${user.id}`, - fileTypes: ['image/png', 'image/jpg', 'image/jpeg'], + allowedTypes: ['image/png', 'image/jpg', 'image/jpeg'], maxSize: 1 * 1024 * 1024, // 5MB in bytes, }, -} satisfies Record< - FilesCollection, - { - getKey: (params: { user: User }) => string; - fileTypes?: Array; - maxSize?: number; - } ->; +} 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 index df2128c1b..63c938d8f 100644 --- a/src/lib/s3/schemas.ts +++ b/src/lib/s3/schemas.ts @@ -1,43 +1,43 @@ import { t } from 'i18next'; import { z } from 'zod'; -import { zFilesCollection } from '@/lib/s3/config'; +import { getFieldPath } from '@/lib/form/getFieldPath'; +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 UploadFileType = z.infer; -export const zUploadFileType = z.enum(['image', 'application/pdf']); - export type FieldMetadata = z.infer>; export const zFieldMetadata = () => z.object({ fileUrl: zu.string.nonEmptyNullish(z.string()), lastModifiedDate: z.date().optional(), name: zu.string.nonEmptyNullish(z.string()), - size: zu.string.nonEmptyNullish(z.string()), + size: z.coerce.number().nullish(), type: zu.string.nonEmptyNullish(z.string()), }); export type FieldUploadValue = z.infer>; -export const zFieldUploadValue = (acceptedTypes?: UploadFileType[]) => +export const zFieldUploadValue = (collection: FilesCollectionName) => zFieldMetadata() .extend({ file: z.instanceof(File).optional(), lastModified: z.number().optional(), }) - .refine( - (file) => { - if (!acceptedTypes || acceptedTypes.length === 0) { - return true; - } + .superRefine((input, ctx) => { + const config = FILES_COLLECTIONS_CONFIG[collection]; + const validateFileReturn = validateFile({ input, config }); - return acceptedTypes.some((type) => file.type?.startsWith(type)); - }, - { - message: t('common:files.invalid', { - acceptedTypes: acceptedTypes?.join(', '), - }), + if (!validateFileReturn.success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t(`files.errors.${validateFileReturn.error.key}`), + }); } - ); + }); export type UploadSignedUrlInput = z.infer< ReturnType @@ -49,9 +49,9 @@ export const zUploadSignedUrlInput = () => */ metadata: z.string().optional(), name: z.string(), - fileType: z.string(), + type: z.string(), size: z.number(), - collection: zFilesCollection(), + collection: zFilesCollectionName(), }); export type UploadSignedUrlOutput = z.infer< ReturnType diff --git a/src/lib/s3/utils.ts b/src/lib/s3/utils.ts new file mode 100644 index 000000000..904b2988c --- /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 ?? '') + ) { + 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/common.json b/src/locales/en/common.json index 60fbcabfc..225a92c8b 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -21,7 +21,10 @@ "cancelText": "Stay on the page" }, "files": { - "invalid": "Provided file must be of type : {{acceptedTypes}}" + "errors": { + "tooLarge": "File too large", + "typeNotAllowed": "File type not allowed" + } }, "filter": "Filter", "clear": "Clear", diff --git a/src/locales/fr/common.json b/src/locales/fr/common.json index ddda086f0..524a73ae9 100644 --- a/src/locales/fr/common.json +++ b/src/locales/fr/common.json @@ -20,10 +20,13 @@ "message": "Vous êtes sur le point de quitter la page sans sauvegarder vos modifications.", "title": "Quitter la page ?" }, - "files": { - "invalid": "Le fichier doit être de type : {{acceptedTypes}}" - }, "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 index f8d8d0fcc..863a08aeb 100644 --- a/src/server/config/s3.ts +++ b/src/server/config/s3.ts @@ -6,11 +6,7 @@ import { import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { env } from '@/env.mjs'; -import { - FieldMetadata, - UploadFileType, - UploadSignedUrlOutput, -} from '@/lib/s3/schemas'; +import { FieldMetadata, UploadSignedUrlOutput } from '@/lib/s3/schemas'; const SIGNED_URL_EXPIRATION_TIME_SECONDS = 60; // 1 minute @@ -24,8 +20,6 @@ const S3 = new S3Client({ }); type UploadSignedUrlOptions = { - allowedFileTypes?: UploadFileType[]; - expiresIn?: number; /** The tree structure of the file in S3 */ key: string; metadata?: Record; @@ -41,7 +35,7 @@ export const getS3UploadSignedUrl = async ( Key: options.key, Metadata: options.metadata, }), - { expiresIn: options.expiresIn ?? SIGNED_URL_EXPIRATION_TIME_SECONDS } + { expiresIn: SIGNED_URL_EXPIRATION_TIME_SECONDS } ); return { @@ -62,7 +56,7 @@ export const fetchFileMetadata = async (key: string) => { return { fileUrl, - size: fileResponse.ContentLength?.toString(), + size: fileResponse.ContentLength, type: fileResponse.ContentType, lastModifiedDate: fileResponse.LastModified ? new Date(fileResponse.LastModified) diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts index b4ca39992..62791d7f8 100644 --- a/src/server/routers/files.ts +++ b/src/server/routers/files.ts @@ -1,4 +1,5 @@ import { TRPCError } from '@trpc/server'; +import { t } from 'i18next'; import { parse } from 'superjson'; import { FILES_COLLECTIONS_CONFIG } from '@/lib/s3/config'; @@ -6,6 +7,7 @@ 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'; @@ -31,17 +33,12 @@ export const filesRouter = createTRPCRouter({ }); } - if (config.maxSize && input.size >= config.maxSize) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `File size is too big ${input.size}/${config.maxSize}`, - }); - } + const validateFileResult = validateFile({ input, config }); - if (config.fileTypes && !config.fileTypes.includes(input.fileType)) { + if (!validateFileResult.success) { throw new TRPCError({ code: 'BAD_REQUEST', - message: `Incorrect file type ${input.fileType} (authorized: ${config.fileTypes.join(',')})`, + message: validateFileResult.error.message, }); } From 983cde7239408291989dfc1c685331a314959ed3 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Fri, 21 Feb 2025 10:24:08 +0100 Subject: [PATCH 19/27] fix: file upload field elipsis --- src/components/Form/FieldUpload/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/Form/FieldUpload/index.tsx b/src/components/Form/FieldUpload/index.tsx index 55d42e49f..278c25c0a 100644 --- a/src/components/Form/FieldUpload/index.tsx +++ b/src/components/Form/FieldUpload/index.tsx @@ -77,7 +77,11 @@ export const FieldUpload = < ) : ( )} - {!props.isLoading && (!value ? props.inputText : value.name)} + {!props.isLoading && ( + + {!value ? props.inputText : value.name} + + )} From 4e999800c60e1be766496ad7959936b3af558ef2 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Fri, 21 Feb 2025 16:55:34 +0100 Subject: [PATCH 20/27] fix: remove unused imports --- src/lib/s3/schemas.ts | 1 - src/server/routers/files.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/lib/s3/schemas.ts b/src/lib/s3/schemas.ts index 63c938d8f..c7dc90b73 100644 --- a/src/lib/s3/schemas.ts +++ b/src/lib/s3/schemas.ts @@ -1,7 +1,6 @@ import { t } from 'i18next'; import { z } from 'zod'; -import { getFieldPath } from '@/lib/form/getFieldPath'; import { FILES_COLLECTIONS_CONFIG, FilesCollectionName, diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts index 62791d7f8..50155060c 100644 --- a/src/server/routers/files.ts +++ b/src/server/routers/files.ts @@ -1,5 +1,4 @@ import { TRPCError } from '@trpc/server'; -import { t } from 'i18next'; import { parse } from 'superjson'; import { FILES_COLLECTIONS_CONFIG } from '@/lib/s3/config'; From 599c6898efa06ef3cde50896f70b9a19a892ea77 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 24 Feb 2025 11:19:55 +0100 Subject: [PATCH 21/27] fix: fetch metadata error response --- src/lib/s3/config.ts | 1 - src/lib/s3/schemas.ts | 1 - src/server/config/s3.ts | 35 ++++++++++++++++------------------ src/server/routers/account.tsx | 8 ++++++-- 4 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/lib/s3/config.ts b/src/lib/s3/config.ts index ad6e8f636..4103660ca 100644 --- a/src/lib/s3/config.ts +++ b/src/lib/s3/config.ts @@ -7,7 +7,6 @@ export type FilesCollectionName = z.infer< >; export const zFilesCollectionName = () => z.enum(['avatar']); -// TODO Also use this config in form validation export const FILES_COLLECTIONS_CONFIG = { avatar: { getKey: ({ user }) => `avatars/${user.id}`, diff --git a/src/lib/s3/schemas.ts b/src/lib/s3/schemas.ts index c7dc90b73..de693758a 100644 --- a/src/lib/s3/schemas.ts +++ b/src/lib/s3/schemas.ts @@ -12,7 +12,6 @@ import { zu } from '@/lib/zod/zod-utils'; export type FieldMetadata = z.infer>; export const zFieldMetadata = () => z.object({ - fileUrl: zu.string.nonEmptyNullish(z.string()), lastModifiedDate: z.date().optional(), name: zu.string.nonEmptyNullish(z.string()), size: z.coerce.number().nullish(), diff --git a/src/server/config/s3.ts b/src/server/config/s3.ts index 863a08aeb..86290c8d9 100644 --- a/src/server/config/s3.ts +++ b/src/server/config/s3.ts @@ -44,9 +44,10 @@ export const getS3UploadSignedUrl = async ( }; }; -export const fetchFileMetadata = async (key: string) => { +export const fetchFileMetadata = async ( + key: string +): Promise<{ success: true; data: FieldMetadata } | { success: false }> => { const s3key = key.split('?')[0]; // Remove the ?timestamp - const fileUrl = `${env.NEXT_PUBLIC_S3_BUCKET_PUBLIC_URL}/${s3key}`; try { const command = new HeadObjectCommand({ Bucket: env.S3_BUCKET_NAME, @@ -55,23 +56,19 @@ export const fetchFileMetadata = async (key: string) => { const fileResponse = await S3.send(command); return { - fileUrl, - size: fileResponse.ContentLength, - type: fileResponse.ContentType, - lastModifiedDate: fileResponse.LastModified - ? new Date(fileResponse.LastModified) - : undefined, - name: fileResponse.Metadata?.name, - } satisfies FieldMetadata; - } catch (e) { - // TODO Better error handle - console.error('------- ERROR ------', e); + success: true, + data: { + size: fileResponse.ContentLength, + type: fileResponse.ContentType, + lastModifiedDate: fileResponse.LastModified + ? new Date(fileResponse.LastModified) + : undefined, + name: fileResponse.Metadata?.name, + }, + }; + } catch { return { - fileUrl, - size: undefined, - type: undefined, - lastModifiedDate: undefined, - name: undefined, - } satisfies FieldMetadata; + success: false, + }; } }; diff --git a/src/server/routers/account.tsx b/src/server/routers/account.tsx index aa0b733c4..d9a08e313 100644 --- a/src/server/routers/account.tsx +++ b/src/server/routers/account.tsx @@ -38,10 +38,14 @@ export const accountRouter = createTRPCRouter({ .query(async ({ ctx }) => { ctx.logger.info('Return the current user'); + const imageMetadataResponse = ctx.user.image + ? await fetchFileMetadata(ctx.user.image) + : undefined; + return { ...ctx.user, - imageMetadata: ctx.user.image - ? await fetchFileMetadata(ctx.user.image) + imageMetadata: imageMetadataResponse?.success + ? imageMetadataResponse.data : undefined, }; }), From 74a947bed8ba414ab1119c56f2c66a8fe3e0efd4 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 24 Feb 2025 11:32:04 +0100 Subject: [PATCH 22/27] fix: use placeholder instead of inputText --- e2e/avatar-upload.spec.ts | 4 ++-- src/components/Form/FieldUpload/FieldUpload.spec.tsx | 2 +- src/components/Form/FieldUpload/index.tsx | 5 ++--- src/features/account/AccountProfileForm.tsx | 2 +- src/locales/en/account.json | 2 +- src/locales/fr/account.json | 2 +- 6 files changed, 8 insertions(+), 9 deletions(-) diff --git a/e2e/avatar-upload.spec.ts b/e2e/avatar-upload.spec.ts index e242714b2..9e226b4d9 100644 --- a/e2e/avatar-upload.spec.ts +++ b/e2e/avatar-upload.spec.ts @@ -24,12 +24,12 @@ test.describe('Avatar upload flow', () => { await page.waitForURL(`**${ROUTES_ACCOUNT.app.root()}`); await expect( - page.getByText(locales.en.account.data.avatar.inputText) + page.getByText(locales.en.account.data.avatar.placeholder) ).toBeVisible(); const fileChooserPromise = page.waitForEvent('filechooser'); - await page.getByText(locales.en.account.data.avatar.inputText).click(); + await page.getByText(locales.en.account.data.avatar.placeholder).click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles('./public/android-chrome-192x192.png'); diff --git a/src/components/Form/FieldUpload/FieldUpload.spec.tsx b/src/components/Form/FieldUpload/FieldUpload.spec.tsx index d9529357d..a8df34ad1 100644 --- a/src/components/Form/FieldUpload/FieldUpload.spec.tsx +++ b/src/components/Form/FieldUpload/FieldUpload.spec.tsx @@ -39,7 +39,7 @@ test('update value', async () => { type="upload" name="file" control={form.control} - inputText="Upload" + placeholder="Upload" /> )} diff --git a/src/components/Form/FieldUpload/index.tsx b/src/components/Form/FieldUpload/index.tsx index 278c25c0a..6fe8b8222 100644 --- a/src/components/Form/FieldUpload/index.tsx +++ b/src/components/Form/FieldUpload/index.tsx @@ -14,7 +14,6 @@ export type FieldUploadProps< TName extends FieldPath = FieldPath, > = { type: 'upload'; - inputText?: string; isLoading?: boolean; } & InputRootProps & FieldCommonProps; @@ -78,8 +77,8 @@ export const FieldUpload = < )} {!props.isLoading && ( - - {!value ? props.inputText : value.name} + + {!value ? props.placeholder ?? '...' : value.name} )} diff --git a/src/features/account/AccountProfileForm.tsx b/src/features/account/AccountProfileForm.tsx index 1c4524c55..874361943 100644 --- a/src/features/account/AccountProfileForm.tsx +++ b/src/features/account/AccountProfileForm.tsx @@ -94,7 +94,7 @@ export const AccountProfileForm = () => { name="image" type="upload" control={form.control} - inputText={t('account:data.avatar.inputText')} + placeholder={t('account:data.avatar.placeholder')} /> diff --git a/src/locales/en/account.json b/src/locales/en/account.json index 1c7c381f3..ec008c516 100644 --- a/src/locales/en/account.json +++ b/src/locales/en/account.json @@ -74,7 +74,7 @@ "data": { "avatar": { "label": "Avatar", - "inputText": "Update avatar...", + "placeholder": "Update avatar...", "required": "Avatar is required" }, "name": { diff --git a/src/locales/fr/account.json b/src/locales/fr/account.json index a03ba1ae5..f81c04f40 100644 --- a/src/locales/fr/account.json +++ b/src/locales/fr/account.json @@ -24,7 +24,7 @@ "data": { "avatar": { "label": "Avatar", - "inputText": "Modifier l'avatar...", + "placeholder": "Modifier l'avatar...", "required": "L'avatar est requis" }, "name": { From 8f69e89da32733f9308cf87830cea4f8a5f8a849 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 24 Feb 2025 11:36:49 +0100 Subject: [PATCH 23/27] feat: add accept on file upload field --- src/components/Form/FieldUpload/index.tsx | 2 ++ src/features/account/AccountProfileForm.tsx | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/components/Form/FieldUpload/index.tsx b/src/components/Form/FieldUpload/index.tsx index 6fe8b8222..814c88da9 100644 --- a/src/components/Form/FieldUpload/index.tsx +++ b/src/components/Form/FieldUpload/index.tsx @@ -15,6 +15,7 @@ export type FieldUploadProps< > = { type: 'upload'; isLoading?: boolean; + accept?: string; } & InputRootProps & FieldCommonProps; @@ -69,6 +70,7 @@ export const FieldUpload = < onChange={handleChange} type="file" disabled={isFieldUploadDisabled} + accept={props.accept} {...fieldProps} /> {props.isLoading ? ( diff --git a/src/features/account/AccountProfileForm.tsx b/src/features/account/AccountProfileForm.tsx index 874361943..c7eeba284 100644 --- a/src/features/account/AccountProfileForm.tsx +++ b/src/features/account/AccountProfileForm.tsx @@ -23,6 +23,7 @@ import { DEFAULT_LANGUAGE_KEY, } from '@/lib/i18n/constants'; import { useUploadFileMutation } from '@/lib/s3/client'; +import { FILES_COLLECTIONS_CONFIG } from '@/lib/s3/config'; import { trpc } from '@/lib/trpc/client'; export const AccountProfileForm = () => { @@ -95,6 +96,9 @@ export const AccountProfileForm = () => { type="upload" control={form.control} placeholder={t('account:data.avatar.placeholder')} + accept={FILES_COLLECTIONS_CONFIG['avatar'].allowedTypes.join( + ',' + )} /> From dbac239c63b7f462c6be1b5f9084c1f76049b36b Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 24 Feb 2025 12:06:49 +0100 Subject: [PATCH 24/27] fix: wrong file size for avatar --- src/lib/s3/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/s3/config.ts b/src/lib/s3/config.ts index 4103660ca..13c5a84b8 100644 --- a/src/lib/s3/config.ts +++ b/src/lib/s3/config.ts @@ -11,7 +11,7 @@ export const FILES_COLLECTIONS_CONFIG = { avatar: { getKey: ({ user }) => `avatars/${user.id}`, allowedTypes: ['image/png', 'image/jpg', 'image/jpeg'], - maxSize: 1 * 1024 * 1024, // 5MB in bytes, + maxSize: 5 * 1024 * 1024, // 5MB in bytes, }, } satisfies Record; From 36922cbdeb93c987ee70b5895e478d11b3acb859 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 24 Feb 2025 12:41:59 +0100 Subject: [PATCH 25/27] fix: simple usage --- src/features/account/AccountProfileForm.tsx | 53 +++++++++-------- src/lib/s3/client.ts | 64 ++++++++++----------- src/lib/s3/utils.ts | 4 +- 3 files changed, 59 insertions(+), 62 deletions(-) diff --git a/src/features/account/AccountProfileForm.tsx b/src/features/account/AccountProfileForm.tsx index c7eeba284..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,7 +23,7 @@ import { AVAILABLE_LANGUAGES, DEFAULT_LANGUAGE_KEY, } from '@/lib/i18n/constants'; -import { useUploadFileMutation } from '@/lib/s3/client'; +import { uploadFile } from '@/lib/s3/client'; import { FILES_COLLECTIONS_CONFIG } from '@/lib/s3/config'; import { trpc } from '@/lib/trpc/client'; @@ -33,9 +34,24 @@ export const AccountProfileForm = () => { staleTime: Infinity, }); - const uploadAvatar = useUploadFileMutation('avatar'); - - 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({ @@ -61,31 +77,18 @@ export const AccountProfileForm = () => { }, }); - const onSubmit: SubmitHandler = async ({ - image, - ...values - }) => { - try { - updateAccount.mutate({ - ...values, - image: image?.file - ? await uploadAvatar.mutateAsync(image.file) - : account.data?.image, - }); - } catch { - form.setError('image', { - message: t('account:profile.feedbacks.uploadError.title'), - }); - } - }; - return ( <> {account.isLoading && } {account.isError && } {account.isSuccess && ( - + { + updateAccount.mutate(values); + }} + > @@ -129,7 +132,7 @@ export const AccountProfileForm = () => { diff --git a/src/lib/s3/client.ts b/src/lib/s3/client.ts index 54a2f0996..8dfc13220 100644 --- a/src/lib/s3/client.ts +++ b/src/lib/s3/client.ts @@ -1,48 +1,42 @@ -import { useMutation } from '@tanstack/react-query'; -import { TRPCError } from '@trpc/server'; import { stringify } from 'superjson'; import { env } from '@/env.mjs'; import { trpc } from '@/lib/trpc/client'; import { RouterInputs } from '@/lib/trpc/types'; -export const useUploadFileMutation = ( - collection: RouterInputs['files']['uploadPresignedUrl']['collection'], - params: { - getMetadata?: (file: File) => Record; - } = {} -) => { - const uploadPresignedUrl = trpc.files.uploadPresignedUrl.useMutation(); - return useMutation({ - mutationFn: async (file: File) => { - const presignedUrlOutput = await uploadPresignedUrl.mutateAsync({ +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 is a Record but should be serialized for trpc-openapi - metadata: stringify({ - ...params.getMetadata?.(file), - }), - collection, - type: file.type, - size: file.size, - name: file.name, + metadata: params.metadata ? stringify(params.metadata) : undefined, + collection: params.collection, + type: params.file.type, + size: params.file.size, + name: params.file.name, }); - try { - await fetch(presignedUrlOutput.signedUrl, { - method: 'PUT', - headers: { 'Content-Type': file.type }, - body: file, - }); - } catch (e) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Unable to upload the file', - cause: e, - }); - } + const response = await fetch(presignedUrlOutput.signedUrl, { + method: 'PUT', + headers: { 'Content-Type': params.file.type }, + body: params.file, + }); - return presignedUrlOutput.key; - }, - }); + 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) => { diff --git a/src/lib/s3/utils.ts b/src/lib/s3/utils.ts index 904b2988c..1a08f973f 100644 --- a/src/lib/s3/utils.ts +++ b/src/lib/s3/utils.ts @@ -17,7 +17,7 @@ export const validateFile = (params: { }): ValidateReturn => { if ( params.config.maxSize && - (params.input.size ?? 0) >= params.config.maxSize + (params.input.size ?? 0) > params.config.maxSize ) { return { error: { @@ -30,7 +30,7 @@ export const validateFile = (params: { if ( params.config.allowedTypes && - !params.config.allowedTypes.includes(params.input.type ?? '') + !params.config.allowedTypes.includes(params.input.type?.toLowerCase() ?? '') ) { return { error: { From 98853476b60ed6674fbd1fe5666af52085bbed78 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Wed, 5 Mar 2025 17:19:22 -0500 Subject: [PATCH 26/27] fix: docker compose port issue --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4332e2edc..aed0c2d93 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ services: postgres: condition: service_healthy environment: - DATABASE_URL: 'postgres://${DOCKER_DATABASE_USERNAME}:${DOCKER_DATABASE_PASSWORD}@postgres:${DOCKER_DATABASE_PORT}/${DOCKER_DATABASE_NAME}' + 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' From 602d924c0540127abd72df5299cf1284024acb93 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Thu, 6 Mar 2025 08:07:19 -0500 Subject: [PATCH 27/27] fix: metadata schema --- src/lib/s3/client.ts | 5 +---- src/lib/s3/schemas.ts | 10 +++++----- src/server/routers/files.ts | 3 +-- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/lib/s3/client.ts b/src/lib/s3/client.ts index 8dfc13220..4ed2416a1 100644 --- a/src/lib/s3/client.ts +++ b/src/lib/s3/client.ts @@ -1,5 +1,3 @@ -import { stringify } from 'superjson'; - import { env } from '@/env.mjs'; import { trpc } from '@/lib/trpc/client'; import { RouterInputs } from '@/lib/trpc/types'; @@ -14,8 +12,7 @@ export const uploadFile = async (params: { try { const presignedUrlOutput = await params.trpcClient.files.uploadPresignedUrl.mutate({ - // Metadata is a Record but should be serialized for trpc-openapi - metadata: params.metadata ? stringify(params.metadata) : undefined, + metadata: params.metadata, collection: params.collection, type: params.file.type, size: params.file.size, diff --git a/src/lib/s3/schemas.ts b/src/lib/s3/schemas.ts index de693758a..0e035b391 100644 --- a/src/lib/s3/schemas.ts +++ b/src/lib/s3/schemas.ts @@ -42,11 +42,11 @@ export type UploadSignedUrlInput = z.infer< >; export const zUploadSignedUrlInput = () => z.object({ - /** - * Must be a string as trpc-openapi requires that attributes must be serialized - */ - metadata: z.string().optional(), - name: z.string(), + 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(), diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts index 50155060c..92cabafcf 100644 --- a/src/server/routers/files.ts +++ b/src/server/routers/files.ts @@ -1,5 +1,4 @@ import { TRPCError } from '@trpc/server'; -import { parse } from 'superjson'; import { FILES_COLLECTIONS_CONFIG } from '@/lib/s3/config'; import { @@ -44,7 +43,7 @@ export const filesRouter = createTRPCRouter({ return await getS3UploadSignedUrl({ key: config.getKey({ user: ctx.user }), metadata: input.metadata - ? { name: input.name, ...parse(input.metadata) } + ? { name: input.name, ...input.metadata } : undefined, }); }),