2
0

(s3) Improve storage management and type safety

Closes #756
This commit is contained in:
Baptiste Arnaud
2023-09-08 15:28:11 +02:00
parent 43be38cf50
commit fbb198af9d
47 changed files with 790 additions and 128 deletions

View File

@@ -68,7 +68,6 @@
"libphonenumber-js": "1.10.37", "libphonenumber-js": "1.10.37",
"micro": "10.0.1", "micro": "10.0.1",
"micro-cors": "0.1.1", "micro-cors": "0.1.1",
"minio": "7.1.1",
"next": "13.4.3", "next": "13.4.3",
"next-auth": "4.22.1", "next-auth": "4.22.1",
"next-international": "0.9.5", "next-international": "0.9.5",
@@ -106,7 +105,6 @@
"@types/canvas-confetti": "1.6.0", "@types/canvas-confetti": "1.6.0",
"@types/jsonwebtoken": "9.0.2", "@types/jsonwebtoken": "9.0.2",
"@types/micro-cors": "0.1.3", "@types/micro-cors": "0.1.3",
"@types/minio": "7.1.1",
"@types/node": "20.4.2", "@types/node": "20.4.2",
"@types/nodemailer": "6.4.8", "@types/nodemailer": "6.4.8",
"@types/nprogress": "0.2.0", "@types/nprogress": "0.2.0",

View File

@@ -10,16 +10,17 @@ import {
import React from 'react' import React from 'react'
import { EmojiOrImageIcon } from './EmojiOrImageIcon' import { EmojiOrImageIcon } from './EmojiOrImageIcon'
import { ImageUploadContent } from './ImageUploadContent' import { ImageUploadContent } from './ImageUploadContent'
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
type Props = { type Props = {
uploadFilePath: string uploadFileProps: FilePathUploadProps
icon?: string | null icon?: string | null
onChangeIcon: (icon: string) => void onChangeIcon: (icon: string) => void
boxSize?: string boxSize?: string
} }
export const EditableEmojiOrImageIcon = ({ export const EditableEmojiOrImageIcon = ({
uploadFilePath, uploadFileProps,
icon, icon,
onChangeIcon, onChangeIcon,
boxSize, boxSize,
@@ -54,7 +55,7 @@ export const EditableEmojiOrImageIcon = ({
</Tooltip> </Tooltip>
<PopoverContent p="2"> <PopoverContent p="2">
<ImageUploadContent <ImageUploadContent
filePath={uploadFilePath} uploadFileProps={uploadFileProps}
defaultUrl={icon ?? ''} defaultUrl={icon ?? ''}
onSubmit={onChangeIcon} onSubmit={onChangeIcon}
excludedTabs={['giphy', 'unsplash']} excludedTabs={['giphy', 'unsplash']}

View File

@@ -6,12 +6,12 @@ import { TextInput } from '../inputs/TextInput'
import { EmojiSearchableList } from './emoji/EmojiSearchableList' import { EmojiSearchableList } from './emoji/EmojiSearchableList'
import { UnsplashPicker } from './UnsplashPicker' import { UnsplashPicker } from './UnsplashPicker'
import { IconPicker } from './IconPicker' import { IconPicker } from './IconPicker'
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
type Tabs = 'link' | 'upload' | 'giphy' | 'emoji' | 'unsplash' | 'icon' type Tabs = 'link' | 'upload' | 'giphy' | 'emoji' | 'unsplash' | 'icon'
type Props = { type Props = {
filePath: string | undefined uploadFileProps: FilePathUploadProps | undefined
includeFileName?: boolean
defaultUrl?: string defaultUrl?: string
imageSize?: 'small' | 'regular' | 'thumb' imageSize?: 'small' | 'regular' | 'thumb'
initialTab?: Tabs initialTab?: Tabs
@@ -36,8 +36,7 @@ const defaultDisplayedTabs: Tabs[] = [
] ]
export const ImageUploadContent = ({ export const ImageUploadContent = ({
filePath, uploadFileProps,
includeFileName,
defaultUrl, defaultUrl,
onSubmit, onSubmit,
imageSize = 'regular', imageSize = 'regular',
@@ -123,8 +122,7 @@ export const ImageUploadContent = ({
</HStack> </HStack>
<BodyContent <BodyContent
filePath={filePath} uploadFileProps={uploadFileProps}
includeFileName={includeFileName}
tab={currentTab} tab={currentTab}
imageSize={imageSize} imageSize={imageSize}
onSubmit={handleSubmit} onSubmit={handleSubmit}
@@ -135,15 +133,13 @@ export const ImageUploadContent = ({
} }
const BodyContent = ({ const BodyContent = ({
includeFileName, uploadFileProps,
filePath,
tab, tab,
defaultUrl, defaultUrl,
imageSize, imageSize,
onSubmit, onSubmit,
}: { }: {
includeFileName?: boolean uploadFileProps?: FilePathUploadProps
filePath: string | undefined
tab: Tabs tab: Tabs
defaultUrl?: string defaultUrl?: string
imageSize: 'small' | 'regular' | 'thumb' imageSize: 'small' | 'regular' | 'thumb'
@@ -151,11 +147,10 @@ const BodyContent = ({
}) => { }) => {
switch (tab) { switch (tab) {
case 'upload': { case 'upload': {
if (!filePath) return null if (!uploadFileProps) return null
return ( return (
<UploadFileContent <UploadFileContent
filePath={filePath} uploadFileProps={uploadFileProps}
includeFileName={includeFileName}
onNewUrl={onSubmit} onNewUrl={onSubmit}
/> />
) )
@@ -176,16 +171,14 @@ const BodyContent = ({
type ContentProps = { onNewUrl: (url: string) => void } type ContentProps = { onNewUrl: (url: string) => void }
const UploadFileContent = ({ const UploadFileContent = ({
filePath, uploadFileProps,
includeFileName,
onNewUrl, onNewUrl,
}: ContentProps & { filePath: string; includeFileName?: boolean }) => ( }: ContentProps & { uploadFileProps: FilePathUploadProps }) => (
<Flex justify="center" py="2"> <Flex justify="center" py="2">
<UploadButton <UploadButton
fileType="image" fileType="image"
filePath={filePath} filePathProps={uploadFileProps}
onFileUploaded={onNewUrl} onFileUploaded={onNewUrl}
includeFileName={includeFileName}
colorScheme="blue" colorScheme="blue"
> >
Choose an image Choose an image

View File

@@ -1,25 +1,44 @@
import { useToast } from '@/hooks/useToast' import { useToast } from '@/hooks/useToast'
import { Button, ButtonProps, chakra } from '@chakra-ui/react' import { Button, ButtonProps, chakra } from '@chakra-ui/react'
import { ChangeEvent, useState } from 'react' import { ChangeEvent, useState } from 'react'
import { uploadFiles } from '@typebot.io/lib/s3/uploadFiles' import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
import { trpc } from '@/lib/trpc'
import { compressFile } from '@/helpers/compressFile' import { compressFile } from '@/helpers/compressFile'
type UploadButtonProps = { type UploadButtonProps = {
fileType: 'image' | 'audio' fileType: 'image' | 'audio'
filePath: string filePathProps: FilePathUploadProps
includeFileName?: boolean
onFileUploaded: (url: string) => void onFileUploaded: (url: string) => void
} & ButtonProps } & ButtonProps
export const UploadButton = ({ export const UploadButton = ({
fileType, fileType,
filePath, filePathProps,
includeFileName,
onFileUploaded, onFileUploaded,
...props ...props
}: UploadButtonProps) => { }: UploadButtonProps) => {
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
const { showToast } = useToast() const { showToast } = useToast()
const [file, setFile] = useState<File>()
const { mutate } = trpc.generateUploadUrl.useMutation({
onSettled: () => {
setIsUploading(false)
},
onSuccess: async (data) => {
const upload = await fetch(data.presignedUrl, {
method: 'PUT',
body: file,
})
if (!upload.ok) {
showToast({ description: 'Error while trying to upload the file.' })
return
}
onFileUploaded(data.fileUrl + '?v=' + Date.now())
},
})
const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => { const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target?.files) return if (!e.target?.files) return
@@ -27,16 +46,11 @@ export const UploadButton = ({
const file = e.target.files[0] as File | undefined const file = e.target.files[0] as File | undefined
if (!file) if (!file)
return showToast({ description: 'Could not read file.', status: 'error' }) return showToast({ description: 'Could not read file.', status: 'error' })
const urls = await uploadFiles({ setFile(await compressFile(file))
files: [ mutate({
{ filePathProps,
file: await compressFile(file), fileType: file.type,
path: `public/${filePath}${includeFileName ? `/${file.name}` : ''}`,
},
],
}) })
if (urls.length && urls[0]) onFileUploaded(urls[0] + '?v=' + Date.now())
setIsUploading(false)
} }
return ( return (

View File

@@ -36,15 +36,20 @@ export const MyAccountForm = () => {
name={user?.name ?? undefined} name={user?.name ?? undefined}
/> />
<Stack> <Stack>
<UploadButton {user?.id && (
size="sm" <UploadButton
fileType="image" size="sm"
filePath={`users/${user?.id}/avatar`} fileType="image"
leftIcon={<UploadIcon />} filePathProps={{
onFileUploaded={handleFileUploaded} userId: user.id,
> fileName: 'avatar',
{scopedT('changePhotoButton.label')} }}
</UploadButton> leftIcon={<UploadIcon />}
onFileUploaded={handleFileUploaded}
>
{scopedT('changePhotoButton.label')}
</UploadButton>
)}
<Text color="gray.500" fontSize="sm"> <Text color="gray.500" fontSize="sm">
{scopedT('changePhotoButton.specification')} {scopedT('changePhotoButton.specification')}
</Text> </Text>

View File

@@ -4,6 +4,7 @@ import { parseDefaultGroupWithBlock } from '@typebot.io/lib/playwright/databaseH
import { BubbleBlockType, defaultAudioBubbleContent } from '@typebot.io/schemas' import { BubbleBlockType, defaultAudioBubbleContent } from '@typebot.io/schemas'
import { createId } from '@paralleldrive/cuid2' import { createId } from '@paralleldrive/cuid2'
import { getTestAsset } from '@/test/utils/playwright' import { getTestAsset } from '@/test/utils/playwright'
import { proWorkspaceId } from '@typebot.io/lib/playwright/databaseSetup'
const audioSampleUrl = const audioSampleUrl =
'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3' 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
@@ -30,11 +31,17 @@ test('should work as expected', async ({ page }) => {
await page.setInputFiles('input[type="file"]', getTestAsset('sample.mp3')) await page.setInputFiles('input[type="file"]', getTestAsset('sample.mp3'))
await expect(page.locator('audio')).toHaveAttribute( await expect(page.locator('audio')).toHaveAttribute(
'src', 'src',
RegExp(`/public/typebots/${typebotId}/blocks`, 'gm') RegExp(
`/public/workspaces/${proWorkspaceId}/typebots/${typebotId}/blocks`,
'gm'
)
) )
await page.getByRole('button', { name: 'Preview', exact: true }).click() await page.getByRole('button', { name: 'Preview', exact: true }).click()
await expect(page.locator('audio')).toHaveAttribute( await expect(page.locator('audio')).toHaveAttribute(
'src', 'src',
RegExp(`/public/typebots/${typebotId}/blocks`, 'gm') RegExp(
`/public/workspaces/${proWorkspaceId}/typebots/${typebotId}/blocks`,
'gm'
)
) )
}) })

View File

@@ -5,15 +5,16 @@ import { useState } from 'react'
import { UploadButton } from '@/components/ImageUploadContent/UploadButton' import { UploadButton } from '@/components/ImageUploadContent/UploadButton'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel' import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { useScopedI18n } from '@/locales' import { useScopedI18n } from '@/locales'
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
type Props = { type Props = {
fileUploadPath: string uploadFileProps: FilePathUploadProps
content: AudioBubbleContent content: AudioBubbleContent
onContentChange: (content: AudioBubbleContent) => void onContentChange: (content: AudioBubbleContent) => void
} }
export const AudioBubbleForm = ({ export const AudioBubbleForm = ({
fileUploadPath, uploadFileProps,
content, content,
onContentChange, onContentChange,
}: Props) => { }: Props) => {
@@ -49,7 +50,7 @@ export const AudioBubbleForm = ({
<Flex justify="center" py="2"> <Flex justify="center" py="2">
<UploadButton <UploadButton
fileType="audio" fileType="audio"
filePath={fileUploadPath} filePathProps={uploadFileProps}
onFileUploaded={updateUrl} onFileUploaded={updateUrl}
colorScheme="blue" colorScheme="blue"
> >

View File

@@ -1,6 +1,7 @@
import { ImageUploadContent } from '@/components/ImageUploadContent' import { ImageUploadContent } from '@/components/ImageUploadContent'
import { TextInput } from '@/components/inputs' import { TextInput } from '@/components/inputs'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel' import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
import { useScopedI18n } from '@/locales' import { useScopedI18n } from '@/locales'
import { Stack } from '@chakra-ui/react' import { Stack } from '@chakra-ui/react'
import { isDefined, isNotEmpty } from '@typebot.io/lib' import { isDefined, isNotEmpty } from '@typebot.io/lib'
@@ -8,13 +9,13 @@ import { ImageBubbleBlock } from '@typebot.io/schemas'
import React, { useState } from 'react' import React, { useState } from 'react'
type Props = { type Props = {
typebotId: string uploadFileProps: FilePathUploadProps
block: ImageBubbleBlock block: ImageBubbleBlock
onContentChange: (content: ImageBubbleBlock['content']) => void onContentChange: (content: ImageBubbleBlock['content']) => void
} }
export const ImageBubbleSettings = ({ export const ImageBubbleSettings = ({
typebotId, uploadFileProps,
block, block,
onContentChange, onContentChange,
}: Props) => { }: Props) => {
@@ -53,7 +54,7 @@ export const ImageBubbleSettings = ({
return ( return (
<Stack p="2" spacing={4}> <Stack p="2" spacing={4}>
<ImageUploadContent <ImageUploadContent
filePath={`typebots/${typebotId}/blocks/${block.id}`} uploadFileProps={uploadFileProps}
defaultUrl={block.content?.url} defaultUrl={block.content?.url}
onSubmit={updateImage} onSubmit={updateImage}
/> />

View File

@@ -4,6 +4,7 @@ import { parseDefaultGroupWithBlock } from '@typebot.io/lib/playwright/databaseH
import { BubbleBlockType, defaultImageBubbleContent } from '@typebot.io/schemas' import { BubbleBlockType, defaultImageBubbleContent } from '@typebot.io/schemas'
import { createId } from '@paralleldrive/cuid2' import { createId } from '@paralleldrive/cuid2'
import { getTestAsset } from '@/test/utils/playwright' import { getTestAsset } from '@/test/utils/playwright'
import { proWorkspaceId } from '@typebot.io/lib/playwright/databaseSetup'
const unsplashImageSrc = const unsplashImageSrc =
'https://images.unsplash.com/photo-1504297050568-910d24c426d3?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1287&q=80' 'https://images.unsplash.com/photo-1504297050568-910d24c426d3?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1287&q=80'
@@ -29,7 +30,10 @@ test.describe.parallel('Image bubble block', () => {
await page.setInputFiles('input[type="file"]', getTestAsset('avatar.jpg')) await page.setInputFiles('input[type="file"]', getTestAsset('avatar.jpg'))
await expect(page.locator('img')).toHaveAttribute( await expect(page.locator('img')).toHaveAttribute(
'src', 'src',
new RegExp(`/public/typebots/${typebotId}/blocks/block2`, 'gm') new RegExp(
`/public/workspaces/${proWorkspaceId}/typebots/${typebotId}/blocks/block2`,
'gm'
)
) )
}) })

View File

@@ -134,6 +134,7 @@ export const PictureChoiceItemNode = ({
> >
{typebot && ( {typebot && (
<PictureChoiceItemSettings <PictureChoiceItemSettings
workspaceId={typebot.workspaceId}
typebotId={typebot.id} typebotId={typebot.id}
item={item} item={item}
blockId={ blockId={

View File

@@ -16,6 +16,7 @@ import { ConditionForm } from '@/features/blocks/logic/condition/components/Cond
import { Condition, LogicalOperator } from '@typebot.io/schemas' import { Condition, LogicalOperator } from '@typebot.io/schemas'
type Props = { type Props = {
workspaceId: string
typebotId: string typebotId: string
blockId: string blockId: string
item: PictureChoiceItem item: PictureChoiceItem
@@ -23,6 +24,7 @@ type Props = {
} }
export const PictureChoiceItemSettings = ({ export const PictureChoiceItemSettings = ({
workspaceId,
typebotId, typebotId,
blockId, blockId,
item, item,
@@ -69,7 +71,12 @@ export const PictureChoiceItemSettings = ({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent p="4" w="500px"> <PopoverContent p="4" w="500px">
<ImageUploadContent <ImageUploadContent
filePath={`typebots/${typebotId}/blocks/${blockId}/items/${item.id}`} uploadFileProps={{
workspaceId,
typebotId,
blockId,
itemId: item.id,
}}
defaultUrl={item.pictureSrc} defaultUrl={item.pictureSrc}
onSubmit={(url) => { onSubmit={(url) => {
updateImage(url) updateImage(url)

View File

@@ -7,10 +7,10 @@ import { openAICredentialsSchema } from '@typebot.io/schemas/features/blocks/int
import { smtpCredentialsSchema } from '@typebot.io/schemas/features/blocks/integrations/sendEmail' import { smtpCredentialsSchema } from '@typebot.io/schemas/features/blocks/integrations/sendEmail'
import { encrypt } from '@typebot.io/lib/api/encryption' import { encrypt } from '@typebot.io/lib/api/encryption'
import { z } from 'zod' import { z } from 'zod'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
import { whatsAppCredentialsSchema } from '@typebot.io/schemas/features/whatsapp' import { whatsAppCredentialsSchema } from '@typebot.io/schemas/features/whatsapp'
import { Credentials } from '@typebot.io/schemas' import { Credentials } from '@typebot.io/schemas'
import { isDefined } from '@typebot.io/lib/utils' import { isDefined } from '@typebot.io/lib/utils'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
const inputShape = { const inputShape = {
data: true, data: true,

View File

@@ -2,7 +2,7 @@ import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc' import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { z } from 'zod' import { z } from 'zod'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy' import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
export const deleteCredentials = authenticatedProcedure export const deleteCredentials = authenticatedProcedure
.meta({ .meta({

View File

@@ -3,9 +3,9 @@ import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { z } from 'zod' import { z } from 'zod'
import { customDomainSchema } from '@typebot.io/schemas/features/customDomains' import { customDomainSchema } from '@typebot.io/schemas/features/customDomains'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
import got, { HTTPError } from 'got' import got, { HTTPError } from 'got'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
export const createCustomDomain = authenticatedProcedure export const createCustomDomain = authenticatedProcedure
.meta({ .meta({

View File

@@ -2,9 +2,9 @@ import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc' import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { z } from 'zod' import { z } from 'zod'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
import got from 'got' import got from 'got'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
export const deleteCustomDomain = authenticatedProcedure export const deleteCustomDomain = authenticatedProcedure
.meta({ .meta({

View File

@@ -179,7 +179,11 @@ export const TypebotHeader = () => {
<HStack spacing={1}> <HStack spacing={1}>
{typebot && ( {typebot && (
<EditableEmojiOrImageIcon <EditableEmojiOrImageIcon
uploadFilePath={`typebots/${typebot.id}/icon`} uploadFileProps={{
workspaceId: typebot.workspaceId,
typebotId: typebot.id,
fileName: 'icon',
}}
icon={typebot?.icon} icon={typebot?.icon}
onChangeIcon={handleChangeIcon} onChangeIcon={handleChangeIcon}
/> />

View File

@@ -284,7 +284,11 @@ export const BlockNode = ({
)} )}
{typebot && isMediaBubbleBlock(block) && ( {typebot && isMediaBubbleBlock(block) && (
<MediaBubblePopoverContent <MediaBubblePopoverContent
typebotId={typebot.id} uploadFileProps={{
workspaceId: typebot.workspaceId,
typebotId: typebot.id,
blockId: block.id,
}}
block={block} block={block}
onContentChange={handleContentChange} onContentChange={handleContentChange}
/> />

View File

@@ -2,6 +2,7 @@ import { AudioBubbleForm } from '@/features/blocks/bubbles/audio/components/Audi
import { EmbedUploadContent } from '@/features/blocks/bubbles/embed/components/EmbedUploadContent' import { EmbedUploadContent } from '@/features/blocks/bubbles/embed/components/EmbedUploadContent'
import { ImageBubbleSettings } from '@/features/blocks/bubbles/image/components/ImageBubbleSettings' import { ImageBubbleSettings } from '@/features/blocks/bubbles/image/components/ImageBubbleSettings'
import { VideoUploadContent } from '@/features/blocks/bubbles/video/components/VideoUploadContent' import { VideoUploadContent } from '@/features/blocks/bubbles/video/components/VideoUploadContent'
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
import { import {
Portal, Portal,
PopoverContent, PopoverContent,
@@ -17,7 +18,7 @@ import {
import { useRef } from 'react' import { useRef } from 'react'
type Props = { type Props = {
typebotId: string uploadFileProps: FilePathUploadProps
block: Exclude<BubbleBlock, TextBubbleBlock> block: Exclude<BubbleBlock, TextBubbleBlock>
onContentChange: (content: BubbleBlockContent) => void onContentChange: (content: BubbleBlockContent) => void
} }
@@ -42,7 +43,7 @@ export const MediaBubblePopoverContent = (props: Props) => {
} }
export const MediaBubbleContent = ({ export const MediaBubbleContent = ({
typebotId, uploadFileProps,
block, block,
onContentChange, onContentChange,
}: Props) => { }: Props) => {
@@ -50,7 +51,7 @@ export const MediaBubbleContent = ({
case BubbleBlockType.IMAGE: { case BubbleBlockType.IMAGE: {
return ( return (
<ImageBubbleSettings <ImageBubbleSettings
typebotId={typebotId} uploadFileProps={uploadFileProps}
block={block} block={block}
onContentChange={onContentChange} onContentChange={onContentChange}
/> />
@@ -76,7 +77,7 @@ export const MediaBubbleContent = ({
return ( return (
<AudioBubbleForm <AudioBubbleForm
content={block.content} content={block.content}
fileUploadPath={`typebots/${typebotId}/blocks/${block.id}`} uploadFileProps={uploadFileProps}
onContentChange={onContentChange} onContentChange={onContentChange}
/> />
) )

View File

@@ -81,7 +81,7 @@ export const ButtonThemeSettings = ({ buttonTheme, onChange }: Props) => {
updateCustomIconSrc(url) updateCustomIconSrc(url)
onClose() onClose()
}} }}
filePath={undefined} uploadFileProps={undefined}
/> />
</PopoverContent> </PopoverContent>
</> </>

View File

@@ -16,6 +16,7 @@ import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { TextInput, Textarea } from '@/components/inputs' import { TextInput, Textarea } from '@/components/inputs'
type Props = { type Props = {
workspaceId: string
typebotId: string typebotId: string
typebotName: string typebotName: string
metadata: Metadata metadata: Metadata
@@ -23,6 +24,7 @@ type Props = {
} }
export const MetadataForm = ({ export const MetadataForm = ({
workspaceId,
typebotId, typebotId,
typebotName, typebotName,
metadata, metadata,
@@ -61,7 +63,11 @@ export const MetadataForm = ({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent p="4" w="400px"> <PopoverContent p="4" w="400px">
<ImageUploadContent <ImageUploadContent
filePath={`typebots/${typebotId}/favIcon`} uploadFileProps={{
workspaceId,
typebotId,
fileName: 'favIcon',
}}
defaultUrl={metadata.favIconUrl ?? ''} defaultUrl={metadata.favIconUrl ?? ''}
onSubmit={handleFavIconSubmit} onSubmit={handleFavIconSubmit}
excludedTabs={['giphy', 'unsplash', 'emoji']} excludedTabs={['giphy', 'unsplash', 'emoji']}
@@ -87,7 +93,11 @@ export const MetadataForm = ({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent p="4" w="500px"> <PopoverContent p="4" w="500px">
<ImageUploadContent <ImageUploadContent
filePath={`typebots/${typebotId}/ogImage`} uploadFileProps={{
workspaceId,
typebotId,
fileName: 'ogImage',
}}
defaultUrl={metadata.imageUrl} defaultUrl={metadata.imageUrl}
onSubmit={handleImageSubmit} onSubmit={handleImageSubmit}
excludedTabs={['giphy', 'icon', 'emoji']} excludedTabs={['giphy', 'icon', 'emoji']}

View File

@@ -94,6 +94,7 @@ export const SettingsSideMenu = () => {
<AccordionPanel pb={4} px="6"> <AccordionPanel pb={4} px="6">
{typebot && ( {typebot && (
<MetadataForm <MetadataForm
workspaceId={typebot.workspaceId}
typebotId={typebot.id} typebotId={typebot.id}
typebotName={typebot.name} typebotName={typebot.name}
metadata={typebot.settings.metadata} metadata={typebot.settings.metadata}

View File

@@ -125,6 +125,7 @@ export const ThemeSideMenu = () => {
<AccordionPanel pb={4}> <AccordionPanel pb={4}>
{typebot && ( {typebot && (
<ChatThemeSettings <ChatThemeSettings
workspaceId={typebot.workspaceId}
typebotId={typebot.id} typebotId={typebot.id}
chatTheme={typebot.theme.chat} chatTheme={typebot.theme.chat}
onChatThemeChange={updateChatTheme} onChatThemeChange={updateChatTheme}

View File

@@ -17,9 +17,10 @@ import {
import { ImageUploadContent } from '@/components/ImageUploadContent' import { ImageUploadContent } from '@/components/ImageUploadContent'
import { DefaultAvatar } from '../DefaultAvatar' import { DefaultAvatar } from '../DefaultAvatar'
import { useOutsideClick } from '@/hooks/useOutsideClick' import { useOutsideClick } from '@/hooks/useOutsideClick'
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
type Props = { type Props = {
uploadFilePath: string uploadFileProps: FilePathUploadProps
title: string title: string
avatarProps?: AvatarProps avatarProps?: AvatarProps
isDefaultCheck?: boolean isDefaultCheck?: boolean
@@ -27,7 +28,7 @@ type Props = {
} }
export const AvatarForm = ({ export const AvatarForm = ({
uploadFilePath, uploadFileProps,
title, title,
avatarProps, avatarProps,
isDefaultCheck = false, isDefaultCheck = false,
@@ -90,7 +91,7 @@ export const AvatarForm = ({
w="500px" w="500px"
> >
<ImageUploadContent <ImageUploadContent
filePath={uploadFilePath} uploadFileProps={uploadFileProps}
defaultUrl={avatarProps?.url} defaultUrl={avatarProps?.url}
imageSize="thumb" imageSize="thumb"
onSubmit={handleImageUrl} onSubmit={handleImageUrl}

View File

@@ -19,12 +19,14 @@ import { HostBubbles } from './HostBubbles'
import { InputsTheme } from './InputsTheme' import { InputsTheme } from './InputsTheme'
type Props = { type Props = {
workspaceId: string
typebotId: string typebotId: string
chatTheme: ChatTheme chatTheme: ChatTheme
onChatThemeChange: (chatTheme: ChatTheme) => void onChatThemeChange: (chatTheme: ChatTheme) => void
} }
export const ChatThemeSettings = ({ export const ChatThemeSettings = ({
workspaceId,
typebotId, typebotId,
chatTheme, chatTheme,
onChatThemeChange, onChatThemeChange,
@@ -46,14 +48,22 @@ export const ChatThemeSettings = ({
return ( return (
<Stack spacing={6}> <Stack spacing={6}>
<AvatarForm <AvatarForm
uploadFilePath={`typebots/${typebotId}/hostAvatar`} uploadFileProps={{
workspaceId,
typebotId,
fileName: 'hostAvatar',
}}
title="Bot avatar" title="Bot avatar"
avatarProps={chatTheme.hostAvatar} avatarProps={chatTheme.hostAvatar}
isDefaultCheck isDefaultCheck
onAvatarChange={handleHostAvatarChange} onAvatarChange={handleHostAvatarChange}
/> />
<AvatarForm <AvatarForm
uploadFilePath={`typebots/${typebotId}/guestAvatar`} uploadFileProps={{
workspaceId,
typebotId,
fileName: 'guestAvatar',
}}
title="User avatar" title="User avatar"
avatarProps={chatTheme.guestAvatar} avatarProps={chatTheme.guestAvatar}
onAvatarChange={handleGuestAvatarChange} onAvatarChange={handleGuestAvatarChange}

View File

@@ -42,6 +42,7 @@ export const BackgroundContent = ({
</Flex> </Flex>
) )
case BackgroundType.IMAGE: case BackgroundType.IMAGE:
if (!typebot) return null
return ( return (
<Popover isLazy placement="top"> <Popover isLazy placement="top">
<PopoverTrigger> <PopoverTrigger>
@@ -63,7 +64,11 @@ export const BackgroundContent = ({
<Portal> <Portal>
<PopoverContent p="4" w="500px"> <PopoverContent p="4" w="500px">
<ImageUploadContent <ImageUploadContent
filePath={`typebots/${typebot?.id}/background`} uploadFileProps={{
workspaceId: typebot.workspaceId,
typebotId: typebot.id,
fileName: 'background',
}}
defaultUrl={background.content} defaultUrl={background.content}
onSubmit={handleContentChange} onSubmit={handleContentChange}
excludedTabs={['giphy', 'icon']} excludedTabs={['giphy', 'icon']}
@@ -72,7 +77,5 @@ export const BackgroundContent = ({
</Portal> </Portal>
</Popover> </Popover>
) )
default:
return <></>
} }
} }

View File

@@ -11,7 +11,7 @@ export const isWriteTypebotForbidden = async (
typebot: Pick<Typebot, 'workspaceId'> & { typebot: Pick<Typebot, 'workspaceId'> & {
collaborators: Pick<CollaboratorsOnTypebots, 'userId' | 'type'>[] collaborators: Pick<CollaboratorsOnTypebots, 'userId' | 'type'>[]
}, },
user: Pick<User, 'email' | 'id'> user: Pick<User, 'id'>
) => { ) => {
if ( if (
typebot.collaborators.find( typebot.collaborators.find(

View File

@@ -0,0 +1,173 @@
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { z } from 'zod'
import { env } from '@typebot.io/env'
import { TRPCError } from '@trpc/server'
import { generatePresignedUrl } from '@typebot.io/lib/s3/generatePresignedUrl'
import prisma from '@/lib/prisma'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
import { isWriteTypebotForbidden } from '@/features/typebot/helpers/isWriteTypebotForbidden'
const inputSchema = z.object({
filePathProps: z
.object({
workspaceId: z.string(),
typebotId: z.string(),
blockId: z.string(),
itemId: z.string().optional(),
})
.or(
z.object({
workspaceId: z.string(),
typebotId: z.string(),
fileName: z.string(),
})
)
.or(
z.object({
userId: z.string(),
fileName: z.string(),
})
)
.or(
z.object({
workspaceId: z.string(),
fileName: z.string(),
})
),
fileType: z.string().optional(),
})
export type FilePathUploadProps = z.infer<
typeof inputSchema.shape.filePathProps
>
export const generateUploadUrl = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/generate-upload-url',
summary: 'Generate upload URL',
description: 'Generate the needed URL to upload a file from the client',
},
})
.input(inputSchema)
.output(
z.object({
presignedUrl: z.string(),
fileUrl: z.string(),
})
)
.mutation(async ({ input: { filePathProps, fileType }, ctx: { user } }) => {
if (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY || !env.S3_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message:
'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY',
})
if ('resultId' in filePathProps && !user)
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in to upload a file',
})
const filePath = await parseFilePath({
authenticatedUserId: user?.id,
uploadProps: filePathProps,
})
const presignedUrl = await generatePresignedUrl({
fileType,
filePath,
})
return {
presignedUrl,
fileUrl: env.S3_PUBLIC_CUSTOM_DOMAIN
? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}`
: presignedUrl.split('?')[0],
}
})
type Props = {
authenticatedUserId?: string
uploadProps: FilePathUploadProps
}
const parseFilePath = async ({
authenticatedUserId,
uploadProps: input,
}: Props): Promise<string> => {
if (!authenticatedUserId)
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in to upload this type of file',
})
if ('userId' in input) {
if (input.userId !== authenticatedUserId)
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You are not authorized to upload a file for this user',
})
return `public/users/${input.userId}/${input.fileName}`
}
if (!('workspaceId' in input))
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'workspaceId is missing',
})
if (!('typebotId' in input)) {
const workspace = await prisma.workspace.findUnique({
where: {
id: input.workspaceId,
},
select: {
members: {
select: {
userId: true,
role: true,
},
},
},
})
if (
!workspace ||
isWriteWorkspaceForbidden(workspace, { id: authenticatedUserId })
)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
return `public/workspaces/${input.workspaceId}/${input.fileName}`
}
const typebot = await prisma.typebot.findUnique({
where: {
id: input.typebotId,
},
select: {
workspaceId: true,
collaborators: {
select: {
userId: true,
type: true,
},
},
},
})
if (
!typebot ||
(await isWriteTypebotForbidden(typebot, {
id: authenticatedUserId,
}))
)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Typebot not found',
})
if (!('blockId' in input)) {
return `public/workspaces/${input.workspaceId}/typebots/${input.typebotId}/${input.fileName}`
}
return `public/workspaces/${input.workspaceId}/typebots/${
input.typebotId
}/blocks/${input.blockId}${input.itemId ? `/items/${input.itemId}` : ''}`
}

View File

@@ -24,9 +24,7 @@ export const WorkspaceSettingsForm = ({ onClose }: { onClose: () => void }) => {
updateWorkspace({ name }) updateWorkspace({ name })
} }
const handleChangeIcon = (icon: string) => { const handleChangeIcon = (icon: string) => updateWorkspace({ icon })
updateWorkspace({ icon })
}
const handleDeleteClick = async () => { const handleDeleteClick = async () => {
await deleteCurrentWorkspace() await deleteCurrentWorkspace()
@@ -40,7 +38,10 @@ export const WorkspaceSettingsForm = ({ onClose }: { onClose: () => void }) => {
<Flex> <Flex>
{workspace && ( {workspace && (
<EditableEmojiOrImageIcon <EditableEmojiOrImageIcon
uploadFilePath={`workspaces/${workspace.id}/icon`} uploadFileProps={{
workspaceId: workspace.id,
fileName: 'icon',
}}
icon={workspace.icon} icon={workspace.icon}
onChangeIcon={handleChangeIcon} onChangeIcon={handleChangeIcon}
boxSize="40px" boxSize="40px"

View File

@@ -4,7 +4,7 @@ export const isWriteWorkspaceForbidden = (
workspace: { workspace: {
members: Pick<MemberInWorkspace, 'userId' | 'role'>[] members: Pick<MemberInWorkspace, 'userId' | 'role'>[]
}, },
user: Pick<User, 'email' | 'id'> user: Pick<User, 'id'>
) => { ) => {
const userRole = workspace.members.find( const userRole = workspace.members.find(
(member) => member.userId === user.id (member) => member.userId === user.id

View File

@@ -15,6 +15,7 @@ import { collaboratorsRouter } from '@/features/collaboration/api/router'
import { customDomainsRouter } from '@/features/customDomains/api/router' import { customDomainsRouter } from '@/features/customDomains/api/router'
import { whatsAppRouter } from '@/features/whatsapp/router' import { whatsAppRouter } from '@/features/whatsapp/router'
import { openAIRouter } from '@/features/blocks/integrations/openai/api/router' import { openAIRouter } from '@/features/blocks/integrations/openai/api/router'
import { generateUploadUrl } from '@/features/upload/api/generateUploadUrl'
export const trpcRouter = router({ export const trpcRouter = router({
getAppVersionProcedure, getAppVersionProcedure,
@@ -33,6 +34,7 @@ export const trpcRouter = router({
customDomains: customDomainsRouter, customDomains: customDomainsRouter,
whatsApp: whatsAppRouter, whatsApp: whatsAppRouter,
openAI: openAIRouter, openAI: openAIRouter,
generateUploadUrl,
}) })
export type AppRouter = typeof trpcRouter export type AppRouter = typeof trpcRouter

View File

@@ -133,15 +133,16 @@ For `*_PATH` parameters, you can use dot notation to access nested properties (i
Used for uploading images, videos, etc... It can be any S3 compatible object storage service (Minio, Digital Oceans Space, AWS S3...) Used for uploading images, videos, etc... It can be any S3 compatible object storage service (Minio, Digital Oceans Space, AWS S3...)
| Parameter | Default | Description | | Parameter | Default | Description |
| ------------- | ------- | -------------------------------------------------------------- | | ----------------------- | ------- | ---------------------------------------------------------------------------------- |
| S3_ACCESS_KEY | | S3 access key. Also used to check if upload feature is enabled | | S3_ACCESS_KEY | | S3 access key. Also used to check if upload feature is enabled |
| S3_SECRET_KEY | | S3 secret key. | | S3_SECRET_KEY | | S3 secret key. |
| S3_BUCKET | typebot | Name of the bucket where assets will be uploaded in. | | S3_BUCKET | typebot | Name of the bucket where assets will be uploaded in. |
| S3_PORT | | S3 Host port number | | S3_PORT | | S3 Host port number |
| S3_ENDPOINT | | S3 endpoint (i.e. `s3.domain.com`). | | S3_ENDPOINT | | S3 endpoint (i.e. `s3.domain.com`). |
| S3_SSL | true | Use SSL when establishing the connection. | | S3_SSL | true | Use SSL when establishing the connection. |
| S3_REGION | | S3 region. | | S3_REGION | | S3 region. |
| S3_PUBLIC_CUSTOM_DOMAIN | | If the final URL that is used to read public files is different from `S3_ENDPOINT` |
Note that for AWS S3, your endpoint is usually: `s3.<S3_REGION>.amazonaws.com` Note that for AWS S3, your endpoint is usually: `s3.<S3_REGION>.amazonaws.com`

View File

@@ -32214,6 +32214,148 @@
} }
} }
} }
},
"/generate-upload-url": {
"post": {
"operationId": "generateUploadUrl",
"summary": "Generate upload URL",
"description": "Generate the needed URL to upload a file from the client",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"filePathProps": {
"anyOf": [
{
"anyOf": [
{
"anyOf": [
{
"type": "object",
"properties": {
"workspaceId": {
"type": "string"
},
"typebotId": {
"type": "string"
},
"blockId": {
"type": "string"
},
"itemId": {
"type": "string"
}
},
"required": [
"workspaceId",
"typebotId",
"blockId"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"workspaceId": {
"type": "string"
},
"typebotId": {
"type": "string"
},
"fileName": {
"type": "string"
}
},
"required": [
"workspaceId",
"typebotId",
"fileName"
],
"additionalProperties": false
}
]
},
{
"type": "object",
"properties": {
"userId": {
"type": "string"
},
"fileName": {
"type": "string"
}
},
"required": [
"userId",
"fileName"
],
"additionalProperties": false
}
]
},
{
"type": "object",
"properties": {
"workspaceId": {
"type": "string"
},
"fileName": {
"type": "string"
}
},
"required": [
"workspaceId",
"fileName"
],
"additionalProperties": false
}
]
},
"fileType": {
"type": "string"
}
},
"required": [
"filePathProps"
],
"additionalProperties": false
}
}
}
},
"parameters": [],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"presignedUrl": {
"type": "string"
},
"fileUrl": {
"type": "string"
}
},
"required": [
"presignedUrl",
"fileUrl"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
}
} }
}, },
"components": { "components": {

View File

@@ -6282,6 +6282,86 @@
"default": { "default": {
"$ref": "#/components/responses/error" "$ref": "#/components/responses/error"
} }
},
"deprecated": true
}
},
"/generate-upload-url": {
"post": {
"operationId": "generateUploadUrl",
"summary": "Generate upload URL",
"description": "Used to upload anything from the client to S3 bucket",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"filePathProps": {
"type": "object",
"properties": {
"typebotId": {
"type": "string"
},
"blockId": {
"type": "string"
},
"resultId": {
"type": "string"
},
"fileName": {
"type": "string"
}
},
"required": [
"typebotId",
"blockId",
"resultId",
"fileName"
],
"additionalProperties": false
},
"fileType": {
"type": "string"
}
},
"required": [
"filePathProps"
],
"additionalProperties": false
}
}
}
},
"parameters": [],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"presignedUrl": {
"type": "string"
},
"fileUrl": {
"type": "string"
}
},
"required": [
"presignedUrl",
"fileUrl"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
} }
} }
}, },

View File

@@ -20,6 +20,7 @@ export const getUploadUrl = publicProcedure
path: '/typebots/{typebotId}/blocks/{blockId}/storage/upload-url', path: '/typebots/{typebotId}/blocks/{blockId}/storage/upload-url',
summary: 'Get upload URL for a file', summary: 'Get upload URL for a file',
description: 'Used for the web client to get the bucket upload file.', description: 'Used for the web client to get the bucket upload file.',
deprecated: true,
}, },
}) })
.input( .input(

View File

@@ -0,0 +1,76 @@
import { publicProcedure } from '@/helpers/server/trpc'
import prisma from '@/lib/prisma'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { generatePresignedUrl } from '@typebot.io/lib/s3/generatePresignedUrl'
import { env } from '@typebot.io/env'
export const generateUploadUrl = publicProcedure
.meta({
openapi: {
method: 'POST',
path: '/generate-upload-url',
summary: 'Generate upload URL',
description: 'Used to upload anything from the client to S3 bucket',
},
})
.input(
z.object({
filePathProps: z.object({
typebotId: z.string(),
blockId: z.string(),
resultId: z.string(),
fileName: z.string(),
}),
fileType: z.string().optional(),
})
)
.output(
z.object({
presignedUrl: z.string(),
fileUrl: z.string(),
})
)
.mutation(async ({ input: { filePathProps, fileType } }) => {
if (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY || !env.S3_SECRET_KEY)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message:
'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY',
})
const publicTypebot = await prisma.publicTypebot.findFirst({
where: {
typebotId: filePathProps.typebotId,
},
select: {
typebot: {
select: {
workspaceId: true,
},
},
},
})
const workspaceId = publicTypebot?.typebot.workspaceId
if (!workspaceId)
throw new TRPCError({
code: 'BAD_REQUEST',
message: "Can't find workspaceId",
})
const filePath = `public/workspaces/${workspaceId}/typebots/${filePathProps.typebotId}/results/${filePathProps.resultId}/${filePathProps.fileName}`
const presignedUrl = await generatePresignedUrl({
fileType,
filePath,
})
return {
presignedUrl,
fileUrl: env.S3_PUBLIC_CUSTOM_DOMAIN
? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}`
: presignedUrl.split('?')[0],
}
})

View File

@@ -1,4 +1,5 @@
import { getUploadUrl } from '@/features/blocks/inputs/fileUpload/api/getUploadUrl' import { getUploadUrl } from '@/features/blocks/inputs/fileUpload/api/deprecated/getUploadUrl'
import { generateUploadUrl } from '@/features/blocks/inputs/fileUpload/api/generateUploadUrl'
import { sendMessage } from '@/features/chat/api/sendMessage' import { sendMessage } from '@/features/chat/api/sendMessage'
import { whatsAppRouter } from '@/features/whatsApp/api/router' import { whatsAppRouter } from '@/features/whatsApp/api/router'
import { router } from '../../trpc' import { router } from '../../trpc'
@@ -7,6 +8,7 @@ import { updateTypebotInSession } from '@/features/chat/api/updateTypebotInSessi
export const appRouter = router({ export const appRouter = router({
sendMessage, sendMessage,
getUploadUrl, getUploadUrl,
generateUploadUrl,
updateTypebotInSession, updateTypebotInSession,
whatsAppRouter, whatsAppRouter,
}) })

View File

@@ -4,7 +4,7 @@ import { useTypebot } from '@/providers/TypebotProvider'
import { InputSubmitContent } from '@/types' import { InputSubmitContent } from '@/types'
import { defaultFileInputOptions, FileInputBlock } from '@typebot.io/schemas' import { defaultFileInputOptions, FileInputBlock } from '@typebot.io/schemas'
import React, { ChangeEvent, FormEvent, useState, DragEvent } from 'react' import React, { ChangeEvent, FormEvent, useState, DragEvent } from 'react'
import { uploadFiles } from '@typebot.io/lib/s3/uploadFiles' import { uploadFiles } from '../helpers/uploadFiles'
type Props = { type Props = {
block: FileInputBlock block: FileInputBlock

View File

@@ -1,4 +1,4 @@
import { sendRequest } from '../utils' import { sendRequest } from '@typebot.io/lib/utils'
type UploadFileProps = { type UploadFileProps = {
basePath?: string basePath?: string

View File

@@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/js", "name": "@typebot.io/js",
"version": "0.1.25", "version": "0.1.26",
"description": "Javascript library to display typebots on your website", "description": "Javascript library to display typebots on your website",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -1,12 +1,12 @@
import { SendButton } from '@/components/SendButton' import { SendButton } from '@/components/SendButton'
import { BotContext, InputSubmitContent } from '@/types' import { BotContext, InputSubmitContent } from '@/types'
import { guessApiHost } from '@/utils/guessApiHost'
import { FileInputBlock } from '@typebot.io/schemas' import { FileInputBlock } from '@typebot.io/schemas'
import { defaultFileInputOptions } from '@typebot.io/schemas/features/blocks/inputs/file' import { defaultFileInputOptions } from '@typebot.io/schemas/features/blocks/inputs/file'
import { createSignal, Match, Show, Switch } from 'solid-js' import { createSignal, Match, Show, Switch } from 'solid-js'
import { uploadFiles } from '@typebot.io/lib/s3/uploadFiles'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Spinner } from '@/components/Spinner' import { Spinner } from '@/components/Spinner'
import { uploadFiles } from '../helpers/uploadFiles'
import { guessApiHost } from '@/utils/guessApiHost'
type Props = { type Props = {
context: BotContext context: BotContext
@@ -46,20 +46,23 @@ export const FileUploadForm = (props: Props) => {
} }
const startSingleFileUpload = async (file: File) => { const startSingleFileUpload = async (file: File) => {
if (props.context.isPreview) if (props.context.isPreview || !props.context.resultId)
return props.onSubmit({ return props.onSubmit({
label: `File uploaded`, label: `File uploaded`,
value: 'http://fake-upload-url.com', value: 'http://fake-upload-url.com',
}) })
setIsUploading(true) setIsUploading(true)
const urls = await uploadFiles({ const urls = await uploadFiles({
basePath: `${props.context.apiHost ?? guessApiHost()}/api/typebots/${ apiHost: props.context.apiHost ?? guessApiHost(),
props.context.typebot.id
}/blocks/${props.block.id}`,
files: [ files: [
{ {
file, file,
path: `public/results/${props.context.resultId}/${props.block.id}/${file.name}`, input: {
resultId: props.context.resultId,
typebotId: props.context.typebot.id,
blockId: props.block.id,
fileName: file.name,
},
}, },
], ],
}) })
@@ -69,7 +72,8 @@ export const FileUploadForm = (props: Props) => {
setErrorMessage('An error occured while uploading the file') setErrorMessage('An error occured while uploading the file')
} }
const startFilesUpload = async (files: File[]) => { const startFilesUpload = async (files: File[]) => {
if (props.context.isPreview) const resultId = props.context.resultId
if (props.context.isPreview || !resultId)
return props.onSubmit({ return props.onSubmit({
label: `${files.length} file${files.length > 1 ? 's' : ''} uploaded`, label: `${files.length} file${files.length > 1 ? 's' : ''} uploaded`,
value: files value: files
@@ -78,12 +82,15 @@ export const FileUploadForm = (props: Props) => {
}) })
setIsUploading(true) setIsUploading(true)
const urls = await uploadFiles({ const urls = await uploadFiles({
basePath: `${props.context.apiHost ?? guessApiHost()}/api/typebots/${ apiHost: props.context.apiHost ?? guessApiHost(),
props.context.typebot.id
}/blocks/${props.block.id}`,
files: files.map((file) => ({ files: files.map((file) => ({
file: file, file: file,
path: `public/results/${props.context.resultId}/${props.block.id}/${file.name}`, input: {
resultId,
typebotId: props.context.typebot.id,
blockId: props.block.id,
fileName: file.name,
},
})), })),
onUploadProgress: setUploadProgressPercent, onUploadProgress: setUploadProgressPercent,
}) })

View File

@@ -0,0 +1,54 @@
import { sendRequest } from '@typebot.io/lib/utils'
type UploadFileProps = {
apiHost: string
files: {
file: File
input: {
typebotId: string
blockId: string
resultId: string
fileName: string
}
}[]
onUploadProgress?: (percent: number) => void
}
type UrlList = (string | null)[]
export const uploadFiles = async ({
apiHost,
files,
onUploadProgress,
}: UploadFileProps): Promise<UrlList> => {
const urls = []
let i = 0
for (const { input, file } of files) {
onUploadProgress && onUploadProgress((i / files.length) * 100)
i += 1
const { data } = await sendRequest<{
presignedUrl: string
fileUrl: string
}>({
method: 'POST',
url: `${apiHost}/api/v1/generate-upload-url`,
body: {
filePathProps: input,
fileType: file.type,
},
})
if (!data?.presignedUrl) continue
else {
const upload = await fetch(data.presignedUrl, {
method: 'PUT',
body: file,
})
if (!upload.ok) continue
urls.push(data.fileUrl)
}
}
return urls
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/nextjs", "name": "@typebot.io/nextjs",
"version": "0.1.25", "version": "0.1.26",
"description": "Convenient library to display typebots on your Next.js website", "description": "Convenient library to display typebots on your Next.js website",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/react", "name": "@typebot.io/react",
"version": "0.1.25", "version": "0.1.26",
"description": "Convenient library to display typebots on your React app", "description": "Convenient library to display typebots on your React app",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

1
packages/env/env.ts vendored
View File

@@ -170,6 +170,7 @@ const s3Env = {
S3_ENDPOINT: z.string().min(1).optional(), S3_ENDPOINT: z.string().min(1).optional(),
S3_SSL: boolean.optional().default('true'), S3_SSL: boolean.optional().default('true'),
S3_REGION: z.string().min(1).optional(), S3_REGION: z.string().min(1).optional(),
S3_PUBLIC_CUSTOM_DOMAIN: z.string().url().optional(),
}, },
} }

View File

@@ -23,6 +23,6 @@
}, },
"dependencies": { "dependencies": {
"got": "12.6.0", "got": "12.6.0",
"minio": "7.1.1" "minio": "7.1.3"
} }
} }

View File

@@ -0,0 +1,42 @@
import { env } from '@typebot.io/env'
import { Client } from 'minio'
type Props = {
folderPath: string
}
export const getFolderSize = async ({ folderPath }: Props) => {
if (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY || !env.S3_SECRET_KEY)
throw new Error(
'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY'
)
const minioClient = new Client({
endPoint: env.S3_ENDPOINT,
port: env.S3_PORT,
useSSL: env.S3_SSL,
accessKey: env.S3_ACCESS_KEY,
secretKey: env.S3_SECRET_KEY,
region: env.S3_REGION,
})
return new Promise<number>((resolve, reject) => {
let totalSize = 0
const stream = minioClient.listObjectsV2(
env.S3_BUCKET,
'public/' + folderPath,
true
)
stream.on('data', function (obj) {
totalSize += obj.size
})
stream.on('error', function (err) {
reject(err)
})
stream.on('end', function () {
resolve(totalSize)
})
})
}

47
pnpm-lock.yaml generated
View File

@@ -197,9 +197,6 @@ importers:
micro-cors: micro-cors:
specifier: 0.1.1 specifier: 0.1.1
version: 0.1.1 version: 0.1.1
minio:
specifier: 7.1.1
version: 7.1.1
next: next:
specifier: 13.4.3 specifier: 13.4.3
version: 13.4.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) version: 13.4.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0)
@@ -306,9 +303,6 @@ importers:
'@types/micro-cors': '@types/micro-cors':
specifier: 0.1.3 specifier: 0.1.3
version: 0.1.3 version: 0.1.3
'@types/minio':
specifier: 7.1.1
version: 7.1.1
'@types/node': '@types/node':
specifier: 20.4.2 specifier: 20.4.2
version: 20.4.2 version: 20.4.2
@@ -1128,8 +1122,8 @@ importers:
specifier: 12.6.0 specifier: 12.6.0
version: 12.6.0 version: 12.6.0
minio: minio:
specifier: 7.1.1 specifier: 7.1.3
version: 7.1.1 version: 7.1.3
devDependencies: devDependencies:
'@paralleldrive/cuid2': '@paralleldrive/cuid2':
specifier: 2.2.1 specifier: 2.2.1
@@ -8686,13 +8680,6 @@ packages:
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==} resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
dev: false dev: false
/@types/minio@7.1.1:
resolution: {integrity: sha512-B7OWB7JwIxVBxypiS3gA96gaK4yo2UknGdqmuQsTccZZ/ABiQ2F3fTe9lZIXL6ZuN23l+mWIC3J4CefKNyWjxA==}
deprecated: This is a stub types definition. minio provides its own type definitions, so you do not need this installed.
dependencies:
minio: 7.1.1
dev: true
/@types/node@17.0.45: /@types/node@17.0.45:
resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==}
dev: false dev: false
@@ -10035,6 +10022,7 @@ packages:
/@zxing/text-encoding@0.9.0: /@zxing/text-encoding@0.9.0:
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
requiresBuild: true requiresBuild: true
dev: false
optional: true optional: true
/abab@2.0.6: /abab@2.0.6:
@@ -10467,6 +10455,7 @@ packages:
/async@3.2.4: /async@3.2.4:
resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==}
dev: false
/asynckit@0.4.0: /asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -10817,6 +10806,7 @@ packages:
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
dependencies: dependencies:
readable-stream: 3.6.2 readable-stream: 3.6.2
dev: false
/body-parser@1.20.1: /body-parser@1.20.1:
resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==} resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==}
@@ -10924,6 +10914,7 @@ packages:
/browser-or-node@2.1.1: /browser-or-node@2.1.1:
resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==} resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==}
dev: false
/browserslist@4.21.10: /browserslist@4.21.10:
resolution: {integrity: sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==} resolution: {integrity: sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==}
@@ -10950,6 +10941,7 @@ packages:
/buffer-crc32@0.2.13: /buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
dev: false
/buffer-equal-constant-time@1.0.1: /buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
@@ -12115,6 +12107,7 @@ packages:
/decode-uri-component@0.2.2: /decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
dev: false
/decode-uri-component@0.4.1: /decode-uri-component@0.4.1:
resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==}
@@ -13628,6 +13621,7 @@ packages:
hasBin: true hasBin: true
dependencies: dependencies:
strnum: 1.0.5 strnum: 1.0.5
dev: false
/fastest-stable-stringify@2.0.2: /fastest-stable-stringify@2.0.2:
resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==} resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==}
@@ -13739,6 +13733,7 @@ packages:
/filter-obj@1.1.0: /filter-obj@1.1.0:
resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: false
/filter-obj@5.1.0: /filter-obj@5.1.0:
resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==}
@@ -15070,6 +15065,7 @@ packages:
/ipaddr.js@2.1.0: /ipaddr.js@2.1.0:
resolution: {integrity: sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==} resolution: {integrity: sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
dev: false
/iron-webcrypto@0.8.2: /iron-webcrypto@0.8.2:
resolution: {integrity: sha512-jGiwmpgTuF19Vt4hn3+AzaVFGpVZt7A1ysd5ivFel2r4aNVFwqaYa6aU6qsF1PM7b+WFivZHz3nipwUOXaOnHg==} resolution: {integrity: sha512-jGiwmpgTuF19Vt4hn3+AzaVFGpVZt7A1ysd5ivFel2r4aNVFwqaYa6aU6qsF1PM7b+WFivZHz3nipwUOXaOnHg==}
@@ -15092,6 +15088,7 @@ packages:
dependencies: dependencies:
call-bind: 1.0.2 call-bind: 1.0.2
has-tostringtag: 1.0.0 has-tostringtag: 1.0.0
dev: false
/is-array-buffer@3.0.2: /is-array-buffer@3.0.2:
resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==}
@@ -15197,6 +15194,7 @@ packages:
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
dependencies: dependencies:
has-tostringtag: 1.0.0 has-tostringtag: 1.0.0
dev: false
/is-glob@4.0.3: /is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
@@ -16120,6 +16118,7 @@ packages:
/json-stream@1.0.0: /json-stream@1.0.0:
resolution: {integrity: sha512-H/ZGY0nIAg3QcOwE1QN/rK/Fa7gJn7Ii5obwp6zyPO4xiPNwpIMjqy2gwjBEGqzkF/vSWEIBQCBuN19hYiL6Qg==} resolution: {integrity: sha512-H/ZGY0nIAg3QcOwE1QN/rK/Fa7gJn7Ii5obwp6zyPO4xiPNwpIMjqy2gwjBEGqzkF/vSWEIBQCBuN19hYiL6Qg==}
dev: false
/json5@1.0.2: /json5@1.0.2:
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
@@ -16761,8 +16760,8 @@ packages:
/minimist@1.2.8: /minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
/minio@7.1.1: /minio@7.1.3:
resolution: {integrity: sha512-HBLRFXs1CkNwAkahU+j1ilB9YS/Tmkdc6orpxVW1YN11NlEJyLjarIpBYu/inF+dj+tJIsA8PSKNnRmUNm+9qQ==} resolution: {integrity: sha512-xPrLjWkTT5E7H7VnzOjF//xBp9I40jYB4aWhb2xTFopXXfw+Wo82DDWngdUju7Doy3Wk7R8C4LAgwhLHHnf0wA==}
engines: {node: ^16 || ^18 || >=20} engines: {node: ^16 || ^18 || >=20}
dependencies: dependencies:
async: 3.2.4 async: 3.2.4
@@ -16779,6 +16778,7 @@ packages:
web-encoding: 1.1.5 web-encoding: 1.1.5
xml: 1.0.1 xml: 1.0.1
xml2js: 0.5.0 xml2js: 0.5.0
dev: false
/mjml-accordion@4.14.1: /mjml-accordion@4.14.1:
resolution: {integrity: sha512-dpNXyjnhYwhM75JSjD4wFUa9JgHm86M2pa0CoTzdv1zOQz67ilc4BoK5mc2S0gOjJpjBShM5eOJuCyVIuAPC6w==} resolution: {integrity: sha512-dpNXyjnhYwhM75JSjD4wFUa9JgHm86M2pa0CoTzdv1zOQz67ilc4BoK5mc2S0gOjJpjBShM5eOJuCyVIuAPC6w==}
@@ -18867,6 +18867,7 @@ packages:
filter-obj: 1.1.0 filter-obj: 1.1.0
split-on-first: 1.1.0 split-on-first: 1.1.0
strict-uri-encode: 2.0.0 strict-uri-encode: 2.0.0
dev: false
/query-string@8.1.0: /query-string@8.1.0:
resolution: {integrity: sha512-BFQeWxJOZxZGix7y+SByG3F36dA0AbTy9o6pSmKFcFz7DAj0re9Frkty3saBn3nHo3D0oZJ/+rx3r8H8r8Jbpw==} resolution: {integrity: sha512-BFQeWxJOZxZGix7y+SByG3F36dA0AbTy9o6pSmKFcFz7DAj0re9Frkty3saBn3nHo3D0oZJ/+rx3r8H8r8Jbpw==}
@@ -19472,6 +19473,7 @@ packages:
inherits: 2.0.4 inherits: 2.0.4
string_decoder: 1.3.0 string_decoder: 1.3.0
util-deprecate: 1.0.2 util-deprecate: 1.0.2
dev: false
/readdirp@3.6.0: /readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
@@ -19972,6 +19974,7 @@ packages:
/sax@1.2.4: /sax@1.2.4:
resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==} resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==}
dev: false
/saxes@6.0.0: /saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
@@ -20515,6 +20518,7 @@ packages:
/split-on-first@1.1.0: /split-on-first@1.1.0:
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
engines: {node: '>=6'} engines: {node: '>=6'}
dev: false
/split-on-first@3.0.0: /split-on-first@3.0.0:
resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==} resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==}
@@ -20620,6 +20624,7 @@ packages:
/strict-uri-encode@2.0.0: /strict-uri-encode@2.0.0:
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
engines: {node: '>=4'} engines: {node: '>=4'}
dev: false
/string-hash@1.1.3: /string-hash@1.1.3:
resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==}
@@ -20703,6 +20708,7 @@ packages:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
dev: false
/stringify-object@3.3.0: /stringify-object@3.3.0:
resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==}
@@ -20775,6 +20781,7 @@ packages:
/strnum@1.0.5: /strnum@1.0.5:
resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
dev: false
/style-inject@0.3.0: /style-inject@0.3.0:
resolution: {integrity: sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==} resolution: {integrity: sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==}
@@ -21183,6 +21190,7 @@ packages:
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
dependencies: dependencies:
readable-stream: 3.6.2 readable-stream: 3.6.2
dev: false
/through@2.3.8: /through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
@@ -22092,6 +22100,7 @@ packages:
is-generator-function: 1.0.10 is-generator-function: 1.0.10
is-typed-array: 1.1.12 is-typed-array: 1.1.12
which-typed-array: 1.1.11 which-typed-array: 1.1.11
dev: false
/utila@0.4.0: /utila@0.4.0:
resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==} resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==}
@@ -22298,6 +22307,7 @@ packages:
util: 0.12.5 util: 0.12.5
optionalDependencies: optionalDependencies:
'@zxing/text-encoding': 0.9.0 '@zxing/text-encoding': 0.9.0
dev: false
/web-namespaces@1.1.4: /web-namespaces@1.1.4:
resolution: {integrity: sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==} resolution: {integrity: sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==}
@@ -22696,13 +22706,16 @@ packages:
dependencies: dependencies:
sax: 1.2.4 sax: 1.2.4
xmlbuilder: 11.0.1 xmlbuilder: 11.0.1
dev: false
/xml@1.0.1: /xml@1.0.1:
resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
dev: false
/xmlbuilder@11.0.1: /xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
dev: false
/xmlchars@2.2.0: /xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}