From fbb198af9de1e8e44184373a36973a90c8f77b5f Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Fri, 8 Sep 2023 15:28:11 +0200 Subject: [PATCH] :zap: (s3) Improve storage management and type safety Closes #756 --- apps/builder/package.json | 2 - .../components/EditableEmojiOrImageIcon.tsx | 7 +- .../ImageUploadContent/ImageUploadContent.tsx | 29 ++- .../ImageUploadContent/UploadButton.tsx | 42 +++-- .../account/components/MyAccountForm.tsx | 23 ++- .../blocks/bubbles/audio/audio.spec.ts | 11 +- .../audio/components/AudioBubbleForm.tsx | 7 +- .../image/components/ImageBubbleSettings.tsx | 7 +- .../blocks/bubbles/image/image.spec.ts | 6 +- .../components/PictureChoiceItemNode.tsx | 1 + .../components/PictureChoiceItemSettings.tsx | 9 +- .../credentials/api/createCredentials.ts | 2 +- .../credentials/api/deleteCredentials.ts | 2 +- .../customDomains/api/createCustomDomain.ts | 2 +- .../customDomains/api/deleteCustomDomain.ts | 2 +- .../editor/components/TypebotHeader.tsx | 6 +- .../components/nodes/block/BlockNode.tsx | 6 +- .../nodes/block/MediaBubblePopoverContent.tsx | 9 +- .../BubbleSettings/ButtonThemeSettings.tsx | 2 +- .../settings/components/MetadataForm.tsx | 14 +- .../settings/components/SettingsSideMenu.tsx | 1 + .../theme/components/ThemeSideMenu.tsx | 1 + .../theme/components/chat/AvatarForm.tsx | 7 +- .../components/chat/ChatThemeSettings.tsx | 14 +- .../components/general/BackgroundContent.tsx | 9 +- .../helpers/isWriteTypebotForbidden.ts | 2 +- .../features/upload/api/generateUploadUrl.ts | 173 ++++++++++++++++++ .../components/WorkspaceSettingsForm.tsx | 9 +- ...n copy.ts => isWriteWorkspaceForbidden.ts} | 2 +- .../helpers/server/routers/v1/trpcRouter.ts | 2 + apps/docs/docs/self-hosting/configuration.md | 19 +- apps/docs/openapi/builder/_spec_.json | 142 ++++++++++++++ apps/docs/openapi/chat/_spec_.json | 80 ++++++++ .../api/{ => deprecated}/getUploadUrl.ts | 1 + .../fileUpload/api/generateUploadUrl.ts | 76 ++++++++ .../src/helpers/server/routers/v1/_app.ts | 4 +- .../fileUpload/components/FileUploadForm.tsx | 2 +- .../inputs/fileUpload/helpers}/uploadFiles.ts | 2 +- packages/embeds/js/package.json | 2 +- .../fileUpload/components/FileUploadForm.tsx | 31 ++-- .../inputs/fileUpload/helpers/uploadFiles.ts | 54 ++++++ packages/embeds/nextjs/package.json | 2 +- packages/embeds/react/package.json | 2 +- packages/env/env.ts | 1 + packages/lib/package.json | 2 +- packages/lib/s3/getFolderSize.ts | 42 +++++ pnpm-lock.yaml | 47 +++-- 47 files changed, 790 insertions(+), 128 deletions(-) create mode 100644 apps/builder/src/features/upload/api/generateUploadUrl.ts rename apps/builder/src/features/workspace/helpers/{isWriteWorkspaceForbidden copy.ts => isWriteWorkspaceForbidden.ts} (90%) rename apps/viewer/src/features/blocks/inputs/fileUpload/api/{ => deprecated}/getUploadUrl.ts (99%) create mode 100644 apps/viewer/src/features/blocks/inputs/fileUpload/api/generateUploadUrl.ts rename packages/{lib/s3 => deprecated/bot-engine/src/features/blocks/inputs/fileUpload/helpers}/uploadFiles.ts (95%) create mode 100644 packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/uploadFiles.ts create mode 100644 packages/lib/s3/getFolderSize.ts diff --git a/apps/builder/package.json b/apps/builder/package.json index 38481f2bf..41d495f7b 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -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", diff --git a/apps/builder/src/components/EditableEmojiOrImageIcon.tsx b/apps/builder/src/components/EditableEmojiOrImageIcon.tsx index 5b2c59aa6..6d04d3367 100644 --- a/apps/builder/src/components/EditableEmojiOrImageIcon.tsx +++ b/apps/builder/src/components/EditableEmojiOrImageIcon.tsx @@ -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 = ({ { switch (tab) { case 'upload': { - if (!filePath) return null + if (!uploadFileProps) return null return ( ) @@ -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 }) => ( Choose an image diff --git a/apps/builder/src/components/ImageUploadContent/UploadButton.tsx b/apps/builder/src/components/ImageUploadContent/UploadButton.tsx index 544717ac4..9eb63ad6c 100644 --- a/apps/builder/src/components/ImageUploadContent/UploadButton.tsx +++ b/apps/builder/src/components/ImageUploadContent/UploadButton.tsx @@ -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() + + 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) => { 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 ( diff --git a/apps/builder/src/features/account/components/MyAccountForm.tsx b/apps/builder/src/features/account/components/MyAccountForm.tsx index 1ba385cd0..439735f8f 100644 --- a/apps/builder/src/features/account/components/MyAccountForm.tsx +++ b/apps/builder/src/features/account/components/MyAccountForm.tsx @@ -36,15 +36,20 @@ export const MyAccountForm = () => { name={user?.name ?? undefined} /> - } - onFileUploaded={handleFileUploaded} - > - {scopedT('changePhotoButton.label')} - + {user?.id && ( + } + onFileUploaded={handleFileUploaded} + > + {scopedT('changePhotoButton.label')} + + )} {scopedT('changePhotoButton.specification')} diff --git a/apps/builder/src/features/blocks/bubbles/audio/audio.spec.ts b/apps/builder/src/features/blocks/bubbles/audio/audio.spec.ts index 033a5a79e..4cddcadc6 100644 --- a/apps/builder/src/features/blocks/bubbles/audio/audio.spec.ts +++ b/apps/builder/src/features/blocks/bubbles/audio/audio.spec.ts @@ -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' + ) ) }) diff --git a/apps/builder/src/features/blocks/bubbles/audio/components/AudioBubbleForm.tsx b/apps/builder/src/features/blocks/bubbles/audio/components/AudioBubbleForm.tsx index 403251134..7b455eb63 100644 --- a/apps/builder/src/features/blocks/bubbles/audio/components/AudioBubbleForm.tsx +++ b/apps/builder/src/features/blocks/bubbles/audio/components/AudioBubbleForm.tsx @@ -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 = ({ diff --git a/apps/builder/src/features/blocks/bubbles/image/components/ImageBubbleSettings.tsx b/apps/builder/src/features/blocks/bubbles/image/components/ImageBubbleSettings.tsx index c02c515a7..ee79147da 100644 --- a/apps/builder/src/features/blocks/bubbles/image/components/ImageBubbleSettings.tsx +++ b/apps/builder/src/features/blocks/bubbles/image/components/ImageBubbleSettings.tsx @@ -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 ( diff --git a/apps/builder/src/features/blocks/bubbles/image/image.spec.ts b/apps/builder/src/features/blocks/bubbles/image/image.spec.ts index 20e0d3417..08861d9b7 100644 --- a/apps/builder/src/features/blocks/bubbles/image/image.spec.ts +++ b/apps/builder/src/features/blocks/bubbles/image/image.spec.ts @@ -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' + ) ) }) diff --git a/apps/builder/src/features/blocks/inputs/pictureChoice/components/PictureChoiceItemNode.tsx b/apps/builder/src/features/blocks/inputs/pictureChoice/components/PictureChoiceItemNode.tsx index a2381c52d..8035536f8 100644 --- a/apps/builder/src/features/blocks/inputs/pictureChoice/components/PictureChoiceItemNode.tsx +++ b/apps/builder/src/features/blocks/inputs/pictureChoice/components/PictureChoiceItemNode.tsx @@ -134,6 +134,7 @@ export const PictureChoiceItemNode = ({ > {typebot && ( { updateImage(url) diff --git a/apps/builder/src/features/credentials/api/createCredentials.ts b/apps/builder/src/features/credentials/api/createCredentials.ts index 6bbfd4ca1..d19b938ea 100644 --- a/apps/builder/src/features/credentials/api/createCredentials.ts +++ b/apps/builder/src/features/credentials/api/createCredentials.ts @@ -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, diff --git a/apps/builder/src/features/credentials/api/deleteCredentials.ts b/apps/builder/src/features/credentials/api/deleteCredentials.ts index 4d5716ff3..14bd14ba3 100644 --- a/apps/builder/src/features/credentials/api/deleteCredentials.ts +++ b/apps/builder/src/features/credentials/api/deleteCredentials.ts @@ -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({ diff --git a/apps/builder/src/features/customDomains/api/createCustomDomain.ts b/apps/builder/src/features/customDomains/api/createCustomDomain.ts index e1577e5a8..7f9d92935 100644 --- a/apps/builder/src/features/customDomains/api/createCustomDomain.ts +++ b/apps/builder/src/features/customDomains/api/createCustomDomain.ts @@ -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({ diff --git a/apps/builder/src/features/customDomains/api/deleteCustomDomain.ts b/apps/builder/src/features/customDomains/api/deleteCustomDomain.ts index 5fe3b9e66..a84d143cb 100644 --- a/apps/builder/src/features/customDomains/api/deleteCustomDomain.ts +++ b/apps/builder/src/features/customDomains/api/deleteCustomDomain.ts @@ -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({ diff --git a/apps/builder/src/features/editor/components/TypebotHeader.tsx b/apps/builder/src/features/editor/components/TypebotHeader.tsx index a0c1446fa..e4bf9768e 100644 --- a/apps/builder/src/features/editor/components/TypebotHeader.tsx +++ b/apps/builder/src/features/editor/components/TypebotHeader.tsx @@ -179,7 +179,11 @@ export const TypebotHeader = () => { {typebot && ( diff --git a/apps/builder/src/features/graph/components/nodes/block/BlockNode.tsx b/apps/builder/src/features/graph/components/nodes/block/BlockNode.tsx index 79ac1271e..251524c25 100644 --- a/apps/builder/src/features/graph/components/nodes/block/BlockNode.tsx +++ b/apps/builder/src/features/graph/components/nodes/block/BlockNode.tsx @@ -284,7 +284,11 @@ export const BlockNode = ({ )} {typebot && isMediaBubbleBlock(block) && ( diff --git a/apps/builder/src/features/graph/components/nodes/block/MediaBubblePopoverContent.tsx b/apps/builder/src/features/graph/components/nodes/block/MediaBubblePopoverContent.tsx index 594f9cd24..dc1850a5b 100644 --- a/apps/builder/src/features/graph/components/nodes/block/MediaBubblePopoverContent.tsx +++ b/apps/builder/src/features/graph/components/nodes/block/MediaBubblePopoverContent.tsx @@ -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 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 ( @@ -76,7 +77,7 @@ export const MediaBubbleContent = ({ return ( ) diff --git a/apps/builder/src/features/publish/components/embeds/settings/BubbleSettings/ButtonThemeSettings.tsx b/apps/builder/src/features/publish/components/embeds/settings/BubbleSettings/ButtonThemeSettings.tsx index 942663384..aaba53c99 100644 --- a/apps/builder/src/features/publish/components/embeds/settings/BubbleSettings/ButtonThemeSettings.tsx +++ b/apps/builder/src/features/publish/components/embeds/settings/BubbleSettings/ButtonThemeSettings.tsx @@ -81,7 +81,7 @@ export const ButtonThemeSettings = ({ buttonTheme, onChange }: Props) => { updateCustomIconSrc(url) onClose() }} - filePath={undefined} + uploadFileProps={undefined} /> diff --git a/apps/builder/src/features/settings/components/MetadataForm.tsx b/apps/builder/src/features/settings/components/MetadataForm.tsx index f735e5aed..735443eb6 100644 --- a/apps/builder/src/features/settings/components/MetadataForm.tsx +++ b/apps/builder/src/features/settings/components/MetadataForm.tsx @@ -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 = ({ { {typebot && ( { {typebot && ( void } export const ChatThemeSettings = ({ + workspaceId, typebotId, chatTheme, onChatThemeChange, @@ -46,14 +48,22 @@ export const ChatThemeSettings = ({ return ( ) case BackgroundType.IMAGE: + if (!typebot) return null return ( @@ -63,7 +64,11 @@ export const BackgroundContent = ({ ) - default: - return <> } } diff --git a/apps/builder/src/features/typebot/helpers/isWriteTypebotForbidden.ts b/apps/builder/src/features/typebot/helpers/isWriteTypebotForbidden.ts index 6450906f2..d70a1160b 100644 --- a/apps/builder/src/features/typebot/helpers/isWriteTypebotForbidden.ts +++ b/apps/builder/src/features/typebot/helpers/isWriteTypebotForbidden.ts @@ -11,7 +11,7 @@ export const isWriteTypebotForbidden = async ( typebot: Pick & { collaborators: Pick[] }, - user: Pick + user: Pick ) => { if ( typebot.collaborators.find( diff --git a/apps/builder/src/features/upload/api/generateUploadUrl.ts b/apps/builder/src/features/upload/api/generateUploadUrl.ts new file mode 100644 index 000000000..c03d40a46 --- /dev/null +++ b/apps/builder/src/features/upload/api/generateUploadUrl.ts @@ -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 => { + 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}` : ''}` +} diff --git a/apps/builder/src/features/workspace/components/WorkspaceSettingsForm.tsx b/apps/builder/src/features/workspace/components/WorkspaceSettingsForm.tsx index 0bdaeba89..d9257d4bd 100644 --- a/apps/builder/src/features/workspace/components/WorkspaceSettingsForm.tsx +++ b/apps/builder/src/features/workspace/components/WorkspaceSettingsForm.tsx @@ -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 }) => { {workspace && ( [] }, - user: Pick + user: Pick ) => { const userRole = workspace.members.find( (member) => member.userId === user.id diff --git a/apps/builder/src/helpers/server/routers/v1/trpcRouter.ts b/apps/builder/src/helpers/server/routers/v1/trpcRouter.ts index d96561d5c..e1d9fcc07 100644 --- a/apps/builder/src/helpers/server/routers/v1/trpcRouter.ts +++ b/apps/builder/src/helpers/server/routers/v1/trpcRouter.ts @@ -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 diff --git a/apps/docs/docs/self-hosting/configuration.md b/apps/docs/docs/self-hosting/configuration.md index 5ef22d942..b0aae723f 100644 --- a/apps/docs/docs/self-hosting/configuration.md +++ b/apps/docs/docs/self-hosting/configuration.md @@ -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..amazonaws.com` diff --git a/apps/docs/openapi/builder/_spec_.json b/apps/docs/openapi/builder/_spec_.json index 2a9ef123c..21d590a35 100644 --- a/apps/docs/openapi/builder/_spec_.json +++ b/apps/docs/openapi/builder/_spec_.json @@ -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": { diff --git a/apps/docs/openapi/chat/_spec_.json b/apps/docs/openapi/chat/_spec_.json index 9d9007337..1823d9faf 100644 --- a/apps/docs/openapi/chat/_spec_.json +++ b/apps/docs/openapi/chat/_spec_.json @@ -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" + } } } }, diff --git a/apps/viewer/src/features/blocks/inputs/fileUpload/api/getUploadUrl.ts b/apps/viewer/src/features/blocks/inputs/fileUpload/api/deprecated/getUploadUrl.ts similarity index 99% rename from apps/viewer/src/features/blocks/inputs/fileUpload/api/getUploadUrl.ts rename to apps/viewer/src/features/blocks/inputs/fileUpload/api/deprecated/getUploadUrl.ts index 12c8064db..059997515 100644 --- a/apps/viewer/src/features/blocks/inputs/fileUpload/api/getUploadUrl.ts +++ b/apps/viewer/src/features/blocks/inputs/fileUpload/api/deprecated/getUploadUrl.ts @@ -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( diff --git a/apps/viewer/src/features/blocks/inputs/fileUpload/api/generateUploadUrl.ts b/apps/viewer/src/features/blocks/inputs/fileUpload/api/generateUploadUrl.ts new file mode 100644 index 000000000..1c66a0bb6 --- /dev/null +++ b/apps/viewer/src/features/blocks/inputs/fileUpload/api/generateUploadUrl.ts @@ -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], + } + }) diff --git a/apps/viewer/src/helpers/server/routers/v1/_app.ts b/apps/viewer/src/helpers/server/routers/v1/_app.ts index d307ac269..df4d56d89 100644 --- a/apps/viewer/src/helpers/server/routers/v1/_app.ts +++ b/apps/viewer/src/helpers/server/routers/v1/_app.ts @@ -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, }) diff --git a/packages/deprecated/bot-engine/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx b/packages/deprecated/bot-engine/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx index f60a4c502..5a50af35f 100644 --- a/packages/deprecated/bot-engine/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx +++ b/packages/deprecated/bot-engine/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx @@ -4,7 +4,7 @@ import { useTypebot } from '@/providers/TypebotProvider' import { InputSubmitContent } from '@/types' import { defaultFileInputOptions, FileInputBlock } from '@typebot.io/schemas' import React, { ChangeEvent, FormEvent, useState, DragEvent } from 'react' -import { uploadFiles } from '@typebot.io/lib/s3/uploadFiles' +import { uploadFiles } from '../helpers/uploadFiles' type Props = { block: FileInputBlock diff --git a/packages/lib/s3/uploadFiles.ts b/packages/deprecated/bot-engine/src/features/blocks/inputs/fileUpload/helpers/uploadFiles.ts similarity index 95% rename from packages/lib/s3/uploadFiles.ts rename to packages/deprecated/bot-engine/src/features/blocks/inputs/fileUpload/helpers/uploadFiles.ts index ddbbae438..f793aa6d9 100644 --- a/packages/lib/s3/uploadFiles.ts +++ b/packages/deprecated/bot-engine/src/features/blocks/inputs/fileUpload/helpers/uploadFiles.ts @@ -1,4 +1,4 @@ -import { sendRequest } from '../utils' +import { sendRequest } from '@typebot.io/lib/utils' type UploadFileProps = { basePath?: string diff --git a/packages/embeds/js/package.json b/packages/embeds/js/package.json index 7b612e679..48a47d343 100644 --- a/packages/embeds/js/package.json +++ b/packages/embeds/js/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/js", - "version": "0.1.25", + "version": "0.1.26", "description": "Javascript library to display typebots on your website", "type": "module", "main": "dist/index.js", diff --git a/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx b/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx index 036b7d4d9..455848d76 100644 --- a/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/fileUpload/components/FileUploadForm.tsx @@ -1,12 +1,12 @@ import { SendButton } from '@/components/SendButton' import { BotContext, InputSubmitContent } from '@/types' -import { guessApiHost } from '@/utils/guessApiHost' import { FileInputBlock } from '@typebot.io/schemas' import { defaultFileInputOptions } from '@typebot.io/schemas/features/blocks/inputs/file' import { createSignal, Match, Show, Switch } from 'solid-js' -import { uploadFiles } from '@typebot.io/lib/s3/uploadFiles' import { Button } from '@/components/Button' import { Spinner } from '@/components/Spinner' +import { uploadFiles } from '../helpers/uploadFiles' +import { guessApiHost } from '@/utils/guessApiHost' type Props = { context: BotContext @@ -46,20 +46,23 @@ export const FileUploadForm = (props: Props) => { } const startSingleFileUpload = async (file: File) => { - if (props.context.isPreview) + if (props.context.isPreview || !props.context.resultId) return props.onSubmit({ label: `File uploaded`, value: 'http://fake-upload-url.com', }) setIsUploading(true) const urls = await uploadFiles({ - basePath: `${props.context.apiHost ?? guessApiHost()}/api/typebots/${ - props.context.typebot.id - }/blocks/${props.block.id}`, + apiHost: props.context.apiHost ?? guessApiHost(), files: [ { 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') } const startFilesUpload = async (files: File[]) => { - if (props.context.isPreview) + const resultId = props.context.resultId + if (props.context.isPreview || !resultId) return props.onSubmit({ label: `${files.length} file${files.length > 1 ? 's' : ''} uploaded`, value: files @@ -78,12 +82,15 @@ export const FileUploadForm = (props: Props) => { }) setIsUploading(true) const urls = await uploadFiles({ - basePath: `${props.context.apiHost ?? guessApiHost()}/api/typebots/${ - props.context.typebot.id - }/blocks/${props.block.id}`, + apiHost: props.context.apiHost ?? guessApiHost(), files: files.map((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, }) diff --git a/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/uploadFiles.ts b/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/uploadFiles.ts new file mode 100644 index 000000000..c0e5af889 --- /dev/null +++ b/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/uploadFiles.ts @@ -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 => { + 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 +} diff --git a/packages/embeds/nextjs/package.json b/packages/embeds/nextjs/package.json index 4134d523a..0973f898e 100644 --- a/packages/embeds/nextjs/package.json +++ b/packages/embeds/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/nextjs", - "version": "0.1.25", + "version": "0.1.26", "description": "Convenient library to display typebots on your Next.js website", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/embeds/react/package.json b/packages/embeds/react/package.json index aec848688..c26cfaf85 100644 --- a/packages/embeds/react/package.json +++ b/packages/embeds/react/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/react", - "version": "0.1.25", + "version": "0.1.26", "description": "Convenient library to display typebots on your React app", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/env/env.ts b/packages/env/env.ts index b06ecf026..bc460ef75 100644 --- a/packages/env/env.ts +++ b/packages/env/env.ts @@ -170,6 +170,7 @@ const s3Env = { S3_ENDPOINT: z.string().min(1).optional(), S3_SSL: boolean.optional().default('true'), S3_REGION: z.string().min(1).optional(), + S3_PUBLIC_CUSTOM_DOMAIN: z.string().url().optional(), }, } diff --git a/packages/lib/package.json b/packages/lib/package.json index 748b67247..55479b010 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -23,6 +23,6 @@ }, "dependencies": { "got": "12.6.0", - "minio": "7.1.1" + "minio": "7.1.3" } } diff --git a/packages/lib/s3/getFolderSize.ts b/packages/lib/s3/getFolderSize.ts new file mode 100644 index 000000000..309331f2a --- /dev/null +++ b/packages/lib/s3/getFolderSize.ts @@ -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((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) + }) + }) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b63c63cc..5fedfd7ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,9 +197,6 @@ importers: micro-cors: specifier: 0.1.1 version: 0.1.1 - minio: - specifier: 7.1.1 - version: 7.1.1 next: specifier: 13.4.3 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': specifier: 0.1.3 version: 0.1.3 - '@types/minio': - specifier: 7.1.1 - version: 7.1.1 '@types/node': specifier: 20.4.2 version: 20.4.2 @@ -1128,8 +1122,8 @@ importers: specifier: 12.6.0 version: 12.6.0 minio: - specifier: 7.1.1 - version: 7.1.1 + specifier: 7.1.3 + version: 7.1.3 devDependencies: '@paralleldrive/cuid2': specifier: 2.2.1 @@ -8686,13 +8680,6 @@ packages: resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==} 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: resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} dev: false @@ -10035,6 +10022,7 @@ packages: /@zxing/text-encoding@0.9.0: resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} requiresBuild: true + dev: false optional: true /abab@2.0.6: @@ -10467,6 +10455,7 @@ packages: /async@3.2.4: resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} + dev: false /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -10817,6 +10806,7 @@ packages: resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} dependencies: readable-stream: 3.6.2 + dev: false /body-parser@1.20.1: resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==} @@ -10924,6 +10914,7 @@ packages: /browser-or-node@2.1.1: resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==} + dev: false /browserslist@4.21.10: resolution: {integrity: sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==} @@ -10950,6 +10941,7 @@ packages: /buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + dev: false /buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -12115,6 +12107,7 @@ packages: /decode-uri-component@0.2.2: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} + dev: false /decode-uri-component@0.4.1: resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} @@ -13628,6 +13621,7 @@ packages: hasBin: true dependencies: strnum: 1.0.5 + dev: false /fastest-stable-stringify@2.0.2: resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==} @@ -13739,6 +13733,7 @@ packages: /filter-obj@1.1.0: resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} engines: {node: '>=0.10.0'} + dev: false /filter-obj@5.1.0: resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} @@ -15070,6 +15065,7 @@ packages: /ipaddr.js@2.1.0: resolution: {integrity: sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==} engines: {node: '>= 10'} + dev: false /iron-webcrypto@0.8.2: resolution: {integrity: sha512-jGiwmpgTuF19Vt4hn3+AzaVFGpVZt7A1ysd5ivFel2r4aNVFwqaYa6aU6qsF1PM7b+WFivZHz3nipwUOXaOnHg==} @@ -15092,6 +15088,7 @@ packages: dependencies: call-bind: 1.0.2 has-tostringtag: 1.0.0 + dev: false /is-array-buffer@3.0.2: resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} @@ -15197,6 +15194,7 @@ packages: engines: {node: '>= 0.4'} dependencies: has-tostringtag: 1.0.0 + dev: false /is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} @@ -16120,6 +16118,7 @@ packages: /json-stream@1.0.0: resolution: {integrity: sha512-H/ZGY0nIAg3QcOwE1QN/rK/Fa7gJn7Ii5obwp6zyPO4xiPNwpIMjqy2gwjBEGqzkF/vSWEIBQCBuN19hYiL6Qg==} + dev: false /json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} @@ -16761,8 +16760,8 @@ packages: /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - /minio@7.1.1: - resolution: {integrity: sha512-HBLRFXs1CkNwAkahU+j1ilB9YS/Tmkdc6orpxVW1YN11NlEJyLjarIpBYu/inF+dj+tJIsA8PSKNnRmUNm+9qQ==} + /minio@7.1.3: + resolution: {integrity: sha512-xPrLjWkTT5E7H7VnzOjF//xBp9I40jYB4aWhb2xTFopXXfw+Wo82DDWngdUju7Doy3Wk7R8C4LAgwhLHHnf0wA==} engines: {node: ^16 || ^18 || >=20} dependencies: async: 3.2.4 @@ -16779,6 +16778,7 @@ packages: web-encoding: 1.1.5 xml: 1.0.1 xml2js: 0.5.0 + dev: false /mjml-accordion@4.14.1: resolution: {integrity: sha512-dpNXyjnhYwhM75JSjD4wFUa9JgHm86M2pa0CoTzdv1zOQz67ilc4BoK5mc2S0gOjJpjBShM5eOJuCyVIuAPC6w==} @@ -18867,6 +18867,7 @@ packages: filter-obj: 1.1.0 split-on-first: 1.1.0 strict-uri-encode: 2.0.0 + dev: false /query-string@8.1.0: resolution: {integrity: sha512-BFQeWxJOZxZGix7y+SByG3F36dA0AbTy9o6pSmKFcFz7DAj0re9Frkty3saBn3nHo3D0oZJ/+rx3r8H8r8Jbpw==} @@ -19472,6 +19473,7 @@ packages: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + dev: false /readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} @@ -19972,6 +19974,7 @@ packages: /sax@1.2.4: resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==} + dev: false /saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} @@ -20515,6 +20518,7 @@ packages: /split-on-first@1.1.0: resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} engines: {node: '>=6'} + dev: false /split-on-first@3.0.0: resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==} @@ -20620,6 +20624,7 @@ packages: /strict-uri-encode@2.0.0: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} + dev: false /string-hash@1.1.3: resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} @@ -20703,6 +20708,7 @@ packages: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} dependencies: safe-buffer: 5.2.1 + dev: false /stringify-object@3.3.0: resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} @@ -20775,6 +20781,7 @@ packages: /strnum@1.0.5: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} + dev: false /style-inject@0.3.0: resolution: {integrity: sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==} @@ -21183,6 +21190,7 @@ packages: resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} dependencies: readable-stream: 3.6.2 + dev: false /through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -22092,6 +22100,7 @@ packages: is-generator-function: 1.0.10 is-typed-array: 1.1.12 which-typed-array: 1.1.11 + dev: false /utila@0.4.0: resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==} @@ -22298,6 +22307,7 @@ packages: util: 0.12.5 optionalDependencies: '@zxing/text-encoding': 0.9.0 + dev: false /web-namespaces@1.1.4: resolution: {integrity: sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==} @@ -22696,13 +22706,16 @@ packages: dependencies: sax: 1.2.4 xmlbuilder: 11.0.1 + dev: false /xml@1.0.1: resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + dev: false /xmlbuilder@11.0.1: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + dev: false /xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}