diff --git a/.vscode/settings.json b/.vscode/settings.json index e664ce2a..84c45555 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,7 @@ "ARROWRIGHT", "Astia", "camelcase", + "cloudflarestorage", "CredentialsSignin", "Eterna", "exif", diff --git a/README.md b/README.md index 919f2553..8ebd954d 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,42 @@ Installation - `NEXT_PUBLIC_GRID_ASPECT_RATIO = 1.5` sets aspect ratio for grid tiles (defaults to `1`—setting to `0` removes the constraint) - `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top) -### Setup alternate storage +## Alternate storage providers -#### AWS S3 +Only one storage adapter—Vercel Blob, Cloudflare R2, or AWS S3—can be used at a time. Ideally, this is configured before photos are uploaded (see [Issue #34](https://github.com/sambecker/exif-photo-blog/issues/34) for migration considerations). If you have multiple adapters, you can set one as preferred by storing "aws-s3," "cloudflare-r2," or "vercel-blob" in `NEXT_PUBLIC_STORAGE_PREFERENCE`. + +### Cloudflare R2 + +1. Setup bucket + - [Create R2 bucket](https://developers.cloudflare.com/r2/) with default settings + - Setup CORS under bucket settings: + ```json + [{ + "AllowedHeaders": ["*"] + "AllowedOrigins": [ + "http://localhost:3000", + "https://{VERCEL_PROJECT_NAME}*.vercel.app", + "{PRODUCTION_DOMAIN}" + ], + "AllowedMethods": [ + "GET", + "PUT" + ], + }] + ``` + - Enable R2.dev subdomain (necessary in order to serve files publicly without a custom domain) + - Store configuration: + - `NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET`: bucket name + - `NEXT_PUBLIC_CLOUDFLARE_R2_ACCOUNT_ID`: account id (found on R2 overview page) + - `NEXT_PUBLIC_CLOUDFLARE_R2_DEV_SUBDOMAIN`: r2.dev subdomain, e.g., "pub-jf90908..." +2. Setup credentials + - Create API token by selecting "Manage R2 API Tokens," and clicking "Create API Token" + - Select "Object Read & Write," choose "Apply to specific buckets only," and select the bucket created in Step 1. + - Store credentials (⚠️ _Ensure access keys are not prefixed with `NEXT_PUBLIC`_): + - `CLOUDFLARE_R2_ACCESS_KEY` + - `CLOUDFLARE_R2_SECRET_ACCESS_KEY` + +### AWS S3 1. Setup bucket - [Create S3 bucket](https://s3.console.aws.amazon.com/s3) with "ACLs enabled," and "Block all public access" turned off diff --git a/next.config.js b/next.config.js index 7852092f..b8999f9a 100644 --- a/next.config.js +++ b/next.config.js @@ -6,6 +6,11 @@ const VERCEL_BLOB_HOSTNAME = VERCEL_BLOB_STORE_ID ? `${VERCEL_BLOB_STORE_ID}.public.blob.vercel-storage.com` : undefined; +const CLOUDFLARE_R2_HOSTNAME = + process.env.NEXT_PUBLIC_CLOUDFLARE_R2_DEV_SUBDOMAIN + ? `${process.env.NEXT_PUBLIC_CLOUDFLARE_R2_DEV_SUBDOMAIN}.r2.dev` + : undefined; + const AWS_S3_HOSTNAME = process.env.NEXT_PUBLIC_AWS_S3_BUCKET && process.env.NEXT_PUBLIC_AWS_S3_REGION @@ -28,6 +33,7 @@ const nextConfig = { imageSizes: [200], remotePatterns: [] .concat(createRemotePattern(VERCEL_BLOB_HOSTNAME)) + .concat(createRemotePattern(CLOUDFLARE_R2_HOSTNAME)) .concat(createRemotePattern(AWS_S3_HOSTNAME)), minimumCacheTTL: 31536000, }, diff --git a/package.json b/package.json index 5a3309e5..f7bef977 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "autoprefixer": "10.4.17", "camelcase-keys": "^9.1.3", "clsx": "^2.1.0", - "date-fns": "^3.2.0", + "date-fns": "^3.3.0", "eslint": "8.56.0", "eslint-config-next": "14.1.0", "exifr": "^7.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9daf19bf..2ac050f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,8 @@ dependencies: '@next/bundle-analyzer': specifier: 14.1.0 version: 14.1.0 + specifier: 14.1.0 + version: 14.1.0 '@tailwindcss/forms': specifier: ^0.5.7 version: 0.5.7(tailwindcss@3.4.1) @@ -32,24 +34,36 @@ dependencies: '@types/node': specifier: ^20.11.5 version: 20.11.5 + specifier: ^20.11.5 + version: 20.11.5 '@types/react': specifier: 18.2.48 version: 18.2.48 + specifier: 18.2.48 + version: 18.2.48 '@types/react-dom': specifier: 18.2.18 version: 18.2.18 '@typescript-eslint/eslint-plugin': specifier: ^6.19.0 version: 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3) + specifier: ^6.19.0 + version: 6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/parser': specifier: ^6.19.0 version: 6.19.0(eslint@8.56.0)(typescript@5.3.3) + specifier: ^6.19.0 + version: 6.19.0(eslint@8.56.0)(typescript@5.3.3) '@vercel/analytics': specifier: ^1.1.2 version: 1.1.2 + specifier: ^1.1.2 + version: 1.1.2 '@vercel/blob': specifier: ^0.19.0 version: 0.19.0 + specifier: ^0.19.0 + version: 0.19.0 '@vercel/postgres': specifier: 0.5.1 version: 0.5.1 @@ -59,21 +73,27 @@ dependencies: autoprefixer: specifier: 10.4.17 version: 10.4.17(postcss@8.4.33) + specifier: 10.4.17 + version: 10.4.17(postcss@8.4.33) camelcase-keys: specifier: ^9.1.3 version: 9.1.3 + specifier: ^9.1.3 + version: 9.1.3 clsx: specifier: ^2.1.0 version: 2.1.0 date-fns: - specifier: ^3.2.0 - version: 3.2.0 + specifier: ^3.3.0 + version: 3.3.0 eslint: specifier: 8.56.0 version: 8.56.0 eslint-config-next: specifier: 14.1.0 version: 14.1.0(eslint@8.56.0)(typescript@5.3.3) + specifier: 14.1.0 + version: 14.1.0(eslint@8.56.0)(typescript@5.3.3) exifr: specifier: ^7.1.3 version: 7.1.3 @@ -83,6 +103,7 @@ dependencies: jest: specifier: ^29.7.0 version: 29.7.0(@types/node@20.11.5) + version: 29.7.0(@types/node@20.11.5) jest-environment-jsdom: specifier: ^29.7.0 version: 29.7.0 @@ -147,8 +168,8 @@ packages: '@jridgewell/trace-mapping': 0.3.20 dev: false - /@auth/core@0.18.4: - resolution: {integrity: sha512-GsNhsP1xE/3FoNS3dVkPjqRljLNJ4iyL2OLv3klQGNvw3bMpROFcK4lqhx7+pPHiamnVaYt2vg1xbB+lsNaevg==} + /@auth/core@0.21.0: + resolution: {integrity: sha512-jUWYs8gjy2GvtP9dd/4S9KcwZ660Cm/IkybiAq96/2Ooku9SKk5SUG+UTEwkyLuaQ38ZgfwggfpDOgzsXEcufA==} peerDependencies: nodemailer: ^6.8.0 peerDependenciesMeta: @@ -156,6 +177,7 @@ packages: optional: true dependencies: '@panva/hkdf': 1.1.1 + '@types/cookie': 0.6.0 cookie: 0.6.0 jose: 5.2.0 oauth4webapi: 2.4.0 @@ -700,6 +722,8 @@ packages: tslib: 2.6.2 dev: false + /@aws-sdk/util-arn-parser@3.495.0: + resolution: {integrity: sha512-hwdA3XAippSEUxs7jpznwD63YYFR+LtQvlEcebPTgWR9oQgG9TfS+39PUfbnEeje1ICuOrN3lrFqFbmP9uzbMg==} /@aws-sdk/util-arn-parser@3.495.0: resolution: {integrity: sha512-hwdA3XAippSEUxs7jpznwD63YYFR+LtQvlEcebPTgWR9oQgG9TfS+39PUfbnEeje1ICuOrN3lrFqFbmP9uzbMg==} engines: {node: '>=14.0.0'} @@ -1113,6 +1137,11 @@ packages: engines: {node: '>=10.0.0'} dev: false + /@discoveryjs/json-ext@0.5.7: + resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} + engines: {node: '>=10.0.0'} + dev: false + /@emotion/is-prop-valid@0.8.8: resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==} requiresBuild: true @@ -1325,6 +1354,204 @@ packages: dev: false optional: true + /@esbuild/android-arm64@0.18.20: + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: false + optional: true + + /@esbuild/android-arm@0.18.20: + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: false + optional: true + + /@esbuild/android-x64@0.18.20: + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: false + optional: true + + /@esbuild/darwin-arm64@0.18.20: + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@esbuild/darwin-x64@0.18.20: + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@esbuild/freebsd-arm64@0.18.20: + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: false + optional: true + + /@esbuild/freebsd-x64@0.18.20: + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: false + optional: true + + /@esbuild/linux-arm64@0.18.20: + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@esbuild/linux-arm@0.18.20: + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@esbuild/linux-ia32@0.18.20: + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@esbuild/linux-loong64@0.18.20: + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@esbuild/linux-mips64el@0.18.20: + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@esbuild/linux-ppc64@0.18.20: + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@esbuild/linux-riscv64@0.18.20: + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@esbuild/linux-s390x@0.18.20: + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@esbuild/linux-x64@0.18.20: + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@esbuild/netbsd-x64@0.18.20: + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: false + optional: true + + /@esbuild/openbsd-x64@0.18.20: + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: false + optional: true + + /@esbuild/sunos-x64@0.18.20: + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: false + optional: true + + /@esbuild/win32-arm64@0.18.20: + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@esbuild/win32-ia32@0.18.20: + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@esbuild/win32-x64@0.18.20: + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@eslint-community/eslint-utils@4.4.0(eslint@8.56.0): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1477,6 +1704,7 @@ packages: dependencies: '@jest/types': 29.6.3 '@types/node': 20.11.5 + '@types/node': 20.11.5 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -1498,6 +1726,7 @@ packages: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@types/node': 20.11.5 + '@types/node': 20.11.5 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 @@ -1505,6 +1734,7 @@ packages: graceful-fs: 4.2.11 jest-changed-files: 29.7.0 jest-config: 29.7.0(@types/node@20.11.5) + jest-config: 29.7.0(@types/node@20.11.5) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -1533,6 +1763,7 @@ packages: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 '@types/node': 20.11.5 + '@types/node': 20.11.5 jest-mock: 29.7.0 dev: false @@ -1560,6 +1791,7 @@ packages: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 '@types/node': 20.11.5 + '@types/node': 20.11.5 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -1593,6 +1825,7 @@ packages: '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.20 '@types/node': 20.11.5 + '@types/node': 20.11.5 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -1681,6 +1914,7 @@ packages: '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 '@types/node': 20.11.5 + '@types/node': 20.11.5 '@types/yargs': 17.0.32 chalk: 4.1.2 dev: false @@ -1721,10 +1955,13 @@ packages: '@types/pg': 8.6.6 dev: false + /@next/bundle-analyzer@14.1.0: + resolution: {integrity: sha512-RJWjnlMp/1WSW0ahAdawV22WgJiC6BVaFS5Xfhw6gP7NJEX3cAJjh4JqSHKGr8GnLNRaFCVTQdDPoX84E421BA==} /@next/bundle-analyzer@14.1.0: resolution: {integrity: sha512-RJWjnlMp/1WSW0ahAdawV22WgJiC6BVaFS5Xfhw6gP7NJEX3cAJjh4JqSHKGr8GnLNRaFCVTQdDPoX84E421BA==} dependencies: webpack-bundle-analyzer: 4.10.1 + webpack-bundle-analyzer: 4.10.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -1744,8 +1981,11 @@ packages: resolution: {integrity: sha512-x4FavbNEeXx/baD/zC/SdrvkjSby8nBn8KcCREqk6UuwvwoAPZmaV8TFCAuo/cpovBRTIY67mHhe86MQQm/68Q==} dependencies: glob: 10.3.10 + glob: 10.3.10 dev: false + /@next/swc-darwin-arm64@14.1.0: + resolution: {integrity: sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ==} /@next/swc-darwin-arm64@14.1.0: resolution: {integrity: sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ==} engines: {node: '>= 10'} @@ -2673,6 +2913,7 @@ packages: css.escape: 1.5.1 dom-accessibility-api: 0.6.3 jest: 29.7.0(@types/node@20.11.5) + jest: 29.7.0(@types/node@20.11.5) lodash: 4.17.21 redent: 3.0.0 dev: false @@ -2745,6 +2986,7 @@ packages: resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} dependencies: '@types/node': 20.11.5 + '@types/node': 20.11.5 dev: false /@types/istanbul-lib-coverage@2.0.6: @@ -2773,6 +3015,7 @@ packages: /@types/jsdom@20.0.1: resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} dependencies: + '@types/node': 20.11.5 '@types/node': 20.11.5 '@types/tough-cookie': 4.0.5 parse5: 7.1.2 @@ -2786,6 +3029,8 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: false + /@types/node@20.11.5: + resolution: {integrity: sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==} /@types/node@20.11.5: resolution: {integrity: sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==} dependencies: @@ -2795,6 +3040,7 @@ packages: /@types/pg@8.6.6: resolution: {integrity: sha512-O2xNmXebtwVekJDD+02udOncjVcMZQuTEQEMpKJ0ZRf5E7/9JJX3izhKUcUifBkyKpljyUM6BTgy2trmviKlpw==} dependencies: + '@types/node': 20.11.5 '@types/node': 20.11.5 pg-protocol: 1.6.0 pg-types: 2.2.0 @@ -2808,8 +3054,11 @@ packages: resolution: {integrity: sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==} dependencies: '@types/react': 18.2.48 + '@types/react': 18.2.48 dev: false + /@types/react@18.2.48: + resolution: {integrity: sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==} /@types/react@18.2.48: resolution: {integrity: sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==} dependencies: @@ -2844,6 +3093,8 @@ packages: '@types/yargs-parser': 21.0.3 dev: false + /@typescript-eslint/eslint-plugin@6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3): + resolution: {integrity: sha512-DUCUkQNklCQYnrBSSikjVChdc84/vMPDQSgJTHBZ64G9bA9w0Crc0rd2diujKbTdp6w2J47qkeHQLoi0rpLCdg==} /@typescript-eslint/eslint-plugin@6.19.0(@typescript-eslint/parser@6.19.0)(eslint@8.56.0)(typescript@5.3.3): resolution: {integrity: sha512-DUCUkQNklCQYnrBSSikjVChdc84/vMPDQSgJTHBZ64G9bA9w0Crc0rd2diujKbTdp6w2J47qkeHQLoi0rpLCdg==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2861,6 +3112,11 @@ packages: '@typescript-eslint/type-utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.19.0 + '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/scope-manager': 6.19.0 + '@typescript-eslint/type-utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.19.0 debug: 4.3.4 eslint: 8.56.0 graphemer: 1.4.0 @@ -2873,6 +3129,8 @@ packages: - supports-color dev: false + /@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3): + resolution: {integrity: sha512-1DyBLG5SH7PYCd00QlroiW60YJ4rWMuUGa/JBV0iZuqi4l4IK3twKPq5ZkEebmGqRjXWVgsUzfd3+nZveewgow==} /@typescript-eslint/parser@6.19.0(eslint@8.56.0)(typescript@5.3.3): resolution: {integrity: sha512-1DyBLG5SH7PYCd00QlroiW60YJ4rWMuUGa/JBV0iZuqi4l4IK3twKPq5ZkEebmGqRjXWVgsUzfd3+nZveewgow==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2883,6 +3141,10 @@ packages: typescript: optional: true dependencies: + '@typescript-eslint/scope-manager': 6.19.0 + '@typescript-eslint/types': 6.19.0 + '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.19.0 '@typescript-eslint/scope-manager': 6.19.0 '@typescript-eslint/types': 6.19.0 '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3) @@ -2894,14 +3156,20 @@ packages: - supports-color dev: false + /@typescript-eslint/scope-manager@6.19.0: + resolution: {integrity: sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==} /@typescript-eslint/scope-manager@6.19.0: resolution: {integrity: sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: '@typescript-eslint/types': 6.19.0 '@typescript-eslint/visitor-keys': 6.19.0 + '@typescript-eslint/types': 6.19.0 + '@typescript-eslint/visitor-keys': 6.19.0 dev: false + /@typescript-eslint/type-utils@6.19.0(eslint@8.56.0)(typescript@5.3.3): + resolution: {integrity: sha512-mcvS6WSWbjiSxKCwBcXtOM5pRkPQ6kcDds/juxcy/727IQr3xMEcwr/YLHW2A2+Fp5ql6khjbKBzOyjuPqGi/w==} /@typescript-eslint/type-utils@6.19.0(eslint@8.56.0)(typescript@5.3.3): resolution: {integrity: sha512-mcvS6WSWbjiSxKCwBcXtOM5pRkPQ6kcDds/juxcy/727IQr3xMEcwr/YLHW2A2+Fp5ql6khjbKBzOyjuPqGi/w==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2912,6 +3180,8 @@ packages: typescript: optional: true dependencies: + '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3) + '@typescript-eslint/utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3) '@typescript-eslint/utils': 6.19.0(eslint@8.56.0)(typescript@5.3.3) debug: 4.3.4 @@ -2922,11 +3192,15 @@ packages: - supports-color dev: false + /@typescript-eslint/types@6.19.0: + resolution: {integrity: sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==} /@typescript-eslint/types@6.19.0: resolution: {integrity: sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==} engines: {node: ^16.0.0 || >=18.0.0} dev: false + /@typescript-eslint/typescript-estree@6.19.0(typescript@5.3.3): + resolution: {integrity: sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==} /@typescript-eslint/typescript-estree@6.19.0(typescript@5.3.3): resolution: {integrity: sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2936,6 +3210,8 @@ packages: typescript: optional: true dependencies: + '@typescript-eslint/types': 6.19.0 + '@typescript-eslint/visitor-keys': 6.19.0 '@typescript-eslint/types': 6.19.0 '@typescript-eslint/visitor-keys': 6.19.0 debug: 4.3.4 @@ -2949,6 +3225,8 @@ packages: - supports-color dev: false + /@typescript-eslint/utils@6.19.0(eslint@8.56.0)(typescript@5.3.3): + resolution: {integrity: sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==} /@typescript-eslint/utils@6.19.0(eslint@8.56.0)(typescript@5.3.3): resolution: {integrity: sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2961,6 +3239,9 @@ packages: '@typescript-eslint/scope-manager': 6.19.0 '@typescript-eslint/types': 6.19.0 '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3) + '@typescript-eslint/scope-manager': 6.19.0 + '@typescript-eslint/types': 6.19.0 + '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.3.3) eslint: 8.56.0 semver: 7.5.4 transitivePeerDependencies: @@ -2968,10 +3249,13 @@ packages: - typescript dev: false + /@typescript-eslint/visitor-keys@6.19.0: + resolution: {integrity: sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==} /@typescript-eslint/visitor-keys@6.19.0: resolution: {integrity: sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: + '@typescript-eslint/types': 6.19.0 '@typescript-eslint/types': 6.19.0 eslint-visitor-keys: 3.4.3 dev: false @@ -2980,12 +3264,16 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: false + /@vercel/analytics@1.1.2: + resolution: {integrity: sha512-CodhkLCQ/EHzjX8k+Qg+OzTBY0UadykrcfolfSOJVZZY/ZJM5nbhztm9KdbYvMfqKlasAr1+OYy0ThZnDA/MYA==} /@vercel/analytics@1.1.2: resolution: {integrity: sha512-CodhkLCQ/EHzjX8k+Qg+OzTBY0UadykrcfolfSOJVZZY/ZJM5nbhztm9KdbYvMfqKlasAr1+OYy0ThZnDA/MYA==} dependencies: server-only: 0.0.1 dev: false + /@vercel/blob@0.19.0: + resolution: {integrity: sha512-F3TliTcBeSa8AJ1FfP5A2MPolyfQr27JvQ0ElrhYK0uK8eN0K7V5+1h3Dp+vVPLQold80nRC8VGzwHZsHFDKew==} /@vercel/blob@0.19.0: resolution: {integrity: sha512-F3TliTcBeSa8AJ1FfP5A2MPolyfQr27JvQ0ElrhYK0uK8eN0K7V5+1h3Dp+vVPLQold80nRC8VGzwHZsHFDKew==} engines: {node: '>=16.14'} @@ -3123,6 +3411,103 @@ packages: dev: false optional: true + /@vue/compiler-core@3.4.15: + resolution: {integrity: sha512-XcJQVOaxTKCnth1vCxEChteGuwG6wqnUHxAm1DO3gCz0+uXKaJNx8/digSz4dLALCy8n2lKq24jSUs8segoqIw==} + requiresBuild: true + dependencies: + '@babel/parser': 7.23.6 + '@vue/shared': 3.4.15 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.0.2 + dev: false + optional: true + + /@vue/compiler-dom@3.4.15: + resolution: {integrity: sha512-wox0aasVV74zoXyblarOM3AZQz/Z+OunYcIHe1OsGclCHt8RsRm04DObjefaI82u6XDzv+qGWZ24tIsRAIi5MQ==} + requiresBuild: true + dependencies: + '@vue/compiler-core': 3.4.15 + '@vue/shared': 3.4.15 + dev: false + optional: true + + /@vue/compiler-sfc@3.4.15: + resolution: {integrity: sha512-LCn5M6QpkpFsh3GQvs2mJUOAlBQcCco8D60Bcqmf3O3w5a+KWS5GvYbrrJBkgvL1BDnTp+e8q0lXCLgHhKguBA==} + requiresBuild: true + dependencies: + '@babel/parser': 7.23.6 + '@vue/compiler-core': 3.4.15 + '@vue/compiler-dom': 3.4.15 + '@vue/compiler-ssr': 3.4.15 + '@vue/shared': 3.4.15 + estree-walker: 2.0.2 + magic-string: 0.30.5 + postcss: 8.4.33 + source-map-js: 1.0.2 + dev: false + optional: true + + /@vue/compiler-ssr@3.4.15: + resolution: {integrity: sha512-1jdeQyiGznr8gjFDadVmOJqZiLNSsMa5ZgqavkPZ8O2wjHv0tVuAEsw5hTdUoUW4232vpBbL/wJhzVW/JwY1Uw==} + requiresBuild: true + dependencies: + '@vue/compiler-dom': 3.4.15 + '@vue/shared': 3.4.15 + dev: false + optional: true + + /@vue/devtools-api@6.5.1: + resolution: {integrity: sha512-+KpckaAQyfbvshdDW5xQylLni1asvNSGme1JFs8I1+/H5pHEhqUKMEQD/qn3Nx5+/nycBq11qAEi8lk+LXI2dA==} + requiresBuild: true + dev: false + optional: true + + /@vue/reactivity@3.4.15: + resolution: {integrity: sha512-55yJh2bsff20K5O84MxSvXKPHHt17I2EomHznvFiJCAZpJTNW8IuLj1xZWMLELRhBK3kkFV/1ErZGHJfah7i7w==} + requiresBuild: true + dependencies: + '@vue/shared': 3.4.15 + dev: false + optional: true + + /@vue/runtime-core@3.4.15: + resolution: {integrity: sha512-6E3by5m6v1AkW0McCeAyhHTw+3y17YCOKG0U0HDKDscV4Hs0kgNT5G+GCHak16jKgcCDHpI9xe5NKb8sdLCLdw==} + requiresBuild: true + dependencies: + '@vue/reactivity': 3.4.15 + '@vue/shared': 3.4.15 + dev: false + optional: true + + /@vue/runtime-dom@3.4.15: + resolution: {integrity: sha512-EVW8D6vfFVq3V/yDKNPBFkZKGMFSvZrUQmx196o/v2tHKdwWdiZjYUBS+0Ez3+ohRyF8Njwy/6FH5gYJ75liUw==} + requiresBuild: true + dependencies: + '@vue/runtime-core': 3.4.15 + '@vue/shared': 3.4.15 + csstype: 3.1.3 + dev: false + optional: true + + /@vue/server-renderer@3.4.15(vue@3.4.15): + resolution: {integrity: sha512-3HYzaidu9cHjrT+qGUuDhFYvF/j643bHC6uUN9BgM11DVy+pM6ATsG6uPBLnkwOgs7BpJABReLmpL3ZPAsUaqw==} + requiresBuild: true + peerDependencies: + vue: 3.4.15 + dependencies: + '@vue/compiler-ssr': 3.4.15 + '@vue/shared': 3.4.15 + vue: 3.4.15(typescript@5.3.3) + dev: false + optional: true + + /@vue/shared@3.4.15: + resolution: {integrity: sha512-KzfPTxVaWfB+eGcGdbSf4CWdaXcGDqckoeXUh7SB3fZdEtzPCK2Vq9B/lRRL3yutax/LWITz+SwvgyOxz5V75g==} + requiresBuild: true + dev: false + optional: true + /abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -3348,6 +3733,8 @@ packages: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} dev: false + /autoprefixer@10.4.17(postcss@8.4.33): + resolution: {integrity: sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==} /autoprefixer@10.4.17(postcss@8.4.33): resolution: {integrity: sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==} engines: {node: ^10 || ^12 || >=14} @@ -3357,6 +3744,7 @@ packages: dependencies: browserslist: 4.22.2 caniuse-lite: 1.0.30001579 + caniuse-lite: 1.0.30001579 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.0.0 @@ -3388,6 +3776,14 @@ packages: dev: false optional: true + /axobject-query@4.0.0: + resolution: {integrity: sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==} + requiresBuild: true + dependencies: + dequal: 2.0.3 + dev: false + optional: true + /babel-jest@29.7.0(@babel/core@7.23.7): resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3498,6 +3894,7 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: + caniuse-lite: 1.0.30001579 caniuse-lite: 1.0.30001579 electron-to-chromium: 1.4.616 node-releases: 2.0.14 @@ -3552,6 +3949,8 @@ packages: engines: {node: '>= 6'} dev: false + /camelcase-keys@9.1.3: + resolution: {integrity: sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==} /camelcase-keys@9.1.3: resolution: {integrity: sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==} engines: {node: '>=16'} @@ -3577,6 +3976,8 @@ packages: engines: {node: '>=16'} dev: false + /caniuse-lite@1.0.30001579: + resolution: {integrity: sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==} /caniuse-lite@1.0.30001579: resolution: {integrity: sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==} dev: false @@ -3675,6 +4076,18 @@ packages: dev: false optional: true + /code-red@1.0.4: + resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} + requiresBuild: true + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + '@types/estree': 1.0.5 + acorn: 8.11.3 + estree-walker: 3.0.3 + periscopic: 3.1.0 + dev: false + optional: true + /collect-v8-coverage@1.0.2: resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} dev: false @@ -3732,11 +4145,19 @@ packages: dev: false optional: true + /cookie@0.5.0: + resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} + engines: {node: '>= 0.6'} + requiresBuild: true + dev: false + optional: true + /cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} dev: false + /create-jest@29.7.0(@types/node@20.11.5): /create-jest@29.7.0(@types/node@20.11.5): resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3747,6 +4168,7 @@ packages: exit: 0.1.2 graceful-fs: 4.2.11 jest-config: 29.7.0(@types/node@20.11.5) + jest-config: 29.7.0(@types/node@20.11.5) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -3775,6 +4197,16 @@ packages: dev: false optional: true + /css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + requiresBuild: true + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.0.2 + dev: false + optional: true + /css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} dev: false @@ -3933,6 +4365,12 @@ packages: dev: false optional: true + /devalue@4.3.2: + resolution: {integrity: sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==} + requiresBuild: true + dev: false + optional: true + /didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} dev: false @@ -4160,6 +4598,37 @@ packages: dev: false optional: true + /esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + dev: false + optional: true + /escalade@3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} engines: {node: '>=6'} @@ -4192,6 +4661,8 @@ packages: source-map: 0.6.1 dev: false + /eslint-config-next@14.1.0(eslint@8.56.0)(typescript@5.3.3): + resolution: {integrity: sha512-SBX2ed7DoRFXC6CQSLc/SbLY9Ut6HxNB2wPTcoIWjUMd7aF7O/SIE7111L8FdZ9TXsNV4pulUDnfthpyPtbFUg==} /eslint-config-next@14.1.0(eslint@8.56.0)(typescript@5.3.3): resolution: {integrity: sha512-SBX2ed7DoRFXC6CQSLc/SbLY9Ut6HxNB2wPTcoIWjUMd7aF7O/SIE7111L8FdZ9TXsNV4pulUDnfthpyPtbFUg==} peerDependencies: @@ -4201,13 +4672,17 @@ packages: typescript: optional: true dependencies: + '@next/eslint-plugin-next': 14.1.0 '@next/eslint-plugin-next': 14.1.0 '@rushstack/eslint-patch': 1.6.1 '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3) eslint: 8.56.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.56.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.56.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.56.0) eslint-plugin-react: 7.33.2(eslint@8.56.0) eslint-plugin-react-hooks: 4.6.0(eslint@8.56.0) @@ -4227,6 +4702,7 @@ packages: - supports-color dev: false + /eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.56.0): /eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.56.0): resolution: {integrity: sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==} engines: {node: ^14.18.0 || >=16.0.0} @@ -4239,6 +4715,8 @@ packages: eslint: 8.56.0 eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) fast-glob: 3.3.2 get-tsconfig: 4.7.2 is-core-module: 2.13.1 @@ -4250,6 +4728,7 @@ packages: - supports-color dev: false + /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0): /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0): resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} engines: {node: '>=4'} @@ -4271,15 +4750,18 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: + '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3) debug: 3.2.7 eslint: 8.56.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.56.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.56.0) transitivePeerDependencies: - supports-color dev: false + /eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0): /eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0): resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} engines: {node: '>=4'} @@ -4290,6 +4772,7 @@ packages: '@typescript-eslint/parser': optional: true dependencies: + '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/parser': 6.19.0(eslint@8.56.0)(typescript@5.3.3) array-includes: 3.1.7 array.prototype.findlastindex: 1.2.3 @@ -4300,6 +4783,7 @@ packages: eslint: 8.56.0 eslint-import-resolver-node: 0.3.9 eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.19.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0) hasown: 2.0.0 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -4440,6 +4924,12 @@ packages: dev: false optional: true + /esm-env@1.0.0: + resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==} + requiresBuild: true + dev: false + optional: true + /espree@9.6.1: resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -4488,6 +4978,20 @@ packages: dev: false optional: true + /estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + requiresBuild: true + dev: false + optional: true + + /estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + requiresBuild: true + dependencies: + '@types/estree': 1.0.5 + dev: false + optional: true + /esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -4793,6 +5297,12 @@ packages: dev: false optional: true + /globalyzer@0.1.0: + resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + requiresBuild: true + dev: false + optional: true + /globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} @@ -4811,6 +5321,12 @@ packages: dev: false optional: true + /globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + requiresBuild: true + dev: false + optional: true + /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: @@ -5104,6 +5620,11 @@ packages: engines: {node: '>=0.10.0'} dev: false + /is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + dev: false + /is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} dev: false @@ -5116,6 +5637,14 @@ packages: dev: false optional: true + /is-reference@3.0.2: + resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==} + requiresBuild: true + dependencies: + '@types/estree': 1.0.5 + dev: false + optional: true + /is-regex@1.1.4: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} engines: {node: '>= 0.4'} @@ -5281,6 +5810,7 @@ packages: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 '@types/node': 20.11.5 + '@types/node': 20.11.5 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.1 @@ -5301,6 +5831,7 @@ packages: - supports-color dev: false + /jest-cli@29.7.0(@types/node@20.11.5): /jest-cli@29.7.0(@types/node@20.11.5): resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5316,9 +5847,11 @@ packages: '@jest/types': 29.6.3 chalk: 4.1.2 create-jest: 29.7.0(@types/node@20.11.5) + create-jest: 29.7.0(@types/node@20.11.5) exit: 0.1.2 import-local: 3.1.0 jest-config: 29.7.0(@types/node@20.11.5) + jest-config: 29.7.0(@types/node@20.11.5) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -5329,6 +5862,7 @@ packages: - ts-node dev: false + /jest-config@29.7.0(@types/node@20.11.5): /jest-config@29.7.0(@types/node@20.11.5): resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5345,6 +5879,7 @@ packages: '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 '@types/node': 20.11.5 + '@types/node': 20.11.5 babel-jest: 29.7.0(@babel/core@7.23.7) chalk: 4.1.2 ci-info: 3.9.0 @@ -5411,6 +5946,7 @@ packages: '@jest/types': 29.6.3 '@types/jsdom': 20.0.1 '@types/node': 20.11.5 + '@types/node': 20.11.5 jest-mock: 29.7.0 jest-util: 29.7.0 jsdom: 20.0.3 @@ -5428,6 +5964,7 @@ packages: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 '@types/node': 20.11.5 + '@types/node': 20.11.5 jest-mock: 29.7.0 jest-util: 29.7.0 dev: false @@ -5444,6 +5981,7 @@ packages: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 '@types/node': 20.11.5 + '@types/node': 20.11.5 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -5495,6 +6033,7 @@ packages: dependencies: '@jest/types': 29.6.3 '@types/node': 20.11.5 + '@types/node': 20.11.5 jest-util: 29.7.0 dev: false @@ -5550,6 +6089,7 @@ packages: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@types/node': 20.11.5 + '@types/node': 20.11.5 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -5581,6 +6121,7 @@ packages: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@types/node': 20.11.5 + '@types/node': 20.11.5 chalk: 4.1.2 cjs-module-lexer: 1.2.3 collect-v8-coverage: 1.0.2 @@ -5633,6 +6174,7 @@ packages: dependencies: '@jest/types': 29.6.3 '@types/node': 20.11.5 + '@types/node': 20.11.5 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -5658,6 +6200,7 @@ packages: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 '@types/node': 20.11.5 + '@types/node': 20.11.5 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -5669,12 +6212,14 @@ packages: resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: + '@types/node': 20.11.5 '@types/node': 20.11.5 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 dev: false + /jest@29.7.0(@types/node@20.11.5): /jest@29.7.0(@types/node@20.11.5): resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5689,6 +6234,7 @@ packages: '@jest/types': 29.6.3 import-local: 3.1.0 jest-cli: 29.7.0(@types/node@20.11.5) + jest-cli: 29.7.0(@types/node@20.11.5) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -5828,6 +6374,13 @@ packages: dev: false optional: true + /kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + requiresBuild: true + dev: false + optional: true + /language-subtag-registry@0.3.22: resolution: {integrity: sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==} dev: false @@ -5872,6 +6425,12 @@ packages: dev: false optional: true + /locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + requiresBuild: true + dev: false + optional: true + /locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -5933,6 +6492,15 @@ packages: dev: false optional: true + /magic-string@0.30.5: + resolution: {integrity: sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==} + engines: {node: '>=12'} + requiresBuild: true + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + dev: false + optional: true + /make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -5957,6 +6525,12 @@ packages: dev: false optional: true + /mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + requiresBuild: true + dev: false + optional: true + /merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} dev: false @@ -6030,6 +6604,13 @@ packages: dev: false optional: true + /mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + requiresBuild: true + dev: false + optional: true + /mrmime@1.0.1: resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} engines: {node: '>=10'} @@ -6037,6 +6618,13 @@ packages: dev: false optional: true + /mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} + engines: {node: '>=10'} + requiresBuild: true + dev: false + optional: true + /mrmime@2.0.0: resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} engines: {node: '>=10'} @@ -6101,6 +6689,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /next@14.1.0(@babel/core@7.23.7)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==} /next@14.1.0(@babel/core@7.23.7)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==} engines: {node: '>=18.17.0'} @@ -6117,10 +6707,12 @@ packages: sass: optional: true dependencies: + '@next/env': 14.1.0 '@next/env': 14.1.0 '@swc/helpers': 0.5.2 busboy: 1.6.0 caniuse-lite: 1.0.30001579 + caniuse-lite: 1.0.30001579 graceful-fs: 4.2.11 postcss: 8.4.31 react: 18.2.0 @@ -6427,6 +7019,16 @@ packages: dev: false optional: true + /periscopic@3.1.0: + resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} + requiresBuild: true + dependencies: + '@types/estree': 1.0.5 + estree-walker: 3.0.3 + is-reference: 3.0.2 + dev: false + optional: true + /pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} @@ -6818,6 +7420,15 @@ packages: dev: false optional: true + /rollup@3.29.4: + resolution: {integrity: sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.3 + dev: false + optional: true + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -6833,6 +7444,15 @@ packages: dev: false optional: true + /sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + requiresBuild: true + dependencies: + mri: 1.2.0 + dev: false + optional: true + /safe-array-concat@1.0.1: resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==} engines: {node: '>=0.4'} @@ -6895,6 +7515,12 @@ packages: dev: false optional: true + /set-cookie-parser@2.6.0: + resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} + requiresBuild: true + dev: false + optional: true + /set-function-length@1.1.1: resolution: {integrity: sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==} engines: {node: '>= 0.4'} @@ -6943,6 +7569,8 @@ packages: engines: {node: '>=14'} dev: false + /sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} /sirv@2.0.4: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} @@ -6950,6 +7578,8 @@ packages: '@polka/url': 1.0.0-next.24 mrmime: 2.0.0 totalist: 3.0.1 + mrmime: 2.0.0 + totalist: 3.0.1 dev: false /sisteransi@1.0.5: @@ -7211,6 +7841,38 @@ packages: dev: false optional: true + /svelte-hmr@0.15.3(svelte@4.2.9): + resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + requiresBuild: true + peerDependencies: + svelte: ^3.19.0 || ^4.0.0 + dependencies: + svelte: 4.2.9 + dev: false + optional: true + + /svelte@4.2.9: + resolution: {integrity: sha512-hsoB/WZGEPFXeRRLPhPrbRz67PhP6sqYgvwcAs+gWdSQSvNDw+/lTeUJSWe5h2xC97Fz/8QxAOqItwBzNJPU8w==} + engines: {node: '>=16'} + dependencies: + '@ampproject/remapping': 2.2.1 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.20 + '@types/estree': 1.0.5 + acorn: 8.11.3 + aria-query: 5.3.0 + axobject-query: 4.0.0 + code-red: 1.0.4 + css-tree: 2.3.1 + estree-walker: 3.0.3 + is-reference: 3.0.2 + locate-character: 3.0.0 + magic-string: 0.30.5 + periscopic: 3.1.0 + dev: false + optional: true + /symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} dev: false @@ -7290,6 +7952,15 @@ packages: dev: false optional: true + /tiny-glob@0.2.9: + resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + requiresBuild: true + dependencies: + globalyzer: 0.1.0 + globrex: 0.1.2 + dev: false + optional: true + /tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} dev: false @@ -7306,6 +7977,8 @@ packages: is-number: 7.0.0 dev: false + /totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} /totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -7457,6 +8130,15 @@ packages: dev: false optional: true + /undici@5.26.5: + resolution: {integrity: sha512-cSb4bPFd5qgR7qr2jYAi0hlX9n5YKK2ONKkLFkxl+v/9BvC0sOpZjBHDBSXc5lWAf5ty9oZdRXytBIHzgUcerw==} + engines: {node: '>=14.0'} + requiresBuild: true + dependencies: + '@fastify/busboy': 2.1.0 + dev: false + optional: true + /undici@5.28.2: resolution: {integrity: sha512-wh1pHJHnUeQV5Xa8/kyQhO7WFa8M34l026L5P/+2TYiakvGy5Rdc8jWZVyG7ieht/0WgJLEd3kcU5gKx+6GC8w==} engines: {node: '>=14.0'} @@ -7614,23 +8296,32 @@ packages: engines: {node: '>=12'} dev: false + /webpack-bundle-analyzer@4.10.1: + resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==} /webpack-bundle-analyzer@4.10.1: resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==} engines: {node: '>= 10.13.0'} hasBin: true dependencies: + '@discoveryjs/json-ext': 0.5.7 '@discoveryjs/json-ext': 0.5.7 acorn: 8.11.3 acorn-walk: 8.3.1 commander: 7.2.0 debounce: 1.2.1 escape-string-regexp: 4.0.0 + debounce: 1.2.1 + escape-string-regexp: 4.0.0 gzip-size: 6.0.0 html-escaper: 2.0.2 is-plain-object: 5.0.0 + html-escaper: 2.0.2 + is-plain-object: 5.0.0 opener: 1.5.2 picocolors: 1.0.0 sirv: 2.0.4 + picocolors: 1.0.0 + sirv: 2.0.4 ws: 7.5.9 transitivePeerDependencies: - bufferutil diff --git a/src/admin/BlobUrls.tsx b/src/admin/StorageUrls.tsx similarity index 92% rename from src/admin/BlobUrls.tsx rename to src/admin/StorageUrls.tsx index 29020f8e..c4c1ef55 100644 --- a/src/admin/BlobUrls.tsx +++ b/src/admin/StorageUrls.tsx @@ -2,7 +2,7 @@ import { Fragment } from 'react'; import AdminGrid from './AdminGrid'; import Link from 'next/link'; import ImageTiny from '@/components/ImageTiny'; -import { fileNameForBlobUrl } from '@/services/blob'; +import { fileNameForStorageUrl } from '@/services/storage'; import FormWithConfirm from '@/components/FormWithConfirm'; import { deleteBlobPhotoAction } from '@/photo/actions'; import DeleteButton from './DeleteButton'; @@ -10,7 +10,7 @@ import { clsx } from 'clsx/lite'; import { pathForAdminUploadUrl } from '@/site/paths'; import AddButton from './AddButton'; -export default function BlobUrls({ +export default function StorageUrls({ title, urls, }: { @@ -21,7 +21,7 @@ export default function BlobUrls({ {urls.map(url => { const addUploadPath = pathForAdminUploadUrl(url); - const uploadFileName = fileNameForBlobUrl(url); + const uploadFileName = fileNameForStorageUrl(url); return photos.length; @@ -60,7 +60,7 @@ export default async function AdminPhotosPage({ 'border-b pb-6', 'border-gray-200 dark:border-gray-700', )}> - diff --git a/src/app/admin/tags/page.tsx b/src/app/admin/tags/page.tsx index a3db7b47..54756b28 100644 --- a/src/app/admin/tags/page.tsx +++ b/src/app/admin/tags/page.tsx @@ -41,7 +41,7 @@ export default async function AdminTagsPage() { action={deletePhotoTagGloballyAction} confirmText={ // eslint-disable-next-line max-len - `Are you sure you want to remove "${formatTag(tag)}?" from ${photoQuantityText(count, false).toLowerCase()}?`} + `Are you sure you want to remove "${formatTag(tag)}" from ${photoQuantityText(count, false).toLowerCase()}?`} > diff --git a/src/app/admin/uploads/page.tsx b/src/app/admin/uploads/page.tsx index 9e4e281a..8108e6eb 100644 --- a/src/app/admin/uploads/page.tsx +++ b/src/app/admin/uploads/page.tsx @@ -1,12 +1,12 @@ -import BlobUrls from '@/admin/BlobUrls'; -import { getBlobUploadUrlsNoStore } from '@/cache'; +import StorageUrls from '@/admin/StorageUrls'; +import { getStorageUploadUrlsNoStore } from '@/cache'; import SiteGrid from '@/components/SiteGrid'; export default async function AdminUploadsPage() { - const blobUrls = await getBlobUploadUrlsNoStore(); + const storageUrls = await getStorageUploadUrlsNoStore(); return ( } + contentMain={} /> ); } diff --git a/src/app/api/aws-s3/presigned-url/[key]/route.ts b/src/app/api/storage/presigned-url/[key]/route.ts similarity index 57% rename from src/app/api/aws-s3/presigned-url/[key]/route.ts rename to src/app/api/storage/presigned-url/[key]/route.ts index e2e858fe..da506a22 100644 --- a/src/app/api/aws-s3/presigned-url/[key]/route.ts +++ b/src/app/api/storage/presigned-url/[key]/route.ts @@ -2,7 +2,12 @@ import { auth } from '@/auth'; import { awsS3Client, awsS3PutObjectCommandForKey, -} from '@/services/blob/aws-s3'; +} from '@/services/storage/aws-s3'; +import { + cloudflareR2Client, + cloudflareR2PutObjectCommandForKey, +} from '@/services/storage/cloudflare-r2'; +import { CURRENT_STORAGE } from '@/site/config'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; export const runtime = 'edge'; @@ -14,8 +19,12 @@ export async function GET( const session = await auth(); if (session?.user && key) { const url = await getSignedUrl( - awsS3Client(), - awsS3PutObjectCommandForKey(key), + CURRENT_STORAGE === 'cloudflare-r2' + ? cloudflareR2Client() + : awsS3Client(), + CURRENT_STORAGE === 'cloudflare-r2' + ? cloudflareR2PutObjectCommandForKey(key) + : awsS3PutObjectCommandForKey(key), { expiresIn: 3600 } ); return new Response( diff --git a/src/app/admin/uploads/blob/route.tsx b/src/app/api/storage/vercel-blob/route.ts similarity index 92% rename from src/app/admin/uploads/blob/route.tsx rename to src/app/api/storage/vercel-blob/route.ts index 22b14c3d..e41543c6 100644 --- a/src/app/admin/uploads/blob/route.tsx +++ b/src/app/api/storage/vercel-blob/route.ts @@ -4,12 +4,12 @@ import { ACCEPTED_PHOTO_FILE_TYPES, MAX_PHOTO_UPLOAD_SIZE_IN_BYTES, } from '@/photo'; -import { isUploadPathnameValid } from '@/services/blob'; +import { isUploadPathnameValid } from '@/services/storage'; import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'; import { NextResponse } from 'next/server'; export async function POST(request: Request): Promise { - const body = (await request.json()) as HandleUploadBody; + const body: HandleUploadBody = await request.json(); try { const jsonResponse = await handleUpload({ diff --git a/src/auth/index.ts b/src/auth/index.ts index 0bf0d476..53337314 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -20,7 +20,7 @@ export const { process.env.ADMIN_EMAIL && process.env.ADMIN_EMAIL === email && process.env.ADMIN_PASSWORD && process.env.ADMIN_PASSWORD === password ) { - const user: User = { id: '1', email, name: 'Admin User' }; + const user: User = { email, name: 'Admin User' }; return user; } else { return null; diff --git a/src/cache/index.ts b/src/cache/index.ts index ce35f63b..179bbbbb 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -24,7 +24,7 @@ import { getPhotosNearId, } from '@/services/vercel-postgres'; import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo'; -import { getBlobPhotoUrls, getBlobUploadUrls } from '@/services/blob'; +import { getStoragePhotoUrls, getStorageUploadUrls } from '@/services/storage'; import type { Session } from 'next-auth'; import { createCameraKey } from '@/camera'; import { PATHS_ADMIN } from '@/site/paths'; @@ -218,15 +218,17 @@ export const getPhotoNoStore = (...args: Parameters) => { return getPhoto(...args); }; -export const getBlobUploadUrlsNoStore: typeof getBlobUploadUrls = (...args) => { - unstable_noStore(); - return getBlobUploadUrls(...args); -}; +export const getStorageUploadUrlsNoStore: typeof getStorageUploadUrls = + (...args) => { + unstable_noStore(); + return getStorageUploadUrls(...args); + }; -export const getBlobPhotoUrlsNoStore: typeof getBlobPhotoUrls = (...args) => { - unstable_noStore(); - return getBlobPhotoUrls(...args); -}; +export const getStoragePhotoUrlsNoStore: typeof getStoragePhotoUrls = + (...args) => { + unstable_noStore(); + return getStoragePhotoUrls(...args); + }; export const getImageCacheHeadersForAuth = (session: Session | null) => { return { diff --git a/src/photo/PhotoUpload.tsx b/src/photo/PhotoUpload.tsx index 71d6a12f..b50fcdff 100644 --- a/src/photo/PhotoUpload.tsx +++ b/src/photo/PhotoUpload.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import { uploadPhotoFromClient } from '@/services/blob'; +import { uploadPhotoFromClient } from '@/services/storage'; import { useRouter } from 'next/navigation'; import { PATH_ADMIN_UPLOADS, pathForAdminUploadUrl } from '@/site/paths'; import ImageInput from '../components/ImageInput'; diff --git a/src/photo/actions.ts b/src/photo/actions.ts index f017d0f4..bbbe1865 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -16,8 +16,8 @@ import { import { redirect } from 'next/navigation'; import { convertUploadToPhoto, - deleteBlobUrl, -} from '@/services/blob'; + deleteStorageUrl, +} from '@/services/storage'; import { revalidateAdminPaths, revalidateAllKeysAndPaths, @@ -66,7 +66,7 @@ export async function toggleFavoritePhoto(photoId: string) { export async function deletePhotoAction(formData: FormData) { await Promise.all([ - deleteBlobUrl(formData.get('url') as string), + deleteStorageUrl(formData.get('url') as string), sqlDeletePhoto(formData.get('id') as string), ]); @@ -94,7 +94,7 @@ export async function renamePhotoTagGloballyAction(formData: FormData) { } export async function deleteBlobPhotoAction(formData: FormData) { - await deleteBlobUrl(formData.get('url') as string); + await deleteStorageUrl(formData.get('url') as string); revalidateAdminPaths(); diff --git a/src/photo/server.ts b/src/photo/server.ts index 7971c917..ce40fe79 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -1,7 +1,7 @@ import { - getExtensionFromBlobUrl, - getIdFromBlobUrl, -} from '@/services/blob'; + getExtensionFromStorageUrl, + getIdFromStorageUrl, +} from '@/services/storage'; import { convertExifToFormData } from '@/photo/form'; import { getFujifilmSimulationFromMakerNote, @@ -19,9 +19,9 @@ export const extractExifDataFromBlobPath = async ( }> => { const url = decodeURIComponent(blobPath); - const blobId = getIdFromBlobUrl(url); + const blobId = getIdFromStorageUrl(url); - const extension = getExtensionFromBlobUrl(url); + const extension = getExtensionFromStorageUrl(url); const fileBytes = blobPath ? await fetch(url) diff --git a/src/services/blob/index.ts b/src/services/blob/index.ts deleted file mode 100644 index fc9f6c17..00000000 --- a/src/services/blob/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - VERCEL_BLOB_BASE_URL, - vercelBlobCopy, - vercelBlobDelete, - vercelBlobList, - vercelBlobUploadFromClient, -} from './vercel-blob'; -import { - AWS_S3_BASE_URL, - awsS3Copy, - awsS3Delete, - awsS3List, - awsS3UploadFromClient, - isUrlFromAwsS3, -} from './aws-s3'; -import { HAS_AWS_S3_STORAGE, HAS_AWS_S3_STORAGE_CLIENT } from '@/site/config'; - -const PREFIX_UPLOAD = 'upload'; -const PREFIX_PHOTO = 'photo'; -const BLOB_BASE_URL = HAS_AWS_S3_STORAGE_CLIENT - ? AWS_S3_BASE_URL - : VERCEL_BLOB_BASE_URL; - -const REGEX_UPLOAD_PATH = new RegExp( - `(?:${PREFIX_UPLOAD})\.[a-z]{1,4}`, - 'i', -); - -const REGEX_UPLOAD_ID = new RegExp( - `.${PREFIX_UPLOAD}-([a-z0-9]+)\.[a-z]{1,4}$`, - 'i', -); - -export const fileNameForBlobUrl = (url: string) => - url.replace(`${BLOB_BASE_URL}/`, ''); - -export const getExtensionFromBlobUrl = (url: string) => - url.match(/.([a-z]{1,4})$/i)?.[1]; - -export const getIdFromBlobUrl = (url: string) => - url.match(REGEX_UPLOAD_ID)?.[1]; - -export const isUploadPathnameValid = (pathname?: string) => - pathname?.match(REGEX_UPLOAD_PATH); - -const getFileNameFromBlobUrl = (url: string) => - (new URL(url).pathname.match(/\/(.+)$/)?.[1]) ?? ''; - -export const uploadPhotoFromClient = async ( - file: File | Blob, - extension = 'jpg', -) => HAS_AWS_S3_STORAGE_CLIENT - ? awsS3UploadFromClient(file, PREFIX_UPLOAD, extension, true) - : vercelBlobUploadFromClient(file, `${PREFIX_UPLOAD}.${extension}`); - -export const convertUploadToPhoto = async ( - uploadUrl: string, - photoId?: string, -): Promise => { - const fileName = photoId ? `${PREFIX_PHOTO}-${photoId}` : `${PREFIX_PHOTO}`; - const fileExtension = getExtensionFromBlobUrl(uploadUrl); - const photoUrl = `${fileName}.${fileExtension ?? 'jpg'}`; - - const useAwsS3 = HAS_AWS_S3_STORAGE && isUrlFromAwsS3(uploadUrl); - - const url = await (useAwsS3 - ? awsS3Copy(uploadUrl, photoUrl, photoId === undefined) - : vercelBlobCopy(uploadUrl, photoUrl, photoId === undefined)); - - if (url) { - await (useAwsS3 - ? awsS3Delete(getFileNameFromBlobUrl(uploadUrl)) - : vercelBlobDelete(uploadUrl)); - } - - return url; -}; - -export const deleteBlobUrl = (url: string) => - HAS_AWS_S3_STORAGE && isUrlFromAwsS3(url) - ? awsS3Delete(getFileNameFromBlobUrl(url)) - : vercelBlobDelete(url); - -export const getBlobUploadUrls = (): Promise => HAS_AWS_S3_STORAGE - ? awsS3List(`${PREFIX_UPLOAD}-`) - : vercelBlobList(`${PREFIX_UPLOAD}-`); - -export const getBlobPhotoUrls = (): Promise => HAS_AWS_S3_STORAGE - ? awsS3List(`${PREFIX_PHOTO}-`) - : vercelBlobList(`${PREFIX_PHOTO}-`); diff --git a/src/services/blob/aws-s3.ts b/src/services/storage/aws-s3.ts similarity index 73% rename from src/services/blob/aws-s3.ts rename to src/services/storage/aws-s3.ts index bff189ca..2ec518d5 100644 --- a/src/services/blob/aws-s3.ts +++ b/src/services/storage/aws-s3.ts @@ -1,4 +1,3 @@ -import { generateNanoid } from '@/utility/nanoid'; import { S3Client, CopyObjectCommand, @@ -6,13 +5,14 @@ import { ListObjectsCommand, PutObjectCommand, } from '@aws-sdk/client-s3'; +import { generateStorageId } from '.'; const AWS_S3_BUCKET = process.env.NEXT_PUBLIC_AWS_S3_BUCKET ?? ''; const AWS_S3_REGION = process.env.NEXT_PUBLIC_AWS_S3_REGION ?? ''; const AWS_S3_ACCESS_KEY = process.env.AWS_S3_ACCESS_KEY ?? ''; const AWS_S3_SECRET_ACCESS_KEY = process.env.AWS_S3_SECRET_ACCESS_KEY ?? ''; - -const API_PATH_PRESIGNED_URL = '/api/aws-s3/presigned-url'; +export const AWS_S3_BASE_URL = + `https://${AWS_S3_BUCKET}.s3.${AWS_S3_REGION}.amazonaws.com`; export const awsS3Client = () => new S3Client({ region: AWS_S3_REGION, @@ -22,36 +22,14 @@ export const awsS3Client = () => new S3Client({ }, }); -export const AWS_S3_BASE_URL = - `https://${AWS_S3_BUCKET}.s3.${AWS_S3_REGION}.amazonaws.com`; +const urlForKey = (key?: string) => `${AWS_S3_BASE_URL}/${key}`; export const isUrlFromAwsS3 = (url: string) => url.startsWith(AWS_S3_BASE_URL); -const urlForKey = (key?: string) => `${AWS_S3_BASE_URL}/${key}`; - -const generateBlobId = () => generateNanoid(16); - export const awsS3PutObjectCommandForKey = (Key: string) => new PutObjectCommand({ Bucket: AWS_S3_BUCKET, Key, ACL: 'public-read' }); -export const awsS3UploadFromClient = async ( - file: File | Blob, - fileName: string, - extension: string, - addRandomSuffix?: boolean, -) => { - const key = addRandomSuffix - ? `${fileName}-${generateBlobId()}.${extension}` - : `${fileName}.${extension}`; - - const url = await fetch(`${API_PATH_PRESIGNED_URL}/${key}`) - .then((response) => response.text()); - - return fetch(url, { method: 'PUT', body: file }) - .then(() => urlForKey(key)); -}; - export const awsS3Copy = async ( fileNameSource: string, fileNameDestination: string, @@ -60,7 +38,7 @@ export const awsS3Copy = async ( const name = fileNameSource.split('.')[0]; const extension = fileNameSource.split('.')[1]; const Key = addRandomSuffix - ? `${name}-${generateBlobId()}.${extension}` + ? `${name}-${generateStorageId()}.${extension}` : fileNameDestination; return awsS3Client().send(new CopyObjectCommand({ Bucket: AWS_S3_BUCKET, @@ -71,16 +49,16 @@ export const awsS3Copy = async ( .then(() => urlForKey(fileNameDestination)); }; -export const awsS3Delete = async (Key: string) => { - awsS3Client().send(new DeleteObjectCommand({ - Bucket: AWS_S3_BUCKET, - Key, - })); -}; - export const awsS3List = async (Prefix: string) => awsS3Client().send(new ListObjectsCommand({ Bucket: AWS_S3_BUCKET, Prefix, })) .then((data) => data.Contents?.map(({ Key }) => urlForKey(Key)) ?? []); + +export const awsS3Delete = async (Key: string) => { + awsS3Client().send(new DeleteObjectCommand({ + Bucket: AWS_S3_BUCKET, + Key, + })); +}; diff --git a/src/services/storage/cloudflare-r2.ts b/src/services/storage/cloudflare-r2.ts new file mode 100644 index 00000000..7866005a --- /dev/null +++ b/src/services/storage/cloudflare-r2.ts @@ -0,0 +1,79 @@ +import { + S3Client, + ListObjectsCommand, + PutObjectCommand, + DeleteObjectCommand, + CopyObjectCommand, +} from '@aws-sdk/client-s3'; +import { generateStorageId } from '.'; + +const CLOUDFLARE_R2_BUCKET = + process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET ?? ''; +const CLOUDFLARE_R2_ACCOUNT_ID = + process.env.NEXT_PUBLIC_CLOUDFLARE_R2_ACCOUNT_ID ?? ''; +const CLOUDFLARE_R2_DEV_SUBDOMAIN = + process.env.NEXT_PUBLIC_CLOUDFLARE_R2_DEV_SUBDOMAIN ?? ''; +const CLOUDFLARE_R2_ACCESS_KEY = + process.env.CLOUDFLARE_R2_ACCESS_KEY ?? ''; +const CLOUDFLARE_R2_SECRET_ACCESS_KEY = + process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY ?? ''; +const CLOUDFLARE_R2_ENDPOINT = + `https://${CLOUDFLARE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com`; + +export const CLOUDFLARE_R2_BASE_URL_PRIVATE = + `${CLOUDFLARE_R2_ENDPOINT}/${CLOUDFLARE_R2_BUCKET}`; + +export const CLOUDFLARE_R2_BASE_URL_PUBLIC = + `https://${CLOUDFLARE_R2_DEV_SUBDOMAIN}.r2.dev`; + +export const cloudflareR2Client = () => new S3Client({ + region: 'auto', + endpoint: CLOUDFLARE_R2_ENDPOINT, + credentials: { + accessKeyId: CLOUDFLARE_R2_ACCESS_KEY, + secretAccessKey: CLOUDFLARE_R2_SECRET_ACCESS_KEY, + }, +}); + +const urlForKey = (key?: string, isPublic = true) => isPublic + ? `${CLOUDFLARE_R2_BASE_URL_PUBLIC}/${key}` + : `${CLOUDFLARE_R2_BASE_URL_PRIVATE}/${key}`; + +export const isUrlFromCloudflareR2 = (url: string) => + url.startsWith(CLOUDFLARE_R2_BASE_URL_PRIVATE) || + url.startsWith(CLOUDFLARE_R2_BASE_URL_PUBLIC); + +export const cloudflareR2PutObjectCommandForKey = (Key: string) => + new PutObjectCommand({ Bucket: CLOUDFLARE_R2_BUCKET, Key }); + +export const cloudflareR2Copy = async ( + fileNameSource: string, + fileNameDestination: string, + addRandomSuffix?: boolean, +) => { + const name = fileNameSource.split('.')[0]; + const extension = fileNameSource.split('.')[1]; + const Key = addRandomSuffix + ? `${name}-${generateStorageId()}.${extension}` + : fileNameDestination; + return cloudflareR2Client().send(new CopyObjectCommand({ + Bucket: CLOUDFLARE_R2_BUCKET, + CopySource: `${CLOUDFLARE_R2_BUCKET}/${fileNameSource}`, + Key, + })) + .then(() => urlForKey(fileNameDestination)); +}; + +export const cloudflareR2List = async (Prefix: string) => + cloudflareR2Client().send(new ListObjectsCommand({ + Bucket: CLOUDFLARE_R2_BUCKET, + Prefix, + })) + .then((data) => data.Contents?.map(({ Key }) => urlForKey(Key)) ?? []); + +export const cloudflareR2Delete = async (Key: string) => { + cloudflareR2Client().send(new DeleteObjectCommand({ + Bucket: CLOUDFLARE_R2_BUCKET, + Key, + })); +}; diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts new file mode 100644 index 00000000..2b58ebe3 --- /dev/null +++ b/src/services/storage/index.ts @@ -0,0 +1,205 @@ +import { + VERCEL_BLOB_BASE_URL, + vercelBlobCopy, + vercelBlobDelete, + vercelBlobList, + vercelBlobUploadFromClient, +} from './vercel-blob'; +import { + AWS_S3_BASE_URL, + awsS3Copy, + awsS3Delete, + awsS3List, + isUrlFromAwsS3, +} from './aws-s3'; +import { + CURRENT_STORAGE, + HAS_AWS_S3_STORAGE, + HAS_VERCEL_BLOB_STORAGE, + HAS_CLOUDFLARE_R2_STORAGE, +} from '@/site/config'; +import { generateNanoid } from '@/utility/nanoid'; +import { + CLOUDFLARE_R2_BASE_URL_PUBLIC, + cloudflareR2Copy, + cloudflareR2Delete, + cloudflareR2List, + isUrlFromCloudflareR2, +} from './cloudflare-r2'; +import { PATH_API_PRESIGNED_URL } from '@/site/paths'; + +export const generateStorageId = () => generateNanoid(16); + +export type StorageType = + 'vercel-blob' | + 'aws-s3' | + 'cloudflare-r2'; + +export const labelForStorage = (type: StorageType): string => { + switch (type) { + case 'vercel-blob': return 'Vercel Blob'; + case 'cloudflare-r2': return 'Cloudflare R2'; + case 'aws-s3': return 'AWS S3'; + } +}; + +export const baseUrlForStorage = (type: StorageType) => { + switch (type) { + case 'vercel-blob': return VERCEL_BLOB_BASE_URL; + case 'cloudflare-r2': return CLOUDFLARE_R2_BASE_URL_PUBLIC; + case 'aws-s3': return AWS_S3_BASE_URL; + } +}; + +export const storageTypeFromUrl = (url: string): StorageType => { + if (isUrlFromCloudflareR2(url)) { + return 'cloudflare-r2'; + } else if (isUrlFromAwsS3(url)) { + return 'aws-s3'; + } else { + return 'vercel-blob'; + } +}; + +const PREFIX_UPLOAD = 'upload'; +const PREFIX_PHOTO = 'photo'; + +const REGEX_UPLOAD_PATH = new RegExp( + `(?:${PREFIX_UPLOAD})\.[a-z]{1,4}`, + 'i', +); + +const REGEX_UPLOAD_ID = new RegExp( + `.${PREFIX_UPLOAD}-([a-z0-9]+)\.[a-z]{1,4}$`, + 'i', +); + +export const fileNameForStorageUrl = (url: string) => { + switch (storageTypeFromUrl(url)) { + case 'vercel-blob': + return url.replace(`${VERCEL_BLOB_BASE_URL}/`, ''); + case 'cloudflare-r2': + return url.replace(`${CLOUDFLARE_R2_BASE_URL_PUBLIC}/`, ''); + case 'aws-s3': + return url.replace(`${AWS_S3_BASE_URL}/`, ''); + } +}; + +export const getExtensionFromStorageUrl = (url: string) => + url.match(/.([a-z]{1,4})$/i)?.[1]; + +export const getIdFromStorageUrl = (url: string) => + url.match(REGEX_UPLOAD_ID)?.[1]; + +export const isUploadPathnameValid = (pathname?: string) => + pathname?.match(REGEX_UPLOAD_PATH); + +const getFileNameFromStorageUrl = (url: string) => + (new URL(url).pathname.match(/\/(.+)$/)?.[1]) ?? ''; + +export const uploadFromClientViaPresignedUrl = async ( + file: File | Blob, + fileName: string, + extension: string, + addRandomSuffix?: boolean, +) => { + const key = addRandomSuffix + ? `${fileName}-${generateStorageId()}.${extension}` + : `${fileName}.${extension}`; + + const url = await fetch(`${PATH_API_PRESIGNED_URL}/${key}`) + .then((response) => response.text()); + + return fetch(url, { method: 'PUT', body: file }) + .then(() => `${baseUrlForStorage(CURRENT_STORAGE)}/${key}`); +}; + +export const uploadPhotoFromClient = async ( + file: File | Blob, + extension = 'jpg', +) => ( + CURRENT_STORAGE === 'cloudflare-r2' || + CURRENT_STORAGE === 'aws-s3' +) + ? uploadFromClientViaPresignedUrl(file, PREFIX_UPLOAD, extension, true) + : vercelBlobUploadFromClient(file, `${PREFIX_UPLOAD}.${extension}`); + +export const convertUploadToPhoto = async ( + uploadUrl: string, + photoId?: string, +): Promise => { + const fileName = photoId ? `${PREFIX_PHOTO}-${photoId}` : `${PREFIX_PHOTO}`; + const fileExtension = getExtensionFromStorageUrl(uploadUrl); + const photoUrl = `${fileName}.${fileExtension ?? 'jpg'}`; + + const storageType = storageTypeFromUrl(uploadUrl); + + let url: string | undefined; + + // Copy file + switch (storageType) { + case 'vercel-blob': + url = await vercelBlobCopy(uploadUrl, photoUrl, photoId === undefined); + break; + case 'cloudflare-r2': + url = await cloudflareR2Copy( + getFileNameFromStorageUrl(uploadUrl), + photoUrl, + photoId === undefined, + ); + break; + case 'aws-s3': + url = await awsS3Copy(uploadUrl, photoUrl, photoId === undefined); + break; + } + + // If successful, delete original file + if (url) { + switch (storageType) { + case 'vercel-blob': + await vercelBlobDelete(uploadUrl); + break; + case 'cloudflare-r2': + await cloudflareR2Delete(getFileNameFromStorageUrl(uploadUrl)); + break; + case 'aws-s3': + await awsS3Delete(getFileNameFromStorageUrl(uploadUrl)); + break; + } + } + + return url; +}; + +export const deleteStorageUrl = (url: string) => { + switch (storageTypeFromUrl(url)) { + case 'vercel-blob': + return vercelBlobDelete(url); + case 'cloudflare-r2': + return cloudflareR2Delete(getFileNameFromStorageUrl(url)); + case 'aws-s3': + return awsS3Delete(getFileNameFromStorageUrl(url)); + } +}; + +const getStorageUrlsForPrefix = async (prefix = ''): Promise => { + const urls: string[] = []; + + if (HAS_VERCEL_BLOB_STORAGE) { + urls.push(...await vercelBlobList(prefix)); + } + if (HAS_AWS_S3_STORAGE) { + urls.push(...await awsS3List(prefix)); + } + if (HAS_CLOUDFLARE_R2_STORAGE) { + urls.push(...await cloudflareR2List(prefix)); + } + + return urls; +}; + +export const getStorageUploadUrls = () => + getStorageUrlsForPrefix(`${PREFIX_UPLOAD}-`); + +export const getStoragePhotoUrls = () => + getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`); diff --git a/src/services/blob/vercel-blob.ts b/src/services/storage/vercel-blob.ts similarity index 83% rename from src/services/blob/vercel-blob.ts rename to src/services/storage/vercel-blob.ts index f14b2041..a8b5094f 100644 --- a/src/services/blob/vercel-blob.ts +++ b/src/services/storage/vercel-blob.ts @@ -1,4 +1,4 @@ -import { PATH_ADMIN_UPLOAD_BLOB } from '@/site/paths'; +import { PATH_API_VERCEL_BLOB_UPLOAD } from '@/site/paths'; import { copy, del, list } from '@vercel/blob'; import { upload } from '@vercel/blob/client'; @@ -9,6 +9,9 @@ const VERCEL_BLOB_STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match( export const VERCEL_BLOB_BASE_URL = `https://${VERCEL_BLOB_STORE_ID}.public.blob.vercel-storage.com`; +export const isUrlFromVercelBlob = (url: string) => + url.startsWith(VERCEL_BLOB_BASE_URL); + export const vercelBlobUploadFromClient = async ( file: File | Blob, fileName: string, @@ -18,7 +21,7 @@ export const vercelBlobUploadFromClient = async ( file, { access: 'public', - handleUploadUrl: PATH_ADMIN_UPLOAD_BLOB, + handleUploadUrl: PATH_API_VERCEL_BLOB_UPLOAD, }, ) .then(({ url }) => url); diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx index 80484739..6ea9004e 100644 --- a/src/site/SiteChecklistClient.tsx +++ b/src/site/SiteChecklistClient.tsx @@ -19,12 +19,16 @@ import Checklist from '@/components/Checklist'; import { toastSuccess } from '@/toast'; import { ConfigChecklistStatus } from './config'; import StatusIcon from '@/components/StatusIcon'; +import { labelForStorage } from '@/services/storage'; export default function SiteChecklistClient({ hasPostgres, - hasBlob, - hasVercelBlob, + hasStorage, + hasVercelBlobStorage, + hasCloudflareR2Storage, hasAwsS3Storage, + hasMultipleStorageProviders, + currentStorage, hasAuth, hasAdminUser, hasTitle, @@ -36,8 +40,8 @@ export default function SiteChecklistClient({ isPriorityOrderEnabled, isPublicApiEnabled, isOgTextBottomAligned, - showRefreshButton, gridAspectRatio, + showRefreshButton, secret, }: ConfigChecklistStatus & { showRefreshButton?: boolean @@ -139,14 +143,18 @@ export default function SiteChecklistClient({ and connect to project {renderSubStatus( - hasVercelBlob ? 'checked' : 'optional', + hasVercelBlobStorage ? 'checked' : 'optional', <> - Vercel Blob: + {labelForStorage('vercel-blob')}: {' '} {renderLink( // eslint-disable-next-line max-len @@ -157,10 +165,21 @@ export default function SiteChecklistClient({ and connect to project , )} + {renderSubStatus( + hasCloudflareR2Storage ? 'checked' : 'optional', + <> + {labelForStorage('cloudflare-r2')}: + {' '} + {renderLink( + 'https://github.com/sambecker/exif-photo-blog#cloudflare-r2', + 'create/configure bucket', + )} + + )} {renderSubStatus( hasAwsS3Storage ? 'checked' : 'optional', <> - AWS S3: + {labelForStorage('aws-s3')}: {' '} {renderLink( 'https://github.com/sambecker/exif-photo-blog#aws-s3', diff --git a/src/site/config.ts b/src/site/config.ts index 4cfb07be..c8f131f1 100644 --- a/src/site/config.ts +++ b/src/site/config.ts @@ -1,3 +1,4 @@ +import type { StorageType } from '@/services/storage'; import { makeUrlAbsolute, shortenUrl } from '@/utility/url'; // META / DOMAINS @@ -31,12 +32,22 @@ export const BASE_URL = process.env.NODE_ENV === 'production' : 'http://localhost:3000'; // STORAGE: VERCEL BLOB -export const HAS_VERCEL_BLOB = +export const HAS_VERCEL_BLOB_STORAGE = (process.env.BLOB_READ_WRITE_TOKEN ?? '').length > 0; +// STORAGE: Cloudflare R2 +// Includes separate check for client-side usage, i.e., url construction +export const HAS_CLOUDFLARE_R2_STORAGE_CLIENT = + (process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET ?? '').length > 0 && + (process.env.NEXT_PUBLIC_CLOUDFLARE_R2_ACCOUNT_ID ?? '').length > 0 && + (process.env.NEXT_PUBLIC_CLOUDFLARE_R2_DEV_SUBDOMAIN ?? '').length > 0; +export const HAS_CLOUDFLARE_R2_STORAGE = + HAS_CLOUDFLARE_R2_STORAGE_CLIENT && + (process.env.CLOUDFLARE_R2_ACCESS_KEY ?? '').length > 0 && + (process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY ?? '').length > 0; + // STORAGE: AWS S3 -// Includes separate check for client-side usage, -// i.e., uploading, url construction +// Includes separate check for client-side usage, i.e., url construction export const HAS_AWS_S3_STORAGE_CLIENT = (process.env.NEXT_PUBLIC_AWS_S3_BUCKET ?? '').length > 0 && (process.env.NEXT_PUBLIC_AWS_S3_REGION ?? '').length > 0; @@ -45,6 +56,23 @@ export const HAS_AWS_S3_STORAGE = (process.env.AWS_S3_ACCESS_KEY ?? '').length > 0 && (process.env.AWS_S3_SECRET_ACCESS_KEY ?? '').length > 0; +export const HAS_MULTIPLE_STORAGE_PROVIDERS = [ + HAS_VERCEL_BLOB_STORAGE, + HAS_CLOUDFLARE_R2_STORAGE, + HAS_AWS_S3_STORAGE, +].filter(Boolean).length > 1; + +// Storage preference requires client-available keys +// so it can be reached in the browser when uploading +export const CURRENT_STORAGE: StorageType = + (process.env.NEXT_PUBLIC_STORAGE_PREFERENCE as StorageType | undefined) || ( + HAS_CLOUDFLARE_R2_STORAGE_CLIENT + ? 'cloudflare-r2' + : HAS_AWS_S3_STORAGE_CLIENT + ? 'aws-s3' + : 'vercel-blob' + ); + // SETTINGS export const PRO_MODE_ENABLED = process.env.NEXT_PUBLIC_PRO_MODE === '1'; @@ -65,9 +93,15 @@ export const HIGH_DENSITY_GRID = GRID_ASPECT_RATIO <= 1; export const CONFIG_CHECKLIST_STATUS = { hasPostgres: (process.env.POSTGRES_HOST ?? '').length > 0, - hasBlob: HAS_VERCEL_BLOB || HAS_AWS_S3_STORAGE, - hasVercelBlob: HAS_VERCEL_BLOB, + hasVercelBlobStorage: HAS_VERCEL_BLOB_STORAGE, + hasCloudflareR2Storage: HAS_CLOUDFLARE_R2_STORAGE, hasAwsS3Storage: HAS_AWS_S3_STORAGE, + hasStorage: + HAS_VERCEL_BLOB_STORAGE || + HAS_CLOUDFLARE_R2_STORAGE || + HAS_AWS_S3_STORAGE, + hasMultipleStorageProviders: HAS_MULTIPLE_STORAGE_PROVIDERS, + currentStorage: CURRENT_STORAGE, hasAuth: (process.env.AUTH_SECRET ?? '').length > 0, hasAdminUser: ( (process.env.ADMIN_EMAIL ?? '').length > 0 && @@ -89,6 +123,6 @@ export type ConfigChecklistStatus = typeof CONFIG_CHECKLIST_STATUS; export const IS_SITE_READY = CONFIG_CHECKLIST_STATUS.hasPostgres && - CONFIG_CHECKLIST_STATUS.hasBlob && + CONFIG_CHECKLIST_STATUS.hasStorage && CONFIG_CHECKLIST_STATUS.hasAuth && CONFIG_CHECKLIST_STATUS.hasAdminUser; diff --git a/src/site/paths.ts b/src/site/paths.ts index 77c8d9f2..e6b699ce 100644 --- a/src/site/paths.ts +++ b/src/site/paths.ts @@ -12,6 +12,7 @@ export const PATH_ROOT = '/'; export const PATH_GRID = '/grid'; export const PATH_SETS = '/sets'; export const PATH_ADMIN = '/admin'; +export const PATH_API = '/api'; export const PATH_SIGN_IN = '/sign-in'; export const PATH_OG = '/og'; @@ -31,9 +32,13 @@ const PATH_FILM_SIMULATION_DYNAMIC = `${PREFIX_FILM_SIMULATION}/[simulation]`; export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`; export const PATH_ADMIN_UPLOADS = `${PATH_ADMIN}/uploads`; export const PATH_ADMIN_TAGS = `${PATH_ADMIN}/tags`; -export const PATH_ADMIN_UPLOAD_BLOB = `${PATH_ADMIN_UPLOADS}/blob`; export const PATH_ADMIN_CONFIGURATION = `${PATH_ADMIN}/configuration`; +// API paths +export const PATH_API_STORAGE = `${PATH_API}/storage`; +export const PATH_API_VERCEL_BLOB_UPLOAD = `${PATH_API_STORAGE}/vercel-blob`; +export const PATH_API_PRESIGNED_URL = `${PATH_API_STORAGE}/presigned-url`; + // Modifiers const SHARE = 'share'; const NEXT = 'next'; @@ -44,7 +49,6 @@ export const PATHS_ADMIN = [ PATH_ADMIN_PHOTOS, PATH_ADMIN_UPLOADS, PATH_ADMIN_TAGS, - PATH_ADMIN_UPLOAD_BLOB, PATH_ADMIN_CONFIGURATION, ];