Get image upload to cloudinary with the same public id

This means we can put a consistent url in the head for the card image
This commit is contained in:
Kurt Hutten
2021-08-14 15:19:48 +10:00
parent 50e9ac61f8
commit 32155ba98c
17 changed files with 425 additions and 77 deletions

View File

@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "SocialCard" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"url" TEXT,
"outOfDate" BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SocialCard_projectId_unique" ON "SocialCard"("projectId");
-- AddForeignKey
ALTER TABLE "SocialCard" ADD FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -56,12 +56,24 @@ model Project {
userId String userId String
deleted Boolean @default(false) deleted Boolean @default(false)
cadPackage CadPackage @default(openscad) cadPackage CadPackage @default(openscad)
socialCard SocialCard?
Comment Comment[] Comment Comment[]
Reaction ProjectReaction[] Reaction ProjectReaction[]
@@unique([title, userId]) @@unique([title, userId])
} }
model SocialCard {
id String @id @default(uuid())
projectId String
project Project @relation(fields: [projectId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
url String? // link to cloudinary
outOfDate Boolean @default(true)
}
model ProjectReaction { model ProjectReaction {
id String @id @default(uuid()) id String @id @default(uuid())
emote String // an emoji emote String // an emoji

View File

@@ -0,0 +1,72 @@
import type { APIGatewayEvent } from 'aws-lambda'
import { logger } from 'src/lib/logger'
import { v2 as cloudinary, UploadApiResponse } from 'cloudinary'
// import { requireOwnership } from 'src/lib/owner'
// import { requireAuth } from 'src/lib/auth'
cloudinary.config({
cloud_name: 'irevdev',
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
})
/**
* The handler function is your code that processes http request events.
* You can use return and throw to send a response or error, respectively.
*
* Important: When deployed, a custom serverless function is an open API endpoint and
* is your responsibility to secure appropriately.
*
* @see {@link https://redwoodjs.com/docs/serverless-functions#security-considerations|Serverless Function Considerations}
* in the RedwoodJS documentation for more information.
*
* @typedef { import('aws-lambda').APIGatewayEvent } APIGatewayEvent
* @typedef { import('aws-lambda').Context } Context
* @param { APIGatewayEvent } event - an object which contains information from the invoker.
* @param { Context } context - contains information about the invocation,
* function, and execution environment.
*/
export const handler = async (event: APIGatewayEvent, _context) => {
logger.info('Invoked image-upload function')
// requireAuth()
const {
image64,
upload_preset,
public_id,
invalidate,
projectId,
}: {
image64: string
upload_preset: string
public_id: string
invalidate: boolean
projectId: string
} = JSON.parse(event.body)
// await requireOwnership({projectId})
const uploadResult: UploadApiResponse = await new Promise(
(resolve, reject) => {
cloudinary.uploader.upload(
image64,
{ upload_preset, public_id, invalidate },
(error, result) => {
if (error) {
reject(error)
return
}
resolve(result)
}
)
}
)
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
publicId: uploadResult.public_id,
}),
}
}

View File

@@ -11,6 +11,7 @@ export const schema = gql`
userId: String! userId: String!
deleted: Boolean! deleted: Boolean!
cadPackage: CadPackage! cadPackage: CadPackage!
socialCard: SocialCard
Comment: [Comment]! Comment: [Comment]!
Reaction(userId: String): [ProjectReaction]! Reaction(userId: String): [ProjectReaction]!
} }

View File

@@ -0,0 +1,35 @@
export const schema = gql`
type SocialCard {
id: String!
projectId: String!
project: Project!
createdAt: DateTime!
updatedAt: DateTime!
url: String
outOfDate: Boolean!
}
type Query {
socialCards: [SocialCard!]!
socialCard(id: String!): SocialCard
}
input CreateSocialCardInput {
projectId: String!
url: String
outOfDate: Boolean!
}
input UpdateSocialCardInput {
projectId: String
url: String
outOfDate: Boolean
}
type Mutation {
createSocialCard(input: CreateSocialCardInput!): SocialCard!
updateSocialCard(id: String!, input: UpdateSocialCardInput!): SocialCard!
deleteSocialCard(id: String!): SocialCard!
updateSocialCardByProjectId(projectId: String!, url: String!): SocialCard!
}
`

View File

@@ -5,10 +5,16 @@ export const requireOwnership = async ({
userId, userId,
userName, userName,
projectId, projectId,
}: { userId?: string; userName?: string; projectId?: string } = {}) => { sub,
}: {
userId?: string
userName?: string
projectId?: string
sub?: string
} = {}) => {
// IMPORTANT, don't forget to await this function, as it will only block // IMPORTANT, don't forget to await this function, as it will only block
// unwanted db actions if it has time to look up resources in the db. // unwanted db actions if it has time to look up resources in the db.
if (!context.currentUser) { if (!(context?.currentUser || sub)) {
throw new AuthenticationError("You don't have permission to do that.") throw new AuthenticationError("You don't have permission to do that.")
} }
if (!userId && !userName && !projectId) { if (!userId && !userName && !projectId) {
@@ -22,7 +28,7 @@ export const requireOwnership = async ({
return return
} }
const netlifyUserId = context.currentUser?.sub const netlifyUserId = context?.currentUser?.sub || sub
if (userId && userId !== netlifyUserId) { if (userId && userId !== netlifyUserId) {
throw new ForbiddenError("You don't own this resource.") throw new ForbiddenError("You don't own this resource.")
} }

View File

@@ -130,6 +130,8 @@ export const deleteProject = async ({ id }: Prisma.ProjectWhereUniqueInput) => {
export const Project = { export const Project = {
user: (_obj, { root }: ResolverArgs<ReturnType<typeof project>>) => user: (_obj, { root }: ResolverArgs<ReturnType<typeof project>>) =>
db.project.findUnique({ where: { id: root.id } }).user(), db.project.findUnique({ where: { id: root.id } }).user(),
socialCard: (_obj, { root }: ResolverArgs<ReturnType<typeof project>>) =>
db.project.findUnique({ where: { id: root.id } }).socialCard(),
Comment: (_obj, { root }: ResolverArgs<ReturnType<typeof project>>) => Comment: (_obj, { root }: ResolverArgs<ReturnType<typeof project>>) =>
db.project db.project
.findUnique({ where: { id: root.id } }) .findUnique({ where: { id: root.id } })

View File

@@ -0,0 +1,84 @@
import type { Prisma } from '@prisma/client'
import type { ResolverArgs, BeforeResolverSpecType } from '@redwoodjs/api'
import { db } from 'src/lib/db'
import { requireAuth } from 'src/lib/auth'
// Used when the environment variable REDWOOD_SECURE_SERVICES=1
export const beforeResolver = (rules: BeforeResolverSpecType) => {
rules.add(requireAuth)
}
export const socialCards = () => {
return db.socialCard.findMany()
}
export const socialCard = ({ id }: Prisma.SocialCardWhereUniqueInput) => {
return db.socialCard.findUnique({
where: { id },
})
}
interface CreateSocialCardArgs {
input: Prisma.SocialCardCreateInput
}
export const createSocialCard = ({ input }: CreateSocialCardArgs) => {
return db.socialCard.create({
data: input,
})
}
interface UpdateSocialCardArgs extends Prisma.SocialCardWhereUniqueInput {
input: Prisma.SocialCardUpdateInput
}
export const updateSocialCard = ({ id, input }: UpdateSocialCardArgs) => {
return db.socialCard.update({
data: input,
where: { id },
})
}
export const updateSocialCardByProjectId = async ({
projectId,
url,
}: {
url: string
projectId: string
}) => {
let id: string
try {
const socialCard = await db.project
.findUnique({ where: { id: projectId } })
.socialCard()
id = socialCard.id
} catch (e) {
return db.socialCard.create({
data: {
url,
project: {
connect: {
id: projectId,
},
},
},
})
}
return db.socialCard.update({
data: { url },
where: { id },
})
}
export const deleteSocialCard = ({ id }: Prisma.SocialCardWhereUniqueInput) => {
return db.socialCard.delete({
where: { id },
})
}
export const SocialCard = {
project: (_obj, { root }: ResolverArgs<ReturnType<typeof socialCard>>) =>
db.socialCard.findUnique({ where: { id: root.id } }).project(),
}

View File

@@ -28,6 +28,7 @@
"cloudinary-react": "^1.6.7", "cloudinary-react": "^1.6.7",
"get-active-classes": "^0.0.11", "get-active-classes": "^0.0.11",
"gotrue-js": "^0.9.27", "gotrue-js": "^0.9.27",
"html-to-image": "^1.7.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"netlify-identity-widget": "^1.9.1", "netlify-identity-widget": "^1.9.1",
"pako": "^2.0.3", "pako": "^2.0.3",

View File

@@ -4,8 +4,19 @@ import Popover from '@material-ui/core/Popover'
import Svg from 'src/components/Svg/Svg' import Svg from 'src/components/Svg/Svg'
import Button from 'src/components/Button/Button' import Button from 'src/components/Button/Button'
import { useIdeContext } from 'src/helpers/hooks/useIdeContext' import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
import { canvasToBlob, blobTo64 } from 'src/helpers/canvasToBlob'
import { useUpdateProject } from 'src/helpers/hooks/useUpdateProject' import { useUpdateProject } from 'src/helpers/hooks/useUpdateProject'
import { uploadToCloudinary } from 'src/helpers/cloudinary' import {
useUpdateSocialCard,
makeSocialPublicId,
} from 'src/helpers/hooks/useUpdateSocialCard'
import {
uploadToCloudinary,
serverVerifiedImageUpload,
} from 'src/helpers/cloudinary'
import SocialCardCell from 'src/components/SocialCardCell/SocialCardCell'
import { toJpeg } from 'html-to-image'
import { useAuth } from '@redwoodjs/auth'
const anchorOrigin = { const anchorOrigin = {
vertical: 'bottom', vertical: 'bottom',
@@ -16,51 +27,50 @@ const transformOrigin = {
horizontal: 'center', horizontal: 'center',
} }
const CaptureButton = ({ canEdit, TheButton, shouldUpdateImage }) => { const CaptureButton = ({
canEdit,
TheButton,
shouldUpdateImage,
projectTitle,
userName,
}) => {
const [captureState, setCaptureState] = useState<any>({}) const [captureState, setCaptureState] = useState<any>({})
const [anchorEl, setAnchorEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null)
const [whichPopup, setWhichPopup] = useState(null) const [whichPopup, setWhichPopup] = useState(null)
const { state, project } = useIdeContext() const { state, project } = useIdeContext()
const ref = React.useRef<HTMLDivElement>(null)
const { updateProject } = useUpdateProject({ const { updateProject } = useUpdateProject({
onCompleted: () => toast.success('Image updated'), onCompleted: () => toast.success('Image updated'),
}) })
const { updateSocialCard } = useUpdateSocialCard({})
const { getToken } = useAuth()
const getSocialBlob = async (): Promise<string> => {
const tokenPromise = getToken()
const blob = await toJpeg(ref.current, { cacheBust: true, quality: 0.75 })
const token = await tokenPromise
const { publicId } = await serverVerifiedImageUpload(
blob,
project?.id,
token,
makeSocialPublicId(userName, projectTitle)
)
return publicId
}
const onCapture = async () => { const onCapture = async () => {
const threeInstance = state.threeInstance const threeInstance = state.threeInstance
const isOpenScadImage = state?.objectData?.type === 'png' const isOpenScadImage = state?.objectData?.type === 'png'
let imgBlob let imgBlob
let image64
if (!isOpenScadImage) { if (!isOpenScadImage) {
const updateCanvasSize = ({ imgBlob = canvasToBlob(threeInstance, { width: 400, height: 300 })
width, image64 = blobTo64(
height, await canvasToBlob(threeInstance, { width: 500, height: 522 })
}: { )
width: number
height: number
}) => {
threeInstance.camera.aspect = width / height
threeInstance.camera.updateProjectionMatrix()
threeInstance.gl.setSize(width, height)
threeInstance.gl.render(
threeInstance.scene,
threeInstance.camera,
null,
false
)
}
const oldSize = threeInstance.size
updateCanvasSize({ width: 400, height: 300 })
imgBlob = new Promise((resolve, reject) => {
threeInstance.gl.domElement.toBlob(
(blob) => {
resolve(blob)
},
'image/jpeg',
1
)
})
updateCanvasSize(oldSize)
} else { } else {
imgBlob = state.objectData.data imgBlob = state.objectData.data
image64 = blobTo64(state.objectData.data)
} }
const config = { const config = {
image: await imgBlob, image: await imgBlob,
@@ -69,11 +79,22 @@ const CaptureButton = ({ canEdit, TheButton, shouldUpdateImage }) => {
callback: uploadAndUpdateImage, callback: uploadAndUpdateImage,
cloudinaryImgURL: '', cloudinaryImgURL: '',
updated: false, updated: false,
image64: await image64,
} }
setCaptureState(config)
async function uploadAndUpdateImage() { async function uploadAndUpdateImage() {
// Upload the image to Cloudinary const [cloudinaryImgURL, socialCloudinaryURL] = await Promise.all([
const cloudinaryImgURL = await uploadToCloudinary(config.image) uploadToCloudinary(config.image),
getSocialBlob(),
])
updateSocialCard({
variables: {
projectId: project?.id,
url: socialCloudinaryURL,
},
})
// Save the screenshot as the mainImage // Save the screenshot as the mainImage
updateProject({ updateProject({
@@ -92,9 +113,8 @@ const CaptureButton = ({ canEdit, TheButton, shouldUpdateImage }) => {
if (shouldUpdateImage) { if (shouldUpdateImage) {
config.cloudinaryImgURL = (await uploadAndUpdateImage()).public_id config.cloudinaryImgURL = (await uploadAndUpdateImage()).public_id
config.updated = true config.updated = true
setCaptureState(config)
} }
return config
} }
const handleDownload = (url) => { const handleDownload = (url) => {
@@ -123,7 +143,7 @@ const CaptureButton = ({ canEdit, TheButton, shouldUpdateImage }) => {
<TheButton <TheButton
onClick={async (event) => { onClick={async (event) => {
handleClick({ event, whichPopup: 'capture' }) handleClick({ event, whichPopup: 'capture' })
setCaptureState(await onCapture()) onCapture()
}} }}
/> />
<Popover <Popover
@@ -163,7 +183,7 @@ const CaptureButton = ({ canEdit, TheButton, shouldUpdateImage }) => {
name="refresh" name="refresh"
className="mr-2 w-4 text-indigo-600" className="mr-2 w-4 text-indigo-600"
/>{' '} />{' '}
Update Part Image Update Project Image
</button> </button>
) : ( ) : (
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
@@ -187,6 +207,20 @@ const CaptureButton = ({ canEdit, TheButton, shouldUpdateImage }) => {
</div> </div>
</div> </div>
)} )}
<div className="rounded-lg shadow-md mt-4 overflow-hidden">
<div
className="transform scale-50 origin-top-left"
style={{ width: '600px', height: '315px' }}
>
<div style={{ width: '1200px', height: '630px' }} ref={ref}>
<SocialCardCell
userName={userName}
projectTitle={projectTitle}
image64={captureState.image64}
/>
</div>
</div>
</div>
</div> </div>
</Popover> </Popover>
</div> </div>

View File

@@ -37,12 +37,13 @@ export const Failure = ({ error }: CellFailureProps) => (
export const Success = ({ export const Success = ({
userProject, userProject,
variables: { image64 },
}: CellSuccessProps<FindSocialCardQuery>) => { }: CellSuccessProps<FindSocialCardQuery>) => {
const image = userProject?.Project?.mainImage const image = userProject?.Project?.mainImage
const gravatar = userProject?.image const gravatar = userProject?.image
return ( return (
<div <div
className="flex-col flex h-screen bg-ch-gray-800 text-ch-gray-300" className="flex-col flex h-full bg-ch-gray-800 text-ch-gray-300"
id="social-card-loaded" id="social-card-loaded"
> >
<div <div
@@ -65,7 +66,7 @@ export const Success = ({
{gravatar && ( {gravatar && (
<Gravatar image={gravatar} className="w-14 h-14" size={60} /> <Gravatar image={gravatar} className="w-14 h-14" size={60} />
)} )}
<div className="text-2xl font-fira-sans ml-6"> <div className="text-2xl font-fira-sans ml-6 whitespace-nowrap">
{userProject?.userName} {userProject?.userName}
</div> </div>
</div> </div>
@@ -86,13 +87,19 @@ export const Success = ({
</div> </div>
<div className="h-full overflow-hidden relative"> <div className="h-full overflow-hidden relative">
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<CloudinaryImage {image64 ? (
cloudName="irevdev" <div
publicId={image || 'CadHub/eia1kwru54g2kf02s2xx'} style={{ backgroundImage: `url(${image64})` }}
width={500} className="w-full h-full bg-no-repeat bg-center"
height={522} />
crop="crop" ) : (
/> <div
style={{
backgroundImage: `url(http://res.cloudinary.com/irevdev/image/upload/c_crop,h_522,w_500/v1/${image})`,
}}
className="w-full h-full bg-no-repeat bg-center bg-blend-difference bg-contain bg-ch-gray-800"
/>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -133,7 +140,7 @@ export const Success = ({
CadHub CadHub
</h2> </h2>
<div <div
className="text-pink-400 text-sm font-bold font-ropa-sans hidden md:block" className="text-pink-400 text-sm font-bold font-ropa-sans hidden md:block whitespace-nowrap"
style={{ paddingBottom: '2rem', marginLeft: '-1.8rem' }} style={{ paddingBottom: '2rem', marginLeft: '-1.8rem' }}
> >
pre-alpha pre-alpha

View File

@@ -33,3 +33,16 @@ export const canvasToBlob = async (
updateCanvasSize(oldSize) updateCanvasSize(oldSize)
return imgBlobPromise return imgBlobPromise
} }
export const blobTo64 = async (blob: Blob): Promise<string> => {
return new Promise(async (resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
}
}
reader.onerror = reject
reader.readAsDataURL(blob)
})
}

View File

@@ -1,21 +0,0 @@
// TODO: create a tidy util for uploading to Cloudinary and returning the public ID
import axios from 'axios'
const CLOUDINARY_UPLOAD_PRESET = 'CadHub_project_images'
const CLOUDINARY_UPLOAD_URL = 'https://api.cloudinary.com/v1_1/irevdev/upload'
export async function uploadToCloudinary(imgBlob) {
const imageData = new FormData()
imageData.append('upload_preset', CLOUDINARY_UPLOAD_PRESET)
imageData.append('file', imgBlob)
let upload = axios.post(CLOUDINARY_UPLOAD_URL, imageData)
try {
const { data } = await upload
if (data && data.public_id !== '') {
return data
}
} catch (e) {
console.error('ERROR', e)
}
}

View File

@@ -0,0 +1,57 @@
// TODO: create a tidy util for uploading to Cloudinary and returning the public ID
import axios from 'axios'
const CLOUDINARY_UPLOAD_PRESET = 'CadHub_project_images'
const CLOUDINARY_UPLOAD_URL = 'https://api.cloudinary.com/v1_1/irevdev/upload'
export async function uploadToCloudinary(
imgBlob: Blob,
publicId?: string
): Promise<{ public_id: string }> {
const imageData = new FormData()
imageData.append('upload_preset', CLOUDINARY_UPLOAD_PRESET)
imageData.append('file', imgBlob)
if (publicId) {
imageData.append('public_id', publicId)
}
const upload = axios.post(CLOUDINARY_UPLOAD_URL, imageData)
try {
const { data } = await upload
if (data && data.public_id !== '') {
return data
}
} catch (e) {
console.error('ERROR', e)
}
}
export async function serverVerifiedImageUpload(
imgBlob: string,
projectId: string,
token: string,
publicId?: string
): Promise<{ publicId: string }> {
const imageData = {
image64: imgBlob,
upload_preset: CLOUDINARY_UPLOAD_PRESET,
public_id: publicId,
invalidate: true,
projectId,
}
const upload = axios.post('/.netlify/functions/image-upload', imageData, {
headers: {
'auth-provider': 'goTrue',
authorization: `Bearer ${token}`,
},
})
try {
const { data } = await upload
if (data && data.public_id !== '') {
return data
}
} catch (e) {
console.error('ERROR', e)
}
}

View File

@@ -0,0 +1,24 @@
import { useMutation } from '@redwoodjs/web'
const UPDATE_SOCIAL_CARD_MUTATION_HOOK = gql`
mutation updateSocialCardByProjectId($projectId: String!, $url: String!) {
updateSocialCardByProjectId(projectId: $projectId, url: $url) {
id
url
}
}
`
export const useUpdateSocialCard = ({ onCompleted = () => {} }) => {
const [updateSocialCard, { loading, error }] = useMutation(
UPDATE_SOCIAL_CARD_MUTATION_HOOK,
{ onCompleted }
)
return { updateSocialCard, loading, error }
}
export const makeSocialPublicId = (
userName: string,
projectTitle: string
): string => `u-${userName}-slash-p-${projectTitle}`

View File

@@ -5,16 +5,15 @@ import Seo from 'src/components/Seo/Seo'
import { useIdeState } from 'src/helpers/hooks/useIdeState' import { useIdeState } from 'src/helpers/hooks/useIdeState'
import { IdeContext } from 'src/helpers/hooks/useIdeContext' import { IdeContext } from 'src/helpers/hooks/useIdeContext'
import { Toaster } from '@redwoodjs/web/toast' import { Toaster } from '@redwoodjs/web/toast'
import { makeSocialPublicId } from 'src/helpers/hooks/useUpdateSocialCard'
const ProjectPage = ({ userName, projectTitle }) => { const ProjectPage = ({ userName, projectTitle }) => {
const { currentUser } = useAuth() const { currentUser } = useAuth()
const [state, thunkDispatch] = useIdeState() const [state, thunkDispatch] = useIdeState()
const cacheInvalidator = new Date() const socialImageUrl = `http://res.cloudinary.com/irevdev/image/upload/c_scale,w_1200/v1/CadHub/${makeSocialPublicId(
.toISOString() userName,
.split('-') projectTitle
.slice(0, 2) )}`
.join('-')
const socialImageUrl = `/.netlify/functions/og-image-generator/${userName}/${projectTitle}/og-image-${cacheInvalidator}.jpg`
return ( return (
<> <>
<Seo <Seo

View File

@@ -10198,6 +10198,11 @@ html-tags@^3.1.0:
resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.1.0.tgz#7b5e6f7e665e9fb41f30007ed9e0d41e97fb2140" resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.1.0.tgz#7b5e6f7e665e9fb41f30007ed9e0d41e97fb2140"
integrity sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg== integrity sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg==
html-to-image@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/html-to-image/-/html-to-image-1.7.0.tgz#4ca93bb90c0b9392edaafbfd5d94e8f0d666e18b"
integrity sha512-6egK8mOXMw82nLjj5g3ohERuzrTglgR9+Q6A2cqa7UiuSSKHuFxpABZJSfZztj0EdLC6tAePZJAhjPr4bbU9tw==
html-webpack-plugin@4.0.2: html-webpack-plugin@4.0.2:
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-4.0.2.tgz#c96a48d0ee53d33dcc909d6b65ad28f3d627efd4" resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-4.0.2.tgz#c96a48d0ee53d33dcc909d6b65ad28f3d627efd4"