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