Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({
resolveClientApiIds: vi.fn(),
sendToPipeline: vi.fn(),
updateResponseWithQuotaEvaluation: vi.fn(),
validateFileUploads: vi.fn(),
validateClientFileUploads: vi.fn(),
validateOtherOptionLengthForMultipleChoice: vi.fn(),
validateResponseData: vi.fn(),
}));
Expand Down Expand Up @@ -49,7 +49,7 @@ vi.mock("@/modules/api/v2/lib/element", () => ({
}));

vi.mock("@/modules/storage/utils", () => ({
validateFileUploads: mocks.validateFileUploads,
validateClientFileUploads: mocks.validateClientFileUploads,
}));

vi.mock("./response", () => ({
Expand Down Expand Up @@ -130,7 +130,7 @@ describe("putResponseHandler", () => {
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateClientFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
mocks.validateResponseData.mockReturnValue(null);
});
Expand Down Expand Up @@ -312,7 +312,7 @@ describe("putResponseHandler", () => {
});

test("rejects invalid file upload updates", async () => {
mocks.validateFileUploads.mockReturnValue(false);
mocks.validateClientFileUploads.mockReturnValue(false);

const result = await putResponseHandler(createHandlerParams());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { validateClientFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./response";
import { getValidatedResponseUpdateInput } from "./validated-response-update-input";

Expand Down Expand Up @@ -127,15 +127,24 @@ const getSurveyForResponse = async (
const validateUpdateRequest = (
existingResponse: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
responseUpdateInput: TResponseUpdateInput,
workspaceId: string
): TRouteResult | undefined => {
if (existingResponse.finished) {
return {
response: responses.badRequestResponse("Response is already finished", undefined, true),
};
}

if (!validateFileUploads(responseUpdateInput.data, survey.questions)) {
if (
!validateClientFileUploads({
data: responseUpdateInput.data,
workspaceId,
surveyId: survey.id,
blocks: survey.blocks,
questions: survey.questions,
})
) {
return {
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
};
Expand Down Expand Up @@ -250,7 +259,7 @@ export const putResponseHandler = async ({
};
}

const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput);
const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput, workspaceId);
if (validationResult) {
return validationResult;
}
Expand Down
12 changes: 10 additions & 2 deletions apps/web/app/api/v1/client/[workspaceId]/responses/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { validateClientFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation } from "./lib/response";

export const OPTIONS = async (): Promise<Response> => {
Expand Down Expand Up @@ -154,7 +154,15 @@ export const POST = withV1ApiWrapper({
responseInputData.singleUseId = singleUseValidationResult.singleUseId;
}

if (!validateFileUploads(responseInputData.data, survey.questions)) {
if (
!validateClientFileUploads({
data: responseInputData.data,
workspaceId,
surveyId: survey.id,
blocks: survey.blocks,
questions: survey.questions,
})
) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
};
Expand Down
24 changes: 15 additions & 9 deletions apps/web/app/api/v1/client/[workspaceId]/storage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const OPTIONS = async (): Promise<Response> => {
// 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 file upload question response for example
// use this to let users upload files to a file upload element response for example

export const POST = withV1ApiWrapper({
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ workspaceId: string }> }>) => {
Expand Down Expand Up @@ -66,7 +66,7 @@ export const POST = withV1ApiWrapper({
};
}

const { fileName, fileType, surveyId } = parsedInputResult.data;
const { fileName, fileType, surveyId, elementId } = parsedInputResult.data;

const [survey, organizationId] = await Promise.all([
getSurvey(surveyId),
Expand Down Expand Up @@ -109,18 +109,23 @@ export const POST = withV1ApiWrapper({

const fileUploadPermission = validateSurveyAllowsFileUpload({
fileName,
elementId,
blocks: survey.blocks,
questions: survey.questions,
});

if (!fileUploadPermission.ok) {
let responseString: string = "";
if (fileUploadPermission.reason === "no_file_upload_element") {
responseString = "Survey does not allow file uploads";
} else if (fileUploadPermission.reason === "file_upload_element_not_found") {
responseString = "Element does not allow file uploads";
} else {
responseString = "File extension is not allowed for this element";
}

return {
response: responses.badRequestResponse(
fileUploadPermission.reason === "no_file_upload_question"
? "Survey does not allow file uploads"
: "File extension is not allowed for this survey",
undefined
),
response: responses.badRequestResponse(responseString, undefined),
};
}

Expand All @@ -134,7 +139,8 @@ export const POST = withV1ApiWrapper({
workspaceId,
fileType,
"private",
maxFileUploadSize
maxFileUploadSize,
["surveys", surveyId, "elements", elementId]
);

if (!signedUrlResponse.ok) {
Expand Down
13 changes: 13 additions & 0 deletions apps/web/app/api/v2/client/[workspaceId]/responses/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateClientFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation } from "./lib/response";
import { TResponseInputV2, ZResponseInputV2 } from "./types/response";

Expand Down Expand Up @@ -89,6 +90,18 @@ const validateResponseSubmission = async (
return surveyCheckResult;
}

if (
!validateClientFileUploads({
data: responseInputData.data,
workspaceId,
surveyId: survey.id,
blocks: survey.blocks,
questions: survey.questions,
})
) {
return responses.badRequestResponse("Invalid file upload response", undefined, true);
}

const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseInputData.data,
surveyQuestions: getElementsFromBlocks(survey.blocks),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { logger } from "@formbricks/logger";
import { ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { authorizePrivateDownload } from "@/app/storage/[workspaceId]/[accessType]/[fileName]/lib/auth";
import { authorizePrivateDownload } from "@/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/auth";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
Expand All @@ -15,10 +15,14 @@ import { logFileDeletion } from "./lib/audit-logs";

export const GET = async (
request: NextRequest,
props: { params: Promise<{ workspaceId: string; accessType: string; fileName: string }> }
props: { params: Promise<{ workspaceId: string; accessType: string; filePath: string[] }> }
): Promise<Response> => {
const params = await props.params;
const paramValidation = ZDownloadFileRequest.safeParse(params);
const fileName = params.filePath.join("/");
const paramValidation = ZDownloadFileRequest.safeParse({
accessType: params.accessType,
fileName,
});

if (!paramValidation.success) {
return responses.badRequestResponse(
Expand All @@ -28,7 +32,7 @@ export const GET = async (
);
}

const { accessType, fileName } = paramValidation.data;
const { accessType } = paramValidation.data;
const idParam = params.workspaceId;

// Resolve: the URL param may be an environmentId (old uploads) or workspaceId (new uploads)
Expand Down Expand Up @@ -72,10 +76,14 @@ export const GET = async (

export const DELETE = async (
request: NextRequest,
props: { params: Promise<{ workspaceId: string; accessType: string; fileName: string }> }
props: { params: Promise<{ workspaceId: string; accessType: string; filePath: string[] }> }
): Promise<Response> => {
const params = await props.params;
const paramValidation = ZDeleteFileRequest.safeParse(params);
const fileName = params.filePath.join("/");
const paramValidation = ZDeleteFileRequest.safeParse({
accessType: params.accessType,
fileName,
});
if (!paramValidation.success) {
const errorDetails = transformErrorToDetails(paramValidation.error);

Expand All @@ -88,7 +96,7 @@ export const DELETE = async (
return responses.badRequestResponse("Fields are missing or incorrectly formatted", errorDetails, true);
}

const { accessType, fileName } = paramValidation.data;
const { accessType } = paramValidation.data;
const idParam = params.workspaceId;

// Resolve: the URL param may be an environmentId (old uploads) or workspaceId (new uploads)
Expand Down Expand Up @@ -140,6 +148,20 @@ export const DELETE = async (
);

if (!deleteResult.ok) {
if (!("error" in deleteResult)) {
logger.error({ deleteResult }, "Unknown delete failure result shape");

await logFileDeletion({
failureReason: "unknown_delete_failure",
accessType,
userId: session?.user?.id,
workspaceId: resolved.workspaceId,
apiUrl: request.url,
});

return responses.internalServerErrorResponse("Failed to delete file", true);
}

const { error } = deleteResult;

logger.error({ error }, "Error deleting file");
Expand Down
16 changes: 10 additions & 6 deletions apps/web/lib/response/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { deleteFile } from "@/modules/storage/service";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { parseStorageFileUrl, resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getOrganizationIdFromWorkspaceId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { ITEMS_PER_PAGE } from "../constants";
Expand Down Expand Up @@ -597,14 +597,18 @@ const findAndDeleteUploadedFilesInResponse = async (response: TResponse, survey:

const deletionPromises = fileUrls.map(async (fileUrl) => {
try {
const { pathname } = new URL(fileUrl);
const [, storageId, accessType, fileName] = pathname.split("/").filter(Boolean);
const storageFile = parseStorageFileUrl(fileUrl);

if (!storageId || !accessType || !fileName) {
throw new Error(`Invalid file path: ${pathname}`);
if (!storageFile) {
throw new Error(`Invalid storage file URL: ${fileUrl}`);
}

return deleteFile(storageId, accessType as "private" | "public", fileName, survey.workspaceId);
return deleteFile(
storageFile.storageId,
storageFile.accessType,
storageFile.fileName,
survey.workspaceId
);
} catch (error) {
logger.error(error, `Failed to delete file ${fileUrl}`);
}
Expand Down
Loading
Loading