2
0

🛂 (fileUpload) Improve file upload size limit enforcement

Closes #799, closes #797
This commit is contained in:
Baptiste Arnaud
2023-09-19 15:42:33 +02:00
parent f626c9867c
commit bb13c2bd61
19 changed files with 143 additions and 239 deletions

View File

@@ -26,9 +26,15 @@ export const UploadButton = ({
setIsUploading(false) setIsUploading(false)
}, },
onSuccess: async (data) => { onSuccess: async (data) => {
if (!file) return
const formData = new FormData()
Object.entries(data.formData).forEach(([key, value]) => {
formData.append(key, value)
})
formData.append('file', file)
const upload = await fetch(data.presignedUrl, { const upload = await fetch(data.presignedUrl, {
method: 'PUT', method: 'POST',
body: file, body: formData,
}) })
if (!upload.ok) { if (!upload.ok) {

View File

@@ -9,12 +9,11 @@ import {
Tooltip, Tooltip,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { AlertIcon } from '@/components/icons' import { AlertIcon } from '@/components/icons'
import { Plan, Workspace } from '@typebot.io/prisma' import { Workspace } from '@typebot.io/prisma'
import React from 'react' import React from 'react'
import { parseNumberWithCommas } from '@typebot.io/lib' import { parseNumberWithCommas } from '@typebot.io/lib'
import { getChatsLimit, getStorageLimit } from '@typebot.io/lib/pricing' import { getChatsLimit } from '@typebot.io/lib/pricing'
import { defaultQueryOptions, trpc } from '@/lib/trpc' import { defaultQueryOptions, trpc } from '@/lib/trpc'
import { storageToReadable } from '../helpers/storageToReadable'
import { useScopedI18n } from '@/locales' import { useScopedI18n } from '@/locales'
type Props = { type Props = {
@@ -30,19 +29,12 @@ export const UsageProgressBars = ({ workspace }: Props) => {
defaultQueryOptions defaultQueryOptions
) )
const totalChatsUsed = data?.totalChatsUsed ?? 0 const totalChatsUsed = data?.totalChatsUsed ?? 0
const totalStorageUsed = data?.totalStorageUsed ?? 0
const workspaceChatsLimit = getChatsLimit(workspace) const workspaceChatsLimit = getChatsLimit(workspace)
const workspaceStorageLimit = getStorageLimit(workspace)
const workspaceStorageLimitGigabites =
workspaceStorageLimit * 1024 * 1024 * 1024
const chatsPercentage = Math.round( const chatsPercentage = Math.round(
(totalChatsUsed / workspaceChatsLimit) * 100 (totalChatsUsed / workspaceChatsLimit) * 100
) )
const storagePercentage = Math.round(
(totalStorageUsed / workspaceStorageLimitGigabites) * 100
)
return ( return (
<Stack spacing={6}> <Stack spacing={6}>
@@ -103,63 +95,6 @@ export const UsageProgressBars = ({ workspace }: Props) => {
colorScheme={totalChatsUsed >= workspaceChatsLimit ? 'red' : 'blue'} colorScheme={totalChatsUsed >= workspaceChatsLimit ? 'red' : 'blue'}
/> />
</Stack> </Stack>
{workspace.plan !== Plan.FREE && (
<Stack spacing={3}>
<Flex justifyContent="space-between">
<HStack>
<Heading fontSize="xl" as="h3">
{scopedT('storage.heading')}
</Heading>
{storagePercentage >= 80 && (
<Tooltip
placement="top"
rounded="md"
p="3"
label={
<Text>
{scopedT('storage.alert.soonReach')}
<br />
<br />
{scopedT('storage.alert.updatePlan')}
</Text>
}
>
<span>
<AlertIcon color="orange.500" />
</span>
</Tooltip>
)}
</HStack>
<HStack>
<Skeleton
fontWeight="bold"
isLoaded={!isLoading}
h={isLoading ? '5px' : 'auto'}
>
{storageToReadable(totalStorageUsed)}
</Skeleton>
<Text>
/{' '}
{workspaceStorageLimit === -1
? scopedT('unlimited')
: `${workspaceStorageLimit} GB`}
</Text>
</HStack>
</Flex>
<Progress
value={storagePercentage}
h="5px"
colorScheme={
totalStorageUsed >= workspaceStorageLimitGigabites
? 'red'
: 'blue'
}
rounded="full"
hasStripe
isIndeterminate={isLoading}
/>
</Stack>
)}
</Stack> </Stack>
) )
} }

View File

@@ -1,8 +1,8 @@
import { FormLabel, HStack, Stack, Text } from '@chakra-ui/react' import { FormLabel, Stack } from '@chakra-ui/react'
import { CodeEditor } from '@/components/inputs/CodeEditor' import { CodeEditor } from '@/components/inputs/CodeEditor'
import { FileInputOptions, Variable } from '@typebot.io/schemas' import { FileInputOptions, Variable } from '@typebot.io/schemas'
import React from 'react' import React from 'react'
import { TextInput, NumberInput } from '@/components/inputs' import { TextInput } from '@/components/inputs'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel' import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput' import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
@@ -24,9 +24,6 @@ export const FileInputSettings = ({ options, onOptionsChange }: Props) => {
const handleVariableChange = (variable?: Variable) => const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id }) onOptionsChange({ ...options, variableId: variable?.id })
const handleSizeLimitChange = (sizeLimit?: number) =>
onOptionsChange({ ...options, sizeLimit })
const handleRequiredChange = (isRequired: boolean) => const handleRequiredChange = (isRequired: boolean) =>
onOptionsChange({ ...options, isRequired }) onOptionsChange({ ...options, isRequired })
@@ -48,16 +45,6 @@ export const FileInputSettings = ({ options, onOptionsChange }: Props) => {
initialValue={options.isMultipleAllowed} initialValue={options.isMultipleAllowed}
onCheckChange={handleMultipleFilesChange} onCheckChange={handleMultipleFilesChange}
/> />
<HStack>
<NumberInput
label={'Size limit:'}
defaultValue={options.sizeLimit ?? 10}
onValueChange={handleSizeLimitChange}
withVariableButton={false}
/>
<Text>MB</Text>
</HStack>
<Stack> <Stack>
<FormLabel mb="0">Placeholder:</FormLabel> <FormLabel mb="0">Placeholder:</FormLabel>
<CodeEditor <CodeEditor

View File

@@ -3,10 +3,9 @@ import { trpc } from '@/lib/trpc'
import { Flex } from '@chakra-ui/react' import { Flex } from '@chakra-ui/react'
import { Workspace } from '@typebot.io/schemas' import { Workspace } from '@typebot.io/schemas'
import { useMemo } from 'react' import { useMemo } from 'react'
import { getChatsLimit, getStorageLimit } from '@typebot.io/lib/pricing' import { getChatsLimit } from '@typebot.io/lib/pricing'
const ALERT_CHATS_PERCENT_THRESHOLD = 80 const ALERT_CHATS_PERCENT_THRESHOLD = 80
const ALERT_STORAGE_PERCENT_THRESHOLD = 80
type Props = { type Props = {
workspace: Workspace workspace: Workspace
@@ -35,27 +34,6 @@ export const UsageAlertBanners = ({ workspace }: Props) => {
workspace?.plan, workspace?.plan,
]) ])
const storageLimitPercentage = useMemo(() => {
if (!usageData?.totalStorageUsed || !workspace?.plan) return 0
return Math.round(
(usageData.totalStorageUsed /
1024 /
1024 /
1024 /
getStorageLimit({
additionalStorageIndex: workspace.additionalStorageIndex,
plan: workspace.plan,
customStorageLimit: workspace.customStorageLimit,
})) *
100
)
}, [
usageData?.totalStorageUsed,
workspace?.additionalStorageIndex,
workspace?.customStorageLimit,
workspace?.plan,
])
return ( return (
<> <>
{chatsLimitPercentage > ALERT_CHATS_PERCENT_THRESHOLD && ( {chatsLimitPercentage > ALERT_CHATS_PERCENT_THRESHOLD && (
@@ -74,22 +52,6 @@ export const UsageAlertBanners = ({ workspace }: Props) => {
/> />
</Flex> </Flex>
)} )}
{storageLimitPercentage > ALERT_STORAGE_PERCENT_THRESHOLD && (
<Flex p="4">
<UnlockPlanAlertInfo
status="warning"
contentLabel={
<>
Your workspace collected{' '}
<strong>{storageLimitPercentage}%</strong> of your total storage
allowed. Upgrade your plan or delete some existing results to
continue collecting files from your user beyond this limit.
</>
}
buttonLabel="Upgrade"
/>
</Flex>
)}
</> </>
) )
} }

View File

@@ -2,7 +2,7 @@ import { authenticatedProcedure } from '@/helpers/server/trpc'
import { z } from 'zod' import { z } from 'zod'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { generatePresignedUrl } from '@typebot.io/lib/s3/generatePresignedUrl' import { generatePresignedPostPolicy } from '@typebot.io/lib/s3/generatePresignedPostPolicy'
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden' import { isWriteWorkspaceForbidden } from '@/features/workspace/helpers/isWriteWorkspaceForbidden'
import { isWriteTypebotForbidden } from '@/features/typebot/helpers/isWriteTypebotForbidden' import { isWriteTypebotForbidden } from '@/features/typebot/helpers/isWriteTypebotForbidden'
@@ -54,6 +54,7 @@ export const generateUploadUrl = authenticatedProcedure
.output( .output(
z.object({ z.object({
presignedUrl: z.string(), presignedUrl: z.string(),
formData: z.record(z.string(), z.any()),
fileUrl: z.string(), fileUrl: z.string(),
}) })
) )
@@ -76,16 +77,17 @@ export const generateUploadUrl = authenticatedProcedure
uploadProps: filePathProps, uploadProps: filePathProps,
}) })
const presignedUrl = await generatePresignedUrl({ const presignedPostPolicy = await generatePresignedPostPolicy({
fileType, fileType,
filePath, filePath,
}) })
return { return {
presignedUrl, presignedUrl: presignedPostPolicy.postURL,
formData: presignedPostPolicy.formData,
fileUrl: env.S3_PUBLIC_CUSTOM_DOMAIN fileUrl: env.S3_PUBLIC_CUSTOM_DOMAIN
? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}` ? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}`
: presignedUrl.split('?')[0], : `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`,
} }
}) })

View File

@@ -5,7 +5,7 @@ import {
methodNotAllowed, methodNotAllowed,
notAuthenticated, notAuthenticated,
} from '@typebot.io/lib/api' } from '@typebot.io/lib/api'
import { generatePresignedUrl } from '@typebot.io/lib/s3/generatePresignedUrl' import { generatePresignedPostPolicy } from '@typebot.io/lib/s3/generatePresignedPostPolicy'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
const handler = async ( const handler = async (
@@ -25,9 +25,15 @@ const handler = async (
const filePath = req.query.filePath as string | undefined const filePath = req.query.filePath as string | undefined
const fileType = req.query.fileType as string | undefined const fileType = req.query.fileType as string | undefined
if (!filePath || !fileType) return badRequest(res) if (!filePath || !fileType) return badRequest(res)
const presignedUrl = await generatePresignedUrl({ fileType, filePath }) const presignedPostPolicy = await generatePresignedPostPolicy({
fileType,
filePath,
})
return res.status(200).send({ presignedUrl }) return res.status(200).send({
presignedUrl: `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`,
formData: presignedPostPolicy.formData,
})
} }
return methodNotAllowed(res) return methodNotAllowed(res)
} }

View File

@@ -29,4 +29,6 @@ The File upload input block allows you to collect files from your user.
The placeholder accepts [HTML](https://en.wikipedia.org/wiki/HTML). The placeholder accepts [HTML](https://en.wikipedia.org/wiki/HTML).
Note that there is a 10MB fixed limit per file. ## Size limit
There is a 10MB fixed limit per uploaded file. If you want your respondents to upload larger files, you should ask them to upload their files to a cloud storage service (e.g. Google Drive, Dropbox, etc.) and share the link with you.

View File

@@ -12,7 +12,7 @@ Parameters marked with <Asterix/> are required.
## General ## General
| Parameter | Default | Description | | Parameter | Default | Description |
| --------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| DATABASE_URL <Asterix/> | | The database URL | | DATABASE_URL <Asterix/> | | The database URL |
| ENCRYPTION_SECRET <Asterix/> | | A 256-bit key used to encrypt sensitive data. It is strongly recommended to [generate](https://www.allkeysgenerator.com/Random/Security-Encryption-Key-Generator.aspx) a new one. The secret should be the same between builder and viewer. | | ENCRYPTION_SECRET <Asterix/> | | A 256-bit key used to encrypt sensitive data. It is strongly recommended to [generate](https://www.allkeysgenerator.com/Random/Security-Encryption-Key-Generator.aspx) a new one. The secret should be the same between builder and viewer. |
| NEXTAUTH_URL <Asterix/> | | The builder base URL. Should be the publicly accessible URL (i.e. `https://typebot.domain.com`) | | NEXTAUTH_URL <Asterix/> | | The builder base URL. Should be the publicly accessible URL (i.e. `https://typebot.domain.com`) |
@@ -23,6 +23,7 @@ Parameters marked with <Asterix/> are required.
| DISABLE_SIGNUP | false | Disable new user sign ups. Invited users are still able to sign up. | | DISABLE_SIGNUP | false | Disable new user sign ups. Invited users are still able to sign up. |
| NEXT_PUBLIC_ONBOARDING_TYPEBOT_ID | | Typebot ID used for the onboarding. Onboarding page is skipped if not provided. | | NEXT_PUBLIC_ONBOARDING_TYPEBOT_ID | | Typebot ID used for the onboarding. Onboarding page is skipped if not provided. |
| DEBUG | false | If enabled, the server will print valuable logs to debug config issues. | | DEBUG | false | If enabled, the server will print valuable logs to debug config issues. |
| NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE | | Limits the size of each file that can be uploaded in the bots (i.e. Set `10` to limit the file upload to 10MB) |
## Email (Auth, notifications) ## Email (Auth, notifications)

View File

@@ -10,7 +10,7 @@ import {
} from '@typebot.io/schemas' } from '@typebot.io/schemas'
import { byId, isDefined } from '@typebot.io/lib' import { byId, isDefined } from '@typebot.io/lib'
import { z } from 'zod' import { z } from 'zod'
import { generatePresignedUrl } from '@typebot.io/lib/s3/generatePresignedUrl' import { generatePresignedPostPolicy } from '@typebot.io/lib/s3/generatePresignedPostPolicy'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
export const getUploadUrl = publicProcedure export const getUploadUrl = publicProcedure
@@ -34,6 +34,7 @@ export const getUploadUrl = publicProcedure
.output( .output(
z.object({ z.object({
presignedUrl: z.string(), presignedUrl: z.string(),
formData: z.record(z.string(), z.any()),
hasReachedStorageLimit: z.boolean(), hasReachedStorageLimit: z.boolean(),
}) })
) )
@@ -61,13 +62,15 @@ export const getUploadUrl = publicProcedure
message: 'File upload block not found', message: 'File upload block not found',
}) })
const presignedUrl = await generatePresignedUrl({ const presignedPostPolicy = await generatePresignedPostPolicy({
fileType, fileType,
filePath, filePath,
maxFileSize: env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE,
}) })
return { return {
presignedUrl, presignedUrl: `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`,
formData: presignedPostPolicy.formData,
hasReachedStorageLimit: false, hasReachedStorageLimit: false,
} }
}) })

View File

@@ -2,8 +2,9 @@ import { publicProcedure } from '@/helpers/server/trpc'
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { z } from 'zod' import { z } from 'zod'
import { generatePresignedUrl } from '@typebot.io/lib/s3/generatePresignedUrl' import { generatePresignedPostPolicy } from '@typebot.io/lib/s3/generatePresignedPostPolicy'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
import { InputBlockType, publicTypebotSchema } from '@typebot.io/schemas'
export const generateUploadUrl = publicProcedure export const generateUploadUrl = publicProcedure
.meta({ .meta({
@@ -28,6 +29,7 @@ export const generateUploadUrl = publicProcedure
.output( .output(
z.object({ z.object({
presignedUrl: z.string(), presignedUrl: z.string(),
formData: z.record(z.string(), z.any()),
fileUrl: z.string(), fileUrl: z.string(),
}) })
) )
@@ -44,6 +46,7 @@ export const generateUploadUrl = publicProcedure
typebotId: filePathProps.typebotId, typebotId: filePathProps.typebotId,
}, },
select: { select: {
groups: true,
typebot: { typebot: {
select: { select: {
workspaceId: true, workspaceId: true,
@@ -62,15 +65,30 @@ export const generateUploadUrl = publicProcedure
const filePath = `public/workspaces/${workspaceId}/typebots/${filePathProps.typebotId}/results/${filePathProps.resultId}/${filePathProps.fileName}` const filePath = `public/workspaces/${workspaceId}/typebots/${filePathProps.typebotId}/results/${filePathProps.resultId}/${filePathProps.fileName}`
const presignedUrl = await generatePresignedUrl({ const fileUploadBlock = publicTypebotSchema._def.schema.shape.groups
.parse(publicTypebot.groups)
.flatMap((group) => group.blocks)
.find((block) => block.id === filePathProps.blockId)
if (fileUploadBlock?.type !== InputBlockType.FILE)
throw new TRPCError({
code: 'BAD_REQUEST',
message: "Can't find file upload block",
})
const presignedPostPolicy = await generatePresignedPostPolicy({
fileType, fileType,
filePath, filePath,
maxFileSize:
fileUploadBlock.options.sizeLimit ??
env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE,
}) })
return { return {
presignedUrl, presignedUrl: presignedPostPolicy.postURL,
formData: presignedPostPolicy.formData,
fileUrl: env.S3_PUBLIC_CUSTOM_DOMAIN fileUrl: env.S3_PUBLIC_CUSTOM_DOMAIN
? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}` ? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}`
: presignedUrl.split('?')[0], : `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`,
} }
}) })

View File

@@ -93,25 +93,4 @@ test.describe('Storage limit is reached', () => {
fakeStorage: THREE_GIGABYTES, fakeStorage: THREE_GIGABYTES,
}) })
}) })
test("shouldn't upload anything if limit has been reached", async ({
page,
}) => {
await page.goto(`/${typebotId}-public`)
await page
.locator(`input[type="file"]`)
.setInputFiles([
getTestAsset('typebots/api.json'),
getTestAsset('typebots/fileUpload.json'),
getTestAsset('typebots/hugeGroup.json'),
])
await expect(page.locator(`text="3"`)).toBeVisible()
await page.locator('text="Upload 3 files"').click()
await expect(page.locator(`text="3 files uploaded"`)).toBeVisible()
await page.evaluate(() =>
window.localStorage.setItem('workspaceId', 'starterWorkspace')
)
await page.goto(`${env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text="150%"')).toBeVisible()
})
}) })

View File

@@ -1,8 +1,6 @@
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { isNotDefined } from '@typebot.io/lib'
import { Prisma } from '@typebot.io/prisma' import { Prisma } from '@typebot.io/prisma'
import { InputBlock, InputBlockType, SessionState } from '@typebot.io/schemas' import { InputBlock, SessionState } from '@typebot.io/schemas'
import got from 'got'
type Props = { type Props = {
answer: Omit<Prisma.AnswerUncheckedCreateInput, 'resultId'> answer: Omit<Prisma.AnswerUncheckedCreateInput, 'resultId'>
@@ -11,12 +9,9 @@ type Props = {
itemId?: string itemId?: string
state: SessionState state: SessionState
} }
export const upsertAnswer = async ({ answer, reply, block, state }: Props) => { export const upsertAnswer = async ({ answer, block, state }: Props) => {
const resultId = state.typebotsQueue[0].resultId const resultId = state.typebotsQueue[0].resultId
if (!resultId) return if (!resultId) return
if (reply.includes('http') && block.type === InputBlockType.FILE) {
answer.storageUsed = await computeStorageUsed(reply)
}
const where = { const where = {
resultId, resultId,
blockId: block.id, blockId: block.id,
@@ -33,7 +28,6 @@ export const upsertAnswer = async ({ answer, reply, block, state }: Props) => {
where, where,
data: { data: {
content: answer.content, content: answer.content,
storageUsed: answer.storageUsed,
itemId: answer.itemId, itemId: answer.itemId,
}, },
}) })
@@ -41,18 +35,3 @@ export const upsertAnswer = async ({ answer, reply, block, state }: Props) => {
data: [{ ...answer, resultId }], data: [{ ...answer, resultId }],
}) })
} }
const computeStorageUsed = async (reply: string) => {
let storageUsed = 0
const fileUrls = reply.split(', ')
const hasReachedStorageLimit = fileUrls[0] === null
if (!hasReachedStorageLimit) {
for (const url of fileUrls) {
const { headers } = await got(url)
const size = headers['content-length']
if (isNotDefined(size)) continue
storageUsed += parseInt(size, 10)
}
}
return storageUsed
}

View File

@@ -23,6 +23,7 @@ export const uploadFiles = async ({
i += 1 i += 1
const { data } = await sendRequest<{ const { data } = await sendRequest<{
presignedUrl: string presignedUrl: string
formData: Record<string, string>
hasReachedStorageLimit: boolean hasReachedStorageLimit: boolean
}>( }>(
`${basePath}/storage/upload-url?filePath=${encodeURIComponent( `${basePath}/storage/upload-url?filePath=${encodeURIComponent(
@@ -35,9 +36,14 @@ export const uploadFiles = async ({
const url = data.presignedUrl const url = data.presignedUrl
if (data.hasReachedStorageLimit) urls.push(null) if (data.hasReachedStorageLimit) urls.push(null)
else { else {
const upload = await fetch(url, { const formData = new FormData()
method: 'PUT', Object.entries(data.formData).forEach(([key, value]) => {
body: file, formData.append(key, value)
})
formData.append('file', file)
const upload = await fetch(data.presignedUrl, {
method: 'POST',
body: formData,
}) })
if (!upload.ok) continue if (!upload.ok) continue

View File

@@ -7,6 +7,7 @@ import { Button } from '@/components/Button'
import { Spinner } from '@/components/Spinner' import { Spinner } from '@/components/Spinner'
import { uploadFiles } from '../helpers/uploadFiles' import { uploadFiles } from '../helpers/uploadFiles'
import { guessApiHost } from '@/utils/guessApiHost' import { guessApiHost } from '@/utils/guessApiHost'
import { getRuntimeVariable } from '@typebot.io/env/getRuntimeVariable'
type Props = { type Props = {
context: BotContext context: BotContext
@@ -25,15 +26,14 @@ export const FileUploadForm = (props: Props) => {
const onNewFiles = (files: FileList) => { const onNewFiles = (files: FileList) => {
setErrorMessage(undefined) setErrorMessage(undefined)
const newFiles = Array.from(files) const newFiles = Array.from(files)
const sizeLimit =
props.block.options.sizeLimit ??
getRuntimeVariable('NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE')
if ( if (
newFiles.some( sizeLimit &&
(file) => newFiles.some((file) => file.size > sizeLimit * 1024 * 1024)
file.size > (props.block.options.sizeLimit ?? 10) * 1024 * 1024
)
)
return setErrorMessage(
`A file is larger than ${props.block.options.sizeLimit ?? 10}MB`
) )
return setErrorMessage(`A file is larger than ${sizeLimit}MB`)
if (!props.block.options.isMultipleAllowed && files) if (!props.block.options.isMultipleAllowed && files)
return startSingleFileUpload(newFiles[0]) return startSingleFileUpload(newFiles[0])
setSelectedFiles([...selectedFiles(), ...newFiles]) setSelectedFiles([...selectedFiles(), ...newFiles])

View File

@@ -28,6 +28,7 @@ export const uploadFiles = async ({
i += 1 i += 1
const { data } = await sendRequest<{ const { data } = await sendRequest<{
presignedUrl: string presignedUrl: string
formData: Record<string, string>
fileUrl: string fileUrl: string
}>({ }>({
method: 'POST', method: 'POST',
@@ -40,9 +41,14 @@ export const uploadFiles = async ({
if (!data?.presignedUrl) continue if (!data?.presignedUrl) continue
else { else {
const formData = new FormData()
Object.entries(data.formData).forEach(([key, value]) => {
formData.append(key, value)
})
formData.append('file', file)
const upload = await fetch(data.presignedUrl, { const upload = await fetch(data.presignedUrl, {
method: 'PUT', method: 'POST',
body: file, body: formData,
}) })
if (!upload.ok) continue if (!upload.ok) continue

4
packages/env/env.ts vendored
View File

@@ -35,6 +35,7 @@ const baseEnv = {
.transform((string) => string.split(',')), .transform((string) => string.split(',')),
NEXT_PUBLIC_VIEWER_INTERNAL_URL: z.string().url().optional(), NEXT_PUBLIC_VIEWER_INTERNAL_URL: z.string().url().optional(),
NEXT_PUBLIC_ONBOARDING_TYPEBOT_ID: z.string().min(1).optional(), NEXT_PUBLIC_ONBOARDING_TYPEBOT_ID: z.string().min(1).optional(),
NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE: z.coerce.number().optional(),
}, },
runtimeEnv: { runtimeEnv: {
NEXT_PUBLIC_E2E_TEST: getRuntimeVariable('NEXT_PUBLIC_E2E_TEST'), NEXT_PUBLIC_E2E_TEST: getRuntimeVariable('NEXT_PUBLIC_E2E_TEST'),
@@ -45,6 +46,9 @@ const baseEnv = {
NEXT_PUBLIC_ONBOARDING_TYPEBOT_ID: getRuntimeVariable( NEXT_PUBLIC_ONBOARDING_TYPEBOT_ID: getRuntimeVariable(
'NEXT_PUBLIC_ONBOARDING_TYPEBOT_ID' 'NEXT_PUBLIC_ONBOARDING_TYPEBOT_ID'
), ),
NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE: getRuntimeVariable(
'NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE'
),
}, },
} }
const githubEnv = { const githubEnv = {

View File

@@ -0,0 +1,40 @@
import { env } from '@typebot.io/env'
import { Client, PostPolicyResult } from 'minio'
type Props = {
filePath: string
fileType?: string
maxFileSize?: number
}
const tenMinutes = 10 * 60
export const generatePresignedPostPolicy = async ({
filePath,
fileType,
maxFileSize,
}: Props): Promise<PostPolicyResult> => {
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,
})
const postPolicy = minioClient.newPostPolicy()
if (maxFileSize)
postPolicy.setContentLengthRange(0, maxFileSize * 1024 * 1024)
postPolicy.setKey(filePath)
postPolicy.setBucket(env.S3_BUCKET)
postPolicy.setExpires(new Date(Date.now() + tenMinutes))
if (fileType) postPolicy.setContentType(fileType)
return minioClient.presignedPostPolicy(postPolicy)
}

View File

@@ -1,32 +0,0 @@
import { env } from '@typebot.io/env'
import { Client } from 'minio'
type GeneratePresignedUrlProps = {
filePath: string
fileType?: string
}
const tenMinutes = 10 * 60
export const generatePresignedUrl = async ({
filePath,
fileType,
}: GeneratePresignedUrlProps): Promise<string> => {
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 minioClient.presignedUrl('PUT', env.S3_BUCKET, filePath, tenMinutes, {
'Content-Type': fileType,
})
}

View File

@@ -12,7 +12,7 @@ export const fileInputOptionsSchema = optionBaseSchema.merge(
clear: z.string().optional(), clear: z.string().optional(),
skip: z.string().optional(), skip: z.string().optional(),
}), }),
sizeLimit: z.number().optional(), sizeLimit: z.number().optional().describe('Deprecated'),
}) })
) )