mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-21 18:18:48 -06:00
fix: server side checks for file upload (#5566)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
committed by
GitHub
parent
20466c3800
commit
8bdb818995
@@ -1,6 +1,7 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { updateResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -11,6 +12,20 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
const handleDatabaseError = (error: Error, url: string, endpoint: string, responseId: string): Response => {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse("Response", responseId, true);
|
||||
}
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message, undefined, true);
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
logger.error({ error, url }, `Error in ${endpoint}`);
|
||||
return responses.internalServerErrorResponse(error.message, true);
|
||||
}
|
||||
return responses.internalServerErrorResponse("Unknown error occurred", true);
|
||||
};
|
||||
|
||||
export const PUT = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ responseId: string }> }
|
||||
@@ -23,7 +38,6 @@ export const PUT = async (
|
||||
}
|
||||
|
||||
const responseUpdate = await request.json();
|
||||
|
||||
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
@@ -39,19 +53,8 @@ export const PUT = async (
|
||||
try {
|
||||
response = await updateResponse(responseId, inputValidation.data);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse("Response", responseId, true);
|
||||
}
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
logger.error(
|
||||
{ error, url: request.url },
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return handleDatabaseError(error, request.url, endpoint, responseId);
|
||||
}
|
||||
|
||||
// get survey to get environmentId
|
||||
@@ -59,16 +62,12 @@ export const PUT = async (
|
||||
try {
|
||||
survey = await getSurvey(response.surveyId);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
logger.error(
|
||||
{ error, url: request.url },
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return handleDatabaseError(error, request.url, endpoint, responseId);
|
||||
}
|
||||
|
||||
if (!validateFileUploads(response.data, survey.questions)) {
|
||||
return responses.badRequestResponse("Invalid file upload response", undefined, true);
|
||||
}
|
||||
|
||||
// send response update to pipeline
|
||||
@@ -87,7 +86,7 @@ export const PUT = async (
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response: response,
|
||||
response,
|
||||
});
|
||||
}
|
||||
return responses.successResponse({}, true);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
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";
|
||||
@@ -86,6 +87,10 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
);
|
||||
}
|
||||
|
||||
if (!validateFileUploads(responseInputData.data, survey.questions)) {
|
||||
return responses.badRequestResponse("Invalid file upload response");
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta: TResponseInput["meta"] = {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
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";
|
||||
@@ -86,8 +87,14 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
|
||||
|
||||
const fileName = decodeURIComponent(encodedFileName);
|
||||
|
||||
// validate signature
|
||||
// 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 responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType });
|
||||
}
|
||||
|
||||
// validate signature
|
||||
const validated = validateLocalSignedUrl(
|
||||
signedUuid,
|
||||
fileName,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -28,7 +29,6 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
|
||||
const environmentId = params.environmentId;
|
||||
|
||||
const jsonInput = await req.json();
|
||||
|
||||
const inputValidation = ZUploadFileRequest.safeParse({
|
||||
...jsonInput,
|
||||
environmentId,
|
||||
@@ -44,6 +44,12 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
|
||||
|
||||
const { fileName, fileType, surveyId } = inputValidation.data;
|
||||
|
||||
// Perform server-side file validation
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType }, true);
|
||||
}
|
||||
|
||||
const [survey, organization] = await Promise.all([
|
||||
getSurvey(surveyId),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
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";
|
||||
@@ -26,7 +27,7 @@ async function fetchAndAuthorizeResponse(
|
||||
return { error: responses.unauthorizedResponse() };
|
||||
}
|
||||
|
||||
return { response };
|
||||
return { response, survey };
|
||||
}
|
||||
|
||||
export const GET = async (
|
||||
@@ -86,6 +87,10 @@ export const PUT = async (
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
}
|
||||
|
||||
if (!validateFileUploads(responseUpdate.data, result.survey.questions)) {
|
||||
return responses.badRequestResponse("Invalid file upload response");
|
||||
}
|
||||
|
||||
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
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 { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
|
||||
|
||||
export const GET = async (request: NextRequest) => {
|
||||
@@ -47,72 +48,85 @@ export const GET = async (request: NextRequest) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = async (request: Request): Promise<Response> => {
|
||||
const validateInput = async (request: Request) => {
|
||||
let jsonInput;
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
jsonInput = await request.json();
|
||||
} catch (err) {
|
||||
logger.error({ error: err, url: request.url }, "Error parsing JSON input");
|
||||
return { error: responses.badRequestResponse("Malformed JSON input, please check your request body") };
|
||||
}
|
||||
|
||||
let jsonInput;
|
||||
|
||||
try {
|
||||
jsonInput = await request.json();
|
||||
} catch (err) {
|
||||
logger.error({ error: err, url: request.url }, "Error parsing JSON input");
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
}
|
||||
|
||||
const inputValidation = ZResponseInput.safeParse(jsonInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
const inputValidation = ZResponseInput.safeParse(jsonInput);
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
error: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const responseInput = inputValidation.data;
|
||||
return { data: inputValidation.data };
|
||||
};
|
||||
|
||||
const environmentId = responseInput.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
// get and check survey
|
||||
const survey = await getSurvey(responseInput.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
|
||||
}
|
||||
if (survey.environmentId !== environmentId) {
|
||||
return responses.badRequestResponse(
|
||||
const validateSurvey = async (responseInput: TResponseInput, environmentId: string) => {
|
||||
const survey = await getSurvey(responseInput.surveyId);
|
||||
if (!survey) {
|
||||
return { error: responses.notFoundResponse("Survey", responseInput.surveyId, true) };
|
||||
}
|
||||
if (survey.environmentId !== environmentId) {
|
||||
return {
|
||||
error: responses.badRequestResponse(
|
||||
"Survey is part of another environment",
|
||||
{
|
||||
"survey.environmentId": survey.environmentId,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
);
|
||||
),
|
||||
};
|
||||
}
|
||||
return { survey };
|
||||
};
|
||||
|
||||
export const POST = async (request: Request): Promise<Response> => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
const inputResult = await validateInput(request);
|
||||
if (inputResult.error) return inputResult.error;
|
||||
|
||||
const responseInput = inputResult.data;
|
||||
const environmentId = responseInput.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const surveyResult = await validateSurvey(responseInput, environmentId);
|
||||
if (surveyResult.error) return surveyResult.error;
|
||||
|
||||
if (!validateFileUploads(responseInput.data, surveyResult.survey.questions)) {
|
||||
return responses.badRequestResponse("Invalid file upload response");
|
||||
}
|
||||
|
||||
// if there is a createdAt but no updatedAt, set updatedAt to createdAt
|
||||
if (responseInput.createdAt && !responseInput.updatedAt) {
|
||||
responseInput.updatedAt = responseInput.createdAt;
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
response = await createResponse(inputValidation.data);
|
||||
const response = await createResponse(responseInput);
|
||||
return responses.successResponse(response, true);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
} else {
|
||||
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
|
||||
return responses.successResponse(response, true);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
|
||||
import { validateLocalSignedUrl } from "@/lib/crypto";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
import { putFileToLocalStorage } from "@/lib/storage/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
@@ -65,6 +66,12 @@ export const POST = async (req: NextRequest): Promise<Response> => {
|
||||
|
||||
const fileName = decodeURIComponent(encodedFileName);
|
||||
|
||||
// Perform server-side file validation
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return responses.badRequestResponse(fileValidation.error ?? "Invalid file");
|
||||
}
|
||||
|
||||
// validate signature
|
||||
|
||||
const validated = validateLocalSignedUrl(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -36,8 +37,15 @@ export const POST = async (req: NextRequest): Promise<Response> => {
|
||||
return responses.badRequestResponse("environmentId is required");
|
||||
}
|
||||
|
||||
// Perform server-side file validation first to block dangerous file types
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return responses.badRequestResponse(fileValidation.error ?? "Invalid file type");
|
||||
}
|
||||
|
||||
// Also perform client-specified allowed file extensions validation if provided
|
||||
if (allowedFileExtensions?.length) {
|
||||
const fileExtension = fileName.split(".").pop();
|
||||
const fileExtension = fileName.split(".").pop()?.toLowerCase();
|
||||
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
|
||||
return responses.badRequestResponse(
|
||||
`File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}`
|
||||
|
||||
316
apps/web/lib/fileValidation.test.ts
Normal file
316
apps/web/lib/fileValidation.test.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
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";
|
||||
|
||||
// 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];
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("fileValidation", () => {
|
||||
describe("isAllowedFileExtension", () => {
|
||||
test("should return false for a file with no extension", () => {
|
||||
expect(isAllowedFileExtension("filename")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for a file with extension not in allowed list", () => {
|
||||
expect(isAllowedFileExtension("malicious.exe")).toBe(false);
|
||||
expect(isAllowedFileExtension("script.php")).toBe(false);
|
||||
expect(isAllowedFileExtension("config.js")).toBe(false);
|
||||
expect(isAllowedFileExtension("page.html")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for an allowed file extension", () => {
|
||||
Object.values(ZAllowedFileExtension.enum).forEach((ext) => {
|
||||
expect(isAllowedFileExtension(`file.${ext}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle case insensitivity correctly", () => {
|
||||
expect(isAllowedFileExtension("image.PNG")).toBe(true);
|
||||
expect(isAllowedFileExtension("document.PDF")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle filenames with multiple dots", () => {
|
||||
expect(isAllowedFileExtension("example.backup.pdf")).toBe(true);
|
||||
expect(isAllowedFileExtension("document.old.exe")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidFileTypeForExtension", () => {
|
||||
test("should return false for a file with no extension", () => {
|
||||
expect(isValidFileTypeForExtension("filename", "application/octet-stream")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for valid extension and MIME type combinations", () => {
|
||||
expect(isValidFileTypeForExtension("image.jpg", "image/jpeg")).toBe(true);
|
||||
expect(isValidFileTypeForExtension("image.png", "image/png")).toBe(true);
|
||||
expect(isValidFileTypeForExtension("document.pdf", "application/pdf")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for mismatched extension and MIME type", () => {
|
||||
expect(isValidFileTypeForExtension("image.jpg", "image/png")).toBe(false);
|
||||
expect(isValidFileTypeForExtension("document.pdf", "image/jpeg")).toBe(false);
|
||||
expect(isValidFileTypeForExtension("image.png", "application/pdf")).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle case insensitivity correctly", () => {
|
||||
expect(isValidFileTypeForExtension("image.JPG", "image/jpeg")).toBe(true);
|
||||
expect(isValidFileTypeForExtension("image.jpg", "IMAGE/JPEG")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
expect(validateSingleFile("https://example.com/filewithoutextension", ["jpg"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateFileUploads", () => {
|
||||
test("should return true for valid file uploads in response data", () => {
|
||||
const responseData = {
|
||||
question1: ["https://example.com/storage/file1.jpg", "https://example.com/storage/file2.pdf"],
|
||||
};
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: "question1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["jpg", "pdf"],
|
||||
} as TSurveyQuestion,
|
||||
];
|
||||
|
||||
expect(validateFileUploads(responseData, questions)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false when file url is not a string", () => {
|
||||
const responseData = {
|
||||
question1: [123, "https://example.com/storage/file.jpg"],
|
||||
} as TResponseData;
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: "question1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["jpg"],
|
||||
} as TSurveyQuestion,
|
||||
];
|
||||
|
||||
expect(validateFileUploads(responseData, questions)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when file urls are not in an array", () => {
|
||||
const responseData = {
|
||||
question1: "https://example.com/storage/file.jpg",
|
||||
};
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: "question1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["jpg"],
|
||||
} as TSurveyQuestion,
|
||||
];
|
||||
|
||||
expect(validateFileUploads(responseData, questions)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when file extension is not allowed", () => {
|
||||
const responseData = {
|
||||
question1: ["https://example.com/storage/file.exe"],
|
||||
};
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: "question1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["jpg", "pdf"],
|
||||
} as TSurveyQuestion,
|
||||
];
|
||||
|
||||
expect(validateFileUploads(responseData, questions)).toBe(false);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const responseData = {
|
||||
question1: ["https://example.com/invalid-url"],
|
||||
};
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: "question1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["jpg"],
|
||||
} as TSurveyQuestion,
|
||||
];
|
||||
|
||||
expect(validateFileUploads(responseData, questions)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when file has no extension", () => {
|
||||
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(
|
||||
() => "file-without-extension"
|
||||
);
|
||||
|
||||
const responseData = {
|
||||
question1: ["https://example.com/storage/file-without-extension"],
|
||||
};
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: "question1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["jpg"],
|
||||
} as TSurveyQuestion,
|
||||
];
|
||||
|
||||
expect(validateFileUploads(responseData, questions)).toBe(false);
|
||||
});
|
||||
|
||||
test("should ignore non-fileUpload questions", () => {
|
||||
const responseData = {
|
||||
question1: ["https://example.com/storage/file.jpg"],
|
||||
question2: "Some text answer",
|
||||
};
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: "question1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["jpg"],
|
||||
},
|
||||
{
|
||||
id: "question2",
|
||||
type: "text" as const,
|
||||
},
|
||||
] as TSurveyQuestion[];
|
||||
|
||||
expect(validateFileUploads(responseData, questions)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true when no questions are provided", () => {
|
||||
const responseData = {
|
||||
question1: ["https://example.com/storage/file.jpg"],
|
||||
};
|
||||
|
||||
expect(validateFileUploads(responseData)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidImageFile", () => {
|
||||
test("should return true for valid image file extensions", () => {
|
||||
expect(isValidImageFile("https://example.com/image.jpg")).toBe(true);
|
||||
expect(isValidImageFile("https://example.com/image.jpeg")).toBe(true);
|
||||
expect(isValidImageFile("https://example.com/image.png")).toBe(true);
|
||||
expect(isValidImageFile("https://example.com/image.webp")).toBe(true);
|
||||
expect(isValidImageFile("https://example.com/image.heic")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-image file extensions", () => {
|
||||
expect(isValidImageFile("https://example.com/document.pdf")).toBe(false);
|
||||
expect(isValidImageFile("https://example.com/document.docx")).toBe(false);
|
||||
expect(isValidImageFile("https://example.com/document.txt")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when file name cannot be extracted", () => {
|
||||
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => 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"
|
||||
);
|
||||
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.");
|
||||
expect(isValidImageFile("https://example.com/image.")).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle case insensitivity correctly", () => {
|
||||
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => "image.JPG");
|
||||
expect(isValidImageFile("https://example.com/image.JPG")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
94
apps/web/lib/fileValidation.ts
Normal file
94
apps/web/lib/fileValidation.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { getOriginalFileNameFromUrl } from "@/lib/storage/utils";
|
||||
import { TAllowedFileExtension, ZAllowedFileExtension, mimeTypes } from "@formbricks/types/common";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
/**
|
||||
* Validates if the file extension is allowed
|
||||
* @param fileName The name of the file to validate
|
||||
* @returns {boolean} True if the file extension is allowed, false otherwise
|
||||
*/
|
||||
export const isAllowedFileExtension = (fileName: string): boolean => {
|
||||
// Extract the file extension
|
||||
const extension = fileName.split(".").pop()?.toLowerCase();
|
||||
if (!extension || extension === fileName.toLowerCase()) return false;
|
||||
|
||||
// Check if the extension is in the allowed list
|
||||
return Object.values(ZAllowedFileExtension.enum).includes(extension as TAllowedFileExtension);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates if the file type matches the extension
|
||||
* @param fileName The name of the file
|
||||
* @param mimeType The MIME type of the file
|
||||
* @returns {boolean} True if the file type matches the extension, false otherwise
|
||||
*/
|
||||
export const isValidFileTypeForExtension = (fileName: string, mimeType: string): boolean => {
|
||||
const extension = fileName.split(".").pop()?.toLowerCase();
|
||||
if (!extension || extension === fileName.toLowerCase()) return false;
|
||||
|
||||
// Basic MIME type validation for common file types
|
||||
const mimeTypeLower = mimeType.toLowerCase();
|
||||
|
||||
// Check if the MIME type matches the expected type for this extension
|
||||
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 => {
|
||||
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
||||
if (!fileName) return false;
|
||||
const extension = fileName.split(".").pop();
|
||||
if (!extension) return false;
|
||||
return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension);
|
||||
};
|
||||
|
||||
export const validateFileUploads = (data: TResponseData, questions?: TSurveyQuestion[]): boolean => {
|
||||
for (const key of Object.keys(data)) {
|
||||
const question = questions?.find((q) => q.id === key);
|
||||
if (!question || question.type !== TSurveyQuestionTypeEnum.FileUpload) continue;
|
||||
|
||||
const fileUrls = data[key];
|
||||
|
||||
if (!Array.isArray(fileUrls) || !fileUrls.every((url) => typeof url === "string")) return false;
|
||||
|
||||
for (const fileUrl of fileUrls) {
|
||||
if (!validateSingleFile(fileUrl, question.allowedFileExtensions)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const isValidImageFile = (fileUrl: string): boolean => {
|
||||
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
||||
if (!fileName || fileName.endsWith(".")) return false;
|
||||
|
||||
const extension = fileName.split(".").pop()?.toLowerCase();
|
||||
if (!extension) return false;
|
||||
|
||||
const imageExtensions = ["png", "jpeg", "jpg", "webp", "heic"];
|
||||
return imageExtensions.includes(extension);
|
||||
};
|
||||
@@ -18,7 +18,7 @@ import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { capturePosthogEnvironmentEvent } from "../posthogServer";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { surveyCache } from "./cache";
|
||||
import { transformPrismaSurvey } from "./utils";
|
||||
import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
|
||||
|
||||
interface TriggerUpdate {
|
||||
create?: Array<{ actionClassId: string }>;
|
||||
@@ -337,6 +337,8 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
|
||||
updatedSurvey;
|
||||
|
||||
checkForInvalidImagesInQuestions(questions);
|
||||
|
||||
if (languages) {
|
||||
// Process languages update logic here
|
||||
// Extract currentLanguageIds and updatedLanguageIds
|
||||
@@ -678,6 +680,10 @@ export const createSurvey = async (
|
||||
delete data.followUps;
|
||||
}
|
||||
|
||||
if (data.questions) {
|
||||
checkForInvalidImagesInQuestions(data.questions);
|
||||
}
|
||||
|
||||
const survey = await prisma.survey.create({
|
||||
data: {
|
||||
...data,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import "server-only";
|
||||
import { isValidImageFile } from "@/lib/fileValidation";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSurvey>(
|
||||
surveyPrisma: any
|
||||
@@ -32,3 +34,25 @@ export const anySurveyHasFilters = (surveys: TSurvey[]): boolean => {
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
export const checkForInvalidImagesInQuestions = (questions: TSurveyQuestion[]) => {
|
||||
questions.forEach((question, qIndex) => {
|
||||
if (question.imageUrl && !isValidImageFile(question.imageUrl)) {
|
||||
throw new InvalidInputError(`Invalid image file in question ${String(qIndex + 1)}`);
|
||||
}
|
||||
|
||||
if (question.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
if (!Array.isArray(question.choices)) {
|
||||
throw new InvalidInputError(`Choices missing for question ${String(qIndex + 1)}`);
|
||||
}
|
||||
|
||||
question.choices.forEach((choice, cIndex) => {
|
||||
if (!isValidImageFile(choice.imageUrl)) {
|
||||
throw new InvalidInputError(
|
||||
`Invalid image file for choice ${String(cIndex + 1)} in question ${String(qIndex + 1)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { isValidImageFile } from "@/lib/fileValidation";
|
||||
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
@@ -7,7 +8,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbricks/types/user";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { userCache } from "./cache";
|
||||
@@ -97,6 +98,7 @@ export const getUserByEmail = reactCache(
|
||||
// function to update a user's user
|
||||
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
|
||||
validateInputs([personId, ZId], [data, ZUserUpdateInput.partial()]);
|
||||
if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file");
|
||||
|
||||
try {
|
||||
const updatedUser = await prisma.user.update({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
getResponse,
|
||||
updateResponse,
|
||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/response";
|
||||
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { z } from "zod";
|
||||
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
|
||||
@@ -115,6 +117,25 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
});
|
||||
}
|
||||
|
||||
const existingResponse = await getResponse(params.responseId);
|
||||
|
||||
if (!existingResponse.ok) {
|
||||
return handleApiError(request, existingResponse.error);
|
||||
}
|
||||
|
||||
const questionsResponse = await getSurveyQuestions(existingResponse.data.surveyId);
|
||||
|
||||
if (!questionsResponse.ok) {
|
||||
return handleApiError(request, questionsResponse.error);
|
||||
}
|
||||
|
||||
if (!validateFileUploads(body.data, questionsResponse.data.questions)) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "response", issue: "Invalid file upload response" }],
|
||||
});
|
||||
}
|
||||
|
||||
const response = await updateResponse(params.responseId, body);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
|
||||
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
|
||||
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { Response } from "@prisma/client";
|
||||
@@ -76,6 +78,18 @@ export const POST = async (request: Request) =>
|
||||
body.updatedAt = body.createdAt;
|
||||
}
|
||||
|
||||
const surveyQuestions = await getSurveyQuestions(body.surveyId);
|
||||
if (!surveyQuestions.ok) {
|
||||
return handleApiError(request, surveyQuestions.error);
|
||||
}
|
||||
|
||||
if (!validateFileUploads(body.data, surveyQuestions.data.questions)) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "response", issue: "Invalid file upload response" }],
|
||||
});
|
||||
}
|
||||
|
||||
const createResponseResult = await createResponse(environmentId, body);
|
||||
if (!createResponseResult.ok) {
|
||||
return handleApiError(request, createResponseResult.error);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { isValidImageFile } from "@/lib/fileValidation";
|
||||
import { userCache } from "@/lib/user/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
@@ -12,6 +13,10 @@ import { TUserCreateInput, TUserUpdateInput, ZUserEmail, ZUserUpdateInput } from
|
||||
export const updateUser = async (id: string, data: TUserUpdateInput) => {
|
||||
validateInputs([id, ZId], [data, ZUserUpdateInput.partial()]);
|
||||
|
||||
if (data.imageUrl && !isValidImageFile(data.imageUrl)) {
|
||||
throw new InvalidInputError("Invalid image file");
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { segmentCache } from "@/lib/cache/segment";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
|
||||
import { subscribeOrganizationMembersToSurveyResponses } from "@/modules/survey/components/template-list/lib/organization";
|
||||
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
@@ -63,6 +64,8 @@ export const createSurvey = async (
|
||||
delete data.followUps;
|
||||
}
|
||||
|
||||
if (data.questions) checkForInvalidImagesInQuestions(data.questions);
|
||||
|
||||
const survey = await prisma.survey.create({
|
||||
data: {
|
||||
...data,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { isValidImageFile } from "@/lib/fileValidation";
|
||||
import { userCache } from "@/lib/user/cache";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TUser, TUserUpdateInput } from "@formbricks/types/user";
|
||||
|
||||
// function to update a user's user
|
||||
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
|
||||
if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file");
|
||||
|
||||
try {
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { segmentCache } from "@/lib/cache/segment";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
|
||||
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
@@ -26,6 +27,8 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
|
||||
updatedSurvey;
|
||||
|
||||
checkForInvalidImagesInQuestions(questions);
|
||||
|
||||
if (languages) {
|
||||
// Process languages update logic here
|
||||
// Extract currentLanguageIds and updatedLanguageIds
|
||||
|
||||
@@ -5,6 +5,7 @@ import { segmentCache } from "@/lib/cache/segment";
|
||||
import { projectCache } from "@/lib/project/cache";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils";
|
||||
import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment";
|
||||
@@ -528,6 +529,9 @@ export const copySurveyToOtherEnvironment = async (
|
||||
}
|
||||
|
||||
const targetProjectLanguageCodes = targetProject.languages.map((language) => language.code);
|
||||
|
||||
if (surveyData.questions) checkForInvalidImagesInQuestions(surveyData.questions);
|
||||
|
||||
const newSurvey = await prisma.survey.create({
|
||||
data: surveyData,
|
||||
select: {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { segmentCache } from "@/lib/cache/segment";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { Prisma, Survey } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const createSurvey = async (
|
||||
environmentId: string,
|
||||
surveyBody: Pick<Survey, "name" | "questions">
|
||||
): Promise<{ id: string }> => {
|
||||
try {
|
||||
const survey = await prisma.survey.create({
|
||||
data: {
|
||||
...surveyBody,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
environmentId: true,
|
||||
resultShareKey: true,
|
||||
},
|
||||
});
|
||||
|
||||
// if the survey created is an "app" survey, we also create a private segment for it.
|
||||
if (survey.type === "app") {
|
||||
const newSegment = await prisma.segment.create({
|
||||
data: {
|
||||
title: survey.id,
|
||||
filters: [],
|
||||
isPrivate: true,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.survey.update({
|
||||
where: {
|
||||
id: survey.id,
|
||||
},
|
||||
data: {
|
||||
segment: {
|
||||
connect: {
|
||||
id: newSegment.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
segmentCache.revalidate({
|
||||
id: newSegment.id,
|
||||
environmentId: survey.environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
surveyCache.revalidate({
|
||||
id: survey.id,
|
||||
environmentId: survey.environmentId,
|
||||
resultShareKey: survey.resultShareKey ?? undefined,
|
||||
});
|
||||
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
|
||||
surveyId: survey.id,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
return { id: survey.id };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error creating survey");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -155,11 +155,13 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
await expect(
|
||||
page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("button").nth(0)
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator("input[type=file]").setInputFiles({
|
||||
name: "file.txt",
|
||||
mimeType: "text/plain",
|
||||
name: "file.doc",
|
||||
mimeType: "application/msword",
|
||||
buffer: Buffer.from("this is test"),
|
||||
});
|
||||
|
||||
await page.getByText("Uploading...").waitFor({ state: "hidden" });
|
||||
await page.locator("#questionCard-8").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
@@ -842,8 +844,8 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("button").nth(0)
|
||||
).toBeVisible();
|
||||
await page.locator("input[type=file]").setInputFiles({
|
||||
name: "file.txt",
|
||||
mimeType: "text/plain",
|
||||
name: "file.doc",
|
||||
mimeType: "application/msword",
|
||||
buffer: Buffer.from("this is test"),
|
||||
});
|
||||
await page.getByText("Uploading...").waitFor({ state: "hidden" });
|
||||
|
||||
@@ -109,6 +109,11 @@ export default defineConfig({
|
||||
"lib/utils/billing.ts",
|
||||
"lib/crypto.ts",
|
||||
"lib/surveyLogic/utils.ts",
|
||||
"lib/utils/billing.ts",
|
||||
"modules/ui/components/card/index.tsx",
|
||||
"lib/fileValidation.ts",
|
||||
"survey/editor/lib/utils.tsx",
|
||||
"modules/ui/components/card/index.tsx",
|
||||
"modules/ui/components/card/index.tsx",
|
||||
],
|
||||
exclude: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
|
||||
import { TAllowedFileExtension } from "@formbricks/types/common";
|
||||
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";
|
||||
@@ -160,31 +160,5 @@ export const getDefaultLanguageCode = (survey: TJsEnvironmentStateSurvey): strin
|
||||
if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
|
||||
};
|
||||
|
||||
const mimeTypes: { [key in TAllowedFileExtension]: string } = {
|
||||
heic: "image/heic",
|
||||
png: "image/png",
|
||||
jpeg: "image/jpeg",
|
||||
jpg: "image/jpeg",
|
||||
webp: "image/webp",
|
||||
pdf: "application/pdf",
|
||||
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",
|
||||
plain: "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",
|
||||
};
|
||||
|
||||
// Function to convert file extension to its MIME type
|
||||
export const getMimeType = (extension: TAllowedFileExtension): string => mimeTypes[extension];
|
||||
|
||||
@@ -44,6 +44,32 @@ export const ZAllowedFileExtension = z.enum([
|
||||
"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",
|
||||
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",
|
||||
plain: "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();
|
||||
|
||||
Reference in New Issue
Block a user