fix: uses storage package in apps/web

This commit is contained in:
pandeymangg
2025-08-29 11:47:30 +05:30
parent 2502a6ce3c
commit 1e8c862d80
86 changed files with 1307 additions and 2183 deletions

View File

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

View File

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

View File

@@ -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,
};
});

View File

@@ -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()}`,
}));

View File

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

View File

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

View File

@@ -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({

View File

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

View File

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

View File

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

View File

@@ -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",
});
});
});

View File

@@ -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");
}
};

View File

@@ -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"),
};
}
},
});

View File

@@ -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),
};
},
});

View File

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

View File

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

View File

@@ -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" });
});
});

View File

@@ -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");
}
};

View File

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

View File

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

View File

@@ -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"),
};
}
},
});

View File

@@ -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),
};
},
});

View File

@@ -1,3 +0,0 @@
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/local/route";
export { OPTIONS, POST };

View File

@@ -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");
}
};

View File

@@ -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");
}
};

View File

@@ -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");
};

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"),
});
});
});
});

View File

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

View File

@@ -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();
});
});
});

View File

@@ -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");
}
};

View File

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

View File

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

View File

@@ -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" });
};

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -1716,6 +1716,7 @@
"personal_links": {
"create_and_manage_segments": "「連絡先 > セグメント」でセグメントを作成・管理",
"description": "セグメントの個人リンクを生成し、フォームの回答を各連絡先と照合します。",
"error_generating_links": "リンクの生成に失敗しました。再度お試しください。",
"expiry_date_description": "リンクの有効期限が切れると、受信者はフォームに回答できなくなります。",
"expiry_date_optional": "有効期限(オプション)",
"generate_and_download_links": "リンクを生成&ダウンロード",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "生成 & 下載 連結",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),
}));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View 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");
});
});
});

View 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);

View File

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

View File

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

View File

@@ -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(),
}));

View File

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

View File

@@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

@@ -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",

View File

@@ -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,
});
}
};

View File

@@ -1 +1,2 @@
export { deleteFile, getSignedDownloadUrl, getSignedUploadUrl, deleteFilesByPrefix } from "./service";
export { type StorageError, StorageErrorCode } from "../types/error";

View File

@@ -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]],
});

View File

@@ -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,
});
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"],
});
}
}
};

View File

@@ -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
View File

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