mirror of
https://github.com/baptisteArno/typebot.io.git
synced 2026-07-21 21:13:45 +08:00
🐛 Fix file upload advisory (#2544)
- Removed deprecated viewer upload URL V1/V2 routes and handlers. - Hardened V3 upload paths by validating session-derived path segments. - Updated API docs and OpenAPI output to advertise only V3 generate-upload-url. - Added file-input upload URL test coverage and package test wiring.
This commit is contained in:
parent
9c81300e5a
commit
ab729561d4
@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 'Generate upload URL'
|
||||
openapi: POST /v2/generate-upload-url
|
||||
openapi: POST /v3/generate-upload-url
|
||||
---
|
||||
|
||||
The `presignedUrl` and `formData` fields can be then used to upload the file to the S3 bucket, directly from the browser if necessary. Here is an example:
|
||||
|
||||
@ -3936,253 +3936,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/generate-upload-url": {
|
||||
"post": {
|
||||
"operationId": "generateUploadUrlV1",
|
||||
"summary": "Generate upload URL",
|
||||
"description": "Used to upload anything from the client to S3 bucket",
|
||||
"security": [
|
||||
{
|
||||
"Authorization": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filePathProps": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"typebotId": {
|
||||
"type": "string"
|
||||
},
|
||||
"blockId": {
|
||||
"type": "string"
|
||||
},
|
||||
"resultId": {
|
||||
"type": "string"
|
||||
},
|
||||
"fileName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"typebotId",
|
||||
"blockId",
|
||||
"resultId",
|
||||
"fileName"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"fileName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sessionId",
|
||||
"fileName"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"fileType": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"filePathProps"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"presignedUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"formData": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"fileUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"presignedUrl",
|
||||
"formData",
|
||||
"fileUrl"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input data",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.BAD_REQUEST"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authorization not provided",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.UNAUTHORIZED"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Insufficient access",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.FORBIDDEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v2/generate-upload-url": {
|
||||
"post": {
|
||||
"operationId": "generateUploadUrlV2",
|
||||
"summary": "Generate upload URL",
|
||||
"description": "Used to upload anything from the client to S3 bucket",
|
||||
"security": [
|
||||
{
|
||||
"Authorization": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"fileName": {
|
||||
"type": "string"
|
||||
},
|
||||
"fileType": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sessionId",
|
||||
"fileName"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"presignedUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"formData": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"fileUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"presignedUrl",
|
||||
"formData",
|
||||
"fileUrl"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input data",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.BAD_REQUEST"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authorization not provided",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.UNAUTHORIZED"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Insufficient access",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.FORBIDDEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v3/generate-upload-url": {
|
||||
"post": {
|
||||
"operationId": "generateUploadUrl",
|
||||
@ -17876,4 +17629,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
bun.lock
3
bun.lock
@ -402,6 +402,9 @@
|
||||
"@typebot.io/typebot": "workspace:*",
|
||||
"zod": "^4.3.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.9",
|
||||
},
|
||||
},
|
||||
"packages/blocks/inputs": {
|
||||
"name": "@typebot.io/blocks-inputs",
|
||||
|
||||
@ -11,6 +11,9 @@
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@orpc/server": "^1.13.9",
|
||||
"@typebot.io/groups": "workspace:*",
|
||||
@ -24,5 +27,8 @@
|
||||
"@typebot.io/blocks-logic": "workspace:*",
|
||||
"@typebot.io/typebot": "workspace:*",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.9"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,197 +0,0 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { InputBlockType } from "@typebot.io/blocks-inputs/constants";
|
||||
import { getSession } from "@typebot.io/chat-session/queries/getSession";
|
||||
import { env } from "@typebot.io/env";
|
||||
import { getBlockById } from "@typebot.io/groups/helpers/getBlockById";
|
||||
import { parseGroups } from "@typebot.io/groups/helpers/parseGroups";
|
||||
import {
|
||||
createUploadFileName,
|
||||
resolveStoredUploadFileType,
|
||||
resolveUploadContentDisposition,
|
||||
resolveUploadFileType,
|
||||
} from "@typebot.io/lib/s3/createUploadFilePath";
|
||||
import { generateSignedUploadProxyUrl } from "@typebot.io/lib/s3/signedUploadProxy";
|
||||
import prisma from "@typebot.io/prisma";
|
||||
import { z } from "zod";
|
||||
import { getUploadProxyBaseUrl } from "../getUploadProxyBaseUrl";
|
||||
|
||||
export const generateUploadUrlV1InputSchema = z.object({
|
||||
filePathProps: z
|
||||
.object({
|
||||
typebotId: z.string(),
|
||||
blockId: z.string(),
|
||||
resultId: z.string(),
|
||||
fileName: z.string(),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
sessionId: z.string(),
|
||||
fileName: z.string(),
|
||||
}),
|
||||
),
|
||||
fileType: z.string().optional(),
|
||||
fileSize: z.number().optional(),
|
||||
});
|
||||
|
||||
export const handleGenerateUploadUrlV1 = async ({
|
||||
input: { filePathProps, fileType, fileSize },
|
||||
context,
|
||||
}: {
|
||||
input: z.infer<typeof generateUploadUrlV1InputSchema>;
|
||||
context?: { apiOrigin?: string };
|
||||
}) => {
|
||||
if (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY || !env.S3_SECRET_KEY)
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
||||
message:
|
||||
"S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY",
|
||||
});
|
||||
|
||||
const resolvedFileType = resolveUploadFileType(fileType);
|
||||
|
||||
const uploadFileName = createUploadFileName(resolvedFileType);
|
||||
|
||||
if ("typebotId" in filePathProps) {
|
||||
const publicTypebot = await prisma.publicTypebot.findFirst({
|
||||
where: {
|
||||
typebotId: filePathProps.typebotId,
|
||||
},
|
||||
select: {
|
||||
version: true,
|
||||
groups: true,
|
||||
typebot: {
|
||||
select: {
|
||||
workspaceId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const workspaceId = publicTypebot?.typebot.workspaceId;
|
||||
|
||||
if (!workspaceId)
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Can't find workspaceId",
|
||||
});
|
||||
|
||||
const fileUploadBlock = parseGroups(publicTypebot.groups, {
|
||||
typebotVersion: publicTypebot.version,
|
||||
})
|
||||
.flatMap((group) => group.blocks)
|
||||
.find((block) => block.id === filePathProps.blockId);
|
||||
|
||||
if (fileUploadBlock?.type !== InputBlockType.FILE)
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Can't find file upload block",
|
||||
});
|
||||
|
||||
const maxFileSize =
|
||||
fileUploadBlock.options?.sizeLimit ??
|
||||
env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE;
|
||||
|
||||
if (maxFileSize && fileSize && fileSize > maxFileSize * 1024 * 1024)
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: `File size exceeds the ${maxFileSize}MB limit`,
|
||||
});
|
||||
|
||||
return {
|
||||
...generateSignedUploadProxyUrl({
|
||||
baseUrl: getUploadProxyBaseUrl(context?.apiOrigin),
|
||||
fileType: resolveStoredUploadFileType(resolvedFileType),
|
||||
contentDisposition: resolveUploadContentDisposition(resolvedFileType),
|
||||
filePath: `public/workspaces/${workspaceId}/typebots/${filePathProps.typebotId}/results/${filePathProps.resultId}/${uploadFileName}`,
|
||||
maxFileSize,
|
||||
}),
|
||||
fileType: resolvedFileType,
|
||||
};
|
||||
}
|
||||
|
||||
const session = await getSession(filePathProps.sessionId);
|
||||
|
||||
if (!session?.state)
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Can't find session",
|
||||
});
|
||||
|
||||
const typebotId = session.state.typebotsQueue[0].typebot.id;
|
||||
|
||||
const publicTypebot = await prisma.publicTypebot.findFirst({
|
||||
where: {
|
||||
typebotId,
|
||||
},
|
||||
select: {
|
||||
version: true,
|
||||
groups: true,
|
||||
typebot: {
|
||||
select: {
|
||||
workspaceId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const workspaceId = publicTypebot?.typebot.workspaceId;
|
||||
|
||||
if (!workspaceId)
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Can't find workspaceId",
|
||||
});
|
||||
|
||||
if (session.state.currentBlockId === undefined)
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Can't find currentBlockId in session state",
|
||||
});
|
||||
|
||||
const { block: fileUploadBlock } = getBlockById(
|
||||
session.state.currentBlockId,
|
||||
parseGroups(publicTypebot.groups, {
|
||||
typebotVersion: publicTypebot.version,
|
||||
}),
|
||||
);
|
||||
|
||||
if (fileUploadBlock?.type !== InputBlockType.FILE)
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Can't find file upload block",
|
||||
});
|
||||
|
||||
const resultId = session.state.typebotsQueue[0].resultId;
|
||||
const visibility =
|
||||
fileUploadBlock.options?.visibility === "Private" ? "private" : "public";
|
||||
|
||||
const filePath = resultId
|
||||
? `${visibility}/workspaces/${workspaceId}/typebots/${typebotId}/results/${resultId}/${uploadFileName}`
|
||||
: `public/tmp/${typebotId}/${uploadFileName}`;
|
||||
|
||||
const maxFileSize =
|
||||
fileUploadBlock.options && "sizeLimit" in fileUploadBlock.options
|
||||
? fileUploadBlock.options.sizeLimit
|
||||
: env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE;
|
||||
|
||||
if (maxFileSize && fileSize && fileSize > maxFileSize * 1024 * 1024)
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: `File size exceeds the ${maxFileSize}MB limit`,
|
||||
});
|
||||
|
||||
const {
|
||||
presignedUrl,
|
||||
fileUrl: defaultFileUrl,
|
||||
formData,
|
||||
maxFileSize: maxFileSizeMB,
|
||||
} = generateSignedUploadProxyUrl({
|
||||
baseUrl: getUploadProxyBaseUrl(context?.apiOrigin),
|
||||
fileType: resolveStoredUploadFileType(resolvedFileType),
|
||||
contentDisposition: resolveUploadContentDisposition(resolvedFileType),
|
||||
filePath,
|
||||
maxFileSize,
|
||||
});
|
||||
|
||||
return {
|
||||
presignedUrl,
|
||||
formData,
|
||||
fileType: resolvedFileType,
|
||||
maxFileSize: maxFileSizeMB,
|
||||
fileUrl:
|
||||
fileUploadBlock.options?.visibility === "Private" && resultId
|
||||
? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${uploadFileName}`
|
||||
: defaultFileUrl,
|
||||
};
|
||||
};
|
||||
@ -1,170 +0,0 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { InputBlockType } from "@typebot.io/blocks-inputs/constants";
|
||||
import type { FileInputBlock } from "@typebot.io/blocks-inputs/file/schema";
|
||||
import type { TextInputBlock } from "@typebot.io/blocks-inputs/text/schema";
|
||||
import { getSession } from "@typebot.io/chat-session/queries/getSession";
|
||||
import { env } from "@typebot.io/env";
|
||||
import { getBlockById } from "@typebot.io/groups/helpers/getBlockById";
|
||||
import { parseGroups } from "@typebot.io/groups/helpers/parseGroups";
|
||||
import {
|
||||
createUploadFileName,
|
||||
resolveStoredUploadFileType,
|
||||
resolveUploadContentDisposition,
|
||||
resolveUploadFileType,
|
||||
} from "@typebot.io/lib/s3/createUploadFilePath";
|
||||
import { generateSignedUploadProxyUrl } from "@typebot.io/lib/s3/signedUploadProxy";
|
||||
import prisma from "@typebot.io/prisma";
|
||||
import { z } from "zod";
|
||||
import { getUploadProxyBaseUrl } from "../getUploadProxyBaseUrl";
|
||||
|
||||
export const generateUploadUrlV2InputSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
fileName: z.string(),
|
||||
fileType: z.string().optional(),
|
||||
fileSize: z.number().optional(),
|
||||
});
|
||||
|
||||
export const handleGenerateUploadUrlV2 = async ({
|
||||
input: { sessionId, fileType, fileSize },
|
||||
context,
|
||||
}: {
|
||||
input: z.infer<typeof generateUploadUrlV2InputSchema>;
|
||||
context?: { apiOrigin?: string };
|
||||
}) => {
|
||||
if (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY || !env.S3_SECRET_KEY)
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
||||
message:
|
||||
"S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY",
|
||||
});
|
||||
|
||||
const session = await getSession(sessionId);
|
||||
|
||||
if (!session?.state)
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Can't find session",
|
||||
});
|
||||
|
||||
const typebotId = session.state.typebotsQueue[0].typebot.id;
|
||||
|
||||
const isPreview = !session.state.typebotsQueue[0].resultId;
|
||||
|
||||
const typebot = session.state.typebotsQueue[0].resultId
|
||||
? await getAndParsePublicTypebot(session.state.typebotsQueue[0].typebot.id)
|
||||
: session.state.typebotsQueue[0].typebot;
|
||||
|
||||
if (!typebot?.version)
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Can't find typebot",
|
||||
});
|
||||
|
||||
if (session.state.currentBlockId === undefined)
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Can't find currentBlockId in session state",
|
||||
});
|
||||
|
||||
const { block } = getBlockById(
|
||||
session.state.currentBlockId,
|
||||
parseGroups(typebot.groups, {
|
||||
typebotVersion: typebot.version,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
!block ||
|
||||
(block.type !== InputBlockType.FILE &&
|
||||
(block.type !== InputBlockType.TEXT ||
|
||||
!block.options?.attachments?.isEnabled) &&
|
||||
(block.type !== InputBlockType.TEXT ||
|
||||
!block.options?.audioClip?.isEnabled))
|
||||
)
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Current block does not expect file upload",
|
||||
});
|
||||
|
||||
const resolvedFileType = resolveUploadFileType(fileType);
|
||||
|
||||
const { visibility, maxFileSize } = parseFileUploadParams(block);
|
||||
|
||||
if (maxFileSize && fileSize && fileSize > maxFileSize * 1024 * 1024)
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: `File size exceeds the ${maxFileSize}MB limit`,
|
||||
});
|
||||
|
||||
const resultId = session.state.typebotsQueue[0].resultId;
|
||||
const uploadFileName = createUploadFileName(resolvedFileType);
|
||||
|
||||
const filePath =
|
||||
"workspaceId" in typebot && typebot.workspaceId && resultId
|
||||
? `${visibility === "Private" ? "private" : "public"}/workspaces/${
|
||||
typebot.workspaceId
|
||||
}/typebots/${typebotId}/results/${resultId}/${uploadFileName}`
|
||||
: `public/tmp/${typebotId}/${uploadFileName}`;
|
||||
|
||||
const {
|
||||
presignedUrl,
|
||||
fileUrl: defaultFileUrl,
|
||||
formData,
|
||||
maxFileSize: maxFileSizeMB,
|
||||
} = generateSignedUploadProxyUrl({
|
||||
baseUrl: getUploadProxyBaseUrl(context?.apiOrigin),
|
||||
fileType: resolveStoredUploadFileType(resolvedFileType),
|
||||
contentDisposition: resolveUploadContentDisposition(resolvedFileType),
|
||||
filePath,
|
||||
maxFileSize,
|
||||
});
|
||||
|
||||
return {
|
||||
presignedUrl,
|
||||
formData,
|
||||
fileType: resolvedFileType,
|
||||
maxFileSize: maxFileSizeMB,
|
||||
fileUrl:
|
||||
visibility === "Private" && !isPreview
|
||||
? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${uploadFileName}`
|
||||
: defaultFileUrl,
|
||||
};
|
||||
};
|
||||
|
||||
const getAndParsePublicTypebot = async (typebotId: string) => {
|
||||
const publicTypebot = await prisma.publicTypebot.findFirst({
|
||||
where: {
|
||||
typebotId,
|
||||
},
|
||||
select: {
|
||||
version: true,
|
||||
groups: true,
|
||||
typebot: {
|
||||
select: {
|
||||
workspaceId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!publicTypebot) return null;
|
||||
|
||||
return { ...publicTypebot, workspaceId: publicTypebot.typebot.workspaceId };
|
||||
};
|
||||
|
||||
const parseFileUploadParams = (
|
||||
block: FileInputBlock | TextInputBlock,
|
||||
): { visibility: "Public" | "Private"; maxFileSize: number | undefined } => {
|
||||
if (block.type === InputBlockType.FILE) {
|
||||
return {
|
||||
visibility:
|
||||
block.options?.visibility === "Private" ? "Private" : "Public",
|
||||
maxFileSize:
|
||||
block.options && "sizeLimit" in block.options
|
||||
? block.options.sizeLimit
|
||||
: env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
visibility:
|
||||
block.options?.attachments?.visibility === "Private"
|
||||
? "Private"
|
||||
: "Public",
|
||||
maxFileSize: env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
import { InputBlockType } from "@typebot.io/blocks-inputs/constants";
|
||||
|
||||
process.env.DATABASE_URL = "postgres://user:password@localhost:5432/typebot";
|
||||
process.env.ENCRYPTION_SECRET = "12345678901234567890123456789012";
|
||||
process.env.NEXTAUTH_URL = "http://localhost:3000";
|
||||
process.env.NEXT_PUBLIC_VIEWER_URL = "http://localhost:3001";
|
||||
process.env.S3_ACCESS_KEY = "minio";
|
||||
process.env.S3_BUCKET = "typebot";
|
||||
process.env.S3_ENDPOINT = "s3.example.com";
|
||||
process.env.S3_SECRET_KEY = "miniostorage";
|
||||
process.env.SKIP_ENV_CHECK = "false";
|
||||
|
||||
const getSessionMock = mock();
|
||||
const publicTypebotFindFirstMock = mock();
|
||||
|
||||
mock.module("@typebot.io/chat-session/queries/getSession", () => ({
|
||||
getSession: getSessionMock,
|
||||
}));
|
||||
|
||||
mock.module("@typebot.io/prisma", () => ({
|
||||
default: {
|
||||
publicTypebot: {
|
||||
findFirst: publicTypebotFindFirstMock,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { handleGenerateUploadUrl } = await import("./handleGenerateUploadUrl");
|
||||
|
||||
const blockIdFromSession = "block-session";
|
||||
const resultIdFromSession = "result-session";
|
||||
const sessionId = "session-id";
|
||||
const typebotIdFromSession = "typebot-session";
|
||||
const workspaceIdFromPublicTypebot = "workspace-session";
|
||||
|
||||
describe("handleGenerateUploadUrl", () => {
|
||||
beforeEach(() => {
|
||||
getSessionMock.mockReset();
|
||||
publicTypebotFindFirstMock.mockReset();
|
||||
getSessionMock.mockResolvedValue(buildSession());
|
||||
publicTypebotFindFirstMock.mockResolvedValue(buildPublicTypebot());
|
||||
});
|
||||
|
||||
it("uses the session block for current uploads", async () => {
|
||||
const response = await handleGenerateUploadUrl({
|
||||
input: {
|
||||
sessionId,
|
||||
blockId: "block-request",
|
||||
fileName: "file.png",
|
||||
fileType: "image/png",
|
||||
fileSize: 1024,
|
||||
},
|
||||
context: { apiOrigin: "http://localhost:3001" },
|
||||
});
|
||||
|
||||
const filePath = parseSignedUploadFilePath(response.presignedUrl);
|
||||
|
||||
expect(filePath.startsWith(expectedCurrentResultPrefix())).toBe(true);
|
||||
expect(filePath.includes("block-request")).toBe(false);
|
||||
expect(filePath.endsWith(".png")).toBe(true);
|
||||
expect(response.fileUrl.startsWith(expectedCurrentPrivateFileUrl())).toBe(
|
||||
true,
|
||||
);
|
||||
expect(response.maxFileSize).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
const buildSession = () => ({
|
||||
state: {
|
||||
currentBlockId: blockIdFromSession,
|
||||
typebotsQueue: [
|
||||
{
|
||||
resultId: resultIdFromSession,
|
||||
typebot: {
|
||||
id: typebotIdFromSession,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const buildPublicTypebot = () => ({
|
||||
version: "5",
|
||||
groups: [
|
||||
{
|
||||
id: "group",
|
||||
title: "Group",
|
||||
graphCoordinates: { x: 0, y: 0 },
|
||||
blocks: [
|
||||
{
|
||||
id: blockIdFromSession,
|
||||
type: InputBlockType.FILE,
|
||||
options: {
|
||||
visibility: "Private",
|
||||
sizeLimit: 5,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
typebot: {
|
||||
workspaceId: workspaceIdFromPublicTypebot,
|
||||
},
|
||||
});
|
||||
|
||||
const parseSignedUploadFilePath = (presignedUrl: string) => {
|
||||
const token = new URL(presignedUrl).pathname.split("/").at(-1);
|
||||
if (!token) throw new Error("Missing upload token");
|
||||
|
||||
const encodedPayload = decodeURIComponent(token).split(".")[0];
|
||||
if (!encodedPayload) throw new Error("Missing upload payload");
|
||||
|
||||
const payload: unknown = JSON.parse(
|
||||
Buffer.from(encodedPayload, "base64url").toString("utf8"),
|
||||
);
|
||||
|
||||
if (
|
||||
typeof payload !== "object" ||
|
||||
payload === null ||
|
||||
!("filePath" in payload) ||
|
||||
typeof payload.filePath !== "string"
|
||||
)
|
||||
throw new Error("Invalid upload payload");
|
||||
|
||||
return payload.filePath;
|
||||
};
|
||||
|
||||
const expectedResultPrefix = () =>
|
||||
`private/workspaces/${workspaceIdFromPublicTypebot}/typebots/${typebotIdFromSession}/results/${resultIdFromSession}/`;
|
||||
|
||||
const expectedCurrentResultPrefix = () =>
|
||||
`${expectedResultPrefix()}blocks/${blockIdFromSession}/`;
|
||||
|
||||
const expectedPrivateFileUrl = () =>
|
||||
`${process.env.NEXTAUTH_URL}/api/typebots/${typebotIdFromSession}/results/${resultIdFromSession}/`;
|
||||
|
||||
const expectedCurrentPrivateFileUrl = () =>
|
||||
`${expectedPrivateFileUrl()}blocks/${blockIdFromSession}/`;
|
||||
@ -9,6 +9,7 @@ import { parseGroups } from "@typebot.io/groups/helpers/parseGroups";
|
||||
import { parseAllowedFileTypesMetadata } from "@typebot.io/lib/extensionFromMimeType";
|
||||
import {
|
||||
createUploadFileName,
|
||||
parseUploadPathSegment,
|
||||
resolveStoredUploadFileType,
|
||||
resolveUploadContentDisposition,
|
||||
resolveUploadFileType,
|
||||
@ -116,13 +117,17 @@ export const handleGenerateUploadUrl = async ({
|
||||
|
||||
const resultId = session.state.typebotsQueue[0].resultId;
|
||||
const uploadFileName = createUploadFileName(resolvedFileType);
|
||||
const typebotPathSegment = parseUploadPathSegment(typebotId);
|
||||
const blockPathSegment = parseUploadPathSegment(currentBlockId);
|
||||
|
||||
const filePath =
|
||||
"workspaceId" in typebot && typebot.workspaceId && resultId
|
||||
? `${visibility === "Private" ? "private" : "public"}/workspaces/${
|
||||
typebot.workspaceId
|
||||
}/typebots/${typebotId}/results/${resultId}/blocks/${currentBlockId}/${uploadFileName}`
|
||||
: `public/tmp/typebots/${typebotId}/blocks/${currentBlockId}/${uploadFileName}`;
|
||||
? `${visibility === "Private" ? "private" : "public"}/workspaces/${parseUploadPathSegment(
|
||||
typebot.workspaceId,
|
||||
)}/typebots/${typebotPathSegment}/results/${parseUploadPathSegment(
|
||||
resultId,
|
||||
)}/blocks/${blockPathSegment}/${uploadFileName}`
|
||||
: `public/tmp/typebots/${typebotPathSegment}/blocks/${blockPathSegment}/${uploadFileName}`;
|
||||
|
||||
const {
|
||||
presignedUrl,
|
||||
|
||||
@ -4,14 +4,6 @@ import {
|
||||
} from "@typebot.io/config/orpc/builder/middlewares";
|
||||
import { publicProcedure } from "@typebot.io/config/orpc/viewer/middlewares";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
generateUploadUrlV1InputSchema,
|
||||
handleGenerateUploadUrlV1,
|
||||
} from "./deprecated/handleGenerateUploadUrlV1";
|
||||
import {
|
||||
generateUploadUrlV2InputSchema,
|
||||
handleGenerateUploadUrlV2,
|
||||
} from "./deprecated/handleGenerateUploadUrlV2";
|
||||
import {
|
||||
generateUploadUrlInputSchema,
|
||||
handleGenerateUploadUrl,
|
||||
@ -79,44 +71,4 @@ export const fileUploadViewerRouter = {
|
||||
}),
|
||||
)
|
||||
.handler(handleGenerateUploadUrl),
|
||||
generateUploadUrlV1Procedure: publicProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/generate-upload-url",
|
||||
summary: "Generate upload URL",
|
||||
description: "Used to upload anything from the client to S3 bucket",
|
||||
deprecated: true,
|
||||
tags: ["File upload"],
|
||||
})
|
||||
.input(generateUploadUrlV1InputSchema)
|
||||
.output(
|
||||
z.object({
|
||||
presignedUrl: z.string(),
|
||||
formData: z.record(z.string(), z.string()),
|
||||
fileType: z.string().optional(),
|
||||
maxFileSize: z.number().optional(),
|
||||
fileUrl: z.string(),
|
||||
}),
|
||||
)
|
||||
.handler(handleGenerateUploadUrlV1),
|
||||
generateUploadUrlV2Procedure: publicProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v2/generate-upload-url",
|
||||
summary: "Generate upload URL",
|
||||
description: "Used to upload anything from the client to S3 bucket",
|
||||
deprecated: true,
|
||||
tags: ["File upload"],
|
||||
})
|
||||
.input(generateUploadUrlV2InputSchema)
|
||||
.output(
|
||||
z.object({
|
||||
presignedUrl: z.string(),
|
||||
formData: z.record(z.string(), z.string()),
|
||||
fileType: z.string().optional(),
|
||||
maxFileSize: z.number().optional(),
|
||||
fileUrl: z.string(),
|
||||
}),
|
||||
)
|
||||
.handler(handleGenerateUploadUrlV2),
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user