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",
"micro": "10.0.1",
"micro-cors": "0.1.1",
"minio": "7.1.1",
"next": "13.4.3",
"next-auth": "4.22.1",
"next-international": "0.9.5",
@@ -106,7 +105,6 @@
"@types/canvas-confetti": "1.6.0",
"@types/jsonwebtoken": "9.0.2",
"@types/micro-cors": "0.1.3",
"@types/minio": "7.1.1",
"@types/node": "20.4.2",
"@types/nodemailer": "6.4.8",
"@types/nprogress": "0.2.0",

View File

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

View File

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

View File

@@ -1,25 +1,44 @@
import { useToast } from '@/hooks/useToast'
import { Button, ButtonProps, chakra } from '@chakra-ui/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'
type UploadButtonProps = {
fileType: 'image' | 'audio'
filePath: string
includeFileName?: boolean
filePathProps: FilePathUploadProps
onFileUploaded: (url: string) => void
} & ButtonProps
export const UploadButton = ({
fileType,
filePath,
includeFileName,
filePathProps,
onFileUploaded,
...props
}: UploadButtonProps) => {
const [isUploading, setIsUploading] = useState(false)
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>) => {
if (!e.target?.files) return
@@ -27,16 +46,11 @@ export const UploadButton = ({
const file = e.target.files[0] as File | undefined
if (!file)
return showToast({ description: 'Could not read file.', status: 'error' })
const urls = await uploadFiles({
files: [
{
file: await compressFile(file),
path: `public/${filePath}${includeFileName ? `/${file.name}` : ''}`,
},
],
setFile(await compressFile(file))
mutate({
filePathProps,
fileType: file.type,
})
if (urls.length && urls[0]) onFileUploaded(urls[0] + '?v=' + Date.now())
setIsUploading(false)
}
return (

View File

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

View File

@@ -4,6 +4,7 @@ import { parseDefaultGroupWithBlock } from '@typebot.io/lib/playwright/databaseH
import { BubbleBlockType, defaultAudioBubbleContent } from '@typebot.io/schemas'
import { createId } from '@paralleldrive/cuid2'
import { getTestAsset } from '@/test/utils/playwright'
import { proWorkspaceId } from '@typebot.io/lib/playwright/databaseSetup'
const audioSampleUrl =
'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 expect(page.locator('audio')).toHaveAttribute(
'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 expect(page.locator('audio')).toHaveAttribute(
'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 { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { useScopedI18n } from '@/locales'
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
type Props = {
fileUploadPath: string
uploadFileProps: FilePathUploadProps
content: AudioBubbleContent
onContentChange: (content: AudioBubbleContent) => void
}
export const AudioBubbleForm = ({
fileUploadPath,
uploadFileProps,
content,
onContentChange,
}: Props) => {
@@ -49,7 +50,7 @@ export const AudioBubbleForm = ({
<Flex justify="center" py="2">
<UploadButton
fileType="audio"
filePath={fileUploadPath}
filePathProps={uploadFileProps}
onFileUploaded={updateUrl}
colorScheme="blue"
>

View File

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

View File

@@ -4,6 +4,7 @@ import { parseDefaultGroupWithBlock } from '@typebot.io/lib/playwright/databaseH
import { BubbleBlockType, defaultImageBubbleContent } from '@typebot.io/schemas'
import { createId } from '@paralleldrive/cuid2'
import { getTestAsset } from '@/test/utils/playwright'
import { proWorkspaceId } from '@typebot.io/lib/playwright/databaseSetup'
const unsplashImageSrc =
'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 expect(page.locator('img')).toHaveAttribute(
'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 && (
<PictureChoiceItemSettings
workspaceId={typebot.workspaceId}
typebotId={typebot.id}
item={item}
blockId={

View File

@@ -16,6 +16,7 @@ import { ConditionForm } from '@/features/blocks/logic/condition/components/Cond
import { Condition, LogicalOperator } from '@typebot.io/schemas'
type Props = {
workspaceId: string
typebotId: string
blockId: string
item: PictureChoiceItem
@@ -23,6 +24,7 @@ type Props = {
}
export const PictureChoiceItemSettings = ({
workspaceId,
typebotId,
blockId,
item,
@@ -69,7 +71,12 @@ export const PictureChoiceItemSettings = ({
</PopoverTrigger>
<PopoverContent p="4" w="500px">
<ImageUploadContent
filePath={`typebots/${typebotId}/blocks/${blockId}/items/${item.id}`}
uploadFileProps={{
workspaceId,
typebotId,
blockId,
itemId: item.id,
}}
defaultUrl={item.pictureSrc}
onSubmit={(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 { encrypt } from '@typebot.io/lib/api/encryption'
import { z } from 'zod'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden copy'
import { whatsAppCredentialsSchema } from '@typebot.io/schemas/features/whatsapp'
import { Credentials } from '@typebot.io/schemas'
import { isDefined } from '@typebot.io/lib/utils'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
const inputShape = {
data: true,

View File

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

View File

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

View File

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

View File

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

View File

@@ -284,7 +284,11 @@ export const BlockNode = ({
)}
{typebot && isMediaBubbleBlock(block) && (
<MediaBubblePopoverContent
typebotId={typebot.id}
uploadFileProps={{
workspaceId: typebot.workspaceId,
typebotId: typebot.id,
blockId: block.id,
}}
block={block}
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 { ImageBubbleSettings } from '@/features/blocks/bubbles/image/components/ImageBubbleSettings'
import { VideoUploadContent } from '@/features/blocks/bubbles/video/components/VideoUploadContent'
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
import {
Portal,
PopoverContent,
@@ -17,7 +18,7 @@ import {
import { useRef } from 'react'
type Props = {
typebotId: string
uploadFileProps: FilePathUploadProps
block: Exclude<BubbleBlock, TextBubbleBlock>
onContentChange: (content: BubbleBlockContent) => void
}
@@ -42,7 +43,7 @@ export const MediaBubblePopoverContent = (props: Props) => {
}
export const MediaBubbleContent = ({
typebotId,
uploadFileProps,
block,
onContentChange,
}: Props) => {
@@ -50,7 +51,7 @@ export const MediaBubbleContent = ({
case BubbleBlockType.IMAGE: {
return (
<ImageBubbleSettings
typebotId={typebotId}
uploadFileProps={uploadFileProps}
block={block}
onContentChange={onContentChange}
/>
@@ -76,7 +77,7 @@ export const MediaBubbleContent = ({
return (
<AudioBubbleForm
content={block.content}
fileUploadPath={`typebots/${typebotId}/blocks/${block.id}`}
uploadFileProps={uploadFileProps}
onContentChange={onContentChange}
/>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ export const isWriteTypebotForbidden = async (
typebot: Pick<Typebot, 'workspaceId'> & {
collaborators: Pick<CollaboratorsOnTypebots, 'userId' | 'type'>[]
},
user: Pick<User, 'email' | 'id'>
user: Pick<User, 'id'>
) => {
if (
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 })
}
const handleChangeIcon = (icon: string) => {
updateWorkspace({ icon })
}
const handleChangeIcon = (icon: string) => updateWorkspace({ icon })
const handleDeleteClick = async () => {
await deleteCurrentWorkspace()
@@ -40,7 +38,10 @@ export const WorkspaceSettingsForm = ({ onClose }: { onClose: () => void }) => {
<Flex>
{workspace && (
<EditableEmojiOrImageIcon
uploadFilePath={`workspaces/${workspace.id}/icon`}
uploadFileProps={{
workspaceId: workspace.id,
fileName: 'icon',
}}
icon={workspace.icon}
onChangeIcon={handleChangeIcon}
boxSize="40px"

View File

@@ -4,7 +4,7 @@ export const isWriteWorkspaceForbidden = (
workspace: {
members: Pick<MemberInWorkspace, 'userId' | 'role'>[]
},
user: Pick<User, 'email' | 'id'>
user: Pick<User, 'id'>
) => {
const userRole = workspace.members.find(
(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 { whatsAppRouter } from '@/features/whatsapp/router'
import { openAIRouter } from '@/features/blocks/integrations/openai/api/router'
import { generateUploadUrl } from '@/features/upload/api/generateUploadUrl'
export const trpcRouter = router({
getAppVersionProcedure,
@@ -33,6 +34,7 @@ export const trpcRouter = router({
customDomains: customDomainsRouter,
whatsApp: whatsAppRouter,
openAI: openAIRouter,
generateUploadUrl,
})
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...)
| Parameter | Default | Description |
| ------------- | ------- | -------------------------------------------------------------- |
| S3_ACCESS_KEY | | S3 access key. Also used to check if upload feature is enabled |
| S3_SECRET_KEY | | S3 secret key. |
| S3_BUCKET | typebot | Name of the bucket where assets will be uploaded in. |
| S3_PORT | | S3 Host port number |
| S3_ENDPOINT | | S3 endpoint (i.e. `s3.domain.com`). |
| S3_SSL | true | Use SSL when establishing the connection. |
| S3_REGION | | S3 region. |
| Parameter | Default | Description |
| ----------------------- | ------- | ---------------------------------------------------------------------------------- |
| S3_ACCESS_KEY | | S3 access key. Also used to check if upload feature is enabled |
| S3_SECRET_KEY | | S3 secret key. |
| S3_BUCKET | typebot | Name of the bucket where assets will be uploaded in. |
| S3_PORT | | S3 Host port number |
| S3_ENDPOINT | | S3 endpoint (i.e. `s3.domain.com`). |
| S3_SSL | true | Use SSL when establishing the connection. |
| 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`

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": {

View File

@@ -6282,6 +6282,86 @@
"default": {
"$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',
summary: 'Get upload URL for a file',
description: 'Used for the web client to get the bucket upload file.',
deprecated: true,
},
})
.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 { whatsAppRouter } from '@/features/whatsApp/api/router'
import { router } from '../../trpc'
@@ -7,6 +8,7 @@ import { updateTypebotInSession } from '@/features/chat/api/updateTypebotInSessi
export const appRouter = router({
sendMessage,
getUploadUrl,
generateUploadUrl,
updateTypebotInSession,
whatsAppRouter,
})