diff --git a/@app/client/src/graphql/CreateUploadUrl.graphql b/@app/client/src/graphql/CreateUploadUrl.graphql new file mode 100644 index 00000000..5c14369e --- /dev/null +++ b/@app/client/src/graphql/CreateUploadUrl.graphql @@ -0,0 +1,5 @@ +mutation CreateUploadUrl($input: CreateUploadUrlInput!) { + createUploadUrl(input: $input) { + uploadUrl + } +} diff --git a/@app/client/src/graphql/UpdateUser.graphql b/@app/client/src/graphql/UpdateUser.graphql index 2f70178f..496cd3f5 100644 --- a/@app/client/src/graphql/UpdateUser.graphql +++ b/@app/client/src/graphql/UpdateUser.graphql @@ -5,6 +5,7 @@ mutation UpdateUser($id: UUID!, $patch: UserPatch!) { id name username + avatarUrl } } } diff --git a/@app/client/src/pages/settings/index.tsx b/@app/client/src/pages/settings/index.tsx index 3c1a1754..4ee79fc0 100644 --- a/@app/client/src/pages/settings/index.tsx +++ b/@app/client/src/pages/settings/index.tsx @@ -1,4 +1,9 @@ -import { ErrorAlert, Redirect, SettingsLayout } from "@app/components"; +import { + AvatarUpload, + ErrorAlert, + Redirect, + SettingsLayout, +} from "@app/components"; import { ProfileSettingsForm_UserFragment, useSettingsProfileQuery, @@ -10,7 +15,7 @@ import { getCodeFromError, tailFormItemLayout, } from "@app/lib"; -import { Alert, Button, Form, Input, PageHeader } from "antd"; +import { Alert, Button, Card, Form, Input, PageHeader } from "antd"; import { useForm } from "antd/lib/form/util"; import { ApolloError } from "apollo-client"; import { NextPage } from "next"; @@ -104,64 +109,76 @@ function ProfileSettingsForm({ const code = getCodeFromError(error); return (
- -
- - - - + + - - - {error ? ( - - - {extractError(error).message} - {code ? ( - - {" "} - (Error code: ERR_{code}) - - ) : null} - - } - /> + + - ) : success ? ( - - + + - ) : null} - - - - + {error ? ( + + + {extractError(error).message} + {code ? ( + + {" "} + (Error code: ERR_{code}) + + ) : null} + + } + /> + + ) : success ? ( + + + + ) : null} + + + + + + +
+ +
+
); } diff --git a/@app/components/package.json b/@app/components/package.json index 85d97d63..3ba1b1b8 100644 --- a/@app/components/package.json +++ b/@app/components/package.json @@ -11,8 +11,11 @@ "@apollo/react-common": "^3.1.4", "@apollo/react-hooks": "^3.1.5", "@app/graphql": "0.0.0", + "@app/lib": "0.0.0", + "@types/axios": "^0.14.0", "antd": "^4.2.0", "apollo-client": "^2.6.8", + "axios": "^0.19.2", "next": "^9.3.6", "react": "^16.13.1", "tslib": "^1.11.1" diff --git a/@app/components/src/AvatarUpload.tsx b/@app/components/src/AvatarUpload.tsx new file mode 100644 index 00000000..1451dfc8 --- /dev/null +++ b/@app/components/src/AvatarUpload.tsx @@ -0,0 +1,158 @@ +import { LoadingOutlined, PlusOutlined } from "@ant-design/icons"; +import { + ProfileSettingsForm_UserFragment, + useCreateUploadUrlMutation, + useUpdateUserMutation, +} from "@app/graphql"; +import { extractError, getExceptionFromError } from "@app/lib"; +import { message, Upload } from "antd"; +import { + RcCustomRequestOptions, + UploadChangeParam, + UploadFile, +} from "antd/lib/upload/interface"; +import axios from "axios"; +import React, { useState } from "react"; +import slugify from "slugify"; + +export function getUid(name: string) { + const randomHex = () => Math.floor(Math.random() * 16777215).toString(16); + const fileNameSlug = slugify(name); + return randomHex() + "-" + fileNameSlug; +} + +const ALLOWED_UPLOAD_CONTENT_TYPES = [ + "image/apng", + "image/bmp", + "image/gif", + "image/jpeg", + "image/png", + "image/svg+xml", + "image/tiff", + "image/webp", +]; +const ALLOWED_UPLOAD_CONTENT_TYPES_STRING = ALLOWED_UPLOAD_CONTENT_TYPES.join( + "," +); + +export function AvatarUpload({ + user, +}: { + user: ProfileSettingsForm_UserFragment; +}) { + const [updateUser] = useUpdateUserMutation(); + + const beforeUpload = (file: any) => { + const fileName = file.name.split(".")[0]; + const fileType = file.name.split(".")[1]; + file.uid = getUid(fileName) + "." + fileType; + const isJpgOrPng = file.type === "image/jpeg" || file.type === "image/png"; + if (!isJpgOrPng) { + message.error("You can only upload JPG or PNG images!"); + file.status = "error"; + } + const isLt3M = file.size / 1024 / 1024 < 3; + if (!isLt3M) { + message.error("Image must smaller than 3MB!"); + file.status = "error"; + } + return isJpgOrPng && isLt3M; + }; + + const [createUploadUrl] = useCreateUploadUrlMutation(); + + const [loading, setLoading] = useState(false); + + const customRequest = async (option: RcCustomRequestOptions) => { + const { onSuccess, onError, file, onProgress } = option; + try { + const contentType = file.type; + const { data } = await createUploadUrl({ + variables: { + input: { + contentType, + }, + }, + }); + const uploadUrl = data?.createUploadUrl?.uploadUrl; + + if (!uploadUrl) { + throw new Error("Failed to generate upload URL"); + } + const response = await axios.put(uploadUrl, file, { + onUploadProgress: (e) => { + const progress = Math.round((e.loaded / e.total) * 100); + onProgress({ percent: progress }, file); + }, + }); + if (response.config.url) { + await updateUser({ + variables: { + id: user.id, + patch: { + avatarUrl: response.config.url.split("?")[0], + }, + }, + }); + onSuccess(response.config, file); + } + } catch (e) { + console.error(e); + onError(e); + } + }; + + const uploadButton = ( +
+ {loading ? : } +
Upload
+
+ ); + + const onChange = (info: UploadChangeParam>) => { + switch (info.file.status) { + case "uploading": { + setLoading(true); + break; + } + case "removed": + case "success": { + setLoading(false); + break; + } + case "error": { + const error: any = getExceptionFromError(info.file.error); + console.dir(error); + message.error( + typeof error === "string" + ? error + : error?.message ?? + "Unknown error occurred" + + (error?.code ? ` (${error.code})` : "") + ); + setLoading(false); + break; + } + } + }; + + return ( +
+ + {user.avatarUrl ? ( + avatar + ) : ( + uploadButton + )} + +
+ ); +} diff --git a/@app/components/src/SharedLayout.tsx b/@app/components/src/SharedLayout.tsx index 0d07ac8c..fca5d7cd 100644 --- a/@app/components/src/SharedLayout.tsx +++ b/@app/components/src/SharedLayout.tsx @@ -18,6 +18,7 @@ import { useCallback } from "react"; import { ErrorAlert, H3, StandardWidth, Warn } from "."; import { Redirect } from "./Redirect"; +import { UserAvatar } from "./UserAvatar"; const { Header, Content, Footer } = Layout; const { Text } = Typography; @@ -243,9 +244,7 @@ export function SharedLayout({ data-cy="layout-dropdown-user" style={{ whiteSpace: "nowrap" }} > - - {(data.currentUser.name && data.currentUser.name[0]) || "?"} - + {data.currentUser.name} diff --git a/@app/components/src/UserAvatar.tsx b/@app/components/src/UserAvatar.tsx new file mode 100644 index 00000000..b5f19ca4 --- /dev/null +++ b/@app/components/src/UserAvatar.tsx @@ -0,0 +1,16 @@ +import { Avatar } from "antd"; +import React, { FC } from "react"; + +export const UserAvatar: FC<{ + user: { + name?: string | null; + avatarUrl?: string | null; + }; +}> = (props) => { + const { name, avatarUrl } = props.user; + if (avatarUrl) { + return ; + } else { + return {(name && name[0]) || "?"}; + } +}; diff --git a/@app/components/src/index.tsx b/@app/components/src/index.tsx index 5dafc0b5..3a16507b 100644 --- a/@app/components/src/index.tsx +++ b/@app/components/src/index.tsx @@ -1,3 +1,4 @@ +export * from "./AvatarUpload"; export * from "./ButtonLink"; export * from "./ErrorAlert"; export * from "./ErrorOccurred"; @@ -11,5 +12,6 @@ export * from "./SocialLoginOptions"; export * from "./SpinPadded"; export * from "./StandardWidth"; export * from "./Text"; +export * from "./UserAvatar"; export * from "./Warn"; export * from "./organizationHooks"; diff --git a/@app/server/package.json b/@app/server/package.json index e922bd70..17f3d91f 100644 --- a/@app/server/package.json +++ b/@app/server/package.json @@ -24,6 +24,8 @@ "@types/passport-github2": "^1.2.4", "@types/pg": "^7.14.1", "@types/redis": "^2.8.18", + "@types/uuid": "^7.0.3", + "aws-sdk": "^2.668.0", "body-parser": "^1.19.0", "chalk": "^4.0.0", "connect-pg-simple": "^6.1.0", @@ -44,7 +46,8 @@ "postgraphile": "^4.7.0", "redis": "^3.0.2", "source-map-support": "^0.5.13", - "tslib": "^1.11.1" + "tslib": "^1.11.1", + "uuid": "^8.0.0" }, "devDependencies": { "@types/node": "^13.13.4", diff --git a/@app/server/src/middleware/installPostGraphile.ts b/@app/server/src/middleware/installPostGraphile.ts index 705d899a..54ba5940 100644 --- a/@app/server/src/middleware/installPostGraphile.ts +++ b/@app/server/src/middleware/installPostGraphile.ts @@ -15,6 +15,7 @@ import { import { makePgSmartTagsFromFilePlugin } from "postgraphile/plugins"; import { getHttpServer, getWebsocketMiddlewares } from "../app"; +import CreateUploadUrlPlugin from "../plugins/CreateUploadUrlPlugin"; import OrdersPlugin from "../plugins/Orders"; import PassportLoginPlugin from "../plugins/PassportLoginPlugin"; import PrimaryKeyMutationsOnlyPlugin from "../plugins/PrimaryKeyMutationsOnlyPlugin"; @@ -183,6 +184,9 @@ export function getPostGraphileOptions({ // Adds custom orders to our GraphQL schema OrdersPlugin, + + // Allows API clients to fetch a pre-signed URL for uploading files + CreateUploadUrlPlugin, ], /* diff --git a/@app/server/src/plugins/CreateUploadUrlPlugin.ts b/@app/server/src/plugins/CreateUploadUrlPlugin.ts new file mode 100644 index 00000000..248730f7 --- /dev/null +++ b/@app/server/src/plugins/CreateUploadUrlPlugin.ts @@ -0,0 +1,170 @@ +import { awsRegion } from "@app/config"; +import * as aws from "aws-sdk"; +import { gql, makeExtendSchemaPlugin } from "graphile-utils"; +import { Pool } from "pg"; +import { v4 as uuidv4 } from "uuid"; + +import { OurGraphQLContext } from "../middleware/installPostGraphile"; + +const uploadBucket = process.env.S3_UPLOADS_BUCKET; + +const s3 = new aws.S3({ + region: awsRegion, + signatureVersion: "v4", +}); + +interface CreateUploadUrlInput { + clientMutationId?: string; + contentType: string; +} + +/** The minimal set of information that this plugin needs to know about users. */ +interface User { + id: string; + isVerified: boolean; +} + +async function getCurrentUser(pool: Pool): Promise { + await pool.query("SAVEPOINT"); + try { + const { + rows: [row], + } = await pool.query( + "select id, is_verified from app_public.users where id = app_public.current_user_id()" + ); + if (!row) { + return null; + } + return { + id: row.id, + isVerified: row.is_verified, + }; + } catch (err) { + await pool.query("ROLLBACK TO SAVEPOINT"); + throw err; + } finally { + await pool.query("RELEASE SAVEPOINT"); + } +} +/** The set of content types that we allow users to upload.*/ +const ALLOWED_UPLOAD_CONTENT_TYPES = [ + "image/apng", + "image/bmp", + "image/gif", + "image/jpeg", + "image/png", + "image/svg+xml", + "image/tiff", + "image/webp", +]; + +const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ + typeDefs: gql` + """ + All input for the \`createUploadUrl\` mutation. + """ + input CreateUploadUrlInput @scope(isMutationInput: true) { + """ + An arbitrary string value with no semantic meaning. Will be included in the + payload verbatim. May be used to track mutations by the client. + """ + clientMutationId: String + + """ + You must provide the content type (or MIME type) of the content you intend + to upload. For further information about content types, see + https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types + """ + contentType: String! + } + + """ + The output of our \`createUploadUrl\` mutation. + """ + type CreateUploadUrlPayload @scope(isMutationPayload: true) { + """ + The exact same \`clientMutationId\` that was provided in the mutation input, + unchanged and unused. May be used by a client to track mutations. + """ + clientMutationId: String + + """ + Upload content to this signed URL. + """ + uploadUrl: String! + } + + extend type Mutation { + """ + Get a signed URL for uploading files. It will expire in 5 minutes. + """ + createUploadUrl( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: CreateUploadUrlInput! + ): CreateUploadUrlPayload + } + `, + resolvers: { + Mutation: { + async createUploadUrl( + _query, + args: { input: CreateUploadUrlInput }, + context: OurGraphQLContext, + _resolveInfo + ) { + if (!uploadBucket) { + const err = new Error( + "Server misconfigured: missing `S3_UPLOADS_BUCKET` envvar" + ); + // @ts-ignore + err.code = "MSCFG"; + throw err; + } + + const user = await getCurrentUser(context.rootPgPool); + + if (!user) { + const err = new Error("Login required"); + // @ts-ignore + err.code = "LOGIN"; + throw err; + } + + if (!user.isVerified) { + const err = new Error("Only verified users may upload files"); + // @ts-ignore + err.code = "DNIED"; + throw err; + } + + const { + input: { contentType, clientMutationId }, + } = args; + if (!ALLOWED_UPLOAD_CONTENT_TYPES.includes(contentType)) { + throw new Error( + `Not allowed to upload that type; allowed types include: '${ALLOWED_UPLOAD_CONTENT_TYPES.join( + "', '" + )}'` + ); + } + const params = { + Bucket: uploadBucket, + ContentType: contentType, + // randomly generated filename, nested under username directory + Key: `avatars/${user.id}/${uuidv4()}`, + Expires: 300, // signed URL will expire in 5 minutes + ACL: "public-read", // uploaded file will be publicly readable + }; + const signedUrl = await s3.getSignedUrlPromise("putObject", params); + return { + clientMutationId, + uploadUrl: signedUrl, + }; + }, + }, + }, +})); + +export default CreateUploadUrlPlugin; diff --git a/@app/server/src/utils/handleErrors.ts b/@app/server/src/utils/handleErrors.ts index a80213eb..e0d85650 100644 --- a/@app/server/src/utils/handleErrors.ts +++ b/@app/server/src/utils/handleErrors.ts @@ -58,6 +58,10 @@ export const ERROR_MESSAGE_OVERRIDES: { [code: string]: typeof pluck } = { fields: conflictFieldsFromError(err), code: "NUNIQ", }), + MSCFG: (err) => ({ + ...pluck(err), + message: err.message, + }), }; function conflictFieldsFromError(err: any) { diff --git a/data/schema.graphql b/data/schema.graphql index ff39193c..df9ec8a6 100644 --- a/data/schema.graphql +++ b/data/schema.graphql @@ -106,6 +106,39 @@ type CreateOrganizationPayload { query: Query } +"""All input for the `createUploadUrl` mutation.""" +input CreateUploadUrlInput { + """ + An arbitrary string value with no semantic meaning. Will be included in the + payload verbatim. May be used to track mutations by the client. + """ + clientMutationId: String + + """ + You must provide the content type (or MIME type) of the content you intend + to upload. For further information about content types, see + https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types + """ + contentType: String! +} + +"""The output of our `createUploadUrl` mutation.""" +type CreateUploadUrlPayload { + """ + The exact same `clientMutationId` that was provided in the mutation input, + unchanged and unused. May be used by a client to track mutations. + """ + clientMutationId: String + + """ + Our root query field type. Allows us to run any query from our mutation payload. + """ + query: Query + + """Upload content to this signed URL.""" + uploadUrl: String! +} + """All input for the create `UserEmail` mutation.""" input CreateUserEmailInput { """ @@ -391,6 +424,14 @@ type Mutation { input: CreateOrganizationInput! ): CreateOrganizationPayload + """Get a signed URL for uploading files. It will expire in 5 minutes.""" + createUploadUrl( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: CreateUploadUrlInput! + ): CreateUploadUrlPayload + """Creates a single `UserEmail`.""" createUserEmail( """ diff --git a/docs/error_codes.md b/docs/error_codes.md index 83898e4b..8c55c58a 100644 --- a/docs/error_codes.md +++ b/docs/error_codes.md @@ -33,6 +33,7 @@ Rewritten, the above rules state: - DNIED: permission denied - NUNIQ: not unique (from PostgreSQL 23505) - NTFND: not found +- MSCFG: server misconfigured ## Registration diff --git a/yarn.lock b/yarn.lock index 6fead50e..3afa9013 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2675,6 +2675,13 @@ dependencies: "@types/node" "*" +"@types/axios@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@types/axios/-/axios-0.14.0.tgz#ec2300fbe7d7dddd7eb9d3abf87999964cafce46" + integrity sha1-7CMA++fX3d1+udOr+HmZlkyvzkY= + dependencies: + axios "*" + "@types/babel__core@^7.1.7": version "7.1.7" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.7.tgz#1dacad8840364a57c98d0dd4855c6dd3752c6b89" @@ -3174,6 +3181,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== +"@types/uuid@^7.0.3": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-7.0.3.tgz#45cd03e98e758f8581c79c535afbd4fc27ba7ac8" + integrity sha512-PUdqTZVrNYTNcIhLHkiaYzoOIaUi5LFg/XLerAdgvwQrUCx+oSbtoBze1AMyvYbcwzUSNC+Isl58SM4Sm/6COw== + "@types/ws@^6.0.1": version "6.0.4" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-6.0.4.tgz#7797707c8acce8f76d8c34b370d4645b70421ff1" @@ -4089,6 +4101,13 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e" integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug== +axios@*, axios@^0.19.2: + version "0.19.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27" + integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA== + dependencies: + follow-redirects "1.5.10" + babel-code-frame@^6.22.0, babel-code-frame@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" @@ -5930,7 +5949,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.9: dependencies: ms "2.0.0" -debug@3.1.0: +debug@3.1.0, debug@=3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== @@ -7305,6 +7324,13 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" +follow-redirects@1.5.10: + version "1.5.10" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" + integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ== + dependencies: + debug "=3.1.0" + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -11962,9 +11988,9 @@ performance-now@^2.1.0: integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= pg-connection-string@0.1.3, pg-connection-string@2.x, pg-connection-string@^2.0.0, pg-connection-string@^2.1.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.2.1.tgz#fd22870c4cdff66f085a8759ee68cde8e9ca0cbf" - integrity sha512-WnIjIJR575VyX+jRqpSt5PqbIzr3+1D793CkBbQWvpiNTGuHffZEO9+VrwAw0XSVfvxea7QiplHINGahWe9rjg== + version "2.2.2" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.2.2.tgz#fdc2fb2084e655f0b3e6e50c69330f6932df452e" + integrity sha512-+hel4DGuSZCjCZwglAuyi+XlodHnKmrbyTw0hVWlmGN2o4AfJDkDo5obAFzblS5M5PFBMx0uDt5Y1QjlNC+tqg== pg-int8@1.0.1: version "1.0.1" @@ -15190,6 +15216,17 @@ ts-log@2.1.4: resolved "https://registry.yarnpkg.com/ts-log/-/ts-log-2.1.4.tgz#063c5ad1cbab5d49d258d18015963489fb6fb59a" integrity sha512-P1EJSoyV+N3bR/IWFeAqXzKPZwHpnLY6j7j58mAvewHRipo+BQM2Y1f9Y9BjEQznKwgqqZm7H8iuixmssU7tYQ== +ts-node@^8.10.1: + version "8.10.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.10.1.tgz#77da0366ff8afbe733596361d2df9a60fc9c9bd3" + integrity sha512-bdNz1L4ekHiJul6SHtZWs1ujEKERJnHs4HxN7rjTyyVOFf3HaJ6sLqe6aPG62XTzAB/63pKRh5jTSWL0D7bsvw== + dependencies: + arg "^4.1.0" + diff "^4.0.1" + make-error "^1.1.1" + source-map-support "^0.5.17" + yn "3.1.1" + ts-node@^8.9.1: version "8.9.1" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.9.1.tgz#2f857f46c47e91dcd28a14e052482eb14cfd65a5" @@ -15623,6 +15660,11 @@ uuid@^7.0.3: resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== +uuid@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.0.0.tgz#bc6ccf91b5ff0ac07bbcdbf1c7c4e150db4dbb6c" + integrity sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw== + v8-compile-cache@^2.0.3: version "2.1.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e"