2
0

feat(bot): ️ Add custom file upload size limit

This commit is contained in:
Baptiste Arnaud
2022-06-21 16:53:45 +02:00
parent 1931a5c9c0
commit ea765640cf
17 changed files with 141 additions and 44 deletions

View File

@@ -1,5 +1,6 @@
import { FormLabel, Stack } from '@chakra-ui/react' import { FormLabel, Stack } from '@chakra-ui/react'
import { CodeEditor } from 'components/shared/CodeEditor' import { CodeEditor } from 'components/shared/CodeEditor'
import { SmartNumberInput } from 'components/shared/SmartNumberInput'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel' import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { Input } from 'components/shared/Textbox' import { Input } from 'components/shared/Textbox'
import { VariableSearchInput } from 'components/shared/VariableSearchInput' import { VariableSearchInput } from 'components/shared/VariableSearchInput'
@@ -20,7 +21,8 @@ export const FileInputSettings = ({ options, onOptionsChange }: Props) => {
onOptionsChange({ ...options, isMultipleAllowed }) onOptionsChange({ ...options, isMultipleAllowed })
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 })
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<SwitchWithLabel <SwitchWithLabel
@@ -29,6 +31,16 @@ export const FileInputSettings = ({ options, onOptionsChange }: Props) => {
initialValue={options.isMultipleAllowed} initialValue={options.isMultipleAllowed}
onCheckChange={handleLongChange} onCheckChange={handleLongChange}
/> />
<Stack>
<FormLabel mb="0" htmlFor="limit">
Size limit (MB):
</FormLabel>
<SmartNumberInput
id="limit"
value={options.sizeLimit ?? 10}
onValueChange={handleSizeLimitChange}
/>
</Stack>
<Stack> <Stack>
<FormLabel mb="0">Placeholder:</FormLabel> <FormLabel mb="0">Placeholder:</FormLabel>
<CodeEditor <CodeEditor

View File

@@ -37,6 +37,7 @@ test('options should work', async ({ page }) => {
await page.click('text="Allow multiple files?"') await page.click('text="Allow multiple files?"')
await page.fill('div[contenteditable=true]', '<strong>Upload now!!</strong>') await page.fill('div[contenteditable=true]', '<strong>Upload now!!</strong>')
await page.fill('[value="Upload"]', 'Go') await page.fill('[value="Upload"]', 'Go')
await page.fill('input[value="10"]', '20')
await page.click('text="Restart"') await page.click('text="Restart"')
await expect(typebotViewer(page).locator(`text="Upload now!!"`)).toBeVisible() await expect(typebotViewer(page).locator(`text="Upload now!!"`)).toBeVisible()
await typebotViewer(page) await typebotViewer(page)

View File

@@ -35,6 +35,7 @@ export const TypebotPage = ({
const [variableUpdateQueue, setVariableUpdateQueue] = useState< const [variableUpdateQueue, setVariableUpdateQueue] = useState<
VariableWithValue[][] VariableWithValue[][]
>([]) >([])
const [chatStarted, setChatStarted] = useState(false)
useEffect(() => { useEffect(() => {
setShowTypebot(true) setShowTypebot(true)
@@ -94,10 +95,16 @@ export const TypebotPage = ({
if (error) setError(error) 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')) if (!resultId) return setError(new Error('Result was not created'))
const { error } = await upsertAnswer({ ...answer, resultId }) const { error } = await upsertAnswer({ ...answer, resultId })
if (error) setError(error) if (error) setError(error)
if (chatStarted) return
updateResult(resultId, {
hasStarted: true,
}).then(({ error }) => (error ? setError(error) : setChatStarted(true)))
} }
const handleCompleted = async () => { const handleCompleted = async () => {

View File

@@ -1,6 +1,8 @@
import { withSentry } from '@sentry/nextjs' import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { InputBlockType, PublicTypebot } from 'models'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { badRequest, generatePresignedUrl, methodNotAllowed } from 'utils' import { badRequest, generatePresignedUrl, methodNotAllowed, byId } from 'utils'
const handler = async ( const handler = async (
req: NextApiRequest, req: NextApiRequest,
@@ -19,8 +21,25 @@ 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
const typebotId = req.query.typebotId as string
const blockId = req.query.blockId as string
if (!filePath || !fileType) return badRequest(res) 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 }) return res.status(200).send({ presignedUrl })
} }

View File

@@ -26,7 +26,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') { if (req.method === 'POST') {
const typebotId = req.query.typebotId as string const typebotId = req.query.typebotId as string
const result = await prisma.result.create({ const result = await prisma.result.create({
data: { typebotId, isCompleted: false }, data: {
typebotId,
isCompleted: false,
},
}) })
return res.send(result) return res.send(result)
} }

View File

@@ -1,14 +1,25 @@
import { withSentry } from '@sentry/nextjs' import { withSentry } from '@sentry/nextjs'
import { Answer } from 'db' import { Answer } from 'db'
import { got } from 'got'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils' import { isNotDefined, methodNotAllowed } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'PUT') { if (req.method === 'PUT') {
const answer = ( const { uploadedFiles, ...answer } = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body 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({ const result = await prisma.answer.upsert({
where: { where: {
resultId_blockId_groupId: { resultId_blockId_groupId: {
@@ -17,8 +28,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
blockId: answer.blockId, blockId: answer.blockId,
}, },
}, },
create: answer, create: { ...answer, storageUsed: storageUsed > 0 ? storageUsed : null },
update: answer, update: { ...answer, storageUsed: storageUsed > 0 ? storageUsed : null },
}) })
return res.send(result) return res.send(result)
} }

View File

@@ -1,7 +1,9 @@
import { Answer } from 'models' import { Answer } from 'models'
import { sendRequest } from 'utils' import { sendRequest } from 'utils'
export const upsertAnswer = async (answer: Answer & { resultId: string }) => export const upsertAnswer = async (
answer: Answer & { resultId: string } & { uploadedFiles?: boolean }
) =>
sendRequest<Answer>({ sendRequest<Answer>({
url: `/api/typebots/t/results/r/answers`, url: `/api/typebots/t/results/r/answers`,
method: 'PUT', method: 'PUT',

View File

@@ -53,6 +53,7 @@ export const InputChatBlock = ({
groupId: block.groupId, groupId: block.groupId,
content: value, content: value,
variableId: variableId ?? null, variableId: variableId ?? null,
uploadedFiles: block.type === InputBlockType.FILE,
}) })
if (!isEditting) onTransitionEnd({ label, value, itemId }, isRetry) if (!isEditting) onTransitionEnd({ label, value, itemId }, isRetry)
setIsEditting(false) setIsEditting(false)

View File

@@ -11,15 +11,17 @@ type Props = {
onSubmit: (url: InputSubmitContent) => void onSubmit: (url: InputSubmitContent) => void
} }
const tenMB = 10 * 1024 * 1024
export const FileUploadForm = ({ export const FileUploadForm = ({
block: { block: {
id, id,
options: { isMultipleAllowed, labels }, options: { isMultipleAllowed, labels, sizeLimit },
}, },
onSubmit, onSubmit,
}: Props) => { }: Props) => {
const { isPreview } = useTypebot() const {
isPreview,
typebot: { typebotId },
} = useTypebot()
const { resultId } = useAnswers() const { resultId } = useAnswers()
const [selectedFiles, setSelectedFiles] = useState<File[]>([]) const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
@@ -35,8 +37,8 @@ export const FileUploadForm = ({
const onNewFiles = (files: FileList) => { const onNewFiles = (files: FileList) => {
setErrorMessage(undefined) setErrorMessage(undefined)
const newFiles = Array.from(files) const newFiles = Array.from(files)
if (newFiles.some((file) => file.size > tenMB)) if (newFiles.some((file) => file.size > (sizeLimit ?? 10) * 1024 * 1024))
return setErrorMessage('A file is larger than 10MB') return setErrorMessage(`A file is larger than ${sizeLimit ?? 10}MB`)
if (!isMultipleAllowed && files) return startSingleFileUpload(newFiles[0]) if (!isMultipleAllowed && files) return startSingleFileUpload(newFiles[0])
setSelectedFiles([...selectedFiles, ...newFiles]) setSelectedFiles([...selectedFiles, ...newFiles])
} }
@@ -55,6 +57,7 @@ export const FileUploadForm = ({
}) })
setIsUploading(true) setIsUploading(true)
const urls = await uploadFiles({ const urls = await uploadFiles({
basePath: `/api/typebots/${typebotId}/blocks/${id}`,
files: [ files: [
{ {
file, file,
@@ -76,6 +79,7 @@ export const FileUploadForm = ({
}) })
setIsUploading(true) setIsUploading(true)
const urls = await uploadFiles({ const urls = await uploadFiles({
basePath: `/api/typebots/${typebotId}/blocks/${id}`,
files: files.map((file) => ({ files: files.map((file) => ({
file: file, file: file,
path: `public/results/${resultId}/${id}/${file.name}`, path: `public/results/${resultId}/${id}/${file.name}`,

View File

@@ -33,7 +33,7 @@ export type TypebotViewerProps = {
startGroupId?: string startGroupId?: string
isLoading?: boolean isLoading?: boolean
onNewGroupVisible?: (edge: Edge) => void onNewGroupVisible?: (edge: Edge) => void
onNewAnswer?: (answer: Answer) => Promise<void> onNewAnswer?: (answer: Answer & { uploadedFiles: boolean }) => Promise<void>
onNewLog?: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void onNewLog?: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
onCompleted?: () => void onCompleted?: () => void
onVariablesUpdated?: (variables: VariableWithValue[]) => void onVariablesUpdated?: (variables: VariableWithValue[]) => void
@@ -64,7 +64,8 @@ export const TypebotViewer = ({
const handleNewGroupVisible = (edge: Edge) => const handleNewGroupVisible = (edge: Edge) =>
onNewGroupVisible && onNewGroupVisible(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'>) => const handleNewLog = (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) =>
onNewLog && onNewLog(log) onNewLog && onNewLog(log)

View File

@@ -4,7 +4,9 @@ import React, { createContext, ReactNode, useContext, useState } from 'react'
const answersContext = createContext<{ const answersContext = createContext<{
resultId?: string resultId?: string
resultValues: ResultValues resultValues: ResultValues
addAnswer: (answer: Answer) => Promise<void> | undefined addAnswer: (
answer: Answer & { uploadedFiles: boolean }
) => Promise<void> | undefined
updateVariables: (variables: VariableWithValue[]) => void updateVariables: (variables: VariableWithValue[]) => void
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
@@ -17,7 +19,9 @@ export const AnswersContext = ({
onVariablesUpdated, onVariablesUpdated,
}: { }: {
resultId?: string resultId?: string
onNewAnswer: (answer: Answer) => Promise<void> | undefined onNewAnswer: (
answer: Answer & { uploadedFiles: boolean }
) => Promise<void> | undefined
onVariablesUpdated?: (variables: VariableWithValue[]) => void onVariablesUpdated?: (variables: VariableWithValue[]) => void
children: ReactNode children: ReactNode
}) => { }) => {
@@ -27,7 +31,7 @@ export const AnswersContext = ({
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}) })
const addAnswer = (answer: Answer) => { const addAnswer = (answer: Answer & { uploadedFiles: boolean }) => {
setResultValues((resultValues) => ({ setResultValues((resultValues) => ({
...resultValues, ...resultValues,
answers: [...resultValues.answers, answer], answers: [...resultValues.answers, answer],

View File

@@ -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;

View File

@@ -112,15 +112,15 @@ enum GraphNavigation {
} }
model CustomDomain { model CustomDomain {
name String @id name String @id
createdAt DateTime @default(now()) createdAt DateTime @default(now())
workspaceId String workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
} }
model Credentials { model Credentials {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
workspaceId String workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
data String // Encrypted data data String // Encrypted data
@@ -155,7 +155,7 @@ model DashboardFolder {
childrenFolder DashboardFolder[] @relation("ParentChild") childrenFolder DashboardFolder[] @relation("ParentChild")
typebots Typebot[] typebots Typebot[]
workspaceId String workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
} }
model Typebot { model Typebot {
@@ -180,7 +180,7 @@ model Typebot {
invitations Invitation[] invitations Invitation[]
webhooks Webhook[] webhooks Webhook[]
workspaceId String workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
} }
model Invitation { model Invitation {
@@ -210,16 +210,16 @@ enum CollaborationType {
} }
model PublicTypebot { model PublicTypebot {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
typebotId String @unique typebotId String @unique
typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade) typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
groups Json groups Json
variables Json[] variables Json[]
edges Json edges Json
theme Json theme Json
settings Json settings Json
} }
model Result { model Result {
@@ -231,6 +231,7 @@ model Result {
answers Answer[] answers Answer[]
variables Json[] variables Json[]
isCompleted Boolean isCompleted Boolean
hasStarted Boolean?
logs Log[] logs Log[]
} }
@@ -248,10 +249,11 @@ model Answer {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
resultId String resultId String
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade) result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
blockId String blockId String
groupId String groupId String
variableId String? variableId String?
content String content String
storageUsed Int?
@@unique([resultId, blockId, groupId]) @@unique([resultId, blockId, groupId])
} }

View File

@@ -1,6 +1,9 @@
import { Answer as AnswerFromPrisma } from 'db' 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 = { export type Stats = {
totalViews: number totalViews: number

View File

@@ -8,6 +8,7 @@ export const fileInputOptionsSchema = optionBaseSchema.and(
placeholder: z.string(), placeholder: z.string(),
button: z.string(), button: z.string(),
}), }),
sizeLimit: z.number().optional(),
}) })
) )

View File

@@ -3,14 +3,16 @@ import { config, Endpoint, S3 } from 'aws-sdk'
type GeneratePresignedUrlProps = { type GeneratePresignedUrlProps = {
filePath: string filePath: string
fileType: string fileType: string
sizeLimit?: number
} }
const tenMB = 10485760 const tenMB = 10 * 1024 * 1024
const oneHundredAndTwentySeconds = 120 const oneHundredAndTwentySeconds = 120
export const generatePresignedUrl = ({ export const generatePresignedUrl = ({
filePath, filePath,
fileType, fileType,
sizeLimit = tenMB,
}: GeneratePresignedUrlProps): S3.PresignedPost => { }: GeneratePresignedUrlProps): S3.PresignedPost => {
if ( if (
!process.env.S3_ENDPOINT || !process.env.S3_ENDPOINT ||
@@ -45,7 +47,7 @@ export const generatePresignedUrl = ({
'Content-Type': fileType, 'Content-Type': fileType,
}, },
Expires: oneHundredAndTwentySeconds, Expires: oneHundredAndTwentySeconds,
Conditions: [['content-length-range', 0, tenMB]], Conditions: [['content-length-range', 0, sizeLimit]],
}) })
return presignedUrl return presignedUrl
} }

View File

@@ -193,6 +193,7 @@ export const generateId = (idDesiredLength: number): string => {
} }
type UploadFileProps = { type UploadFileProps = {
basePath?: string
files: { files: {
file: File file: File
path: string path: string
@@ -202,6 +203,7 @@ type UploadFileProps = {
type UrlList = string[] type UrlList = string[]
export const uploadFiles = async ({ export const uploadFiles = async ({
basePath = '/api',
files, files,
onUploadProgress, onUploadProgress,
}: UploadFileProps): Promise<UrlList> => { }: UploadFileProps): Promise<UrlList> => {
@@ -209,9 +211,9 @@ export const uploadFiles = async ({
const { data } = await sendRequest<{ const { data } = await sendRequest<{
presignedUrl: { url: string; fields: any } presignedUrl: { url: string; fields: any }
}>( }>(
`/api/storage/upload-url?filePath=${encodeURIComponent(path)}&fileType=${ `${basePath}/storage/upload-url?filePath=${encodeURIComponent(
file.type path
}` )}&fileType=${file.type}`
) )
if (!data?.presignedUrl) return null if (!data?.presignedUrl) return null