diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.test.tsx index 90b59b44ee..eb2ccd83f8 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.test.tsx @@ -1,6 +1,7 @@ import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable"; import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { handleFileUpload } from "@/modules/storage/file-upload"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import toast from "react-hot-toast"; @@ -158,6 +159,11 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", ( getResponsesDownloadUrlAction: vi.fn(), })); +// Mock handleFileUpload +vi.mock("@/modules/storage/file-upload", () => ({ + handleFileUpload: vi.fn(), +})); + vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({ deleteResponseAction: vi.fn(), })); @@ -192,6 +198,9 @@ vi.mock("@tolgee/react", () => ({ }), })); +// Global mock anchor for tests +let globalMockAnchor: any; + // Define mock data for tests const mockProps = { data: [ @@ -232,23 +241,78 @@ beforeEach(() => { // Reset all toast mocks before each test vi.mocked(toast.error).mockClear(); vi.mocked(toast.success).mockClear(); + vi.mocked(getResponsesDownloadUrlAction).mockClear(); + vi.mocked(handleFileUpload).mockClear(); // Create a mock anchor element for download tests - const mockAnchor = { + globalMockAnchor = { href: "", click: vi.fn(), style: {}, + download: "", }; + // Override the href setter to capture when it's set + Object.defineProperty(globalMockAnchor, "href", { + get() { + return this._href || ""; + }, + set(value) { + this._href = value; + }, + }); + // Update how we mock the document methods to avoid infinite recursion const originalCreateElement = document.createElement.bind(document); vi.spyOn(document, "createElement").mockImplementation((tagName) => { - if (tagName === "a") return mockAnchor as any; + if (tagName === "a") return globalMockAnchor as any; return originalCreateElement(tagName); }); vi.spyOn(document.body, "appendChild").mockReturnValue(null as any); vi.spyOn(document.body, "removeChild").mockReturnValue(null as any); + + // Mock File constructor to avoid arrayBuffer issues + global.File = class MockFile { + name: string; + type: string; + size: number; + + constructor(chunks: any[], name: string, options: any = {}) { + this.name = name; + this.type = options.type || ""; + this.size = options.size || 0; + } + + arrayBuffer() { + return Promise.resolve(new ArrayBuffer(0)); + } + } as any; + + // Mock atob for base64 decoding + global.atob = vi.fn((str: string) => "decoded binary string"); + + // Mock Uint8Array and Blob + global.Uint8Array = class MockUint8Array extends Array { + constructor(data: any) { + super(); + this.length = typeof data === "number" ? data : 0; + } + + static from(source: any) { + return new MockUint8Array(source.length || 0); + } + } as any; + + global.Blob = class MockBlob { + size: number; + type: string; + + constructor(parts: any[], options: any = {}) { + this.size = 0; + this.type = options.type || ""; + } + } as any; }); // Cleanup after each test @@ -313,7 +377,14 @@ describe("ResponseTable", () => { test("calls downloadSelectedRows with csv format when toolbar button is clicked", async () => { vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({ - data: "https://download.url/file.csv", + data: { + fileContents: "mock,csv,content", + fileName: "survey-responses.csv", + }, + }); + + vi.mocked(handleFileUpload).mockResolvedValueOnce({ + url: "https://download.url/file.csv", }); const container = document.getElementById("test-container"); @@ -321,24 +392,34 @@ describe("ResponseTable", () => { const downloadCsvButton = screen.getByTestId("download-csv"); await userEvent.click(downloadCsvButton); - expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({ - surveyId: "survey1", - format: "csv", - filterCriteria: { responseIds: [] }, - }); + await waitFor(() => { + expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({ + surveyId: "survey1", + format: "csv", + filterCriteria: { responseIds: [] }, + }); - // Check if link was created and clicked - expect(document.createElement).toHaveBeenCalledWith("a"); - const mockLink = document.createElement("a"); - expect(mockLink.href).toBe("https://download.url/file.csv"); - expect(document.body.appendChild).toHaveBeenCalled(); - expect(mockLink.click).toHaveBeenCalled(); - expect(document.body.removeChild).toHaveBeenCalled(); + expect(handleFileUpload).toHaveBeenCalled(); + + // Check if link was created and clicked + expect(document.createElement).toHaveBeenCalledWith("a"); + expect(globalMockAnchor.href).toBe("https://download.url/file.csv"); + expect(document.body.appendChild).toHaveBeenCalled(); + expect(globalMockAnchor.click).toHaveBeenCalled(); + expect(document.body.removeChild).toHaveBeenCalled(); + }); }); test("calls downloadSelectedRows with xlsx format when toolbar button is clicked", async () => { vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({ - data: "https://download.url/file.xlsx", + data: { + fileContents: "bW9jayB4bHN4IGNvbnRlbnQ=", // base64 encoded mock data + fileName: "survey-responses.xlsx", + }, + }); + + vi.mocked(handleFileUpload).mockResolvedValueOnce({ + url: "https://download.url/file.xlsx", }); const container = document.getElementById("test-container"); @@ -346,19 +427,22 @@ describe("ResponseTable", () => { const downloadXlsxButton = screen.getByTestId("download-xlsx"); await userEvent.click(downloadXlsxButton); - expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({ - surveyId: "survey1", - format: "xlsx", - filterCriteria: { responseIds: [] }, - }); + await waitFor(() => { + expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({ + surveyId: "survey1", + format: "xlsx", + filterCriteria: { responseIds: [] }, + }); - // Check if link was created and clicked - expect(document.createElement).toHaveBeenCalledWith("a"); - const mockLink = document.createElement("a"); - expect(mockLink.href).toBe("https://download.url/file.xlsx"); - expect(document.body.appendChild).toHaveBeenCalled(); - expect(mockLink.click).toHaveBeenCalled(); - expect(document.body.removeChild).toHaveBeenCalled(); + expect(handleFileUpload).toHaveBeenCalled(); + + // Check if link was created and clicked + expect(document.createElement).toHaveBeenCalledWith("a"); + expect(globalMockAnchor.href).toBe("https://download.url/file.xlsx"); + expect(document.body.appendChild).toHaveBeenCalled(); + expect(globalMockAnchor.click).toHaveBeenCalled(); + expect(document.body.removeChild).toHaveBeenCalled(); + }); }); // Test response modal diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx index d901966395..3245a9564a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx @@ -5,6 +5,7 @@ import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surv import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns"; import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions"; +import { handleFileUpload } from "@/modules/storage/file-upload"; import { Button } from "@/modules/ui/components/button"; import { DataTableHeader, @@ -192,9 +193,34 @@ export const ResponseTable = ({ }); if (downloadResponse?.data) { + let file: File; + + if (format === "xlsx") { + // Convert base64 back to binary data for XLSX files + const binaryString = atob(downloadResponse.data.fileContents); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + file = new File([bytes], downloadResponse.data.fileName, { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + } else { + // For CSV files, use the string directly + file = new File([downloadResponse.data.fileContents], downloadResponse.data.fileName, { + type: "text/csv", + }); + } + + const { url, error } = await handleFileUpload(file, environment.id); + + if (error) { + toast.error(t("environments.surveys.responses.error_downloading_responses")); + } + const link = document.createElement("a"); - link.href = downloadResponse.data; - link.download = ""; + link.href = url; + link.download = downloadResponse.data.fileName || `${survey.name}-${format}.csv`; document.body.appendChild(link); link.click(); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts index 00f213a609..cf305da2ae 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts @@ -1,8 +1,6 @@ "use server"; import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate"; -import { WEBAPP_URL } from "@/lib/constants"; -import { putFile } from "@/lib/storage/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; @@ -218,16 +216,9 @@ export const generatePersonalLinksAction = authenticatedActionClient const csvContent = await convertToCsv(csvHeaders, csvData); const fileName = `personal-links-${parsedInput.surveyId}-${Date.now()}.csv`; - // Store file temporarily and return download URL - const fileBuffer = Buffer.from(csvContent); - await putFile(fileName, fileBuffer, "private", parsedInput.environmentId); - - const downloadUrl = `${WEBAPP_URL}/storage/${parsedInput.environmentId}/private/${fileName}`; - return { - downloadUrl, fileName, - count: csvData.length, + csvContent, }; }); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.test.tsx index af062231ae..b640e5e8b2 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.test.tsx @@ -19,7 +19,7 @@ vi.mock("./QuestionSummaryHeader", () => ({ })); // Mock utility functions -vi.mock("@/lib/storage/utils", () => ({ +vi.mock("@/modules/storage/utils", () => ({ getOriginalFileNameFromUrl: (url: string) => `original-${url.split("/").pop()}`, })); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx index 39cb0ed6ec..5e167a9848 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx @@ -1,8 +1,8 @@ "use client"; -import { getOriginalFileNameFromUrl } from "@/lib/storage/utils"; import { timeSince } from "@/lib/time"; import { getContactIdentifier } from "@/lib/utils/contact"; +import { getOriginalFileNameFromUrl } from "@/modules/storage/utils"; import { PersonAvatar } from "@/modules/ui/components/avatars"; import { Button } from "@/modules/ui/components/button"; import { useTranslate } from "@tolgee/react"; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx index 123f351729..741f3bba93 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx @@ -2,6 +2,7 @@ import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { handleFileUpload } from "@/modules/storage/file-upload"; import { Button } from "@/modules/ui/components/button"; import { DatePicker } from "@/modules/ui/components/date-picker"; import { @@ -122,7 +123,18 @@ export const PersonalLinksTab = ({ }); if (result?.data) { - downloadFile(result.data.downloadUrl, result.data.fileName || "personal-links.csv"); + const file = new File([result.data.csvContent], result.data.fileName || "personal-links.csv", { + type: "text/csv", + }); + + const { url, error } = await handleFileUpload(file, environmentId, ["csv"]); + + if (error) { + toast.error(t("environments.surveys.share.personal_links.error_generating_links")); + return; + } + + downloadFile(url, result.data.fileName || "personal-links.csv"); toast.success(t("environments.surveys.share.personal_links.links_generated_success_toast"), { duration: 5000, id: "generating-links", @@ -134,6 +146,7 @@ export const PersonalLinksTab = ({ id: "generating-links", }); } + setIsGenerating(false); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts index 19835b4ebf..7c672b00d3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts @@ -1,7 +1,7 @@ "use server"; import { getOrganization } from "@/lib/organization/service"; -import { getResponseDownloadUrl, getResponseFilteringValues } from "@/lib/response/service"; +import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; @@ -43,7 +43,11 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient ], }); - return getResponseDownloadUrl(parsedInput.surveyId, parsedInput.format, parsedInput.filterCriteria); + return await getResponseDownloadFile( + parsedInput.surveyId, + parsedInput.format, + parsedInput.filterCriteria + ); }); const ZGetSurveyFilterDataAction = z.object({ diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx index 9d8acc8c74..7bfc309f7f 100755 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx @@ -8,6 +8,7 @@ import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environ import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { useClickOutside } from "@/lib/utils/hooks/useClickOutside"; +import { handleFileUpload } from "@/modules/storage/file-upload"; import { Calendar } from "@/modules/ui/components/calendar"; import { DropdownMenu, @@ -249,9 +250,39 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => { }); if (responsesDownloadUrlResponse?.data) { + let file: File; + + if (filetype === "xlsx") { + // Convert base64 back to binary data for XLSX files + const binaryString = atob(responsesDownloadUrlResponse.data.fileContents); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + file = new File([bytes], responsesDownloadUrlResponse.data.fileName, { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + } else { + // For CSV files, use the string directly + file = new File( + [responsesDownloadUrlResponse.data.fileContents], + responsesDownloadUrlResponse.data.fileName, + { + type: "text/csv", + } + ); + } + + const { url, error } = await handleFileUpload(file, survey.environmentId); + + if (error) { + toast.error(t("environments.surveys.responses.error_downloading_responses")); + } + const link = document.createElement("a"); - link.href = responsesDownloadUrlResponse.data; - link.download = ""; + link.href = url; + link.download = responsesDownloadUrlResponse.data.fileName || `${survey.name}-${filetype}.csv`; + document.body.appendChild(link); link.click(); document.body.removeChild(link); diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts index 03ccaecb89..11905873c0 100644 --- a/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts @@ -2,10 +2,10 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { sendToPipeline } from "@/app/lib/pipelines"; -import { validateFileUploads } from "@/lib/fileValidation"; import { getResponse, updateResponse } from "@/lib/response/service"; import { getSurvey } from "@/lib/survey/service"; import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question"; +import { validateFileUploads } from "@/modules/storage/utils"; import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/route.ts b/apps/web/app/api/v1/client/[environmentId]/responses/route.ts index 77e5de1875..d7d2ff2261 100644 --- a/apps/web/app/api/v1/client/[environmentId]/responses/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/responses/route.ts @@ -2,10 +2,10 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { sendToPipeline } from "@/app/lib/pipelines"; -import { validateFileUploads } from "@/lib/fileValidation"; import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer"; import { getSurvey } from "@/lib/survey/service"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { validateFileUploads } from "@/modules/storage/utils"; import { headers } from "next/headers"; import { NextRequest } from "next/server"; import { UAParser } from "ua-parser-js"; diff --git a/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.test.ts b/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.test.ts deleted file mode 100644 index cad4f776af..0000000000 --- a/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { getUploadSignedUrl } from "@/lib/storage/service"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import { uploadPrivateFile } from "./uploadPrivateFile"; - -vi.mock("@/lib/storage/service", () => ({ - getUploadSignedUrl: vi.fn(), -})); - -describe("uploadPrivateFile", () => { - afterEach(() => { - vi.resetAllMocks(); - }); - - test("should return a success response with signed URL details when getUploadSignedUrl successfully generates a signed URL", async () => { - const mockSignedUrlResponse = { - signedUrl: "mocked-signed-url", - presignedFields: { field1: "value1" }, - fileUrl: "mocked-file-url", - }; - - vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse); - - const fileName = "test-file.txt"; - const environmentId = "test-env-id"; - const fileType = "text/plain"; - - const result = await uploadPrivateFile(fileName, environmentId, fileType); - const resultData = await result.json(); - - expect(getUploadSignedUrl).toHaveBeenCalledWith(fileName, environmentId, fileType, "private", false); - - expect(resultData).toEqual({ - data: mockSignedUrlResponse, - }); - }); - - test("should return a success response when isBiggerFileUploadAllowed is true and getUploadSignedUrl successfully generates a signed URL", async () => { - const mockSignedUrlResponse = { - signedUrl: "mocked-signed-url", - presignedFields: { field1: "value1" }, - fileUrl: "mocked-file-url", - }; - - vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse); - - const fileName = "test-file.txt"; - const environmentId = "test-env-id"; - const fileType = "text/plain"; - const isBiggerFileUploadAllowed = true; - - const result = await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed); - const resultData = await result.json(); - - expect(getUploadSignedUrl).toHaveBeenCalledWith( - fileName, - environmentId, - fileType, - "private", - isBiggerFileUploadAllowed - ); - - expect(resultData).toEqual({ - data: mockSignedUrlResponse, - }); - }); - - test("should return an internal server error response when getUploadSignedUrl throws an error", async () => { - vi.mocked(getUploadSignedUrl).mockRejectedValue(new Error("S3 unavailable")); - - const fileName = "test-file.txt"; - const environmentId = "test-env-id"; - const fileType = "text/plain"; - - const result = await uploadPrivateFile(fileName, environmentId, fileType); - - expect(result.status).toBe(500); - const resultData = await result.json(); - expect(resultData).toEqual({ - code: "internal_server_error", - details: {}, - message: "Internal server error", - }); - }); - - test("should return an internal server error response when fileName has no extension", async () => { - vi.mocked(getUploadSignedUrl).mockRejectedValue(new Error("File extension not found")); - - const fileName = "test-file"; - const environmentId = "test-env-id"; - const fileType = "text/plain"; - - const result = await uploadPrivateFile(fileName, environmentId, fileType); - const resultData = await result.json(); - - expect(getUploadSignedUrl).toHaveBeenCalledWith(fileName, environmentId, fileType, "private", false); - expect(result.status).toBe(500); - expect(resultData).toEqual({ - code: "internal_server_error", - details: {}, - message: "Internal server error", - }); - }); -}); diff --git a/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.ts b/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.ts deleted file mode 100644 index 0db11e8932..0000000000 --- a/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { responses } from "@/app/lib/api/response"; -import { getUploadSignedUrl } from "@/lib/storage/service"; - -export const uploadPrivateFile = async ( - fileName: string, - environmentId: string, - fileType: string, - isBiggerFileUploadAllowed: boolean = false -) => { - const accessType = "private"; // private files are only accessible by the user who has access to the environment - // if s3 is not configured, we'll upload to a local folder named uploads - - try { - const signedUrlResponse = await getUploadSignedUrl( - fileName, - environmentId, - fileType, - accessType, - isBiggerFileUploadAllowed - ); - - return responses.successResponse({ - ...signedUrlResponse, - }); - } catch (err) { - return responses.internalServerErrorResponse("Internal server error"); - } -}; diff --git a/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts b/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts deleted file mode 100644 index ab4499185e..0000000000 --- a/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts +++ /dev/null @@ -1,178 +0,0 @@ -// headers -> "Content-Type" should be present and set to a valid MIME type -// body -> should be a valid file object (buffer) -// method -> PUT (to be the same as the signedUrl method) -import { responses } from "@/app/lib/api/response"; -import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; -import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants"; -import { validateLocalSignedUrl } from "@/lib/crypto"; -import { validateFile } from "@/lib/fileValidation"; -import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; -import { putFileToLocalStorage } from "@/lib/storage/service"; -import { getSurvey } from "@/lib/survey/service"; -import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils"; -import { NextRequest } from "next/server"; -import { logger } from "@formbricks/logger"; - -interface Context { - params: Promise<{ - environmentId: string; - }>; -} - -export const OPTIONS = async (): Promise => { - return responses.successResponse( - {}, - true, - // Cache CORS preflight responses for 1 hour (conservative approach) - // Balances performance gains with flexibility for CORS policy changes - "public, s-maxage=3600, max-age=3600" - ); -}; - -export const POST = withV1ApiWrapper({ - handler: async ({ req, props }: { req: NextRequest; props: Context }) => { - if (!ENCRYPTION_KEY) { - return { - response: responses.internalServerErrorResponse("Encryption key is not set"), - }; - } - const params = await props.params; - const environmentId = params.environmentId; - - const accessType = "private"; // private files are accessible only by authorized users - - const jsonInput = await req.json(); - const fileType = jsonInput.fileType as string; - const encodedFileName = jsonInput.fileName as string; - const surveyId = jsonInput.surveyId as string; - const signedSignature = jsonInput.signature as string; - const signedUuid = jsonInput.uuid as string; - const signedTimestamp = jsonInput.timestamp as string; - - if (!fileType) { - return { - response: responses.badRequestResponse("contentType is required"), - }; - } - - if (!encodedFileName) { - return { - response: responses.badRequestResponse("fileName is required"), - }; - } - - if (!surveyId) { - return { - response: responses.badRequestResponse("surveyId is required"), - }; - } - - if (!signedSignature) { - return { - response: responses.unauthorizedResponse(), - }; - } - - if (!signedUuid) { - return { - response: responses.unauthorizedResponse(), - }; - } - - if (!signedTimestamp) { - return { - response: responses.unauthorizedResponse(), - }; - } - - const [survey, organization] = await Promise.all([ - getSurvey(surveyId), - getOrganizationByEnvironmentId(environmentId), - ]); - - if (!survey) { - return { - response: responses.notFoundResponse("Survey", surveyId), - }; - } - - if (!organization) { - return { - response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId), - }; - } - - const fileName = decodeURIComponent(encodedFileName); - - // Perform server-side file validation again - // This is crucial as attackers could bypass the initial validation and directly call this endpoint - const fileValidation = validateFile(fileName, fileType); - if (!fileValidation.valid) { - return { - response: responses.badRequestResponse(fileValidation.error ?? "Invalid file", { - fileName, - fileType, - }), - }; - } - - // validate signature - const validated = validateLocalSignedUrl( - signedUuid, - fileName, - environmentId, - fileType, - Number(signedTimestamp), - signedSignature, - ENCRYPTION_KEY - ); - - if (!validated) { - return { - response: responses.unauthorizedResponse(), - }; - } - - const base64String = jsonInput.fileBase64String as string; - - const buffer = Buffer.from(base64String.split(",")[1], "base64"); - const file = new Blob([buffer], { type: fileType }); - - if (!file) { - return { - response: responses.badRequestResponse("fileBuffer is required"), - }; - } - - try { - const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan); - const bytes = await file.arrayBuffer(); - const fileBuffer = Buffer.from(bytes); - - await putFileToLocalStorage( - fileName, - fileBuffer, - accessType, - environmentId, - UPLOADS_DIR, - isBiggerFileUploadAllowed - ); - - return { - response: responses.successResponse({ - message: "File uploaded successfully", - }), - }; - } catch (err) { - logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload"); - if (err.name === "FileTooLargeError") { - return { - response: responses.badRequestResponse(err.message), - }; - } - return { - response: responses.internalServerErrorResponse("File upload failed"), - }; - } - }, -}); diff --git a/apps/web/app/api/v1/client/[environmentId]/storage/route.ts b/apps/web/app/api/v1/client/[environmentId]/storage/route.ts index 5718dee263..0470726eab 100644 --- a/apps/web/app/api/v1/client/[environmentId]/storage/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/storage/route.ts @@ -1,13 +1,14 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; -import { validateFile } from "@/lib/fileValidation"; +import { IS_FORMBRICKS_CLOUD, MAX_FILE_UPLOAD_SIZES } from "@/lib/constants"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getSurvey } from "@/lib/survey/service"; import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils"; +import { getSignedUrlForUpload } from "@/modules/storage/service"; import { NextRequest } from "next/server"; -import { ZUploadFileRequest } from "@formbricks/types/storage"; -import { uploadPrivateFile } from "./lib/uploadPrivateFile"; +import { logger } from "@formbricks/logger"; +import { TUploadPrivateFileRequest, ZUploadPrivateFileRequest } from "@formbricks/types/storage"; interface Context { params: Promise<{ @@ -25,46 +26,37 @@ export const OPTIONS = async (): Promise => { ); }; -// api endpoint for uploading private files +// api endpoint for getting a s3 signed url for uploading private files // uploaded files will be private, only the user who has access to the environment can access the file // uploading private files requires no authentication -// use this to let users upload files to a survey for example -// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage +// use this to let users upload files to a file upload question response for example export const POST = withV1ApiWrapper({ handler: async ({ req, props }: { req: NextRequest; props: Context }) => { const params = await props.params; - const environmentId = params.environmentId; + const { environmentId } = params; - const jsonInput = await req.json(); - const inputValidation = ZUploadFileRequest.safeParse({ + const jsonInput = (await req.json()) as Omit; + const parsedInputResult = ZUploadPrivateFileRequest.safeParse({ ...jsonInput, environmentId, }); - if (!inputValidation.success) { + if (!parsedInputResult.success) { + const errorDetails = transformErrorToDetails(parsedInputResult.error); + + logger.error({ error: errorDetails }, "Fields are missing or incorrectly formatted"); + return { response: responses.badRequestResponse( - "Invalid request", - transformErrorToDetails(inputValidation.error), + "Fields are missing or incorrectly formatted", + errorDetails, true ), }; } - const { fileName, fileType, surveyId } = inputValidation.data; - - // Perform server-side file validation - const fileValidation = validateFile(fileName, fileType); - if (!fileValidation.valid) { - return { - response: responses.badRequestResponse( - fileValidation.error ?? "Invalid file", - { fileName, fileType }, - true - ), - }; - } + const { fileName, fileType, surveyId } = parsedInputResult.data; const [survey, organization] = await Promise.all([ getSurvey(surveyId), @@ -83,10 +75,42 @@ export const POST = withV1ApiWrapper({ }; } + if (survey.environmentId !== environmentId) { + return { + response: responses.badRequestResponse( + "Survey does not belong to the environment", + { surveyId, environmentId }, + true + ), + }; + } + const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan); + const maxFileUploadSize = IS_FORMBRICKS_CLOUD + ? isBiggerFileUploadAllowed + ? MAX_FILE_UPLOAD_SIZES.big + : MAX_FILE_UPLOAD_SIZES.standard + : MAX_FILE_UPLOAD_SIZES.big; // 1GB + + const signedUrlResponse = await getSignedUrlForUpload( + fileName, + environmentId, + fileType, + "private", + maxFileUploadSize + ); + + if (!signedUrlResponse.ok) { + logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload"); + + return { + response: responses.internalServerErrorResponse("Internal server error"), + }; + } + return { - response: await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed), + response: responses.successResponse(signedUrlResponse.data), }; }, }); diff --git a/apps/web/app/api/v1/management/responses/[responseId]/route.ts b/apps/web/app/api/v1/management/responses/[responseId]/route.ts index f8960bfecf..71b1162f53 100644 --- a/apps/web/app/api/v1/management/responses/[responseId]/route.ts +++ b/apps/web/app/api/v1/management/responses/[responseId]/route.ts @@ -2,10 +2,10 @@ import { handleErrorResponse } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; -import { validateFileUploads } from "@/lib/fileValidation"; import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service"; import { getSurvey } from "@/lib/survey/service"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { validateFileUploads } from "@/modules/storage/utils"; import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; import { ZResponseUpdateInput } from "@formbricks/types/responses"; diff --git a/apps/web/app/api/v1/management/responses/route.ts b/apps/web/app/api/v1/management/responses/route.ts index dffb4b8fa2..235ca17c15 100644 --- a/apps/web/app/api/v1/management/responses/route.ts +++ b/apps/web/app/api/v1/management/responses/route.ts @@ -1,10 +1,10 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; -import { validateFileUploads } from "@/lib/fileValidation"; import { getResponses } from "@/lib/response/service"; import { getSurvey } from "@/lib/survey/service"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { validateFileUploads } from "@/modules/storage/utils"; import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; diff --git a/apps/web/app/api/v1/management/storage/lib/getSignedUrl.test.ts b/apps/web/app/api/v1/management/storage/lib/getSignedUrl.test.ts deleted file mode 100644 index 04b3c3f702..0000000000 --- a/apps/web/app/api/v1/management/storage/lib/getSignedUrl.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { responses } from "@/app/lib/api/response"; -import { getUploadSignedUrl } from "@/lib/storage/service"; -import { cleanup } from "@testing-library/react"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import { getSignedUrlForPublicFile } from "./getSignedUrl"; - -vi.mock("@/app/lib/api/response", () => ({ - responses: { - successResponse: vi.fn((data) => ({ data })), - internalServerErrorResponse: vi.fn((message) => ({ message })), - }, -})); - -vi.mock("@/lib/storage/service", () => ({ - getUploadSignedUrl: vi.fn(), -})); - -describe("getSignedUrlForPublicFile", () => { - afterEach(() => { - cleanup(); - vi.clearAllMocks(); - }); - - test("should return success response with signed URL data", async () => { - const mockFileName = "test.jpg"; - const mockEnvironmentId = "env123"; - const mockFileType = "image/jpeg"; - const mockSignedUrlResponse = { - signedUrl: "http://example.com/signed-url", - signingData: { signature: "sig", timestamp: 123, uuid: "uuid" }, - updatedFileName: "test--fid--uuid.jpg", - fileUrl: "http://example.com/file-url", - }; - - vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse); - - const result = await getSignedUrlForPublicFile(mockFileName, mockEnvironmentId, mockFileType); - - expect(getUploadSignedUrl).toHaveBeenCalledWith(mockFileName, mockEnvironmentId, mockFileType, "public"); - expect(responses.successResponse).toHaveBeenCalledWith(mockSignedUrlResponse); - expect(result).toEqual({ data: mockSignedUrlResponse }); - }); - - test("should return internal server error response when getUploadSignedUrl throws an error", async () => { - const mockFileName = "test.png"; - const mockEnvironmentId = "env456"; - const mockFileType = "image/png"; - const mockError = new Error("Failed to get signed URL"); - - vi.mocked(getUploadSignedUrl).mockRejectedValue(mockError); - - const result = await getSignedUrlForPublicFile(mockFileName, mockEnvironmentId, mockFileType); - - expect(getUploadSignedUrl).toHaveBeenCalledWith(mockFileName, mockEnvironmentId, mockFileType, "public"); - expect(responses.internalServerErrorResponse).toHaveBeenCalledWith("Internal server error"); - expect(result).toEqual({ message: "Internal server error" }); - }); -}); diff --git a/apps/web/app/api/v1/management/storage/lib/getSignedUrl.ts b/apps/web/app/api/v1/management/storage/lib/getSignedUrl.ts deleted file mode 100644 index 8b98f1075e..0000000000 --- a/apps/web/app/api/v1/management/storage/lib/getSignedUrl.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { responses } from "@/app/lib/api/response"; -import { getUploadSignedUrl } from "@/lib/storage/service"; - -export const getSignedUrlForPublicFile = async ( - fileName: string, - environmentId: string, - fileType: string -) => { - const accessType = "public"; // public files are accessible by anyone - - // if s3 is not configured, we'll upload to a local folder named uploads - - try { - const signedUrlResponse = await getUploadSignedUrl(fileName, environmentId, fileType, accessType); - - return responses.successResponse({ - ...signedUrlResponse, - }); - } catch (err) { - return responses.internalServerErrorResponse("Internal server error"); - } -}; diff --git a/apps/web/app/api/v1/management/storage/lib/utils.test.ts b/apps/web/app/api/v1/management/storage/lib/utils.test.ts index 270874fe3a..186b1f52dd 100644 --- a/apps/web/app/api/v1/management/storage/lib/utils.test.ts +++ b/apps/web/app/api/v1/management/storage/lib/utils.test.ts @@ -4,7 +4,7 @@ import { hasPermission } from "@/modules/organization/settings/api-keys/lib/util import { Session } from "next-auth"; import { describe, expect, test, vi } from "vitest"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; -import { checkAuth, checkForRequiredFields } from "./utils"; +import { checkAuth } from "./utils"; // Create mock response objects const mockBadRequestResponse = new Response("Bad Request", { status: 400 }); @@ -27,49 +27,6 @@ vi.mock("@/app/lib/api/response", () => ({ }, })); -describe("checkForRequiredFields", () => { - test("should return undefined when all required fields are present", () => { - const result = checkForRequiredFields("env-123", "image/png", "test-file.png"); - expect(result).toBeUndefined(); - }); - - test("should return bad request response when environmentId is missing", () => { - const result = checkForRequiredFields("", "image/png", "test-file.png"); - expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required"); - expect(result).toBe(mockBadRequestResponse); - }); - - test("should return bad request response when fileType is missing", () => { - const result = checkForRequiredFields("env-123", "", "test-file.png"); - expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required"); - expect(result).toBe(mockBadRequestResponse); - }); - - test("should return bad request response when encodedFileName is missing", () => { - const result = checkForRequiredFields("env-123", "image/png", ""); - expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required"); - expect(result).toBe(mockBadRequestResponse); - }); - - test("should return bad request response when environmentId is undefined", () => { - const result = checkForRequiredFields(undefined as any, "image/png", "test-file.png"); - expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required"); - expect(result).toBe(mockBadRequestResponse); - }); - - test("should return bad request response when fileType is undefined", () => { - const result = checkForRequiredFields("env-123", undefined as any, "test-file.png"); - expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required"); - expect(result).toBe(mockBadRequestResponse); - }); - - test("should return bad request response when encodedFileName is undefined", () => { - const result = checkForRequiredFields("env-123", "image/png", undefined as any); - expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required"); - expect(result).toBe(mockBadRequestResponse); - }); -}); - describe("checkAuth", () => { const environmentId = "env-123"; diff --git a/apps/web/app/api/v1/management/storage/lib/utils.ts b/apps/web/app/api/v1/management/storage/lib/utils.ts index bcd229bfd2..b9c0b15c70 100644 --- a/apps/web/app/api/v1/management/storage/lib/utils.ts +++ b/apps/web/app/api/v1/management/storage/lib/utils.ts @@ -3,24 +3,6 @@ import { TApiV1Authentication } from "@/app/lib/api/with-api-logging"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; -export const checkForRequiredFields = ( - environmentId: string, - fileType: string, - encodedFileName: string -): Response | undefined => { - if (!environmentId) { - return responses.badRequestResponse("environmentId is required"); - } - - if (!fileType) { - return responses.badRequestResponse("contentType is required"); - } - - if (!encodedFileName) { - return responses.badRequestResponse("fileName is required"); - } -}; - export const checkAuth = async (authentication: TApiV1Authentication, environmentId: string) => { if (!authentication) { return responses.notAuthenticatedResponse(); diff --git a/apps/web/app/api/v1/management/storage/local/route.ts b/apps/web/app/api/v1/management/storage/local/route.ts deleted file mode 100644 index 148c08cb8d..0000000000 --- a/apps/web/app/api/v1/management/storage/local/route.ts +++ /dev/null @@ -1,109 +0,0 @@ -// headers -> "Content-Type" should be present and set to a valid MIME type -// body -> should be a valid file object (buffer) -// method -> PUT (to be the same as the signedUrl method) -import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils"; -import { responses } from "@/app/lib/api/response"; -import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; -import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants"; -import { validateLocalSignedUrl } from "@/lib/crypto"; -import { validateFile } from "@/lib/fileValidation"; -import { putFileToLocalStorage } from "@/lib/storage/service"; -import { NextRequest } from "next/server"; -import { logger } from "@formbricks/logger"; - -export const POST = withV1ApiWrapper({ - handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => { - if (!ENCRYPTION_KEY) { - return { - response: responses.internalServerErrorResponse("Encryption key is not set"), - }; - } - - const accessType = "public"; // public files are accessible by anyone - - const jsonInput = await req.json(); - const fileType = jsonInput.fileType as string; - const encodedFileName = jsonInput.fileName as string; - const signedSignature = jsonInput.signature as string; - const signedUuid = jsonInput.uuid as string; - const signedTimestamp = jsonInput.timestamp as string; - const environmentId = jsonInput.environmentId as string; - - const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, encodedFileName); - if (requiredFieldResponse) { - return { - response: requiredFieldResponse, - }; - } - - if (!signedSignature || !signedUuid || !signedTimestamp) { - return { - response: responses.unauthorizedResponse(), - }; - } - - const authResponse = await checkAuth(authentication, environmentId); - if (authResponse) return { response: authResponse }; - - const fileName = decodeURIComponent(encodedFileName); - - // Perform server-side file validation - const fileValidation = validateFile(fileName, fileType); - if (!fileValidation.valid) { - return { - response: responses.badRequestResponse(fileValidation.error ?? "Invalid file"), - }; - } - - // validate signature - - const validated = validateLocalSignedUrl( - signedUuid, - fileName, - environmentId, - fileType, - Number(signedTimestamp), - signedSignature, - ENCRYPTION_KEY - ); - - if (!validated) { - return { - response: responses.unauthorizedResponse(), - }; - } - - const base64String = jsonInput.fileBase64String as string; - const buffer = Buffer.from(base64String.split(",")[1], "base64"); - const file = new Blob([buffer], { type: fileType }); - - if (!file) { - return { - response: responses.badRequestResponse("fileBuffer is required"), - }; - } - - try { - const bytes = await file.arrayBuffer(); - const fileBuffer = Buffer.from(bytes); - - await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR); - - return { - response: responses.successResponse({ - message: "File uploaded successfully", - }), - }; - } catch (err) { - logger.error(err, "Error uploading file"); - if (err.name === "FileTooLargeError") { - return { - response: responses.badRequestResponse(err.message), - }; - } - return { - response: responses.internalServerErrorResponse("File upload failed"), - }; - } - }, -}); diff --git a/apps/web/app/api/v1/management/storage/route.ts b/apps/web/app/api/v1/management/storage/route.ts index d45f386b56..23a6dc105e 100644 --- a/apps/web/app/api/v1/management/storage/route.ts +++ b/apps/web/app/api/v1/management/storage/route.ts @@ -1,20 +1,20 @@ -import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils"; +import { checkAuth } from "@/app/api/v1/management/storage/lib/utils"; import { responses } from "@/app/lib/api/response"; +import { transformErrorToDetails } from "@/app/lib/api/validator"; import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; -import { validateFile } from "@/lib/fileValidation"; +import { getSignedUrlForUpload } from "@/modules/storage/service"; import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; -import { getSignedUrlForPublicFile } from "./lib/getSignedUrl"; +import { TUploadPublicFileRequest, ZUploadPublicFileRequest } from "@formbricks/types/storage"; -// api endpoint for uploading public files +// api endpoint for getting a signed url for uploading a public file // uploaded files will be public, anyone can access the file // uploading public files requires authentication -// use this to upload files for a specific resource, e.g. a user profile picture or a survey -// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage +// use this to get a signed url for uploading a public file for a specific resource, e.g. a survey's background image export const POST = withV1ApiWrapper({ handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => { - let storageInput; + let storageInput: TUploadPublicFileRequest; try { storageInput = await req.json(); @@ -25,15 +25,24 @@ export const POST = withV1ApiWrapper({ }; } - const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput; + const parsedInputResult = ZUploadPublicFileRequest.safeParse(storageInput); + + if (!parsedInputResult.success) { + const errorDetails = transformErrorToDetails(parsedInputResult.error); + + logger.error({ error: errorDetails }, "Fields are missing or incorrectly formatted"); - const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName); - if (requiredFieldResponse) { return { - response: requiredFieldResponse, + response: responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + errorDetails, + true + ), }; } + const { fileName, fileType, environmentId } = parsedInputResult.data; + const authResponse = await checkAuth(authentication, environmentId); if (authResponse) { return { @@ -41,28 +50,16 @@ export const POST = withV1ApiWrapper({ }; } - // Perform server-side file validation first to block dangerous file types - const fileValidation = validateFile(fileName, fileType); - if (!fileValidation.valid) { + const signedUrlResponse = await getSignedUrlForUpload(fileName, environmentId, fileType, "public"); + + if (!signedUrlResponse.ok) { return { - response: responses.badRequestResponse(fileValidation.error ?? "Invalid file type"), + response: responses.internalServerErrorResponse("Internal server error"), }; } - // Also perform client-specified allowed file extensions validation if provided - if (allowedFileExtensions?.length) { - const fileExtension = fileName.split(".").pop()?.toLowerCase(); - if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) { - return { - response: responses.badRequestResponse( - `File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}` - ), - }; - } - } - return { - response: await getSignedUrlForPublicFile(fileName, environmentId, fileType), + response: responses.successResponse(signedUrlResponse.data), }; }, }); diff --git a/apps/web/app/api/v2/client/[environmentId]/storage/local/route.ts b/apps/web/app/api/v2/client/[environmentId]/storage/local/route.ts deleted file mode 100644 index cb0a14158f..0000000000 --- a/apps/web/app/api/v2/client/[environmentId]/storage/local/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/local/route"; - -export { OPTIONS, POST }; diff --git a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/delete-file.ts b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/delete-file.ts deleted file mode 100644 index f293d27027..0000000000 --- a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/delete-file.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { responses } from "@/app/lib/api/response"; -import { deleteFile } from "@/lib/storage/service"; -import { type TAccessType } from "@formbricks/types/storage"; - -export const handleDeleteFile = async (environmentId: string, accessType: TAccessType, fileName: string) => { - try { - const { message, success, code } = await deleteFile(environmentId, accessType, fileName); - - if (success) { - return responses.successResponse(message); - } - - if (code === 404) { - return responses.notFoundResponse("File", "File not found"); - } - - return responses.internalServerErrorResponse(message); - } catch (err) { - return responses.internalServerErrorResponse("Something went wrong"); - } -}; diff --git a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/get-file.ts b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/get-file.ts deleted file mode 100644 index 524cca5810..0000000000 --- a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/get-file.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { responses } from "@/app/lib/api/response"; -import { UPLOADS_DIR, isS3Configured } from "@/lib/constants"; -import { getLocalFile, getS3File } from "@/lib/storage/service"; -import { notFound } from "next/navigation"; -import path from "node:path"; - -export const getFile = async ( - environmentId: string, - accessType: string, - fileName: string -): Promise => { - if (!isS3Configured()) { - try { - const { fileBuffer, metaData } = await getLocalFile( - path.join(UPLOADS_DIR, environmentId, accessType, fileName) - ); - - return new Response(fileBuffer, { - headers: { - "Content-Type": metaData.contentType, - "Content-Disposition": "attachment", - "Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300", - Vary: "Accept-Encoding", - }, - }); - } catch (err) { - notFound(); - } - } - - try { - const signedUrl = await getS3File(`${environmentId}/${accessType}/${fileName}`); - - return new Response(null, { - status: 302, - headers: { - Location: signedUrl, - "Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300", - }, - }); - } catch (error: unknown) { - if (error instanceof Error && error.name === "NoSuchKey") { - return responses.notFoundResponse("File not found", fileName); - } - return responses.internalServerErrorResponse("Internal server error"); - } -}; diff --git a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts index 2ef1974252..fd38d0d040 100644 --- a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts +++ b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts @@ -1,24 +1,73 @@ import { authenticateRequest } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { handleDeleteFile } from "@/app/storage/[environmentId]/[accessType]/[fileName]/lib/delete-file"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler"; import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; +import { deleteFile, getSignedUrlForDownload } from "@/modules/storage/service"; import { getServerSession } from "next-auth"; import { type NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; -import { ZStorageRetrievalParams } from "@formbricks/types/storage"; -import { getFile } from "./lib/get-file"; +import { TAccessType, ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage"; + +const getOrgId = async (environmentId: string): Promise => { + try { + return await getOrganizationIdFromEnvironmentId(environmentId); + } catch (error) { + logger.error("Failed to get organization ID for environment", { error }); + return UNKNOWN_DATA; + } +}; + +const logFileDeletion = async ({ + environmentId, + accessType, + userId, + status = "failure", + failureReason, + oldObject, + apiUrl, +}: { + environmentId: string; + accessType?: string; + userId?: string; + status?: TAuditStatus; + failureReason?: string; + oldObject?: Record; + apiUrl: string; +}) => { + try { + const organizationId = await getOrgId(environmentId); + + await queueAuditEvent({ + action: "deleted", + targetType: "file", + userId: userId || UNKNOWN_DATA, // NOSONAR // We want to check for empty user IDs too + userType: "user", + targetId: `${environmentId}:${accessType}`, // Generic target identifier + organizationId, + status, + newObject: { + environmentId, + accessType, + ...(failureReason && { failureReason }), + }, + oldObject, + apiUrl, + }); + } catch (auditError) { + logger.error("Failed to log file deletion audit event:", auditError); + } +}; export const GET = async ( request: NextRequest, - props: { params: Promise<{ environmentId: string; accessType: string; fileName: string }> } + props: { params: Promise<{ environmentId: string; accessType: TAccessType; fileName: string }> } ): Promise => { const params = await props.params; - const paramValidation = ZStorageRetrievalParams.safeParse(params); + const paramValidation = ZDownloadFileRequest.safeParse(params); if (!paramValidation.success) { return responses.badRequestResponse( @@ -28,36 +77,47 @@ export const GET = async ( ); } - const { environmentId, accessType, fileName: fileNameOG } = params; + const { environmentId, accessType, fileName } = paramValidation.data; - const fileName = decodeURIComponent(fileNameOG); + // check auth + if (accessType === "private") { + const session = await getServerSession(authOptions); - if (accessType === "public") { - return await getFile(environmentId, accessType, fileName); + if (!session?.user) { + // check for api key auth + const res = await authenticateRequest(request); + + if (!res) { + return responses.notAuthenticatedResponse(); + } + } else { + const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); + + if (!isUserAuthorized) { + return responses.unauthorizedResponse(); + } + } } - // if the user is authenticated via the session + const signedUrlResult = await getSignedUrlForDownload(fileName, environmentId, accessType); - const session = await getServerSession(authOptions); - - if (!session?.user) { - // check for api key auth - const res = await authenticateRequest(request); - - if (!res) { - return responses.notAuthenticatedResponse(); + if (!signedUrlResult.ok) { + if (signedUrlResult.error.code === "file_not_found_error") { + logger.info({ error: signedUrlResult.error }, "File not found"); + return responses.notFoundResponse("File", fileName); } - return await getFile(environmentId, accessType, fileName); + logger.error({ error: signedUrlResult.error }, "Error getting signed url for download"); + return responses.internalServerErrorResponse("Internal server error"); } - const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); - - if (!isUserAuthorized) { - return responses.unauthorizedResponse(); - } - - return await getFile(environmentId, accessType, fileName); + return new Response(null, { + status: 302, + headers: { + Location: signedUrlResult.data, + "Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300", + }, + }); }; export const DELETE = async ( @@ -66,156 +126,79 @@ export const DELETE = async ( ): Promise => { const params = await props.params; - const getOrgId = async (environmentId: string): Promise => { - try { - return await getOrganizationIdFromEnvironmentId(environmentId); - } catch (error) { - logger.error("Failed to get organization ID for environment", { error }); - return UNKNOWN_DATA; - } - }; - - const logFileDeletion = async ({ - accessType, - userId, - status = "failure", - failureReason, - oldObject, - }: { - accessType?: string; - userId?: string; - status?: TAuditStatus; - failureReason?: string; - oldObject?: Record; - }) => { - try { - const organizationId = await getOrgId(environmentId); - - await queueAuditEvent({ - action: "deleted", - targetType: "file", - userId: userId || UNKNOWN_DATA, // NOSONAR // We want to check for empty user IDs too - userType: "user", - targetId: `${environmentId}:${accessType}`, // Generic target identifier - organizationId, - status, - newObject: { - environmentId, - accessType, - ...(failureReason && { failureReason }), - }, - oldObject, - apiUrl: request.url, - }); - } catch (auditError) { - logger.error("Failed to log file deletion audit event:", auditError); - } - }; - - // Validation - if (!params.fileName) { - await logFileDeletion({ - failureReason: "fileName parameter missing", - }); - return responses.badRequestResponse("Fields are missing or incorrectly formatted", { - fileName: "fileName is required", - }); - } - - const { environmentId, accessType, fileName } = params; - - // Security check: If fileName contains the same properties from the route, ensure they match - // This is to prevent a user from deleting a file from a different environment - const [fileEnvironmentId, fileAccessType, file] = fileName.split("/"); - if (fileEnvironmentId !== environmentId) { - await logFileDeletion({ - failureReason: "Environment ID mismatch between route and fileName", - accessType, - }); - return responses.badRequestResponse("Environment ID mismatch", { - message: "The environment ID in the fileName does not match the route environment ID", - }); - } - - if (fileAccessType !== accessType) { - await logFileDeletion({ - failureReason: "Access type mismatch between route and fileName", - accessType, - }); - return responses.badRequestResponse("Access type mismatch", { - message: "The access type in the fileName does not match the route access type", - }); - } - - const paramValidation = ZStorageRetrievalParams.safeParse({ fileName: file, environmentId, accessType }); + const paramValidation = ZDeleteFileRequest.safeParse(params); if (!paramValidation.success) { + const errorDetails = transformErrorToDetails(paramValidation.error); + await logFileDeletion({ failureReason: "Parameter validation failed", - accessType, + environmentId: params.environmentId, + apiUrl: request.url, }); - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(paramValidation.error), - true - ); + + return responses.badRequestResponse("Fields are missing or incorrectly formatted", errorDetails, true); } - const { - environmentId: validEnvId, - accessType: validAccessType, - fileName: validFileName, - } = paramValidation.data; + const { environmentId, accessType, fileName } = paramValidation.data; - // Authentication const session = await getServerSession(authOptions); + if (!session?.user) { - await logFileDeletion({ - failureReason: "User not authenticated", - accessType: validAccessType, - }); - return responses.notAuthenticatedResponse(); - } + // check for api key auth + const res = await authenticateRequest(request); - // Authorization - const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, validEnvId); - if (!isUserAuthorized) { - await logFileDeletion({ - failureReason: "User not authorized to access environment", - accessType: validAccessType, - userId: session.user.id, - }); - return responses.unauthorizedResponse(); - } + if (!res) { + await logFileDeletion({ + failureReason: "User not authenticated", + accessType, + environmentId, + apiUrl: request.url, + }); - try { - const deleteResult = await handleDeleteFile(validEnvId, validAccessType, validFileName); - const isSuccess = deleteResult.status === 200; - let failureReason = "File deletion failed"; - - if (!isSuccess) { - try { - const responseBody = await deleteResult.json(); - failureReason = responseBody.message || failureReason; // NOSONAR // We want to check for empty messages too - } catch (error) { - logger.error("Failed to parse file delete error response body", { error }); - } + return responses.notAuthenticatedResponse(); } + } else { + const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); - await logFileDeletion({ - status: isSuccess ? "success" : "failure", - failureReason: isSuccess ? undefined : failureReason, - accessType: validAccessType, - userId: session.user.id, - }); + if (!isUserAuthorized) { + await logFileDeletion({ + failureReason: "User not authorized to access environment", + accessType, + userId: session.user.id, + environmentId, + apiUrl: request.url, + }); - return deleteResult; - } catch (error) { - await logFileDeletion({ - failureReason: error instanceof Error ? error.message : "Unexpected error during file deletion", - accessType: validAccessType, - userId: session.user.id, - }); - throw error; + return responses.unauthorizedResponse(); + } } + + const deleteResult = await deleteFile(environmentId, accessType, fileName); + + const isSuccess = deleteResult.ok; + + if (!isSuccess) { + logger.error({ error: deleteResult.error }, "Error deleting file"); + + await logFileDeletion({ + failureReason: deleteResult.error.code, + accessType, + userId: session?.user?.id, + environmentId, + apiUrl: request.url, + }); + + return responses.internalServerErrorResponse("Unexpected error during file deletion"); + } + + await logFileDeletion({ + status: "success", + accessType, + userId: session?.user?.id, + environmentId, + apiUrl: request.url, + }); + + return responses.successResponse("File deleted successfully"); }; diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index 482f632c80..147c0827d4 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -111,19 +111,11 @@ export const S3_ENDPOINT_URL = env.S3_ENDPOINT_URL; export const S3_BUCKET_NAME = env.S3_BUCKET_NAME; export const S3_FORCE_PATH_STYLE = env.S3_FORCE_PATH_STYLE === "1"; export const UPLOADS_DIR = env.UPLOADS_DIR ?? "./uploads"; -export const MAX_SIZES = { +export const MAX_FILE_UPLOAD_SIZES = { standard: 1024 * 1024 * 10, // 10MB big: 1024 * 1024 * 1024, // 1GB } as const; -// Function to check if the necessary S3 configuration is set up -export const isS3Configured = () => { - // This function checks if the S3 bucket name environment variable is defined. - // The AWS SDK automatically resolves credentials through a chain, - // so we do not need to explicitly check for AWS credentials like access key, secret key, or region. - return !!S3_BUCKET_NAME; -}; - // Colors for Survey Bg export const SURVEY_BG_COLORS = [ "#FFFFFF", diff --git a/apps/web/lib/crypto.test.ts b/apps/web/lib/crypto.test.ts index 6592fcf1c8..c341c49e21 100644 --- a/apps/web/lib/crypto.test.ts +++ b/apps/web/lib/crypto.test.ts @@ -1,12 +1,6 @@ import { createCipheriv, randomBytes } from "crypto"; import { describe, expect, test, vi } from "vitest"; -import { - generateLocalSignedUrl, - getHash, - symmetricDecrypt, - symmetricEncrypt, - validateLocalSignedUrl, -} from "./crypto"; +import { getHash, symmetricDecrypt, symmetricEncrypt } from "./crypto"; vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) })); @@ -44,16 +38,4 @@ describe("crypto", () => { expect(typeof h).toBe("string"); expect(h.length).toBeGreaterThan(0); }); - - test("signed URL generation & validation", () => { - const { uuid, timestamp, signature } = generateLocalSignedUrl("f", "e", "t"); - expect(uuid).toHaveLength(32); - expect(typeof timestamp).toBe("number"); - expect(typeof signature).toBe("string"); - expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, signature, key)).toBe(true); - expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, "bad", key)).toBe(false); - expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp - 1000 * 60 * 6, signature, key)).toBe( - false - ); - }); }); diff --git a/apps/web/lib/crypto.ts b/apps/web/lib/crypto.ts index b46294ef3c..d023710805 100644 --- a/apps/web/lib/crypto.ts +++ b/apps/web/lib/crypto.ts @@ -1,4 +1,4 @@ -import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "crypto"; +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto"; import { logger } from "@formbricks/logger"; import { ENCRYPTION_KEY } from "./constants"; @@ -92,39 +92,3 @@ export function symmetricDecrypt(payload: string, key: string): string { } export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex"); - -export const generateLocalSignedUrl = ( - fileName: string, - environmentId: string, - fileType: string -): { signature: string; uuid: string; timestamp: number } => { - const uuid = randomBytes(16).toString("hex"); - const timestamp = Date.now(); - const data = `${uuid}:${fileName}:${environmentId}:${fileType}:${timestamp}`; - const signature = createHmac("sha256", ENCRYPTION_KEY).update(data).digest("hex"); - return { signature, uuid, timestamp }; -}; - -export const validateLocalSignedUrl = ( - uuid: string, - fileName: string, - environmentId: string, - fileType: string, - timestamp: number, - signature: string, - secret: string -): boolean => { - const data = `${uuid}:${fileName}:${environmentId}:${fileType}:${timestamp}`; - const expectedSignature = createHmac("sha256", secret).update(data).digest("hex"); - - if (expectedSignature !== signature) { - return false; - } - - // valid for 5 minutes - if (Date.now() - timestamp > 1000 * 60 * 5) { - return false; - } - - return true; -}; diff --git a/apps/web/lib/response/service.ts b/apps/web/lib/response/service.ts index 2570ccf171..45fd2dc31b 100644 --- a/apps/web/lib/response/service.ts +++ b/apps/web/lib/response/service.ts @@ -1,4 +1,5 @@ import "server-only"; +import { deleteFile } from "@/modules/storage/service"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { z } from "zod"; @@ -16,9 +17,8 @@ import { } from "@formbricks/types/responses"; import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { TTag } from "@formbricks/types/tags"; -import { ITEMS_PER_PAGE, WEBAPP_URL } from "../constants"; +import { ITEMS_PER_PAGE } from "../constants"; import { deleteDisplay } from "../display/service"; -import { deleteFile, putFile } from "../storage/service"; import { getSurvey } from "../survey/service"; import { convertToCsv, convertToXlsxBuffer } from "../utils/file-conversion"; import { validateInputs } from "../utils/validate"; @@ -302,11 +302,11 @@ export const getResponses = reactCache( } ); -export const getResponseDownloadUrl = async ( +export const getResponseDownloadFile = async ( surveyId: string, format: "csv" | "xlsx", filterCriteria?: TResponseFilterCriteria -): Promise => { +): Promise<{ fileContents: string; fileName: string }> => { validateInputs([surveyId, ZId], [format, ZString], [filterCriteria, ZResponseFilterCriteria.optional()]); try { const survey = await getSurvey(surveyId); @@ -315,9 +315,6 @@ export const getResponseDownloadUrl = async ( throw new ResourceNotFoundError("Survey", surveyId); } - const environmentId = survey.environmentId; - - const accessType = "private"; const batchSize = 3000; // Use cursor-based pagination instead of count + offset to avoid expensive queries @@ -365,18 +362,19 @@ export const getResponseDownloadUrl = async ( const jsonData = getResponsesJson(survey, responses, questions, userAttributes, hiddenFields); const fileName = getResponsesFileName(survey?.name || "", format); - let fileBuffer: Buffer; + let fileContents: string; if (format === "xlsx") { - fileBuffer = convertToXlsxBuffer(headers, jsonData); + const buffer = convertToXlsxBuffer(headers, jsonData); + fileContents = buffer.toString("base64"); } else { - const csvFile = await convertToCsv(headers, jsonData); - fileBuffer = Buffer.from(csvFile); + fileContents = await convertToCsv(headers, jsonData); } - await putFile(fileName, fileBuffer, accessType, environmentId); - - return `${WEBAPP_URL}/storage/${environmentId}/${accessType}/${fileName}`; + return { + fileContents, + fileName, + }; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError(error.message); diff --git a/apps/web/lib/response/tests/response.test.ts b/apps/web/lib/response/tests/response.test.ts index a03ad8023f..5f03044a1a 100644 --- a/apps/web/lib/response/tests/response.test.ts +++ b/apps/web/lib/response/tests/response.test.ts @@ -29,7 +29,7 @@ import { getResponse, getResponseBySingleUseId, getResponseCountBySurveyId, - getResponseDownloadUrl, + getResponseDownloadFile, getResponsesByEnvironmentId, updateResponse, } from "../service"; @@ -78,12 +78,12 @@ beforeEach(() => { prisma.response.findMany.mockResolvedValue([mockResponse]); prisma.response.delete.mockResolvedValue(mockResponse); - prisma.display.delete.mockResolvedValue({ ...mockDisplay, status: "seen" }); + prisma.display.delete.mockResolvedValue({ ...mockDisplay, status: "seen" } as unknown as any); prisma.response.count.mockResolvedValue(1); - prisma.organization.findFirst.mockResolvedValue(mockOrganizationOutput); - prisma.organization.findUnique.mockResolvedValue(mockOrganizationOutput); + prisma.organization.findFirst.mockResolvedValue(mockOrganizationOutput as unknown as any); + prisma.organization.findUnique.mockResolvedValue(mockOrganizationOutput as unknown as any); prisma.project.findMany.mockResolvedValue([]); // @ts-expect-error prisma.response.aggregate.mockResolvedValue({ _count: { id: 1 } }); @@ -207,8 +207,8 @@ describe("Tests for getResponseDownloadUrl service", () => { prisma.response.count.mockResolvedValue(1); prisma.response.findMany.mockResolvedValue([mockResponse]); - const url = await getResponseDownloadUrl(mockSurveyId, "csv"); - const fileExtension = url.split(".").pop(); + const result = await getResponseDownloadFile(mockSurveyId, "csv"); + const fileExtension = result.fileName.split(".").pop(); expect(fileExtension).toEqual("csv"); }); @@ -217,22 +217,22 @@ describe("Tests for getResponseDownloadUrl service", () => { prisma.response.count.mockResolvedValue(1); prisma.response.findMany.mockResolvedValue([mockResponse]); - const url = await getResponseDownloadUrl(mockSurveyId, "xlsx", { finished: true }); - const fileExtension = url.split(".").pop(); + const result = await getResponseDownloadFile(mockSurveyId, "xlsx", { finished: true }); + const fileExtension = result.fileName.split(".").pop(); expect(fileExtension).toEqual("xlsx"); }); }); describe("Sad Path", () => { - testInputValidation(getResponseDownloadUrl, mockSurveyId, 123); + testInputValidation(getResponseDownloadFile, mockSurveyId, 123); test("Throws error if response file is of different format than expected", async () => { prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput); prisma.response.count.mockResolvedValue(1); prisma.response.findMany.mockResolvedValue([mockResponse]); - const url = await getResponseDownloadUrl(mockSurveyId, "csv", { finished: true }); - const fileExtension = url.split(".").pop(); + const result = await getResponseDownloadFile(mockSurveyId, "csv", { finished: true }); + const fileExtension = result.fileName.split(".").pop(); expect(fileExtension).not.toEqual("xlsx"); }); @@ -245,7 +245,7 @@ describe("Tests for getResponseDownloadUrl service", () => { prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput); prisma.response.findMany.mockRejectedValue(errToThrow); - await expect(getResponseDownloadUrl(mockSurveyId, "csv")).rejects.toThrow(DatabaseError); + await expect(getResponseDownloadFile(mockSurveyId, "csv")).rejects.toThrow(DatabaseError); }); test("Throws DatabaseError on PrismaClientKnownRequestError, when the getResponses fails", async () => { @@ -259,7 +259,7 @@ describe("Tests for getResponseDownloadUrl service", () => { prisma.response.count.mockResolvedValue(1); prisma.response.findMany.mockRejectedValue(errToThrow); - await expect(getResponseDownloadUrl(mockSurveyId, "csv")).rejects.toThrow(DatabaseError); + await expect(getResponseDownloadFile(mockSurveyId, "csv")).rejects.toThrow(DatabaseError); }); test("Throws a generic Error for unexpected problems", async () => { @@ -268,7 +268,7 @@ describe("Tests for getResponseDownloadUrl service", () => { // error from getSurvey prisma.survey.findUnique.mockRejectedValue(new Error(mockErrorMessage)); - await expect(getResponseDownloadUrl(mockSurveyId, "xlsx")).rejects.toThrow(Error); + await expect(getResponseDownloadFile(mockSurveyId, "xlsx")).rejects.toThrow(Error); }); }); }); diff --git a/apps/web/lib/storage/service.test.ts b/apps/web/lib/storage/service.test.ts deleted file mode 100644 index ed207abe77..0000000000 --- a/apps/web/lib/storage/service.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { S3Client } from "@aws-sdk/client-s3"; -import { readFile } from "fs/promises"; -import { lookup } from "mime-types"; -import path from "path"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; - -// Mock AWS SDK -const mockSend = vi.fn(); -const mockS3Client = { - send: mockSend, -}; - -vi.mock("fs/promises", () => ({ - readFile: vi.fn(), - access: vi.fn(), - mkdir: vi.fn(), - rmdir: vi.fn(), - unlink: vi.fn(), - writeFile: vi.fn(), -})); - -vi.mock("mime-types", () => ({ - lookup: vi.fn(), -})); - -vi.mock("@aws-sdk/client-s3", () => ({ - S3Client: vi.fn(() => mockS3Client), - HeadBucketCommand: vi.fn(), - PutObjectCommand: vi.fn(), - DeleteObjectCommand: vi.fn(), - GetObjectCommand: vi.fn(), -})); - -vi.mock("@aws-sdk/s3-presigned-post", () => ({ - createPresignedPost: vi.fn(() => - Promise.resolve({ - url: "https://test-bucket.s3.test-region.amazonaws.com", - fields: { key: "test-key", policy: "test-policy" }, - }) - ), -})); - -// Mock environment variables -vi.mock("../constants", () => ({ - S3_ACCESS_KEY: "test-access-key", - S3_SECRET_KEY: "test-secret-key", - S3_REGION: "test-region", - S3_BUCKET_NAME: "test-bucket", - S3_ENDPOINT_URL: "http://test-endpoint", - S3_FORCE_PATH_STYLE: true, - isS3Configured: () => true, - IS_FORMBRICKS_CLOUD: false, - MAX_SIZES: { - standard: 5 * 1024 * 1024, - big: 10 * 1024 * 1024, - }, - WEBAPP_URL: "http://test-webapp", - ENCRYPTION_KEY: "test-encryption-key-32-chars-long!!", - UPLOADS_DIR: "/tmp/uploads", -})); - -// Mock getPublicDomain -vi.mock("../getPublicUrl", () => ({ - getPublicDomain: () => "https://public-domain.com", -})); - -// Mock crypto functions -vi.mock("crypto", () => ({ - randomUUID: () => "test-uuid", -})); - -// Mock local signed url generation -vi.mock("../crypto", () => ({ - generateLocalSignedUrl: () => ({ - signature: "test-signature", - timestamp: 123456789, - uuid: "test-uuid", - }), -})); - -// Mock env -vi.mock("../env", () => ({ - env: { - S3_BUCKET_NAME: "test-bucket", - }, -})); - -describe("Storage Service", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe("getS3Client", () => { - test("should create and return S3 client instance", async () => { - const { getS3Client } = await import("./service"); - const client = getS3Client(); - expect(client).toBe(mockS3Client); - expect(S3Client).toHaveBeenCalledWith({ - credentials: { - accessKeyId: "test-access-key", - secretAccessKey: "test-secret-key", - }, - region: "test-region", - endpoint: "http://test-endpoint", - forcePathStyle: true, - }); - }); - - test("should return existing client instance on subsequent calls", async () => { - vi.resetModules(); - const { getS3Client } = await import("./service"); - const client1 = getS3Client(); - const client2 = getS3Client(); - expect(client1).toBe(client2); - expect(S3Client).toHaveBeenCalledTimes(1); - }); - }); - - describe("testS3BucketAccess", () => { - let testS3BucketAccess: any; - - beforeEach(async () => { - const serviceModule = await import("./service"); - testS3BucketAccess = serviceModule.testS3BucketAccess; - }); - - test("should return true when bucket access is successful", async () => { - mockSend.mockResolvedValueOnce({}); - const result = await testS3BucketAccess(); - expect(result).toBe(true); - expect(mockSend).toHaveBeenCalledTimes(1); - }); - - test("should throw error when bucket access fails", async () => { - const error = new Error("Access denied"); - mockSend.mockRejectedValueOnce(error); - await expect(testS3BucketAccess()).rejects.toThrow( - "S3 Bucket Access Test Failed: Error: Access denied" - ); - }); - }); - - describe("putFile", () => { - let putFile: any; - - beforeEach(async () => { - const serviceModule = await import("./service"); - putFile = serviceModule.putFile; - }); - - test("should successfully upload file to S3", async () => { - const fileName = "test.jpg"; - const fileBuffer = Buffer.from("test"); - const accessType = "private"; - const environmentId = "env123"; - - mockSend.mockResolvedValueOnce({}); - - const result = await putFile(fileName, fileBuffer, accessType, environmentId); - expect(result).toEqual({ success: true, message: "File uploaded" }); - expect(mockSend).toHaveBeenCalledTimes(1); - }); - - test("should throw error when S3 upload fails", async () => { - const fileName = "test.jpg"; - const fileBuffer = Buffer.from("test"); - const accessType = "private"; - const environmentId = "env123"; - - const error = new Error("Upload failed"); - mockSend.mockRejectedValueOnce(error); - - await expect(putFile(fileName, fileBuffer, accessType, environmentId)).rejects.toThrow("Upload failed"); - }); - }); - - describe("getUploadSignedUrl", () => { - let getUploadSignedUrl: any; - - beforeEach(async () => { - const serviceModule = await import("./service"); - getUploadSignedUrl = serviceModule.getUploadSignedUrl; - }); - - test("should use PUBLIC_URL for public files with S3", async () => { - const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "public"); - - expect(result.fileUrl).toContain("https://public-domain.com"); - expect(result.fileUrl).toMatch( - /https:\/\/public-domain\.com\/storage\/env123\/public\/test--fid--test-uuid\.jpg/ - ); - }); - - test("should use WEBAPP_URL for private files with S3", async () => { - const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "private"); - - expect(result.fileUrl).toContain("http://test-webapp"); - expect(result.fileUrl).toMatch( - /http:\/\/test-webapp\/storage\/env123\/private\/test--fid--test-uuid\.jpg/ - ); - }); - - test("should contain signed URL and presigned fields for S3", async () => { - const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "public"); - - expect(result.signedUrl).toBe("https://test-bucket.s3.test-region.amazonaws.com"); - expect(result.presignedFields).toEqual({ key: "test-key", policy: "test-policy" }); - }); - - test("use local storage for private files when S3 is not configured", async () => { - vi.resetModules(); - - vi.doMock("../constants", () => ({ - S3_ACCESS_KEY: "test-access-key", - S3_SECRET_KEY: "test-secret-key", - S3_REGION: "test-region", - S3_BUCKET_NAME: "test-bucket", - S3_ENDPOINT_URL: "http://test-endpoint", - S3_FORCE_PATH_STYLE: true, - isS3Configured: () => false, - IS_FORMBRICKS_CLOUD: false, - MAX_SIZES: { - standard: 5 * 1024 * 1024, - big: 10 * 1024 * 1024, - }, - WEBAPP_URL: "http://test-webapp", - ENCRYPTION_KEY: "test-encryption-key-32-chars-long!!", - UPLOADS_DIR: "/tmp/uploads", - })); - - vi.mock("../getPublicUrl", () => ({ - getPublicDomain: () => "https://public-domain.com", - })); - - const freshModule = await import("./service"); - const freshGetUploadSignedUrl = freshModule.getUploadSignedUrl as typeof getUploadSignedUrl; - - const result = await freshGetUploadSignedUrl("test.jpg", "env123", "image/jpeg", "private"); - - expect(result.fileUrl).toContain("http://test-webapp"); - expect(result.fileUrl).toMatch( - /http:\/\/test-webapp\/storage\/env123\/private\/test--fid--test-uuid\.jpg/ - ); - expect(result.fileUrl).not.toContain("test-bucket"); - expect(result.fileUrl).not.toContain("test-endpoint"); - }); - }); - - describe("getLocalFile", () => { - let getLocalFile: any; - - beforeEach(async () => { - const serviceModule = await import("./service"); - getLocalFile = serviceModule.getLocalFile; - }); - - test("should return file buffer and metadata", async () => { - vi.mocked(readFile).mockResolvedValue(Buffer.from("test")); - vi.mocked(lookup).mockReturnValue("image/jpeg"); - - const result = await getLocalFile("/tmp/uploads/test/test.jpg"); - expect(result.fileBuffer).toBeInstanceOf(Buffer); - expect(result.metaData).toEqual({ contentType: "image/jpeg" }); - }); - - test("should throw error when file does not exist", async () => { - vi.mocked(readFile).mockRejectedValue(new Error("File not found")); - await expect(getLocalFile("/tmp/uploads/test/test.jpg")).rejects.toThrow("File not found"); - }); - - test("should throw error when file path attempts traversal outside uploads dir", async () => { - const traversalOutside = path.join("/tmp/uploads", "../outside.txt"); - await expect(getLocalFile(traversalOutside)).rejects.toThrow( - "Invalid file path: Path must be within uploads folder" - ); - }); - - test("should reject path traversal using '../secret' with security error", async () => { - await expect(getLocalFile("../secret")).rejects.toThrow( - "Invalid file path: Path must be within uploads folder" - ); - }); - - test("should reject Windows-style traversal '..\\\\secret' with security error", async () => { - await expect(getLocalFile("..\\secret")).rejects.toThrow( - "Invalid file path: Path must be within uploads folder" - ); - }); - - test("should reject nested traversal 'subdir/../../etc/passwd' with security error", async () => { - await expect(getLocalFile("subdir/../../etc/passwd")).rejects.toThrow( - "Invalid file path: Path must be within uploads folder" - ); - }); - - test("should throw EISDIR when provided path is a directory inside uploads", async () => { - // Simulate Node throwing EISDIR when attempting to read a directory - const eisdirError: any = new Error("EISDIR: illegal operation on a directory, read"); - eisdirError.code = "EISDIR"; - vi.mocked(readFile).mockRejectedValueOnce(eisdirError); - - await expect(getLocalFile("/tmp/uploads/some-dir")).rejects.toMatchObject({ - code: "EISDIR", - message: expect.stringContaining("EISDIR"), - }); - }); - }); -}); diff --git a/apps/web/lib/storage/service.ts b/apps/web/lib/storage/service.ts deleted file mode 100644 index 784f76ba6f..0000000000 --- a/apps/web/lib/storage/service.ts +++ /dev/null @@ -1,435 +0,0 @@ -import { - DeleteObjectCommand, - DeleteObjectsCommand, - GetObjectCommand, - HeadBucketCommand, - ListObjectsCommand, - PutObjectCommand, - S3Client, -} from "@aws-sdk/client-s3"; -import { PresignedPostOptions, createPresignedPost } from "@aws-sdk/s3-presigned-post"; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { randomUUID } from "crypto"; -import { access, mkdir, readFile, rmdir, unlink, writeFile } from "fs/promises"; -import { lookup } from "mime-types"; -import type { WithImplicitCoercion } from "node:buffer"; -import path, { join } from "path"; -import { logger } from "@formbricks/logger"; -import { TAccessType } from "@formbricks/types/storage"; -import { - IS_FORMBRICKS_CLOUD, - MAX_SIZES, - S3_ACCESS_KEY, - S3_BUCKET_NAME, - S3_ENDPOINT_URL, - S3_FORCE_PATH_STYLE, - S3_REGION, - S3_SECRET_KEY, - UPLOADS_DIR, - WEBAPP_URL, - isS3Configured, -} from "../constants"; -import { generateLocalSignedUrl } from "../crypto"; -import { env } from "../env"; -import { getPublicDomain } from "../getPublicUrl"; - -// S3Client Singleton -let s3ClientInstance: S3Client | null = null; - -export const getS3Client = () => { - if (!s3ClientInstance) { - const credentials = - S3_ACCESS_KEY && S3_SECRET_KEY - ? { accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY } - : undefined; - - s3ClientInstance = new S3Client({ - credentials, - region: S3_REGION, - ...(S3_ENDPOINT_URL && { endpoint: S3_ENDPOINT_URL }), - forcePathStyle: S3_FORCE_PATH_STYLE, - }); - } - - return s3ClientInstance; -}; - -export const testS3BucketAccess = async () => { - const s3Client = getS3Client(); - - try { - // Attempt to retrieve metadata about the bucket - const headBucketCommand = new HeadBucketCommand({ - Bucket: S3_BUCKET_NAME, - }); - - await s3Client.send(headBucketCommand); - - return true; - } catch (error) { - logger.error(error, "Failed to access S3 bucket"); - throw new Error(`S3 Bucket Access Test Failed: ${error}`); - } -}; - -// Helper function to validate file paths are within the uploads directory -const validateAndResolvePath = (filePath: string): string => { - // Resolve and normalize the path to prevent directory traversal attacks - const resolvedPath = path.resolve(filePath); - const uploadsPath = path.resolve(UPLOADS_DIR); - - // Ensure the resolved path is within the uploads directory - if (!resolvedPath.startsWith(uploadsPath)) { - throw new Error("Invalid file path: Path must be within uploads folder"); - } - - return resolvedPath; -}; - -const ensureDirectoryExists = async (dirPath: string) => { - const safePath = validateAndResolvePath(dirPath); - - try { - await access(safePath); - } catch (error: any) { - if (error.code === "ENOENT") { - await mkdir(safePath, { recursive: true }); - } else { - throw error; - } - } -}; - -type TGetFileResponse = { - fileBuffer: Buffer; - metaData: { - contentType: string; - }; -}; - -// discriminated union -type TGetSignedUrlResponse = - | { signedUrl: string; fileUrl: string; presignedFields: Object } - | { - signedUrl: string; - updatedFileName: string; - fileUrl: string; - signingData: { - signature: string; - timestamp: number; - uuid: string; - }; - }; - -const getS3SignedUrl = async (fileKey: string): Promise => { - const getObjectCommand = new GetObjectCommand({ - Bucket: S3_BUCKET_NAME, - Key: fileKey, - }); - - try { - const s3Client = getS3Client(); - return await getSignedUrl(s3Client, getObjectCommand, { expiresIn: 30 * 60 }); - } catch (err) { - throw err; - } -}; - -export const getS3File = async (fileKey: string): Promise => { - const signedUrl = await getS3SignedUrl(fileKey); - return signedUrl; -}; - -export const getLocalFile = async (filePath: string): Promise => { - try { - const safeFilePath = validateAndResolvePath(filePath); - const file = await readFile(safeFilePath); - let contentType = ""; - - try { - contentType = lookup(filePath) || ""; - } catch (err) { - throw err; - } - - return { - fileBuffer: file, - metaData: { - contentType: contentType ?? "", - }, - }; - } catch (err) { - throw err; - } -}; - -// a single service for generating a signed url based on user's environment variables -export const getUploadSignedUrl = async ( - fileName: string, - environmentId: string, - fileType: string, - accessType: TAccessType, - isBiggerFileUploadAllowed: boolean = false -): Promise => { - // add a unique id to the file name - - const fileExtension = fileName.split(".").pop(); - const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join("."); - - if (!fileExtension) { - throw new Error("File extension not found"); - } - - const updatedFileName = `${fileNameWithoutExtension}--fid--${randomUUID()}.${fileExtension}`; - - // Use PUBLIC_URL for public files, WEBAPP_URL for private files - const publicDomain = getPublicDomain(); - const baseUrl = accessType === "public" ? getPublicDomain() : WEBAPP_URL; - - // handle the local storage case first - if (!isS3Configured()) { - try { - const { signature, timestamp, uuid } = generateLocalSignedUrl(updatedFileName, environmentId, fileType); - - return { - signedUrl: - accessType === "private" - ? new URL(`${publicDomain}/api/v1/client/${environmentId}/storage/local`).href - : new URL(`${WEBAPP_URL}/api/v1/management/storage/local`).href, - signingData: { - signature, - timestamp, - uuid, - }, - updatedFileName, - fileUrl: new URL(`${baseUrl}/storage/${environmentId}/${accessType}/${updatedFileName}`).href, - }; - } catch (err) { - throw err; - } - } - - try { - const { presignedFields, signedUrl } = await getS3UploadSignedUrl( - updatedFileName, - fileType, - accessType, - environmentId, - isBiggerFileUploadAllowed - ); - - return { - signedUrl, - presignedFields, - fileUrl: new URL(`${baseUrl}/storage/${environmentId}/${accessType}/${updatedFileName}`).href, - }; - } catch (err) { - throw err; - } -}; - -export const getS3UploadSignedUrl = async ( - fileName: string, - contentType: string, - accessType: string, - environmentId: string, - isBiggerFileUploadAllowed: boolean = false -) => { - const maxSize = IS_FORMBRICKS_CLOUD - ? isBiggerFileUploadAllowed - ? MAX_SIZES.big - : MAX_SIZES.standard - : Infinity; - - const postConditions: PresignedPostOptions["Conditions"] = IS_FORMBRICKS_CLOUD - ? [["content-length-range", 0, maxSize]] - : undefined; - - try { - const s3Client = getS3Client(); - const { fields, url } = await createPresignedPost(s3Client, { - Expires: 10 * 60, // 10 minutes - Bucket: env.S3_BUCKET_NAME!, - Key: `${environmentId}/${accessType}/${fileName}`, - Fields: { - "Content-Type": contentType, - "Content-Encoding": "base64", - }, - Conditions: postConditions, - }); - - return { - signedUrl: url, - presignedFields: fields, - }; - } catch (err) { - throw err; - } -}; - -export const putFileToLocalStorage = async ( - fileName: string, - fileBuffer: Buffer, - accessType: string, - environmentId: string, - rootDir: string, - isBiggerFileUploadAllowed: boolean = false -) => { - try { - await ensureDirectoryExists(`${rootDir}/${environmentId}/${accessType}`); - - const uploadPath = `${rootDir}/${environmentId}/${accessType}/${fileName}`; - const safeUploadPath = validateAndResolvePath(uploadPath); - - const buffer = Buffer.from(fileBuffer as unknown as WithImplicitCoercion); - const bufferBytes = buffer.byteLength; - - const maxSize = IS_FORMBRICKS_CLOUD - ? isBiggerFileUploadAllowed - ? MAX_SIZES.big - : MAX_SIZES.standard - : Infinity; - - if (bufferBytes > maxSize) { - const err = new Error(`File size exceeds the ${maxSize / (1024 * 1024)} MB limit`); - err.name = "FileTooLargeError"; - - throw err; - } - - await writeFile(safeUploadPath, buffer as unknown as any); - } catch (err) { - throw err; - } -}; - -// a single service to put file in the storage(local or S3), based on the S3 configuration -export const putFile = async ( - fileName: string, - fileBuffer: Buffer, - accessType: TAccessType, - environmentId: string -) => { - try { - if (!isS3Configured()) { - await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR); - return { success: true, message: "File uploaded" }; - } else { - const input = { - Body: fileBuffer, - Bucket: S3_BUCKET_NAME, - Key: `${environmentId}/${accessType}/${fileName}`, - }; - - const command = new PutObjectCommand(input); - const s3Client = getS3Client(); - await s3Client.send(command); - return { success: true, message: "File uploaded" }; - } - } catch (err) { - throw err; - } -}; - -export const deleteFile = async ( - environmentId: string, - accessType: TAccessType, - fileName: string -): Promise<{ success: boolean; message: string; code?: number }> => { - if (!isS3Configured()) { - try { - await deleteLocalFile(path.join(UPLOADS_DIR, environmentId, accessType, fileName)); - return { success: true, message: "File deleted" }; - } catch (err: any) { - if (err.code !== "ENOENT") { - return { success: false, message: err.message ?? "Something went wrong" }; - } - - return { success: false, message: "File not found", code: 404 }; - } - } - - try { - await deleteS3File(`${environmentId}/${accessType}/${fileName}`); - return { success: true, message: "File deleted" }; - } catch (err: any) { - if (err.name === "NoSuchKey") { - return { success: false, message: "File not found", code: 404 }; - } else { - return { success: false, message: err.message ?? "Something went wrong" }; - } - } -}; - -export const deleteLocalFile = async (filePath: string) => { - try { - const safeFilePath = validateAndResolvePath(filePath); - await unlink(safeFilePath); - } catch (err: any) { - throw err; - } -}; - -export const deleteS3File = async (fileKey: string) => { - const deleteObjectCommand = new DeleteObjectCommand({ - Bucket: S3_BUCKET_NAME, - Key: fileKey, - }); - - try { - const s3Client = getS3Client(); - await s3Client.send(deleteObjectCommand); - } catch (err) { - throw err; - } -}; - -export const deleteS3FilesByEnvironmentId = async (environmentId: string) => { - try { - // List all objects in the bucket with the prefix of environmentId - const s3Client = getS3Client(); - const listObjectsOutput = await s3Client.send( - new ListObjectsCommand({ - Bucket: S3_BUCKET_NAME, - Prefix: environmentId, - }) - ); - - if (listObjectsOutput.Contents) { - const objectsToDelete = listObjectsOutput.Contents.map((obj) => { - return { Key: obj.Key }; - }); - - if (!objectsToDelete.length) { - // no objects to delete - return null; - } - - // Delete the objects - await s3Client.send( - new DeleteObjectsCommand({ - Bucket: S3_BUCKET_NAME, - Delete: { - Objects: objectsToDelete, - }, - }) - ); - } else { - // no objects to delete - return null; - } - } catch (err) { - throw err; - } -}; - -export const deleteLocalFilesByEnvironmentId = async (environmentId: string) => { - const dirPath = join(UPLOADS_DIR, environmentId); - - try { - await ensureDirectoryExists(dirPath); - await rmdir(dirPath, { recursive: true }); - } catch (err) { - throw err; - } -}; diff --git a/apps/web/lib/storage/utils.test.ts b/apps/web/lib/storage/utils.test.ts deleted file mode 100644 index e41fe79a52..0000000000 --- a/apps/web/lib/storage/utils.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { logger } from "@formbricks/logger"; -import { getFileNameWithIdFromUrl, getOriginalFileNameFromUrl } from "./utils"; - -vi.mock("@formbricks/logger", () => ({ - logger: { - error: vi.fn(), - }, -})); - -describe("Storage Utils", () => { - describe("getOriginalFileNameFromUrl", () => { - test("should handle URL without file ID", () => { - const url = "/storage/test-file.pdf"; - expect(getOriginalFileNameFromUrl(url)).toBe("test-file.pdf"); - }); - - test("should handle invalid URL", () => { - const url = "invalid-url"; - expect(getOriginalFileNameFromUrl(url)).toBeUndefined(); - expect(logger.error).toHaveBeenCalled(); - }); - }); - - describe("getFileNameWithIdFromUrl", () => { - test("should get full filename with ID from storage URL", () => { - const url = "/storage/test-file.pdf--fid--123"; - expect(getFileNameWithIdFromUrl(url)).toBe("test-file.pdf--fid--123"); - }); - - test("should get full filename with ID from external URL", () => { - const url = "https://example.com/path/test-file.pdf--fid--123"; - expect(getFileNameWithIdFromUrl(url)).toBe("test-file.pdf--fid--123"); - }); - - test("should handle invalid URL", () => { - const url = "invalid-url"; - expect(getFileNameWithIdFromUrl(url)).toBeUndefined(); - expect(logger.error).toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/web/lib/storage/utils.ts b/apps/web/lib/storage/utils.ts deleted file mode 100644 index 193978ccb0..0000000000 --- a/apps/web/lib/storage/utils.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { logger } from "@formbricks/logger"; - -export const getOriginalFileNameFromUrl = (fileURL: string) => { - try { - const fileNameFromURL = fileURL.startsWith("/storage/") - ? fileURL.split("/").pop() - : new URL(fileURL).pathname.split("/").pop(); - - const fileExt = fileNameFromURL?.split(".").pop() ?? ""; - const originalFileName = fileNameFromURL?.split("--fid--")[0] ?? ""; - const fileId = fileNameFromURL?.split("--fid--")[1] ?? ""; - - if (!fileId) { - const fileName = originalFileName ? decodeURIComponent(originalFileName || "") : ""; - return fileName; - } - - const fileName = originalFileName ? decodeURIComponent(`${originalFileName}.${fileExt}` || "") : ""; - return fileName; - } catch (error) { - logger.error(error, "Error parsing file URL"); - } -}; - -export const getFileNameWithIdFromUrl = (fileURL: string) => { - try { - const fileNameFromURL = fileURL.startsWith("/storage/") - ? fileURL.split("/").pop() - : new URL(fileURL).pathname.split("/").pop(); - - return fileNameFromURL ? decodeURIComponent(fileNameFromURL || "") : ""; - } catch (error) { - logger.error(error, "Error parsing file URL"); - } -}; diff --git a/apps/web/lib/survey/utils.test.ts b/apps/web/lib/survey/utils.test.ts index 18dee96bce..a8e2306d43 100644 --- a/apps/web/lib/survey/utils.test.ts +++ b/apps/web/lib/survey/utils.test.ts @@ -1,4 +1,4 @@ -import * as fileValidation from "@/lib/fileValidation"; +import * as fileValidation from "@/modules/storage/utils"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { InvalidInputError } from "@formbricks/types/errors"; import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; diff --git a/apps/web/lib/survey/utils.ts b/apps/web/lib/survey/utils.ts index d556eaf71b..7af2cc58fe 100644 --- a/apps/web/lib/survey/utils.ts +++ b/apps/web/lib/survey/utils.ts @@ -1,5 +1,5 @@ import "server-only"; -import { isValidImageFile } from "@/lib/fileValidation"; +import { isValidImageFile } from "@/modules/storage/utils"; import { InvalidInputError } from "@formbricks/types/errors"; import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TSegment } from "@formbricks/types/segment"; diff --git a/apps/web/lib/utils/file-conversion.ts b/apps/web/lib/utils/file-conversion.ts index 5c4236cc4f..46ffc81884 100644 --- a/apps/web/lib/utils/file-conversion.ts +++ b/apps/web/lib/utils/file-conversion.ts @@ -15,12 +15,16 @@ export const convertToCsv = async (fields: string[], jsonData: Record[]) => { +export const convertToXlsxBuffer = ( + fields: string[], + jsonData: Record[] +): Buffer => { const wb = xlsx.utils.book_new(); const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields }); xlsx.utils.book_append_sheet(wb, ws, "Sheet1"); - return xlsx.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer; + return xlsx.write(wb, { type: "buffer", bookType: "xlsx" }); }; diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index e14ebd4225..570e83d8b4 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -1716,6 +1716,7 @@ "personal_links": { "create_and_manage_segments": "Erstellen und verwalten Sie Ihre Segmente unter Kontakte > Segmente", "description": "Erstellen Sie persönliche Links für ein Segment und ordnen Sie Umfrageantworten jedem Kontakt zu.", + "error_generating_links": "Erstellen von Links fehlgeschlagen, bitte versuche es erneut", "expiry_date_description": "Sobald der Link abläuft, kann der Empfänger nicht mehr auf die Umfrage antworten.", "expiry_date_optional": "Ablaufdatum (optional)", "generate_and_download_links": "Links generieren und herunterladen", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 740e5d30cc..4dc6b3cca7 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -1716,6 +1716,7 @@ "personal_links": { "create_and_manage_segments": "Create and manage your Segments under Contacts > Segments", "description": "Generate personal links for a segment and match survey responses to each contact.", + "error_generating_links": "Failed to generate links, please try again", "expiry_date_description": "Once the link expires, the recipient cannot respond to survey any longer.", "expiry_date_optional": "Expiry date (optional)", "generate_and_download_links": "Generate & download links", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index c26c7037e2..96170d26e3 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -1716,6 +1716,7 @@ "personal_links": { "create_and_manage_segments": "Créez et gérez vos Segments sous Contacts > Segments", "description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact.", + "error_generating_links": "Échec de la génération des liens. Veuillez réessayer", "expiry_date_description": "Une fois le lien expiré, le destinataire ne peut plus répondre au sondage.", "expiry_date_optional": "Date d'expiration (facultatif)", "generate_and_download_links": "Générer et télécharger les liens", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 9332ef9e05..8a57af2ea1 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -1716,6 +1716,7 @@ "personal_links": { "create_and_manage_segments": "「連絡先 > セグメント」でセグメントを作成・管理", "description": "セグメントの個人リンクを生成し、フォームの回答を各連絡先と照合します。", + "error_generating_links": "リンクの生成に失敗しました。再度お試しください。", "expiry_date_description": "リンクの有効期限が切れると、受信者はフォームに回答できなくなります。", "expiry_date_optional": "有効期限(オプション)", "generate_and_download_links": "リンクを生成&ダウンロード", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 1103460944..8d1b01bf19 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -1716,6 +1716,7 @@ "personal_links": { "create_and_manage_segments": "Crie e gerencie seus Segmentos em Contatos > Segmentos", "description": "Gerar links pessoais para um segmento e associar respostas de pesquisa a cada contato.", + "error_generating_links": "Falha ao gerar links, por favor, tente novamente", "expiry_date_description": "Quando o link expirar, o destinatário não poderá mais responder à pesquisa.", "expiry_date_optional": "Data de expiração (opcional)", "generate_and_download_links": "Gerar & baixar links", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index ae4fceb203..9c222a7dbb 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -1716,6 +1716,7 @@ "personal_links": { "create_and_manage_segments": "Crie e gere os seus Segmentos em Contactos > Segmentos", "description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto.", + "error_generating_links": "Falha ao gerar links, por favor, tente novamente", "expiry_date_description": "Uma vez que o link expira, o destinatário não pode mais responder ao questionário.", "expiry_date_optional": "Data de expiração (opcional)", "generate_and_download_links": "Gerar & descarregar links", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 7ab0ddb562..f950e79b8a 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -1716,6 +1716,7 @@ "personal_links": { "create_and_manage_segments": "Creați și gestionați segmentele dvs. sub Contacte > Segmente", "description": "Generează linkuri personale pentru un segment și corelează răspunsurile la sondaje cu fiecare contact.", + "error_generating_links": "Generarea linkurilor a eșuat, vă rugăm să încercați din nou", "expiry_date_description": "Odată ce link-ul expiră, destinatarul nu mai poate răspunde la sondaj.", "expiry_date_optional": "Dată de expirare (opțional)", "generate_and_download_links": "Generează și descarcă legături", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 29f99ede6a..c7ef7ee4ab 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -1716,6 +1716,7 @@ "personal_links": { "create_and_manage_segments": "在 聯絡人 > 分段 中建立和管理您的分段", "description": "為 一個 群組 生成 個人 連結,並 將 調查 回應 對應 到 每個 聯絡人。", + "error_generating_links": "生成連結失敗,請再試一次", "expiry_date_description": "一旦連結過期,收件者將無法再回應 survey。", "expiry_date_optional": "到期日 (可選)", "generate_and_download_links": "生成 & 下載 連結", diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts index a19b040c4e..f061b8d423 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts @@ -1,5 +1,5 @@ import { environmentId, fileUploadQuestion, openTextQuestion, responseData } from "./__mocks__/utils.mock"; -import { deleteFile } from "@/lib/storage/service"; +import { deleteFile } from "@/modules/storage/service"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { logger } from "@formbricks/logger"; import { okVoid } from "@formbricks/types/error-handlers"; @@ -11,7 +11,7 @@ vi.mock("@formbricks/logger", () => ({ }, })); -vi.mock("@/lib/storage/service", () => ({ +vi.mock("@/modules/storage/service", () => ({ deleteFile: vi.fn(), })); @@ -21,7 +21,7 @@ describe("findAndDeleteUploadedFilesInResponse", () => { }); test("delete files for file upload questions and return okVoid", async () => { - vi.mocked(deleteFile).mockResolvedValue({ success: true, message: "File deleted successfully" }); + vi.mocked(deleteFile).mockResolvedValue({ ok: true, data: undefined }); const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]); @@ -56,7 +56,7 @@ describe("findAndDeleteUploadedFilesInResponse", () => { }); test("process multiple file URLs", async () => { - vi.mocked(deleteFile).mockResolvedValue({ success: true, message: "File deleted successfully" }); + vi.mocked(deleteFile).mockResolvedValue({ ok: true, data: undefined }); const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]); diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts index b76fbd62f2..0efeb97719 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts @@ -1,5 +1,5 @@ -import { deleteFile } from "@/lib/storage/service"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { deleteFile } from "@/modules/storage/service"; import { Response, Survey } from "@prisma/client"; import { logger } from "@formbricks/logger"; import { Result, okVoid } from "@formbricks/types/error-handlers"; diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts index 3d85273e7e..d1211196bc 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts @@ -1,4 +1,3 @@ -import { validateFileUploads } from "@/lib/fileValidation"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question"; import { responses } from "@/modules/api/v2/lib/response"; @@ -12,6 +11,7 @@ import { import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { validateFileUploads } from "@/modules/storage/utils"; import { z } from "zod"; import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses"; diff --git a/apps/web/modules/api/v2/management/responses/route.ts b/apps/web/modules/api/v2/management/responses/route.ts index 3e0879a262..de8f32b088 100644 --- a/apps/web/modules/api/v2/management/responses/route.ts +++ b/apps/web/modules/api/v2/management/responses/route.ts @@ -1,4 +1,3 @@ -import { validateFileUploads } from "@/lib/fileValidation"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question"; import { responses } from "@/modules/api/v2/lib/response"; @@ -8,6 +7,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { validateFileUploads } from "@/modules/storage/utils"; import { Response } from "@prisma/client"; import { NextRequest } from "next/server"; import { createResponse, getResponses } from "./lib/response"; diff --git a/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.test.tsx b/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.test.tsx index cb762d18a9..11f8c8059a 100644 --- a/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.test.tsx +++ b/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.test.tsx @@ -1,9 +1,9 @@ -import { handleFileUpload } from "@/app/lib/fileUpload"; import { removeOrganizationEmailLogoUrlAction, sendTestEmailAction, updateOrganizationEmailLogoUrlAction, } from "@/modules/ee/whitelabel/email-customization/actions"; +import { handleFileUpload } from "@/modules/storage/file-upload"; import "@testing-library/jest-dom/vitest"; import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -18,7 +18,7 @@ vi.mock("@/modules/ee/whitelabel/email-customization/actions", () => ({ updateOrganizationEmailLogoUrlAction: vi.fn(), })); -vi.mock("@/app/lib/fileUpload", () => ({ +vi.mock("@/modules/storage/file-upload", () => ({ handleFileUpload: vi.fn(), })); diff --git a/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx b/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx index ff68bee140..f5af5b1b13 100644 --- a/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx +++ b/apps/web/modules/ee/whitelabel/email-customization/components/email-customization-settings.tsx @@ -1,7 +1,6 @@ "use client"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; -import { handleFileUpload } from "@/app/lib/fileUpload"; import { cn } from "@/lib/cn"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { @@ -9,6 +8,7 @@ import { sendTestEmailAction, updateOrganizationEmailLogoUrlAction, } from "@/modules/ee/whitelabel/email-customization/actions"; +import { handleFileUpload } from "@/modules/storage/file-upload"; import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Button } from "@/modules/ui/components/button"; import { Uploader } from "@/modules/ui/components/file-input/components/uploader"; @@ -20,8 +20,8 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import React, { useRef, useState } from "react"; import { toast } from "react-hot-toast"; -import { TAllowedFileExtension } from "@formbricks/types/common"; import { TOrganization } from "@formbricks/types/organizations"; +import { TAllowedFileExtension } from "@formbricks/types/storage"; import { TUser } from "@formbricks/types/user"; const allowedFileExtensions: TAllowedFileExtension[] = ["jpeg", "png", "jpg", "webp"]; diff --git a/apps/web/modules/email/emails/lib/utils.tsx b/apps/web/modules/email/emails/lib/utils.tsx index 466d333487..9f48f0b457 100644 --- a/apps/web/modules/email/emails/lib/utils.tsx +++ b/apps/web/modules/email/emails/lib/utils.tsx @@ -1,4 +1,4 @@ -import { getOriginalFileNameFromUrl } from "@/lib/storage/utils"; +import { getOriginalFileNameFromUrl } from "@/modules/storage/utils"; import { Column, Container, Img, Link, Row, Text } from "@react-email/components"; import { TFnType } from "@tolgee/react"; import { FileIcon } from "lucide-react"; diff --git a/apps/web/modules/projects/settings/lib/project.test.ts b/apps/web/modules/projects/settings/lib/project.test.ts index 27fd5bf417..7f0b10ef99 100644 --- a/apps/web/modules/projects/settings/lib/project.test.ts +++ b/apps/web/modules/projects/settings/lib/project.test.ts @@ -1,5 +1,5 @@ import { createEnvironment } from "@/lib/environment/service"; -import { deleteLocalFilesByEnvironmentId, deleteS3FilesByEnvironmentId } from "@/lib/storage/service"; +import { deleteFilesByEnvironmentId } from "@/modules/storage/service"; import { Prisma } from "@prisma/client"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; @@ -64,22 +64,14 @@ vi.mock("@formbricks/logger", () => ({ }, })); -vi.mock("@/lib/storage/service", () => ({ - deleteLocalFilesByEnvironmentId: vi.fn(), - deleteS3FilesByEnvironmentId: vi.fn(), +vi.mock("@/modules/storage/service", () => ({ + deleteFilesByEnvironmentId: vi.fn(), })); vi.mock("@/lib/environment/service", () => ({ createEnvironment: vi.fn(), })); -let mockIsS3Configured = true; -vi.mock("@/lib/constants", () => ({ - isS3Configured: () => { - return mockIsS3Configured; - }, -})); - describe("project lib", () => { beforeEach(() => { vi.clearAllMocks(); @@ -156,28 +148,18 @@ describe("project lib", () => { }); describe("deleteProject", () => { - test("deletes project, deletes files, and revalidates cache (S3)", async () => { + test("deletes project, deletes files, and revalidates cache", async () => { vi.mocked(prisma.project.delete).mockResolvedValueOnce(baseProject as any); - vi.mocked(deleteS3FilesByEnvironmentId).mockResolvedValue(undefined); + vi.mocked(deleteFilesByEnvironmentId).mockResolvedValue({ ok: true, data: undefined }); const result = await deleteProject("p1"); expect(result).toEqual(baseProject); - expect(deleteS3FilesByEnvironmentId).toHaveBeenCalledWith("prodenv"); - }); - - test("deletes project, deletes files, and revalidates cache (local)", async () => { - vi.mocked(prisma.project.delete).mockResolvedValueOnce(baseProject as any); - mockIsS3Configured = false; - vi.mocked(deleteLocalFilesByEnvironmentId).mockResolvedValue(undefined); - const result = await deleteProject("p1"); - expect(result).toEqual(baseProject); - expect(deleteLocalFilesByEnvironmentId).toHaveBeenCalledWith("prodenv"); + expect(deleteFilesByEnvironmentId).toHaveBeenCalledWith("prodenv"); }); test("logs error if file deletion fails", async () => { vi.mocked(prisma.project.delete).mockResolvedValueOnce(baseProject as any); - mockIsS3Configured = true; - vi.mocked(deleteS3FilesByEnvironmentId).mockRejectedValueOnce(new Error("fail")); + vi.mocked(deleteFilesByEnvironmentId).mockRejectedValueOnce(new Error("fail")); vi.mocked(logger.error).mockImplementation(() => {}); await deleteProject("p1"); expect(logger.error).toHaveBeenCalled(); diff --git a/apps/web/modules/projects/settings/lib/project.ts b/apps/web/modules/projects/settings/lib/project.ts index c51321ce26..cfb735dec0 100644 --- a/apps/web/modules/projects/settings/lib/project.ts +++ b/apps/web/modules/projects/settings/lib/project.ts @@ -1,8 +1,7 @@ import "server-only"; -import { isS3Configured } from "@/lib/constants"; import { createEnvironment } from "@/lib/environment/service"; -import { deleteLocalFilesByEnvironmentId, deleteS3FilesByEnvironmentId } from "@/lib/storage/service"; import { validateInputs } from "@/lib/utils/validate"; +import { deleteFilesByEnvironmentId } from "@/modules/storage/service"; import { Prisma } from "@prisma/client"; import { z } from "zod"; import { prisma } from "@formbricks/database"; @@ -141,28 +140,15 @@ export const deleteProject = async (projectId: string): Promise => { if (project) { // delete all files from storage related to this project - if (isS3Configured()) { - const s3FilesPromises = project.environments.map(async (environment) => { - return deleteS3FilesByEnvironmentId(environment.id); - }); + const s3FilesPromises = project.environments.map(async (environment) => { + return deleteFilesByEnvironmentId(environment.id); + }); - try { - await Promise.all(s3FilesPromises); - } catch (err) { - // fail silently because we don't want to throw an error if the files are not deleted - logger.error(err, "Error deleting S3 files"); - } - } else { - const localFilesPromises = project.environments.map(async (environment) => { - return deleteLocalFilesByEnvironmentId(environment.id); - }); - - try { - await Promise.all(localFilesPromises); - } catch (err) { - // fail silently because we don't want to throw an error if the files are not deleted - logger.error(err, "Error deleting local files"); - } + try { + await Promise.all(s3FilesPromises); + } catch (err) { + // fail silently because we don't want to throw an error if the files are not deleted + logger.error(err, "Error deleting S3 files"); } } @@ -171,6 +157,7 @@ export const deleteProject = async (projectId: string): Promise => { if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError(error.message); } + throw error; } }; diff --git a/apps/web/modules/projects/settings/look/components/edit-logo.tsx b/apps/web/modules/projects/settings/look/components/edit-logo.tsx index 6332819c82..32ff371157 100644 --- a/apps/web/modules/projects/settings/look/components/edit-logo.tsx +++ b/apps/web/modules/projects/settings/look/components/edit-logo.tsx @@ -1,8 +1,8 @@ "use client"; -import { handleFileUpload } from "@/app/lib/fileUpload"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { updateProjectAction } from "@/modules/projects/settings/actions"; +import { handleFileUpload } from "@/modules/storage/file-upload"; import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Button } from "@/modules/ui/components/button"; diff --git a/apps/web/app/lib/fileUpload.test.ts b/apps/web/modules/storage/file-upload.test.ts similarity index 99% rename from apps/web/app/lib/fileUpload.test.ts rename to apps/web/modules/storage/file-upload.test.ts index 2bf8b049be..9ba09f7e8e 100644 --- a/apps/web/app/lib/fileUpload.test.ts +++ b/apps/web/modules/storage/file-upload.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import * as fileUploadModule from "./fileUpload"; +import * as fileUploadModule from "./file-upload"; // Mock global fetch const mockFetch = vi.fn(); diff --git a/apps/web/app/lib/fileUpload.ts b/apps/web/modules/storage/file-upload.ts similarity index 60% rename from apps/web/app/lib/fileUpload.ts rename to apps/web/modules/storage/file-upload.ts index 007ee42847..e1ec2ee12d 100644 --- a/apps/web/app/lib/fileUpload.ts +++ b/apps/web/modules/storage/file-upload.ts @@ -30,11 +30,6 @@ export const handleFileUpload = async ( url: "", }; } - - if (!file.type.startsWith("image/")) { - return { error: FileUploadError.INVALID_FILE_TYPE, url: "" }; - } - const fileBuffer = await file.arrayBuffer(); const bufferBytes = fileBuffer.byteLength; @@ -72,58 +67,36 @@ export const handleFileUpload = async ( const json = await response.json(); const { data } = json; - const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data; + const { signedUrl, fileUrl, presignedFields } = data as { + signedUrl: string; + presignedFields: Record; + fileUrl: string; + }; - let localUploadDetails: Record = {}; + const fileBase64 = (await toBase64(file)) as string; + const formDataForS3 = new FormData(); - if (signingData) { - const { signature, timestamp, uuid } = signingData; + Object.entries(presignedFields as Record).forEach(([key, value]) => { + formDataForS3.append(key, value); + }); - localUploadDetails = { - fileType: file.type, - fileName: encodeURIComponent(updatedFileName), - environmentId, - signature, - timestamp: String(timestamp), - uuid, + try { + const binaryString = atob(fileBase64.split(",")[1]); + const uint8Array = Uint8Array.from([...binaryString].map((char) => char.charCodeAt(0))); + const blob = new Blob([uint8Array], { type: file.type }); + + formDataForS3.append("file", blob); + } catch (err) { + console.error("Error in uploading file: ", err); + return { + error: FileUploadError.UPLOAD_FAILED, + url: "", }; } - const fileBase64 = (await toBase64(file)) as string; - - const formData: Record = {}; - const formDataForS3 = new FormData(); - - if (presignedFields) { - Object.entries(presignedFields as Record).forEach(([key, value]) => { - formDataForS3.append(key, value); - }); - - try { - const binaryString = atob(fileBase64.split(",")[1]); - const uint8Array = Uint8Array.from([...binaryString].map((char) => char.charCodeAt(0))); - const blob = new Blob([uint8Array], { type: file.type }); - - formDataForS3.append("file", blob); - } catch (err) { - console.error(err); - return { - error: FileUploadError.UPLOAD_FAILED, - url: "", - }; - } - } - - formData.fileBase64String = fileBase64; - const uploadResponse = await fetch(signedUrl, { method: "POST", - body: presignedFields - ? formDataForS3 - : JSON.stringify({ - ...formData, - ...localUploadDetails, - }), + body: formDataForS3, }); if (!uploadResponse.ok) { diff --git a/apps/web/modules/storage/service.test.ts b/apps/web/modules/storage/service.test.ts new file mode 100644 index 0000000000..1c1b416d82 --- /dev/null +++ b/apps/web/modules/storage/service.test.ts @@ -0,0 +1,384 @@ +import { randomUUID } from "crypto"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { StorageErrorCode } from "@formbricks/storage"; +import { TAccessType } from "@formbricks/types/storage"; +import { + deleteFile, + deleteFilesByEnvironmentId, + getSignedUrlForDownload, + getSignedUrlForUpload, +} from "./service"; + +// Mock external dependencies +vi.mock("crypto", () => ({ + randomUUID: vi.fn(), +})); + +vi.mock("@/lib/constants", () => ({ + WEBAPP_URL: "https://webapp.example.com", +})); + +vi.mock("@/lib/getPublicUrl", () => ({ + getPublicDomain: vi.fn(), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +vi.mock("@formbricks/storage", () => ({ + StorageErrorCode: { + Unknown: "UNKNOWN", + FileNotFound: "FILE_NOT_FOUND", + FileSizeExceeded: "FILE_SIZE_EXCEEDED", + }, + deleteFile: vi.fn(), + deleteFilesByPrefix: vi.fn(), + getSignedDownloadUrl: vi.fn(), + getSignedUploadUrl: vi.fn(), +})); + +// Import mocked dependencies +const { logger } = await import("@formbricks/logger"); +const { getPublicDomain } = await import("@/lib/getPublicUrl"); +const { + deleteFile: deleteFileFromS3, + deleteFilesByPrefix, + getSignedDownloadUrl, + getSignedUploadUrl, +} = await import("@formbricks/storage"); + +type MockedSignedUploadReturn = Awaited>; +type MockedSignedDownloadReturn = Awaited>; +type MockedDeleteFileReturn = Awaited>; +type MockedDeleteFilesByPrefixReturn = Awaited>; + +const mockUUID = "test-uuid-123-456-789-10"; + +describe("storage service", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(randomUUID).mockReturnValue(mockUUID); + vi.mocked(getPublicDomain).mockReturnValue("https://public.example.com"); + }); + + describe("getSignedUrlForUpload", () => { + test("should generate signed URL for upload with unique filename", async () => { + const mockSignedUrlResponse = { + ok: true, + data: { + signedUrl: "https://s3.example.com/upload", + presignedFields: { key: "value" }, + }, + } as MockedSignedUploadReturn; + + vi.mocked(getSignedUploadUrl).mockResolvedValue(mockSignedUrlResponse); + + const result = await getSignedUrlForUpload( + "test-image.jpg", + "env-123", + "image/jpeg", + "public" as TAccessType + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toEqual({ + signedUrl: "https://s3.example.com/upload", + presignedFields: { key: "value" }, + fileUrl: `https://public.example.com/storage/env-123/public/test-image--fid--${mockUUID}.jpg`, + }); + } + + expect(getSignedUploadUrl).toHaveBeenCalledWith( + `test-image--fid--${mockUUID}.jpg`, + "image/jpeg", + "env-123/public", + 1024 * 1024 * 10 // 10MB default + ); + }); + + test("should use WEBAPP_URL for private files", async () => { + const mockSignedUrlResponse = { + ok: true, + data: { + signedUrl: "https://s3.example.com/upload", + presignedFields: { key: "value" }, + }, + } as MockedSignedUploadReturn; + + vi.mocked(getSignedUploadUrl).mockResolvedValue(mockSignedUrlResponse); + + const result = await getSignedUrlForUpload( + "test-doc.pdf", + "env-123", + "application/pdf", + "private" as TAccessType + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.fileUrl).toBe( + `https://webapp.example.com/storage/env-123/private/test-doc--fid--${mockUUID}.pdf` + ); + } + }); + + test("should handle files with multiple dots in filename", async () => { + const mockSignedUrlResponse = { + ok: true, + data: { + signedUrl: "https://s3.example.com/upload", + presignedFields: { key: "value" }, + }, + } as MockedSignedUploadReturn; + + vi.mocked(getSignedUploadUrl).mockResolvedValue(mockSignedUrlResponse); + + const result = await getSignedUrlForUpload( + "my.backup.file.pdf", + "env-123", + "application/pdf", + "public" as TAccessType + ); + + expect(result.ok).toBe(true); + expect(getSignedUploadUrl).toHaveBeenCalledWith( + `my.backup.file--fid--${mockUUID}.pdf`, + "application/pdf", + "env-123/public", + 1024 * 1024 * 10 + ); + }); + + test("should use custom maxFileUploadSize when provided", async () => { + const mockSignedUrlResponse = { + ok: true, + data: { + signedUrl: "https://s3.example.com/upload", + presignedFields: { key: "value" }, + }, + } as MockedSignedUploadReturn; + + vi.mocked(getSignedUploadUrl).mockResolvedValue(mockSignedUrlResponse); + + await getSignedUrlForUpload( + "large-file.pdf", + "env-123", + "application/pdf", + "public" as TAccessType, + 1024 * 1024 * 50 // 50MB + ); + + expect(getSignedUploadUrl).toHaveBeenCalledWith( + `large-file--fid--${mockUUID}.pdf`, + "application/pdf", + "env-123/public", + 1024 * 1024 * 50 + ); + }); + + test("should return error when getSignedUploadUrl fails", async () => { + const mockErrorResponse = { + ok: false, + error: { + code: StorageErrorCode.S3ClientError, + }, + } as MockedSignedUploadReturn; + + vi.mocked(getSignedUploadUrl).mockResolvedValue(mockErrorResponse); + + const result = await getSignedUrlForUpload( + "test-file.pdf", + "env-123", + "application/pdf", + "public" as TAccessType + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(StorageErrorCode.S3ClientError); + } + }); + + test("should handle unexpected errors and return unknown error", async () => { + vi.mocked(getSignedUploadUrl).mockRejectedValue(new Error("Unexpected error")); + + const result = await getSignedUrlForUpload( + "test-file.pdf", + "env-123", + "application/pdf", + "public" as TAccessType + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(StorageErrorCode.Unknown); + } + expect(logger.error).toHaveBeenCalledWith( + { error: expect.any(Error) }, + "Error getting signed url for upload" + ); + }); + }); + + describe("getSignedUrlForDownload", () => { + test("should generate signed URL for download", async () => { + const mockSignedUrlResponse = { + ok: true, + data: "https://s3.example.com/download?signature=abc123", + } as MockedSignedDownloadReturn; + + vi.mocked(getSignedDownloadUrl).mockResolvedValue(mockSignedUrlResponse); + + const result = await getSignedUrlForDownload("test-file.jpg", "env-123", "public" as TAccessType); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toBe("https://s3.example.com/download?signature=abc123"); + } + expect(getSignedDownloadUrl).toHaveBeenCalledWith("env-123/public/test-file.jpg"); + }); + + test("should decode URI-encoded filename", async () => { + const mockSignedUrlResponse = { + ok: true, + data: "https://s3.example.com/download?signature=abc123", + } as MockedSignedDownloadReturn; + + vi.mocked(getSignedDownloadUrl).mockResolvedValue(mockSignedUrlResponse); + + const encodedFileName = encodeURIComponent("file with spaces.jpg"); + await getSignedUrlForDownload(encodedFileName, "env-123", "private" as TAccessType); + + expect(getSignedDownloadUrl).toHaveBeenCalledWith("env-123/private/file with spaces.jpg"); + }); + + test("should return error when getSignedDownloadUrl fails", async () => { + const mockErrorResponse = { + ok: false, + error: { + code: StorageErrorCode.S3ClientError, + }, + } as MockedSignedDownloadReturn; + + vi.mocked(getSignedDownloadUrl).mockResolvedValue(mockErrorResponse); + + const result = await getSignedUrlForDownload("missing-file.jpg", "env-123", "public" as TAccessType); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(StorageErrorCode.S3ClientError); + } + }); + + test("should handle unexpected errors and return unknown error", async () => { + vi.mocked(getSignedDownloadUrl).mockRejectedValue(new Error("Network error")); + + const result = await getSignedUrlForDownload("test-file.jpg", "env-123", "public" as TAccessType); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(StorageErrorCode.Unknown); + } + expect(logger.error).toHaveBeenCalledWith( + { error: expect.any(Error) }, + "Error getting signed url for download" + ); + }); + + test("should handle files with special characters", async () => { + const mockSignedUrlResponse = { + ok: true, + data: "https://s3.example.com/download?signature=abc123", + } as MockedSignedDownloadReturn; + + vi.mocked(getSignedDownloadUrl).mockResolvedValue(mockSignedUrlResponse); + + const specialFileName = "file%20with%20%26%20symbols.jpg"; + await getSignedUrlForDownload(specialFileName, "env-123", "public" as TAccessType); + + expect(getSignedDownloadUrl).toHaveBeenCalledWith("env-123/public/file with & symbols.jpg"); + }); + }); + + describe("deleteFile", () => { + test("should call deleteFileFromS3 with correct file key", async () => { + const mockSuccessResult = { + ok: true, + data: undefined, + } as MockedDeleteFileReturn; + + vi.mocked(deleteFileFromS3).mockResolvedValue(mockSuccessResult); + + const result = await deleteFile("env-123", "public" as TAccessType, "test-file.jpg"); + + expect(result).toEqual(mockSuccessResult); + expect(deleteFileFromS3).toHaveBeenCalledWith("env-123/public/test-file.jpg"); + }); + + test("should handle private access type", async () => { + const mockSuccessResult = { + ok: true, + data: undefined, + } as MockedDeleteFileReturn; + + vi.mocked(deleteFileFromS3).mockResolvedValue(mockSuccessResult); + + const result = await deleteFile("env-456", "private" as TAccessType, "private-doc.pdf"); + + expect(result).toEqual(mockSuccessResult); + expect(deleteFileFromS3).toHaveBeenCalledWith("env-456/private/private-doc.pdf"); + }); + + test("should handle when deleteFileFromS3 returns error", async () => { + const mockErrorResult = { + ok: false, + error: { + code: StorageErrorCode.Unknown, + }, + } as MockedDeleteFileReturn; + + vi.mocked(deleteFileFromS3).mockResolvedValue(mockErrorResult); + + const result = await deleteFile("env-123", "public" as TAccessType, "test-file.jpg"); + + expect(result).toEqual(mockErrorResult); + expect(deleteFileFromS3).toHaveBeenCalledWith("env-123/public/test-file.jpg"); + }); + }); + + describe("deleteFilesByEnvironmentId", () => { + test("should call deleteFilesByPrefix with environment ID", async () => { + const mockSuccessResult = { + ok: true, + data: undefined, + } as MockedDeleteFilesByPrefixReturn; + + vi.mocked(deleteFilesByPrefix).mockResolvedValue(mockSuccessResult); + + const result = await deleteFilesByEnvironmentId("env-123"); + + expect(result).toEqual(mockSuccessResult); + expect(deleteFilesByPrefix).toHaveBeenCalledWith("env-123"); + }); + + test("should handle when deleteFilesByPrefix returns error", async () => { + const mockErrorResult = { + ok: false, + error: { + code: StorageErrorCode.Unknown, + }, + } as MockedDeleteFilesByPrefixReturn; + + vi.mocked(deleteFilesByPrefix).mockResolvedValue(mockErrorResult); + + const result = await deleteFilesByEnvironmentId("env-123"); + + expect(result).toEqual(mockErrorResult); + expect(deleteFilesByPrefix).toHaveBeenCalledWith("env-123"); + }); + }); +}); diff --git a/apps/web/modules/storage/service.ts b/apps/web/modules/storage/service.ts new file mode 100644 index 0000000000..f0c7ebae15 --- /dev/null +++ b/apps/web/modules/storage/service.ts @@ -0,0 +1,97 @@ +import { WEBAPP_URL } from "@/lib/constants"; +import { getPublicDomain } from "@/lib/getPublicUrl"; +import { randomUUID } from "crypto"; +import { logger } from "@formbricks/logger"; +import { + type StorageError, + StorageErrorCode, + deleteFile as deleteFileFromS3, + deleteFilesByPrefix, + getSignedDownloadUrl, + getSignedUploadUrl, +} from "@formbricks/storage"; +import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { TAccessType } from "@formbricks/types/storage"; + +export const getSignedUrlForUpload = async ( + fileName: string, + environmentId: string, + fileType: string, + accessType: TAccessType, + maxFileUploadSize: number = 1024 * 1024 * 10 // 10MB +): Promise< + Result< + { + signedUrl: string; + presignedFields: Record; + fileUrl: string; + }, + StorageError + > +> => { + try { + const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join("."); + const fileExtension = fileName.split(".").pop(); + + const updatedFileName = `${fileNameWithoutExtension}--fid--${randomUUID()}.${fileExtension}`; + + const signedUrlResult = await getSignedUploadUrl( + updatedFileName, + fileType, + `${environmentId}/${accessType}`, + maxFileUploadSize + ); + + if (!signedUrlResult.ok) { + return signedUrlResult; + } + + // Use PUBLIC_URL for public files, WEBAPP_URL for private files + const baseUrl = accessType === "public" ? getPublicDomain() : WEBAPP_URL; + + return ok({ + signedUrl: signedUrlResult.data.signedUrl, + presignedFields: signedUrlResult.data.presignedFields, + fileUrl: new URL(`${baseUrl}/storage/${environmentId}/${accessType}/${updatedFileName}`).href, + }); + } catch (error) { + logger.error({ error }, "Error getting signed url for upload"); + + return err({ + code: StorageErrorCode.Unknown, + }); + } +}; + +export const getSignedUrlForDownload = async ( + fileName: string, + environmentId: string, + accessType: TAccessType +): Promise> => { + try { + const fileNameDecoded = decodeURIComponent(fileName); + const fileKey = `${environmentId}/${accessType}/${fileNameDecoded}`; + + const signedUrlResult = await getSignedDownloadUrl(fileKey); + + if (!signedUrlResult.ok) { + return signedUrlResult; + } + + return signedUrlResult; + } catch (error) { + logger.error({ error }, "Error getting signed url for download"); + + return err({ + code: StorageErrorCode.Unknown, + }); + } +}; + +// We don't need to return or throw any errors, even if the file doesn't exist, we should not fail the request, nor log any errors, those will be handled by the deleteFile function +export const deleteFile = async (environmentId: string, accessType: TAccessType, fileName: string) => + await deleteFileFromS3(`${environmentId}/${accessType}/${fileName}`); + +// We don't need to return or throw any errors, even if the files don't exist, we should not fail the request, nor log any errors, those will be handled by the deleteFilesByPrefix function +export const deleteFilesByEnvironmentId = async (environmentId: string) => + await deleteFilesByPrefix(environmentId); diff --git a/apps/web/lib/fileValidation.test.ts b/apps/web/modules/storage/utils.test.ts similarity index 70% rename from apps/web/lib/fileValidation.test.ts rename to apps/web/modules/storage/utils.test.ts index 82a0c069f3..88b496f903 100644 --- a/apps/web/lib/fileValidation.test.ts +++ b/apps/web/modules/storage/utils.test.ts @@ -1,27 +1,31 @@ -import * as storageUtils from "@/lib/storage/utils"; -import { describe, expect, test, vi } from "vitest"; -import { ZAllowedFileExtension } from "@formbricks/types/common"; -import { TResponseData } from "@formbricks/types/responses"; -import { TSurveyQuestion } from "@formbricks/types/surveys/types"; import { isAllowedFileExtension, isValidFileTypeForExtension, isValidImageFile, - validateFile, validateFileUploads, validateSingleFile, -} from "./fileValidation"; +} from "@/modules/storage/utils"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { TResponseData } from "@formbricks/types/responses"; +import { ZAllowedFileExtension } from "@formbricks/types/storage"; +import { TSurveyQuestion } from "@formbricks/types/surveys/types"; -// Mock getOriginalFileNameFromUrl function -vi.mock("@/lib/storage/utils", () => ({ - getOriginalFileNameFromUrl: vi.fn((url) => { - // Extract filename from the URL for testing purposes - const parts = url.split("/"); - return parts[parts.length - 1]; - }), -})); +// Mock the getOriginalFileNameFromUrl function +const mockGetOriginalFileNameFromUrl = vi.hoisted(() => vi.fn()); + +vi.mock("@/modules/storage/utils", async () => { + const actual = await vi.importActual("@/modules/storage/utils"); + return { + ...actual, + getOriginalFileNameFromUrl: mockGetOriginalFileNameFromUrl, + }; +}); + +describe("storage utils", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); -describe("fileValidation", () => { describe("isAllowedFileExtension", () => { test("should return false for a file with no extension", () => { expect(isAllowedFileExtension("filename")).toBe(false); @@ -74,70 +78,24 @@ describe("fileValidation", () => { }); }); - describe("validateFile", () => { - test("should return valid: false when file extension is not allowed", () => { - const result = validateFile("script.php", "application/php"); - expect(result.valid).toBe(false); - expect(result.error).toContain("File type not allowed"); - }); - - test("should return valid: false when file type does not match extension", () => { - const result = validateFile("image.png", "application/pdf"); - expect(result.valid).toBe(false); - expect(result.error).toContain("File type doesn't match"); - }); - - test("should return valid: true when file is allowed and type matches extension", () => { - const result = validateFile("image.jpg", "image/jpeg"); - expect(result.valid).toBe(true); - expect(result.error).toBeUndefined(); - }); - - test("should return valid: true for allowed file types", () => { - Object.values(ZAllowedFileExtension.enum).forEach((ext) => { - // Skip testing extensions that don't have defined MIME types in the test - if (["jpg", "png", "pdf"].includes(ext)) { - const mimeType = ext === "jpg" ? "image/jpeg" : ext === "png" ? "image/png" : "application/pdf"; - const result = validateFile(`file.${ext}`, mimeType); - expect(result.valid).toBe(true); - } - }); - }); - - test("should return valid: false for files with no extension", () => { - const result = validateFile("noextension", "application/octet-stream"); - expect(result.valid).toBe(false); - }); - - test("should handle attempts to bypass with double extension", () => { - const result = validateFile("malicious.jpg.php", "image/jpeg"); - expect(result.valid).toBe(false); - }); - }); - describe("validateSingleFile", () => { test("should return true for allowed file extension", () => { - vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("image.jpg"); + mockGetOriginalFileNameFromUrl.mockReturnValueOnce("image.jpg"); expect(validateSingleFile("https://example.com/image.jpg", ["jpg", "png"])).toBe(true); }); test("should return false for disallowed file extension", () => { - vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("malicious.exe"); + mockGetOriginalFileNameFromUrl.mockReturnValueOnce("malicious.exe"); expect(validateSingleFile("https://example.com/malicious.exe", ["jpg", "png"])).toBe(false); }); test("should return true when no allowed extensions are specified", () => { - vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("image.jpg"); + mockGetOriginalFileNameFromUrl.mockReturnValueOnce("image.jpg"); expect(validateSingleFile("https://example.com/image.jpg")).toBe(true); }); - test("should return false when file name cannot be extracted", () => { - vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce(undefined); - expect(validateSingleFile("https://example.com/unknown")).toBe(false); - }); - test("should return false when file has no extension", () => { - vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("filewithoutextension"); + mockGetOriginalFileNameFromUrl.mockReturnValueOnce("filewithoutextension"); expect(validateSingleFile("https://example.com/filewithoutextension", ["jpg"])).toBe(false); }); }); @@ -209,7 +167,7 @@ describe("fileValidation", () => { test("should return false when file name cannot be extracted", () => { // Mock implementation to return null for this specific URL - vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => undefined); + mockGetOriginalFileNameFromUrl.mockImplementation(() => undefined); const responseData = { question1: ["https://example.com/invalid-url"], @@ -227,9 +185,7 @@ describe("fileValidation", () => { }); test("should return false when file has no extension", () => { - vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce( - () => "file-without-extension" - ); + mockGetOriginalFileNameFromUrl.mockImplementation(() => "file-without-extension"); const responseData = { question1: ["https://example.com/storage/file-without-extension"], @@ -292,24 +248,22 @@ describe("fileValidation", () => { }); test("should return false when file name cannot be extracted", () => { - vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => undefined); + mockGetOriginalFileNameFromUrl.mockImplementation(() => undefined); expect(isValidImageFile("https://example.com/invalid-url")).toBe(false); }); test("should return false when file has no extension", () => { - vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce( - () => "image-without-extension" - ); + mockGetOriginalFileNameFromUrl.mockImplementation(() => "image-without-extension"); expect(isValidImageFile("https://example.com/image-without-extension")).toBe(false); }); test("should return false when file name ends with a dot", () => { - vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => "image."); + mockGetOriginalFileNameFromUrl.mockImplementation(() => "image."); expect(isValidImageFile("https://example.com/image.")).toBe(false); }); test("should handle case insensitivity correctly", () => { - vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => "image.JPG"); + mockGetOriginalFileNameFromUrl.mockImplementation(() => "image.JPG"); expect(isValidImageFile("https://example.com/image.JPG")).toBe(true); }); }); diff --git a/apps/web/lib/fileValidation.ts b/apps/web/modules/storage/utils.ts similarity index 65% rename from apps/web/lib/fileValidation.ts rename to apps/web/modules/storage/utils.ts index f19fdf98bb..81e72438d3 100644 --- a/apps/web/lib/fileValidation.ts +++ b/apps/web/modules/storage/utils.ts @@ -1,8 +1,42 @@ -import { getOriginalFileNameFromUrl } from "@/lib/storage/utils"; -import { TAllowedFileExtension, ZAllowedFileExtension, mimeTypes } from "@formbricks/types/common"; +import { logger } from "@formbricks/logger"; import { TResponseData } from "@formbricks/types/responses"; +import { TAllowedFileExtension, ZAllowedFileExtension, mimeTypes } from "@formbricks/types/storage"; import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +export const getOriginalFileNameFromUrl = (fileURL: string) => { + try { + const fileNameFromURL = fileURL.startsWith("/storage/") + ? fileURL.split("/").pop() + : new URL(fileURL).pathname.split("/").pop(); + + const fileExt = fileNameFromURL?.split(".").pop() ?? ""; + const originalFileName = fileNameFromURL?.split("--fid--")[0] ?? ""; + const fileId = fileNameFromURL?.split("--fid--")[1] ?? ""; + + if (!fileId) { + const fileName = originalFileName ? decodeURIComponent(originalFileName || "") : ""; + return fileName; + } + + const fileName = originalFileName ? decodeURIComponent(`${originalFileName}.${fileExt}` || "") : ""; + return fileName; + } catch (error) { + logger.error(error, "Error parsing file URL"); + } +}; + +export const getFileNameWithIdFromUrl = (fileURL: string) => { + try { + const fileNameFromURL = fileURL.startsWith("/storage/") + ? fileURL.split("/").pop() + : new URL(fileURL).pathname.split("/").pop(); + + return fileNameFromURL ? decodeURIComponent(fileNameFromURL || "") : ""; + } catch (error) { + logger.error(error, "Error parsing file URL"); + } +}; + /** * Validates if the file extension is allowed * @param fileName The name of the file to validate @@ -34,34 +68,19 @@ export const isValidFileTypeForExtension = (fileName: string, mimeType: string): return mimeTypes[extension] === mimeTypeLower; }; -/** - * Validates a file for security concerns - * @param fileName The name of the file to validate - * @param mimeType The MIME type of the file - * @returns {object} An object with validation result and error message if any - */ -export const validateFile = (fileName: string, mimeType: string): { valid: boolean; error?: string } => { - // Check for disallowed extensions - if (!isAllowedFileExtension(fileName)) { - return { valid: false, error: "File type not allowed for security reasons." }; - } - - // Check if the file type matches the extension - if (!isValidFileTypeForExtension(fileName, mimeType)) { - return { valid: false, error: "File type doesn't match the file extension." }; - } - - return { valid: true }; -}; - export const validateSingleFile = ( fileUrl: string, allowedFileExtensions?: TAllowedFileExtension[] ): boolean => { + console.log("validateSingleFile", fileUrl); const fileName = getOriginalFileNameFromUrl(fileUrl); + console.log("fileName", fileName); if (!fileName) return false; const extension = fileName.split(".").pop(); + console.log("extension", extension); if (!extension) return false; + console.log("allowedFileExtensions", allowedFileExtensions); + console.log("includes", allowedFileExtensions?.includes(extension as TAllowedFileExtension)); return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension); }; diff --git a/apps/web/modules/survey/components/template-list/lib/user.test.ts b/apps/web/modules/survey/components/template-list/lib/user.test.ts index 59f49276c1..bad8237d9f 100644 --- a/apps/web/modules/survey/components/template-list/lib/user.test.ts +++ b/apps/web/modules/survey/components/template-list/lib/user.test.ts @@ -6,7 +6,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors"; import { TUser } from "@formbricks/types/user"; import { updateUser } from "./user"; -vi.mock("@/lib/fileValidation", () => ({ +vi.mock("@/modules/storage/utils", () => ({ isValidImageFile: vi.fn(), })); diff --git a/apps/web/modules/survey/editor/components/file-upload-question-form.tsx b/apps/web/modules/survey/editor/components/file-upload-question-form.tsx index e00ab5ba9f..677ddadb6b 100644 --- a/apps/web/modules/survey/editor/components/file-upload-question-form.tsx +++ b/apps/web/modules/survey/editor/components/file-upload-question-form.tsx @@ -13,7 +13,7 @@ import { PlusIcon, XCircleIcon } from "lucide-react"; import Link from "next/link"; import { type JSX, useMemo, useState } from "react"; import { toast } from "react-hot-toast"; -import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/common"; +import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage"; import { TSurvey, TSurveyFileUploadQuestion } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; diff --git a/apps/web/modules/ui/components/file-input/components/uploader.test.tsx b/apps/web/modules/ui/components/file-input/components/uploader.test.tsx index cee8ba2f75..35a28768a5 100644 --- a/apps/web/modules/ui/components/file-input/components/uploader.test.tsx +++ b/apps/web/modules/ui/components/file-input/components/uploader.test.tsx @@ -1,7 +1,7 @@ import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { TAllowedFileExtension } from "@formbricks/types/common"; +import { TAllowedFileExtension } from "@formbricks/types/storage"; import { Uploader } from "./uploader"; describe("Uploader", () => { diff --git a/apps/web/modules/ui/components/file-input/components/uploader.tsx b/apps/web/modules/ui/components/file-input/components/uploader.tsx index 61b6302067..2265fd2598 100644 --- a/apps/web/modules/ui/components/file-input/components/uploader.tsx +++ b/apps/web/modules/ui/components/file-input/components/uploader.tsx @@ -1,7 +1,7 @@ import { cn } from "@/lib/cn"; import { ArrowUpFromLineIcon } from "lucide-react"; import React from "react"; -import { TAllowedFileExtension } from "@formbricks/types/common"; +import { TAllowedFileExtension } from "@formbricks/types/storage"; interface UploaderProps { id: string; diff --git a/apps/web/modules/ui/components/file-input/index.test.tsx b/apps/web/modules/ui/components/file-input/index.test.tsx index efb967364c..82468ea4e1 100644 --- a/apps/web/modules/ui/components/file-input/index.test.tsx +++ b/apps/web/modules/ui/components/file-input/index.test.tsx @@ -1,7 +1,7 @@ import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { TAllowedFileExtension } from "@formbricks/types/common"; +import { TAllowedFileExtension } from "@formbricks/types/storage"; import { FileInput } from "./index"; // Mock dependencies diff --git a/apps/web/modules/ui/components/file-input/index.tsx b/apps/web/modules/ui/components/file-input/index.tsx index b9f6cc78ef..00da64cb74 100644 --- a/apps/web/modules/ui/components/file-input/index.tsx +++ b/apps/web/modules/ui/components/file-input/index.tsx @@ -1,7 +1,7 @@ "use client"; -import { handleFileUpload } from "@/app/lib/fileUpload"; import { cn } from "@/lib/cn"; +import { handleFileUpload } from "@/modules/storage/file-upload"; import { LoadingSpinner } from "@/modules/ui/components/loading-spinner"; import { OptionsSwitch } from "@/modules/ui/components/options-switch"; import { useTranslate } from "@tolgee/react"; @@ -9,7 +9,7 @@ import { FileIcon, XIcon } from "lucide-react"; import Image from "next/image"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; -import { TAllowedFileExtension } from "@formbricks/types/common"; +import { TAllowedFileExtension } from "@formbricks/types/storage"; import { Uploader } from "./components/uploader"; import { VideoSettings } from "./components/video-settings"; import { getAllowedFiles } from "./lib/utils"; diff --git a/apps/web/modules/ui/components/file-input/lib/utils.test.ts b/apps/web/modules/ui/components/file-input/lib/utils.test.ts index 22b6a322c1..5485d0625c 100644 --- a/apps/web/modules/ui/components/file-input/lib/utils.test.ts +++ b/apps/web/modules/ui/components/file-input/lib/utils.test.ts @@ -1,6 +1,6 @@ import { toast } from "react-hot-toast"; import { beforeEach, describe, expect, test, vi } from "vitest"; -import { TAllowedFileExtension } from "@formbricks/types/common"; +import { TAllowedFileExtension } from "@formbricks/types/storage"; import { convertHeicToJpegAction } from "./actions"; import { checkForYoutubePrivacyMode, getAllowedFiles } from "./utils"; diff --git a/apps/web/modules/ui/components/file-input/lib/utils.ts b/apps/web/modules/ui/components/file-input/lib/utils.ts index 29d08297a9..cbd28f7cdb 100644 --- a/apps/web/modules/ui/components/file-input/lib/utils.ts +++ b/apps/web/modules/ui/components/file-input/lib/utils.ts @@ -1,7 +1,7 @@ "use client"; import { toast } from "react-hot-toast"; -import { TAllowedFileExtension } from "@formbricks/types/common"; +import { TAllowedFileExtension } from "@formbricks/types/storage"; import { convertHeicToJpegAction } from "./actions"; const isFileSizeExceed = (fileSizeInMB: number, maxSizeInMB?: number) => { diff --git a/apps/web/modules/ui/components/file-upload-response/index.test.tsx b/apps/web/modules/ui/components/file-upload-response/index.test.tsx index edec5e4fff..0390c408dc 100644 --- a/apps/web/modules/ui/components/file-upload-response/index.test.tsx +++ b/apps/web/modules/ui/components/file-upload-response/index.test.tsx @@ -30,7 +30,7 @@ describe("FileUploadResponse", () => { }); test("renders 'Download' when filename cannot be extracted", () => { - const fileUrls = ["http://example.com/unknown-file"]; + const fileUrls = [""]; render(); expect(screen.getByText("Download")).toBeInTheDocument(); diff --git a/apps/web/modules/ui/components/file-upload-response/index.tsx b/apps/web/modules/ui/components/file-upload-response/index.tsx index 518aeb2f18..774c3a036f 100644 --- a/apps/web/modules/ui/components/file-upload-response/index.tsx +++ b/apps/web/modules/ui/components/file-upload-response/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { getOriginalFileNameFromUrl } from "@/lib/storage/utils"; +import { getOriginalFileNameFromUrl } from "@/modules/storage/utils"; import { useTranslate } from "@tolgee/react"; import { DownloadIcon } from "lucide-react"; diff --git a/apps/web/package.json b/apps/web/package.json index 8cac718802..720cf4a56d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,6 +31,7 @@ "@formbricks/js-core": "workspace:*", "@formbricks/logger": "workspace:*", "@formbricks/surveys": "workspace:*", + "@formbricks/storage": "workspace:*", "@formbricks/types": "workspace:*", "@fortedigital/nextjs-cache-handler": "1.2.0", "@hookform/resolvers": "5.0.1", diff --git a/packages/storage/src/client.ts b/packages/storage/src/client.ts index 192f89dcfb..c415dab212 100644 --- a/packages/storage/src/client.ts +++ b/packages/storage/src/client.ts @@ -1,6 +1,6 @@ import { S3Client } from "@aws-sdk/client-s3"; import { logger } from "@formbricks/logger"; -import { ErrorCode, type Result, type StorageError, err, ok } from "../types/error"; +import { type Result, type StorageError, StorageErrorCode, err, ok } from "../types/error"; import { S3_ACCESS_KEY, S3_BUCKET_NAME, @@ -19,7 +19,7 @@ export const createS3ClientFromEnv = (): Result => { if (!S3_ACCESS_KEY || !S3_SECRET_KEY || !S3_BUCKET_NAME || !S3_REGION) { logger.error("S3 Client: S3 credentials are not set"); return err({ - code: ErrorCode.S3CredentialsError, + code: StorageErrorCode.S3CredentialsError, }); } @@ -34,7 +34,7 @@ export const createS3ClientFromEnv = (): Result => { } catch (error) { logger.error({ error }, "Error creating S3 client from environment variables"); return err({ - code: ErrorCode.Unknown, + code: StorageErrorCode.Unknown, }); } }; diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 089fc91555..007963d604 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -1 +1,2 @@ export { deleteFile, getSignedDownloadUrl, getSignedUploadUrl, deleteFilesByPrefix } from "./service"; +export { type StorageError, StorageErrorCode } from "../types/error"; diff --git a/packages/storage/src/service.test.ts b/packages/storage/src/service.test.ts index 122d318de7..282228679a 100644 --- a/packages/storage/src/service.test.ts +++ b/packages/storage/src/service.test.ts @@ -80,7 +80,7 @@ describe("service.ts", () => { Key: "uploads/images/test-file.jpg", Fields: { "Content-Type": "image/jpeg", - "Content-Encoding": "base64", + // "Content-Encoding": "base64", }, Conditions: [["content-length-range", 0, mockMaxSize]], }); @@ -175,7 +175,7 @@ describe("service.ts", () => { Key: "uploads/images/test-file.jpg", Fields: { "Content-Type": "image/jpeg", - "Content-Encoding": "base64", + // "Content-Encoding": "base64", }, Conditions: [["content-length-range", 0, mockMaxSize]], }); diff --git a/packages/storage/src/service.ts b/packages/storage/src/service.ts index 3b452ab4d0..4219ed0d6a 100644 --- a/packages/storage/src/service.ts +++ b/packages/storage/src/service.ts @@ -13,7 +13,7 @@ import { } from "@aws-sdk/s3-presigned-post"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { logger } from "@formbricks/logger"; -import { ErrorCode, type Result, type StorageError, err, ok } from "../types/error"; +import { type Result, type StorageError, StorageErrorCode, err, ok } from "../types/error"; import { createS3Client } from "./client"; import { S3_BUCKET_NAME } from "./constants"; @@ -45,7 +45,7 @@ export const getSignedUploadUrl = async ( if (!s3Client) { logger.error("Failed to get signed upload URL: S3 client is not set"); return err({ - code: ErrorCode.S3ClientError, + code: StorageErrorCode.S3ClientError, }); } @@ -56,7 +56,7 @@ export const getSignedUploadUrl = async ( if (!S3_BUCKET_NAME) { logger.error("Failed to get signed upload URL: S3 bucket name is not set"); return err({ - code: ErrorCode.S3CredentialsError, + code: StorageErrorCode.S3CredentialsError, }); } @@ -66,7 +66,7 @@ export const getSignedUploadUrl = async ( Key: `${filePath}/${fileName}`, Fields: { "Content-Type": contentType, - "Content-Encoding": "base64", + // "Content-Encoding": "base64", }, Conditions: postConditions, }); @@ -79,7 +79,7 @@ export const getSignedUploadUrl = async ( logger.error({ error }, "Failed to get signed upload URL"); return err({ - code: ErrorCode.Unknown, + code: StorageErrorCode.Unknown, }); } }; @@ -93,13 +93,13 @@ export const getSignedDownloadUrl = async (fileKey: string): Promise(error: E): ResultError => ({ error, }); -export enum ErrorCode { +export enum StorageErrorCode { Unknown = "unknown", S3CredentialsError = "s3_credentials_error", S3ClientError = "s3_client_error", @@ -28,5 +28,5 @@ export enum ErrorCode { } export interface StorageError { - code: ErrorCode; + code: StorageErrorCode; } diff --git a/packages/surveys/src/components/general/file-input.tsx b/packages/surveys/src/components/general/file-input.tsx index 5e1a2cfc1f..f01d6d5826 100644 --- a/packages/surveys/src/components/general/file-input.tsx +++ b/packages/surveys/src/components/general/file-input.tsx @@ -4,8 +4,8 @@ import { getMimeType, isFulfilled, isRejected } from "@/lib/utils"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; import { type JSXInternal } from "preact/src/jsx"; -import { type TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/common"; import { type TJsFileUploadParams } from "@formbricks/types/js"; +import { type TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage"; import { type TUploadFileConfig } from "@formbricks/types/storage"; interface FileInputProps { diff --git a/packages/surveys/src/lib/api-client.ts b/packages/surveys/src/lib/api-client.ts index f8777a4454..ff614e8e62 100644 --- a/packages/surveys/src/lib/api-client.ts +++ b/packages/surveys/src/lib/api-client.ts @@ -98,74 +98,43 @@ export class ApiClient { const { data } = json; - const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data; + const { signedUrl, fileUrl, presignedFields } = data as { + signedUrl: string; + presignedFields: Record; + fileUrl: string; + }; - let localUploadDetails: Record = {}; + const formData = new FormData(); - if (signingData) { - const { signature, timestamp, uuid } = signingData; + Object.entries(presignedFields).forEach(([key, value]) => { + formData.append(key, value); + }); - localUploadDetails = { - fileType: file.type, - fileName: encodeURIComponent(updatedFileName), - surveyId: surveyId ?? "", - signature, - timestamp: String(timestamp), - uuid, - }; + try { + const binaryString = atob(file.base64.split(",")[1]); + const uint8Array = Uint8Array.from([...binaryString].map((char) => char.charCodeAt(0))); + const blob = new Blob([uint8Array], { type: file.type }); + + formData.append("file", blob); + } catch (err) { + console.error(err); + throw new Error("Error uploading file"); } - const formData: Record = {}; - const formDataForS3 = new FormData(); - - if (presignedFields) { - Object.entries(presignedFields).forEach(([key, value]) => { - formDataForS3.append(key, value); - }); - - try { - const binaryString = atob(file.base64.split(",")[1]); - const uint8Array = Uint8Array.from([...binaryString].map((char) => char.charCodeAt(0))); - const blob = new Blob([uint8Array], { type: file.type }); - - formDataForS3.append("file", blob); - } catch (err) { - console.error(err); - 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, { + uploadResponse = await fetch(signedUrl, { method: "POST", - body: presignedFields - ? formDataForS3 - : JSON.stringify({ - ...formData, - ...localUploadDetails, - }), + body: formData, }); } 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"; diff --git a/packages/surveys/src/lib/utils.test.ts b/packages/surveys/src/lib/utils.test.ts index 3e504f9ecf..bcafc11e14 100644 --- a/packages/surveys/src/lib/utils.test.ts +++ b/packages/surveys/src/lib/utils.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { type TAllowedFileExtension, mimeTypes } from "../../../types/common"; import type { TJsEnvironmentStateSurvey } from "../../../types/js"; +import { type TAllowedFileExtension, mimeTypes } from "../../../types/storage"; import type { TSurveyLanguage, TSurveyQuestionChoice } from "../../../types/surveys/types"; import { getDefaultLanguageCode, getMimeType, getShuffledChoicesIds, getShuffledRowIndices } from "./utils"; diff --git a/packages/surveys/src/lib/utils.ts b/packages/surveys/src/lib/utils.ts index 36d7329590..0b0944a338 100644 --- a/packages/surveys/src/lib/utils.ts +++ b/packages/surveys/src/lib/utils.ts @@ -1,8 +1,8 @@ import { ApiResponse, ApiSuccessResponse } from "@/types/api"; -import { TAllowedFileExtension, mimeTypes } from "@formbricks/types/common"; import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers"; import { type ApiErrorResponse } from "@formbricks/types/errors"; import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js"; +import { TAllowedFileExtension, mimeTypes } from "@formbricks/types/storage"; import { type TShuffleOption, type TSurveyLogic, diff --git a/packages/types/common.ts b/packages/types/common.ts index b212e8519f..b3a49559d7 100644 --- a/packages/types/common.ts +++ b/packages/types/common.ts @@ -18,62 +18,6 @@ export const ZPlacement = z.enum(["bottomLeft", "bottomRight", "topLeft", "topRi export type TPlacement = z.infer; -export const ZAllowedFileExtension = z.enum([ - "heic", - "png", - "jpeg", - "jpg", - "webp", - "pdf", - "eml", - "doc", - "docx", - "xls", - "xlsx", - "ppt", - "pptx", - "txt", - "csv", - "mp4", - "mov", - "avi", - "mkv", - "webm", - "zip", - "rar", - "7z", - "tar", -]); - -export const mimeTypes: Record = { - heic: "image/heic", - png: "image/png", - jpeg: "image/jpeg", - jpg: "image/jpeg", - webp: "image/webp", - pdf: "application/pdf", - eml: "message/rfc822", - doc: "application/msword", - docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - xls: "application/vnd.ms-excel", - xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ppt: "application/vnd.ms-powerpoint", - pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", - txt: "text/plain", - csv: "text/csv", - mp4: "video/mp4", - mov: "video/quicktime", - avi: "video/x-msvideo", - mkv: "video/x-matroska", - webm: "video/webm", - zip: "application/zip", - rar: "application/vnd.rar", - "7z": "application/x-7z-compressed", - tar: "application/x-tar", -}; - -export type TAllowedFileExtension = z.infer; - export const ZId = z.string().cuid2(); export const ZUuid = z.string().uuid(); diff --git a/packages/types/storage.ts b/packages/types/storage.ts index 634cbc5ecd..3109ada5d9 100644 --- a/packages/types/storage.ts +++ b/packages/types/storage.ts @@ -1,14 +1,88 @@ import { z } from "zod"; +export const ZAllowedFileExtension = z.enum([ + "heic", + "png", + "jpeg", + "jpg", + "webp", + "pdf", + "eml", + "doc", + "docx", + "xls", + "xlsx", + "ppt", + "pptx", + "txt", + "csv", + "mp4", + "mov", + "avi", + "mkv", + "webm", + "zip", + "rar", + "7z", + "tar", +]); + +export const mimeTypes: Record = { + heic: "image/heic", + png: "image/png", + jpeg: "image/jpeg", + jpg: "image/jpeg", + webp: "image/webp", + pdf: "application/pdf", + eml: "message/rfc822", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + txt: "text/plain", + csv: "text/csv", + mp4: "video/mp4", + mov: "video/quicktime", + avi: "video/x-msvideo", + mkv: "video/x-matroska", + webm: "video/webm", + zip: "application/zip", + rar: "application/vnd.rar", + "7z": "application/x-7z-compressed", + tar: "application/x-tar", +}; + +export type TAllowedFileExtension = z.infer; + export const ZAccessType = z.enum(["public", "private"]); export type TAccessType = z.infer; -export const ZStorageRetrievalParams = z.object({ - fileName: z.string(), +export const ZDownloadFileRequest = z.object({ + fileName: z + .string() + .trim() + .min(1) + .refine( + (fn) => { + const fileExtension = fn.split(".").pop() as TAllowedFileExtension | undefined; + if (!fileExtension || fileExtension.toLowerCase() === fn.toLowerCase()) { + return false; + } + + return true; + }, + { + message: "File name must have an extension", + } + ), environmentId: z.string().cuid2(), accessType: ZAccessType, }); +export const ZDeleteFileRequest = ZDownloadFileRequest; + export const ZUploadFileConfig = z.object({ allowedFileExtensions: z.array(z.string()).optional(), surveyId: z.string().optional(), @@ -16,14 +90,26 @@ export const ZUploadFileConfig = z.object({ export type TUploadFileConfig = z.infer; -export const ZUploadFileRequest = z.object({ - fileName: z.string(), - fileType: z.string(), - surveyId: z.string().cuid2(), - environmentId: z.string().cuid2(), -}); +export const ZUploadPrivateFileRequest = z + .object({ + fileName: z.string().trim().min(1), + fileType: z.string().trim().min(1), + allowedFileExtensions: z.array(ZAllowedFileExtension).optional(), + surveyId: z.string().cuid2(), + environmentId: z.string().cuid2(), + }) + .superRefine((data, ctx) => { + refineFileUploadInput({ + data: { + fileName: data.fileName, + fileType: data.fileType, + allowedFileExtensions: data.allowedFileExtensions, + }, + ctx, + }); + }); -export type TUploadFileRequest = z.infer; +export type TUploadPrivateFileRequest = z.infer; export const ZUploadFileResponse = z.object({ data: z.object({ @@ -42,3 +128,79 @@ export const ZUploadFileResponse = z.object({ }); export type TUploadFileResponse = z.infer; + +export const ZUploadPublicFileRequest = z + .object({ + fileName: z.string().trim().min(1), + fileType: z.string().trim().min(1), + environmentId: z.string().cuid2(), + allowedFileExtensions: z.array(ZAllowedFileExtension).optional(), + }) + .superRefine((data, ctx) => { + refineFileUploadInput({ + data: { + fileName: data.fileName, + fileType: data.fileType, + allowedFileExtensions: data.allowedFileExtensions, + }, + ctx, + }); + }); + +export type TUploadPublicFileRequest = z.infer; + +const refineFileUploadInput = ({ + data, + ctx, +}: { + data: { + fileName: string; + fileType: string; + allowedFileExtensions?: TAllowedFileExtension[]; + }; + ctx: z.RefinementCtx; +}): void => { + const fileExtension = data.fileName.split(".").pop() as TAllowedFileExtension | undefined; + + if (!fileExtension || fileExtension.toLowerCase() === data.fileName.toLowerCase()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "File name must have an extension", + path: ["fileName"], + }); + + return; + } + + const { success } = ZAllowedFileExtension.safeParse(fileExtension); + + if (!success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "File extension is not allowed for security reasons", + path: ["fileName"], + }); + + return; + } + + if (data.fileType.toLowerCase() !== mimeTypes[fileExtension]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "File type doesn't match the file extension", + path: ["fileType"], + }); + + return; + } + + if (data.allowedFileExtensions?.length) { + if (!data.allowedFileExtensions.includes(fileExtension)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `File extension is not allowed, allowed extensions are: ${data.allowedFileExtensions.join(", ")}`, + path: ["fileName"], + }); + } + } +}; diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index db634a1d24..96482ef2c4 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -1,10 +1,11 @@ import { type ZodIssue, z } from "zod"; import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up"; import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes"; -import { ZAllowedFileExtension, ZColor, ZId, ZPlacement, getZSafeUrl } from "../common"; +import { ZColor, ZId, ZPlacement, getZSafeUrl } from "../common"; import { ZContactAttributes } from "../contact-attribute"; import { ZLanguage } from "../project"; import { ZSegment } from "../segment"; +import { ZAllowedFileExtension } from "../storage"; import { ZBaseStyling } from "../styling"; import { FORBIDDEN_IDS, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34014c3896..935c7996fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ importers: '@formbricks/logger': specifier: workspace:* version: link:../../packages/logger + '@formbricks/storage': + specifier: workspace:* + version: link:../../packages/storage '@formbricks/surveys': specifier: workspace:* version: link:../../packages/surveys