🛂 (fileUpload) Improve file upload size limit enforcement
Closes #799, closes #797
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -11,18 +11,19 @@ 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`) |
|
||||||
| NEXT_PUBLIC_VIEWER_URL <Asterix/> | | The viewer base URL. Should be the publicly accessible URL (i.e. `https://bot.domain.com`) |
|
| NEXT_PUBLIC_VIEWER_URL <Asterix/> | | The viewer base URL. Should be the publicly accessible URL (i.e. `https://bot.domain.com`) |
|
||||||
| ADMIN_EMAIL | | The email that will get an `UNLIMITED` plan on user creation. The associated user will be able to bypass database rules. |
|
| ADMIN_EMAIL | | The email that will get an `UNLIMITED` plan on user creation. The associated user will be able to bypass database rules. |
|
||||||
| NEXTAUTH_URL_INTERNAL | | The internal builder base URL. You have to set it only when `NEXTAUTH_URL` can't be reached by your builder container / server. For a docker deployment, you should set it to `http://localhost:3000`. |
|
| NEXTAUTH_URL_INTERNAL | | The internal builder base URL. You have to set it only when `NEXTAUTH_URL` can't be reached by your builder container / server. For a docker deployment, you should set it to `http://localhost:3000`. |
|
||||||
| DEFAULT_WORKSPACE_PLAN | FREE | Default workspace plan on user creation or when a user creates a new workspace. Possible values are `FREE`, `STARTER`, `PRO`, `LIFETIME`, `UNLIMITED`. The default plan for admin user is `UNLIMITED` |
|
| DEFAULT_WORKSPACE_PLAN | FREE | Default workspace plan on user creation or when a user creates a new workspace. Possible values are `FREE`, `STARTER`, `PRO`, `LIFETIME`, `UNLIMITED`. The default plan for admin user is `UNLIMITED` |
|
||||||
| 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}`,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
return setErrorMessage(`A file is larger than ${sizeLimit}MB`)
|
||||||
`A file is larger than ${props.block.options.sizeLimit ?? 10}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])
|
||||||
|
|||||||
@@ -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
4
packages/env/env.ts
vendored
@@ -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 = {
|
||||||
|
|||||||
40
packages/lib/s3/generatePresignedPostPolicy.ts
Normal file
40
packages/lib/s3/generatePresignedPostPolicy.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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'),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user