feat(bot): ⚡️ Add custom file upload size limit
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
import { FormLabel, Stack } from '@chakra-ui/react'
|
||||
import { CodeEditor } from 'components/shared/CodeEditor'
|
||||
import { SmartNumberInput } from 'components/shared/SmartNumberInput'
|
||||
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
|
||||
import { Input } from 'components/shared/Textbox'
|
||||
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
||||
@ -20,7 +21,8 @@ export const FileInputSettings = ({ options, onOptionsChange }: Props) => {
|
||||
onOptionsChange({ ...options, isMultipleAllowed })
|
||||
const handleVariableChange = (variable?: Variable) =>
|
||||
onOptionsChange({ ...options, variableId: variable?.id })
|
||||
|
||||
const handleSizeLimitChange = (sizeLimit?: number) =>
|
||||
onOptionsChange({ ...options, sizeLimit })
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<SwitchWithLabel
|
||||
@ -29,6 +31,16 @@ export const FileInputSettings = ({ options, onOptionsChange }: Props) => {
|
||||
initialValue={options.isMultipleAllowed}
|
||||
onCheckChange={handleLongChange}
|
||||
/>
|
||||
<Stack>
|
||||
<FormLabel mb="0" htmlFor="limit">
|
||||
Size limit (MB):
|
||||
</FormLabel>
|
||||
<SmartNumberInput
|
||||
id="limit"
|
||||
value={options.sizeLimit ?? 10}
|
||||
onValueChange={handleSizeLimitChange}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<FormLabel mb="0">Placeholder:</FormLabel>
|
||||
<CodeEditor
|
||||
|
@ -37,6 +37,7 @@ test('options should work', async ({ page }) => {
|
||||
await page.click('text="Allow multiple files?"')
|
||||
await page.fill('div[contenteditable=true]', '<strong>Upload now!!</strong>')
|
||||
await page.fill('[value="Upload"]', 'Go')
|
||||
await page.fill('input[value="10"]', '20')
|
||||
await page.click('text="Restart"')
|
||||
await expect(typebotViewer(page).locator(`text="Upload now!!"`)).toBeVisible()
|
||||
await typebotViewer(page)
|
||||
|
@ -35,6 +35,7 @@ export const TypebotPage = ({
|
||||
const [variableUpdateQueue, setVariableUpdateQueue] = useState<
|
||||
VariableWithValue[][]
|
||||
>([])
|
||||
const [chatStarted, setChatStarted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setShowTypebot(true)
|
||||
@ -94,10 +95,16 @@ export const TypebotPage = ({
|
||||
if (error) setError(error)
|
||||
}
|
||||
|
||||
const handleNewAnswer = async (answer: Answer) => {
|
||||
const handleNewAnswer = async (
|
||||
answer: Answer & { uploadedFiles: boolean }
|
||||
) => {
|
||||
if (!resultId) return setError(new Error('Result was not created'))
|
||||
const { error } = await upsertAnswer({ ...answer, resultId })
|
||||
if (error) setError(error)
|
||||
if (chatStarted) return
|
||||
updateResult(resultId, {
|
||||
hasStarted: true,
|
||||
}).then(({ error }) => (error ? setError(error) : setChatStarted(true)))
|
||||
}
|
||||
|
||||
const handleCompleted = async () => {
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import prisma from 'libs/prisma'
|
||||
import { InputBlockType, PublicTypebot } from 'models'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { badRequest, generatePresignedUrl, methodNotAllowed } from 'utils'
|
||||
import { badRequest, generatePresignedUrl, methodNotAllowed, byId } from 'utils'
|
||||
|
||||
const handler = async (
|
||||
req: NextApiRequest,
|
||||
@ -19,8 +21,25 @@ const handler = async (
|
||||
)
|
||||
const filePath = req.query.filePath as string | undefined
|
||||
const fileType = req.query.fileType as string | undefined
|
||||
const typebotId = req.query.typebotId as string
|
||||
const blockId = req.query.blockId as string
|
||||
if (!filePath || !fileType) return badRequest(res)
|
||||
const presignedUrl = generatePresignedUrl({ fileType, filePath })
|
||||
const typebot = (await prisma.publicTypebot.findFirst({
|
||||
where: { typebotId },
|
||||
})) as unknown as PublicTypebot
|
||||
const fileUploadBlock = typebot.groups
|
||||
.flatMap((g) => g.blocks)
|
||||
.find(byId(blockId))
|
||||
if (fileUploadBlock?.type !== InputBlockType.FILE) return badRequest(res)
|
||||
const sizeLimit = fileUploadBlock.options.sizeLimit
|
||||
? Math.min(fileUploadBlock.options.sizeLimit, 500)
|
||||
: 10
|
||||
|
||||
const presignedUrl = generatePresignedUrl({
|
||||
fileType,
|
||||
filePath,
|
||||
sizeLimit: sizeLimit * 1024 * 1024,
|
||||
})
|
||||
|
||||
return res.status(200).send({ presignedUrl })
|
||||
}
|
@ -26,7 +26,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method === 'POST') {
|
||||
const typebotId = req.query.typebotId as string
|
||||
const result = await prisma.result.create({
|
||||
data: { typebotId, isCompleted: false },
|
||||
data: {
|
||||
typebotId,
|
||||
isCompleted: false,
|
||||
},
|
||||
})
|
||||
return res.send(result)
|
||||
}
|
||||
|
@ -1,14 +1,25 @@
|
||||
import { withSentry } from '@sentry/nextjs'
|
||||
import { Answer } from 'db'
|
||||
import { got } from 'got'
|
||||
import prisma from 'libs/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { methodNotAllowed } from 'utils'
|
||||
import { isNotDefined, methodNotAllowed } from 'utils'
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method === 'PUT') {
|
||||
const answer = (
|
||||
const { uploadedFiles, ...answer } = (
|
||||
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
||||
) as Answer
|
||||
) as Answer & { uploadedFiles?: boolean }
|
||||
let storageUsed = 0
|
||||
if (uploadedFiles && answer.content.includes('http')) {
|
||||
const fileUrls = answer.content.split(', ')
|
||||
for (const url of fileUrls) {
|
||||
const { headers } = await got(url)
|
||||
const size = headers['content-length']
|
||||
if (isNotDefined(size)) return
|
||||
storageUsed += parseInt(size, 10)
|
||||
}
|
||||
}
|
||||
const result = await prisma.answer.upsert({
|
||||
where: {
|
||||
resultId_blockId_groupId: {
|
||||
@ -17,8 +28,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
blockId: answer.blockId,
|
||||
},
|
||||
},
|
||||
create: answer,
|
||||
update: answer,
|
||||
create: { ...answer, storageUsed: storageUsed > 0 ? storageUsed : null },
|
||||
update: { ...answer, storageUsed: storageUsed > 0 ? storageUsed : null },
|
||||
})
|
||||
return res.send(result)
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { Answer } from 'models'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const upsertAnswer = async (answer: Answer & { resultId: string }) =>
|
||||
export const upsertAnswer = async (
|
||||
answer: Answer & { resultId: string } & { uploadedFiles?: boolean }
|
||||
) =>
|
||||
sendRequest<Answer>({
|
||||
url: `/api/typebots/t/results/r/answers`,
|
||||
method: 'PUT',
|
||||
|
@ -53,6 +53,7 @@ export const InputChatBlock = ({
|
||||
groupId: block.groupId,
|
||||
content: value,
|
||||
variableId: variableId ?? null,
|
||||
uploadedFiles: block.type === InputBlockType.FILE,
|
||||
})
|
||||
if (!isEditting) onTransitionEnd({ label, value, itemId }, isRetry)
|
||||
setIsEditting(false)
|
||||
|
@ -11,15 +11,17 @@ type Props = {
|
||||
onSubmit: (url: InputSubmitContent) => void
|
||||
}
|
||||
|
||||
const tenMB = 10 * 1024 * 1024
|
||||
export const FileUploadForm = ({
|
||||
block: {
|
||||
id,
|
||||
options: { isMultipleAllowed, labels },
|
||||
options: { isMultipleAllowed, labels, sizeLimit },
|
||||
},
|
||||
onSubmit,
|
||||
}: Props) => {
|
||||
const { isPreview } = useTypebot()
|
||||
const {
|
||||
isPreview,
|
||||
typebot: { typebotId },
|
||||
} = useTypebot()
|
||||
const { resultId } = useAnswers()
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
@ -35,8 +37,8 @@ export const FileUploadForm = ({
|
||||
const onNewFiles = (files: FileList) => {
|
||||
setErrorMessage(undefined)
|
||||
const newFiles = Array.from(files)
|
||||
if (newFiles.some((file) => file.size > tenMB))
|
||||
return setErrorMessage('A file is larger than 10MB')
|
||||
if (newFiles.some((file) => file.size > (sizeLimit ?? 10) * 1024 * 1024))
|
||||
return setErrorMessage(`A file is larger than ${sizeLimit ?? 10}MB`)
|
||||
if (!isMultipleAllowed && files) return startSingleFileUpload(newFiles[0])
|
||||
setSelectedFiles([...selectedFiles, ...newFiles])
|
||||
}
|
||||
@ -55,6 +57,7 @@ export const FileUploadForm = ({
|
||||
})
|
||||
setIsUploading(true)
|
||||
const urls = await uploadFiles({
|
||||
basePath: `/api/typebots/${typebotId}/blocks/${id}`,
|
||||
files: [
|
||||
{
|
||||
file,
|
||||
@ -76,6 +79,7 @@ export const FileUploadForm = ({
|
||||
})
|
||||
setIsUploading(true)
|
||||
const urls = await uploadFiles({
|
||||
basePath: `/api/typebots/${typebotId}/blocks/${id}`,
|
||||
files: files.map((file) => ({
|
||||
file: file,
|
||||
path: `public/results/${resultId}/${id}/${file.name}`,
|
||||
|
@ -33,7 +33,7 @@ export type TypebotViewerProps = {
|
||||
startGroupId?: string
|
||||
isLoading?: boolean
|
||||
onNewGroupVisible?: (edge: Edge) => void
|
||||
onNewAnswer?: (answer: Answer) => Promise<void>
|
||||
onNewAnswer?: (answer: Answer & { uploadedFiles: boolean }) => Promise<void>
|
||||
onNewLog?: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
|
||||
onCompleted?: () => void
|
||||
onVariablesUpdated?: (variables: VariableWithValue[]) => void
|
||||
@ -64,7 +64,8 @@ export const TypebotViewer = ({
|
||||
const handleNewGroupVisible = (edge: Edge) =>
|
||||
onNewGroupVisible && onNewGroupVisible(edge)
|
||||
|
||||
const handleNewAnswer = (answer: Answer) => onNewAnswer && onNewAnswer(answer)
|
||||
const handleNewAnswer = (answer: Answer & { uploadedFiles: boolean }) =>
|
||||
onNewAnswer && onNewAnswer(answer)
|
||||
|
||||
const handleNewLog = (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) =>
|
||||
onNewLog && onNewLog(log)
|
||||
|
@ -4,7 +4,9 @@ import React, { createContext, ReactNode, useContext, useState } from 'react'
|
||||
const answersContext = createContext<{
|
||||
resultId?: string
|
||||
resultValues: ResultValues
|
||||
addAnswer: (answer: Answer) => Promise<void> | undefined
|
||||
addAnswer: (
|
||||
answer: Answer & { uploadedFiles: boolean }
|
||||
) => Promise<void> | undefined
|
||||
updateVariables: (variables: VariableWithValue[]) => void
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
@ -17,7 +19,9 @@ export const AnswersContext = ({
|
||||
onVariablesUpdated,
|
||||
}: {
|
||||
resultId?: string
|
||||
onNewAnswer: (answer: Answer) => Promise<void> | undefined
|
||||
onNewAnswer: (
|
||||
answer: Answer & { uploadedFiles: boolean }
|
||||
) => Promise<void> | undefined
|
||||
onVariablesUpdated?: (variables: VariableWithValue[]) => void
|
||||
children: ReactNode
|
||||
}) => {
|
||||
@ -27,7 +31,7 @@ export const AnswersContext = ({
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
const addAnswer = (answer: Answer) => {
|
||||
const addAnswer = (answer: Answer & { uploadedFiles: boolean }) => {
|
||||
setResultValues((resultValues) => ({
|
||||
...resultValues,
|
||||
answers: [...resultValues.answers, answer],
|
||||
|
@ -0,0 +1,22 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Made the column `groups` on table `PublicTypebot` required. This step will fail if there are existing NULL values in that column.
|
||||
- Made the column `edges` on table `PublicTypebot` required. This step will fail if there are existing NULL values in that column.
|
||||
- Made the column `groups` on table `Typebot` required. This step will fail if there are existing NULL values in that column.
|
||||
- Made the column `edges` on table `Typebot` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Answer" ADD COLUMN "storageUsed" INTEGER;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "PublicTypebot" ALTER COLUMN "groups" SET NOT NULL,
|
||||
ALTER COLUMN "edges" SET NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Result" ADD COLUMN "hasStarted" BOOLEAN;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Typebot" ALTER COLUMN "groups" SET NOT NULL,
|
||||
ALTER COLUMN "edges" SET NOT NULL;
|
@ -231,6 +231,7 @@ model Result {
|
||||
answers Answer[]
|
||||
variables Json[]
|
||||
isCompleted Boolean
|
||||
hasStarted Boolean?
|
||||
logs Log[]
|
||||
}
|
||||
|
||||
@ -252,6 +253,7 @@ model Answer {
|
||||
groupId String
|
||||
variableId String?
|
||||
content String
|
||||
storageUsed Int?
|
||||
|
||||
@@unique([resultId, blockId, groupId])
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { Answer as AnswerFromPrisma } from 'db'
|
||||
|
||||
export type Answer = Omit<AnswerFromPrisma, 'resultId' | 'createdAt'>
|
||||
export type Answer = Omit<
|
||||
AnswerFromPrisma,
|
||||
'resultId' | 'createdAt' | 'storageUsed'
|
||||
> & { storageUsed?: number }
|
||||
|
||||
export type Stats = {
|
||||
totalViews: number
|
||||
|
@ -8,6 +8,7 @@ export const fileInputOptionsSchema = optionBaseSchema.and(
|
||||
placeholder: z.string(),
|
||||
button: z.string(),
|
||||
}),
|
||||
sizeLimit: z.number().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
|
@ -3,14 +3,16 @@ import { config, Endpoint, S3 } from 'aws-sdk'
|
||||
type GeneratePresignedUrlProps = {
|
||||
filePath: string
|
||||
fileType: string
|
||||
sizeLimit?: number
|
||||
}
|
||||
|
||||
const tenMB = 10485760
|
||||
const tenMB = 10 * 1024 * 1024
|
||||
const oneHundredAndTwentySeconds = 120
|
||||
|
||||
export const generatePresignedUrl = ({
|
||||
filePath,
|
||||
fileType,
|
||||
sizeLimit = tenMB,
|
||||
}: GeneratePresignedUrlProps): S3.PresignedPost => {
|
||||
if (
|
||||
!process.env.S3_ENDPOINT ||
|
||||
@ -45,7 +47,7 @@ export const generatePresignedUrl = ({
|
||||
'Content-Type': fileType,
|
||||
},
|
||||
Expires: oneHundredAndTwentySeconds,
|
||||
Conditions: [['content-length-range', 0, tenMB]],
|
||||
Conditions: [['content-length-range', 0, sizeLimit]],
|
||||
})
|
||||
return presignedUrl
|
||||
}
|
||||
|
@ -193,6 +193,7 @@ export const generateId = (idDesiredLength: number): string => {
|
||||
}
|
||||
|
||||
type UploadFileProps = {
|
||||
basePath?: string
|
||||
files: {
|
||||
file: File
|
||||
path: string
|
||||
@ -202,6 +203,7 @@ type UploadFileProps = {
|
||||
type UrlList = string[]
|
||||
|
||||
export const uploadFiles = async ({
|
||||
basePath = '/api',
|
||||
files,
|
||||
onUploadProgress,
|
||||
}: UploadFileProps): Promise<UrlList> => {
|
||||
@ -209,9 +211,9 @@ export const uploadFiles = async ({
|
||||
const { data } = await sendRequest<{
|
||||
presignedUrl: { url: string; fields: any }
|
||||
}>(
|
||||
`/api/storage/upload-url?filePath=${encodeURIComponent(path)}&fileType=${
|
||||
file.type
|
||||
}`
|
||||
`${basePath}/storage/upload-url?filePath=${encodeURIComponent(
|
||||
path
|
||||
)}&fileType=${file.type}`
|
||||
)
|
||||
|
||||
if (!data?.presignedUrl) return null
|
||||
|
Reference in New Issue
Block a user