mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 10:30:00 -06:00
fix: uses storage package in apps/web
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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()}`,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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<Response> => {
|
||||
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"),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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<Response> => {
|
||||
);
|
||||
};
|
||||
|
||||
// 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<TUploadPrivateFileRequest, "environmentId">;
|
||||
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),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/local/route";
|
||||
|
||||
export { OPTIONS, POST };
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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<Response> => {
|
||||
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");
|
||||
}
|
||||
};
|
||||
@@ -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<string> => {
|
||||
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<string, unknown>;
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
const params = await props.params;
|
||||
|
||||
const getOrgId = async (environmentId: string): Promise<string> => {
|
||||
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<string, unknown>;
|
||||
}) => {
|
||||
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");
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<string> => {
|
||||
): 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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string> => {
|
||||
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<string> => {
|
||||
const signedUrl = await getS3SignedUrl(fileKey);
|
||||
return signedUrl;
|
||||
};
|
||||
|
||||
export const getLocalFile = async (filePath: string): Promise<TGetFileResponse> => {
|
||||
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<TGetSignedUrlResponse> => {
|
||||
// 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<string>);
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -15,12 +15,16 @@ export const convertToCsv = async (fields: string[], jsonData: Record<string, st
|
||||
logger.error(err, "Failed to convert to CSV");
|
||||
throw new Error("Failed to convert to CSV");
|
||||
}
|
||||
|
||||
return csv;
|
||||
};
|
||||
|
||||
export const convertToXlsxBuffer = (fields: string[], jsonData: Record<string, string | number>[]) => {
|
||||
export const convertToXlsxBuffer = (
|
||||
fields: string[],
|
||||
jsonData: Record<string, string | number>[]
|
||||
): 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" });
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1716,6 +1716,7 @@
|
||||
"personal_links": {
|
||||
"create_and_manage_segments": "「連絡先 > セグメント」でセグメントを作成・管理",
|
||||
"description": "セグメントの個人リンクを生成し、フォームの回答を各連絡先と照合します。",
|
||||
"error_generating_links": "リンクの生成に失敗しました。再度お試しください。",
|
||||
"expiry_date_description": "リンクの有効期限が切れると、受信者はフォームに回答できなくなります。",
|
||||
"expiry_date_optional": "有効期限(オプション)",
|
||||
"generate_and_download_links": "リンクを生成&ダウンロード",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "生成 & 下載 連結",
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<TProject> => {
|
||||
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<TProject> => {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
@@ -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<string, string>;
|
||||
fileUrl: string;
|
||||
};
|
||||
|
||||
let localUploadDetails: Record<string, string> = {};
|
||||
const fileBase64 = (await toBase64(file)) as string;
|
||||
const formDataForS3 = new FormData();
|
||||
|
||||
if (signingData) {
|
||||
const { signature, timestamp, uuid } = signingData;
|
||||
Object.entries(presignedFields as Record<string, string>).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<string, string> = {};
|
||||
const formDataForS3 = new FormData();
|
||||
|
||||
if (presignedFields) {
|
||||
Object.entries(presignedFields as Record<string, string>).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) {
|
||||
384
apps/web/modules/storage/service.test.ts
Normal file
384
apps/web/modules/storage/service.test.ts
Normal file
@@ -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<ReturnType<typeof getSignedUploadUrl>>;
|
||||
type MockedSignedDownloadReturn = Awaited<ReturnType<typeof getSignedDownloadUrl>>;
|
||||
type MockedDeleteFileReturn = Awaited<ReturnType<typeof deleteFile>>;
|
||||
type MockedDeleteFilesByPrefixReturn = Awaited<ReturnType<typeof deleteFilesByPrefix>>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
97
apps/web/modules/storage/service.ts
Normal file
97
apps/web/modules/storage/service.ts
Normal file
@@ -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<string, string>;
|
||||
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<Result<string, StorageError>> => {
|
||||
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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("FileUploadResponse", () => {
|
||||
});
|
||||
|
||||
test("renders 'Download' when filename cannot be extracted", () => {
|
||||
const fileUrls = ["http://example.com/unknown-file"];
|
||||
const fileUrls = [""];
|
||||
render(<FileUploadResponse selected={fileUrls} />);
|
||||
|
||||
expect(screen.getByText("Download")).toBeInTheDocument();
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<S3Client, StorageError> => {
|
||||
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<S3Client, StorageError> => {
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Error creating S3 client from environment variables");
|
||||
return err({
|
||||
code: ErrorCode.Unknown,
|
||||
code: StorageErrorCode.Unknown,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { deleteFile, getSignedDownloadUrl, getSignedUploadUrl, deleteFilesByPrefix } from "./service";
|
||||
export { type StorageError, StorageErrorCode } from "../types/error";
|
||||
|
||||
@@ -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]],
|
||||
});
|
||||
|
||||
@@ -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<Result<stri
|
||||
try {
|
||||
if (!s3Client) {
|
||||
return err({
|
||||
code: ErrorCode.S3ClientError,
|
||||
code: StorageErrorCode.S3ClientError,
|
||||
});
|
||||
}
|
||||
|
||||
if (!S3_BUCKET_NAME) {
|
||||
return err({
|
||||
code: ErrorCode.S3CredentialsError,
|
||||
code: StorageErrorCode.S3CredentialsError,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export const getSignedDownloadUrl = async (fileKey: string): Promise<Result<stri
|
||||
(error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode === 404
|
||||
) {
|
||||
return err({
|
||||
code: ErrorCode.FileNotFoundError,
|
||||
code: StorageErrorCode.FileNotFoundError,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export const getSignedDownloadUrl = async (fileKey: string): Promise<Result<stri
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to get signed download URL");
|
||||
return err({
|
||||
code: ErrorCode.Unknown,
|
||||
code: StorageErrorCode.Unknown,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -148,13 +148,13 @@ export const deleteFile = async (fileKey: string): Promise<Result<void, StorageE
|
||||
try {
|
||||
if (!s3Client) {
|
||||
return err({
|
||||
code: ErrorCode.S3ClientError,
|
||||
code: StorageErrorCode.S3ClientError,
|
||||
});
|
||||
}
|
||||
|
||||
if (!S3_BUCKET_NAME) {
|
||||
return err({
|
||||
code: ErrorCode.S3CredentialsError,
|
||||
code: StorageErrorCode.S3CredentialsError,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ export const deleteFile = async (fileKey: string): Promise<Result<void, StorageE
|
||||
logger.error({ error }, "Failed to delete file");
|
||||
|
||||
return err({
|
||||
code: ErrorCode.Unknown,
|
||||
code: StorageErrorCode.Unknown,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -179,13 +179,13 @@ export const deleteFilesByPrefix = async (prefix: string): Promise<Result<void,
|
||||
try {
|
||||
if (!s3Client) {
|
||||
return err({
|
||||
code: ErrorCode.S3ClientError,
|
||||
code: StorageErrorCode.S3ClientError,
|
||||
});
|
||||
}
|
||||
|
||||
if (!S3_BUCKET_NAME) {
|
||||
return err({
|
||||
code: ErrorCode.S3CredentialsError,
|
||||
code: StorageErrorCode.S3CredentialsError,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ export const deleteFilesByPrefix = async (prefix: string): Promise<Result<void,
|
||||
if (!normalizedPrefix || normalizedPrefix === "/") {
|
||||
logger.error({ prefix }, "Refusing to delete files with an empty or root prefix");
|
||||
return err({
|
||||
code: ErrorCode.InvalidInput,
|
||||
code: StorageErrorCode.InvalidInput,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ export const deleteFilesByPrefix = async (prefix: string): Promise<Result<void,
|
||||
logger.error({ error }, "Failed to delete files by prefix");
|
||||
|
||||
return err({
|
||||
code: ErrorCode.Unknown,
|
||||
code: StorageErrorCode.Unknown,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ export const err = <E = Error>(error: E): ResultError<E> => ({
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, string>;
|
||||
fileUrl: string;
|
||||
};
|
||||
|
||||
let localUploadDetails: Record<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,62 +18,6 @@ export const ZPlacement = z.enum(["bottomLeft", "bottomRight", "topLeft", "topRi
|
||||
|
||||
export type TPlacement = z.infer<typeof ZPlacement>;
|
||||
|
||||
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<TAllowedFileExtension, string> = {
|
||||
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<typeof ZAllowedFileExtension>;
|
||||
|
||||
export const ZId = z.string().cuid2();
|
||||
|
||||
export const ZUuid = z.string().uuid();
|
||||
|
||||
@@ -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<TAllowedFileExtension, string> = {
|
||||
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<typeof ZAllowedFileExtension>;
|
||||
|
||||
export const ZAccessType = z.enum(["public", "private"]);
|
||||
export type TAccessType = z.infer<typeof ZAccessType>;
|
||||
|
||||
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<typeof ZUploadFileConfig>;
|
||||
|
||||
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<typeof ZUploadFileRequest>;
|
||||
export type TUploadPrivateFileRequest = z.infer<typeof ZUploadPrivateFileRequest>;
|
||||
|
||||
export const ZUploadFileResponse = z.object({
|
||||
data: z.object({
|
||||
@@ -42,3 +128,79 @@ export const ZUploadFileResponse = z.object({
|
||||
});
|
||||
|
||||
export type TUploadFileResponse = z.infer<typeof ZUploadFileResponse>;
|
||||
|
||||
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<typeof ZUploadPublicFileRequest>;
|
||||
|
||||
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"],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user