diff --git a/apps/web/modules/storage/file-upload.ts b/apps/web/modules/storage/file-upload.ts index 11118f453e..b324db1fe0 100644 --- a/apps/web/modules/storage/file-upload.ts +++ b/apps/web/modules/storage/file-upload.ts @@ -60,11 +60,15 @@ export const handleFileUpload = async ( if (!response.ok) { if (response.status === 400) { - return { - error: FileUploadError.INVALID_FILE_NAME, - url: "", - }; + const json = (await response.json()) as { details?: { fileName?: string } }; + if (json.details?.fileName) { + return { + error: FileUploadError.INVALID_FILE_NAME, + url: "", + }; + } } + return { error: FileUploadError.UPLOAD_FAILED, url: "", diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 972c7c94cd..362603ecd5 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -115,7 +115,7 @@ const nextConfig = { async headers() { const isProduction = process.env.NODE_ENV === "production"; const scriptSrcUnsafeEval = isProduction ? "" : " 'unsafe-eval'"; - const imgSrcLocal = isProduction ? "" : " http://localhost:*"; + const imgSrcLocal = isProduction ? "" : " http://localhost:*"; // NOSONAR - We want to allow local images in development return [ { diff --git a/packages/js-core/src/lib/common/file-upload.ts b/packages/js-core/src/lib/common/file-upload.ts deleted file mode 100644 index a4fb21d703..0000000000 --- a/packages/js-core/src/lib/common/file-upload.ts +++ /dev/null @@ -1,127 +0,0 @@ -/* eslint-disable no-console -- used for error logging */ -import { type TUploadFileConfig, type TUploadFileResponse } from "@/types/storage"; - -export class StorageAPI { - private appUrl: string; - private environmentId: string; - - constructor(appUrl: string, environmentId: string) { - this.appUrl = appUrl; - this.environmentId = environmentId; - } - - async uploadFile( - file: { - type: string; - name: string; - base64: string; - }, - { allowedFileExtensions, surveyId }: TUploadFileConfig | undefined = {} - ): Promise { - if (!file.name || !file.type || !file.base64) { - throw new Error(`Invalid file object`); - } - - const payload = { - fileName: file.name, - fileType: file.type, - allowedFileExtensions, - surveyId, - }; - - const response = await fetch(`${this.appUrl}/api/v1/client/${this.environmentId}/storage`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error(`Upload failed with status: ${String(response.status)}`); - } - - const json = (await response.json()) as TUploadFileResponse; - - const { data } = json; - - const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data; - - let localUploadDetails: Record = {}; - - if (signingData) { - const { signature, timestamp, uuid } = signingData; - - localUploadDetails = { - fileType: file.type, - fileName: encodeURIComponent(updatedFileName), - surveyId: surveyId ?? "", - signature, - timestamp: String(timestamp), - uuid, - }; - } - - const formData: Record = {}; - const formDataForS3 = new FormData(); - - if (presignedFields) { - Object.keys(presignedFields).forEach((key) => { - formDataForS3.append(key, presignedFields[key]); - }); - - try { - const buffer = Buffer.from(file.base64.split(",")[1], "base64"); - const blob = new Blob([buffer], { type: file.type }); - - formDataForS3.append("file", blob); - } catch (buffErr) { - console.error({ buffErr }); - - throw new Error("Error uploading file"); - } - } - - formData.fileBase64String = file.base64; - - let uploadResponse: Response = {} as Response; - - const signedUrlCopy = signedUrl.replace("http://localhost:3000", this.appUrl); - - try { - uploadResponse = await fetch(signedUrlCopy, { - method: "POST", - body: presignedFields - ? formDataForS3 - : JSON.stringify({ - ...formData, - ...localUploadDetails, - }), - }); - } catch (err) { - console.error("Error uploading file", err); - } - - if (!uploadResponse.ok) { - // if local storage is used, we'll use the json response: - if (signingData) { - const uploadJson = (await uploadResponse.json()) as { message: string }; - const error = new Error(uploadJson.message); - error.name = "FileTooLargeError"; - throw error; - } - - // if s3 is used, we'll use the text response: - const errorText = await uploadResponse.text(); - if (presignedFields && errorText.includes("EntityTooLarge")) { - const error = new Error("File size exceeds the size limit for your plan"); - error.name = "FileTooLargeError"; - throw error; - } - - throw new Error(`Upload failed with status: ${String(uploadResponse.status)}`); - } - - return fileUrl; - } -} diff --git a/packages/js-core/src/lib/common/tests/file-upload.test.ts b/packages/js-core/src/lib/common/tests/file-upload.test.ts deleted file mode 100644 index 4000c69971..0000000000 --- a/packages/js-core/src/lib/common/tests/file-upload.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -// file-upload.test.ts -import { beforeEach, describe, expect, test, vi } from "vitest"; -import { StorageAPI } from "@/lib/common/file-upload"; -import type { TUploadFileConfig } from "@/types/storage"; - -// A global fetch mock so we can capture fetch calls. -// Alternatively, use `vi.stubGlobal("fetch", ...)`. -const fetchMock = vi.fn(); -global.fetch = fetchMock; - -const mockEnvironmentId = "dv46cywjt1fxkkempq7vwued"; - -describe("StorageAPI", () => { - const APP_URL = "https://myapp.example"; - const ENV_ID = mockEnvironmentId; - - let storage: StorageAPI; - - beforeEach(() => { - vi.clearAllMocks(); - storage = new StorageAPI(APP_URL, ENV_ID); - }); - - test("throws an error if file object is invalid", async () => { - // File missing "name", "type", or "base64" - await expect(storage.uploadFile({ type: "", name: "", base64: "" }, {})).rejects.toThrow( - "Invalid file object" - ); - }); - - test("throws if first fetch (storage route) returns non-OK", async () => { - // We provide a valid file object - const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" }; - - // First fetch returns not ok - fetchMock.mockResolvedValueOnce({ - ok: false, - status: 400, - } as Response); - - await expect(storage.uploadFile(file)).rejects.toThrow("Upload failed with status: 400"); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - `${APP_URL}/api/v1/client/${ENV_ID}/storage`, - expect.objectContaining({ - method: "POST", - }) - ); - }); - - test("throws if second fetch returns non-OK (local storage w/ signingData)", async () => { - // Suppose the first fetch is OK and returns JSON with signingData - const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" }; - fetchMock - .mockResolvedValueOnce({ - ok: true, - json: async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - return { - data: { - signedUrl: "https://myapp.example/uploadLocal", - fileUrl: "https://myapp.example/files/test.png", - signingData: { signature: "xxx", timestamp: 1234, uuid: "abc" }, - presignedFields: null, - updatedFileName: "test.png", - }, - }; - }, - } as Response) - // second fetch fails - .mockResolvedValueOnce({ - ok: false, - json: async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - return { message: "File size exceeded your plan limit" }; - }, - } as Response); - - await expect(storage.uploadFile(file)).rejects.toThrow("File size exceeded your plan limit"); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - test("throws if second fetch returns non-OK (S3) containing 'EntityTooLarge'", async () => { - const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" }; - - // First fetch response includes presignedFields => indicates S3 scenario - fetchMock - .mockResolvedValueOnce({ - ok: true, - json: async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - return { - data: { - signedUrl: "https://some-s3-bucket/presigned", - fileUrl: "https://some-s3-bucket/test.png", - signingData: null, // means not local - presignedFields: { - key: "some-key", - policy: "base64policy", - }, - updatedFileName: "test.png", - }, - }; - }, - } as Response) - // second fetch fails with "EntityTooLarge" - .mockResolvedValueOnce({ - ok: false, - text: async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - return "Some error with EntityTooLarge text in it"; - }, - } as Response); - - await expect(storage.uploadFile(file)).rejects.toThrow("File size exceeds the size limit for your plan"); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - test("successful upload returns fileUrl", async () => { - const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" }; - const mockFileUrl = "https://myapp.example/files/test.png"; - - // First fetch => OK, returns JSON with 'signedUrl', 'fileUrl', etc. - fetchMock - .mockResolvedValueOnce({ - ok: true, - json: async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - return { - data: { - signedUrl: "https://myapp.example/uploadLocal", - fileUrl: mockFileUrl, - signingData: { - signature: "xxx", - timestamp: 1234, - uuid: "abc", - }, - presignedFields: null, - updatedFileName: "test.png", - }, - }; - }, - } as Response) - // second fetch => also OK - .mockResolvedValueOnce({ - ok: true, - } as Response); - - const url = await storage.uploadFile(file, { - allowedFileExtensions: [".png", ".jpg"], - surveyId: "survey_123", - } as TUploadFileConfig); - - expect(url).toBe(mockFileUrl); - expect(fetchMock).toHaveBeenCalledTimes(2); - - // We can also check the first fetch request body - const firstCall = fetchMock.mock.calls[0]; - expect(firstCall[0]).toBe(`${APP_URL}/api/v1/client/${ENV_ID}/storage`); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- we know it's a string - const bodyPayload = JSON.parse(firstCall[1].body as string); - - expect(bodyPayload).toMatchObject({ - fileName: "test.png", - fileType: "image/png", - allowedFileExtensions: [".png", ".jpg"], - surveyId: "survey_123", - }); - }); -}); diff --git a/packages/surveys/src/components/general/survey.tsx b/packages/surveys/src/components/general/survey.tsx index a73d907af8..f7b4dca4f1 100644 --- a/packages/surveys/src/components/general/survey.tsx +++ b/packages/surveys/src/components/general/survey.tsx @@ -48,7 +48,6 @@ export function Survey({ isBrandingEnabled, onDisplay, onResponse, - onFileUpload, onClose, onFinished, onRetry, @@ -198,7 +197,7 @@ export function Survey({ return localSurvey.showLanguageSwitch && localSurvey.languages.length > 0 && offset <= 0; }; - const onFileUploadApi = async (file: TJsFileUploadParams["file"], params?: TUploadFileConfig) => { + const onFileUpload = async (file: TJsFileUploadParams["file"], params?: TUploadFileConfig) => { if (isPreviewMode) { // return mock url since an url is required for the preview return `https://example.com/${file.name}`; @@ -720,7 +719,7 @@ export function Survey({ onBack={onBack} ttc={ttc} setTtc={setTtc} - onFileUpload={onFileUpload ?? onFileUploadApi} + onFileUpload={onFileUpload} isFirstQuestion={question.id === localSurvey.questions[0]?.id} skipPrefilled={skipPrefilled} prefilledQuestionValue={getQuestionPrefillData(question.id, offset)} diff --git a/packages/types/formbricks-surveys.ts b/packages/types/formbricks-surveys.ts index 2dc06c81d2..ba954d53c9 100644 --- a/packages/types/formbricks-surveys.ts +++ b/packages/types/formbricks-surveys.ts @@ -50,7 +50,6 @@ export interface SurveyContainerProps extends Omit void | Promise; onResponseCreated?: () => void | Promise; - onFileUpload?: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise; onOpenExternalURL?: (url: string) => void | Promise; mode?: "modal" | "inline"; containerId?: string;