mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-19 03:04:39 -05:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7f83c42a4 | |||
| 9e1a8e96c8 |
+4
-4
@@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({
|
||||
loggerError: vi.fn(),
|
||||
sendToPipeline: vi.fn(),
|
||||
updateResponseWithQuotaEvaluation: vi.fn(),
|
||||
validateFileUploads: vi.fn(),
|
||||
validateClientFileUploads: vi.fn(),
|
||||
validateOtherOptionLengthForMultipleChoice: vi.fn(),
|
||||
validateResponseData: vi.fn(),
|
||||
}));
|
||||
@@ -44,7 +44,7 @@ vi.mock("@/modules/api/v2/lib/element", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/storage/utils", () => ({
|
||||
validateFileUploads: mocks.validateFileUploads,
|
||||
validateClientFileUploads: mocks.validateClientFileUploads,
|
||||
}));
|
||||
|
||||
vi.mock("./response", () => ({
|
||||
@@ -124,7 +124,7 @@ describe("putResponseHandler", () => {
|
||||
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
|
||||
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
|
||||
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
|
||||
mocks.validateFileUploads.mockReturnValue(true);
|
||||
mocks.validateClientFileUploads.mockReturnValue(true);
|
||||
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
|
||||
mocks.validateResponseData.mockReturnValue(null);
|
||||
});
|
||||
@@ -278,7 +278,7 @@ describe("putResponseHandler", () => {
|
||||
});
|
||||
|
||||
test("rejects invalid file upload updates", async () => {
|
||||
mocks.validateFileUploads.mockReturnValue(false);
|
||||
mocks.validateClientFileUploads.mockReturnValue(false);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams());
|
||||
|
||||
|
||||
+13
-4
@@ -11,7 +11,7 @@ import { getSurvey } from "@/lib/survey/service";
|
||||
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";
|
||||
|
||||
@@ -126,7 +126,8 @@ const getSurveyForResponse = async (
|
||||
const validateUpdateRequest = (
|
||||
existingResponse: TResponse,
|
||||
survey: TSurvey,
|
||||
responseUpdateInput: TResponseUpdateInput
|
||||
responseUpdateInput: TResponseUpdateInput,
|
||||
workspaceId: string
|
||||
): TRouteResult | undefined => {
|
||||
if (existingResponse.finished) {
|
||||
return {
|
||||
@@ -134,7 +135,15 @@ const validateUpdateRequest = (
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
@@ -241,7 +250,7 @@ export const putResponseHandler = async ({
|
||||
};
|
||||
}
|
||||
|
||||
const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput);
|
||||
const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput, workspaceId);
|
||||
if (validationResult) {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,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> => {
|
||||
@@ -147,7 +147,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"),
|
||||
};
|
||||
|
||||
@@ -66,7 +66,7 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const { fileName, fileType, surveyId } = parsedInputResult.data;
|
||||
const { fileName, fileType, surveyId, questionId } = parsedInputResult.data;
|
||||
|
||||
const [survey, organizationId] = await Promise.all([
|
||||
getSurvey(surveyId),
|
||||
@@ -109,6 +109,7 @@ export const POST = withV1ApiWrapper({
|
||||
|
||||
const fileUploadPermission = validateSurveyAllowsFileUpload({
|
||||
fileName,
|
||||
questionId,
|
||||
blocks: survey.blocks,
|
||||
questions: survey.questions,
|
||||
});
|
||||
@@ -118,7 +119,9 @@ export const POST = withV1ApiWrapper({
|
||||
response: responses.badRequestResponse(
|
||||
fileUploadPermission.reason === "no_file_upload_question"
|
||||
? "Survey does not allow file uploads"
|
||||
: "File extension is not allowed for this survey",
|
||||
: fileUploadPermission.reason === "file_upload_question_not_found"
|
||||
? "Question does not allow file uploads"
|
||||
: "File extension is not allowed for this question",
|
||||
undefined
|
||||
),
|
||||
};
|
||||
@@ -134,7 +137,8 @@ export const POST = withV1ApiWrapper({
|
||||
workspaceId,
|
||||
fileType,
|
||||
"private",
|
||||
maxFileUploadSize
|
||||
maxFileUploadSize,
|
||||
["surveys", surveyId, "questions", questionId]
|
||||
);
|
||||
|
||||
if (!signedUrlResponse.ok) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
+29
-7
@@ -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";
|
||||
@@ -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(
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
@@ -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");
|
||||
@@ -22,7 +22,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";
|
||||
@@ -589,14 +589,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}`);
|
||||
}
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ export const fileUploadQuestion: Survey["questions"][number] = {
|
||||
export const responseData: Response["data"] = {
|
||||
[openTextQuestion.id]: "Open Text Answer",
|
||||
[fileUploadQuestion.id]: [
|
||||
`https://example.com/dummy/${workspaceId}/private/file1.png`,
|
||||
`https://example.com/dummy/${workspaceId}/private/file2.pdf`,
|
||||
`https://example.com/storage/${workspaceId}/private/file1.png`,
|
||||
`https://example.com/storage/${workspaceId}/private/file2.pdf`,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Result, okVoid } from "@formbricks/types/error-handlers";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { deleteFile } from "@/modules/storage/service";
|
||||
import { parseStorageFileUrl } from "@/modules/storage/utils";
|
||||
|
||||
export const findAndDeleteUploadedFilesInResponse = async (
|
||||
responseData: Response["data"],
|
||||
@@ -24,13 +25,12 @@ export const findAndDeleteUploadedFilesInResponse = async (
|
||||
|
||||
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, workspaceId);
|
||||
return deleteFile(storageFile.storageId, storageFile.accessType, storageFile.fileName, workspaceId);
|
||||
} catch (error) {
|
||||
logger.error({ error, fileUrl }, "Failed to delete file");
|
||||
}
|
||||
|
||||
@@ -117,6 +117,61 @@ describe("storage service", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should generate scoped private upload URL when path segments are provided", 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",
|
||||
"ws-123",
|
||||
"application/pdf",
|
||||
"private" as TAccessType,
|
||||
1024 * 1024 * 10,
|
||||
["surveys", "survey-123", "questions", "question-123"]
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.fileUrl).toBe(
|
||||
`/storage/ws-123/private/surveys/survey-123/questions/question-123/test-doc--fid--${mockUUID}.pdf`
|
||||
);
|
||||
}
|
||||
|
||||
expect(getSignedUploadUrl).toHaveBeenCalledWith(
|
||||
`test-doc--fid--${mockUUID}.pdf`,
|
||||
"application/pdf",
|
||||
"ws-123/private/surveys/survey-123/questions/question-123",
|
||||
1024 * 1024 * 10
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["", ".", "..", "bad segment", "bad/segment", "bad\\segment", "bad?segment", "bad#segment"])(
|
||||
"should reject unsafe scoped private upload path segment %s",
|
||||
async (unsafeSegment) => {
|
||||
const result = await getSignedUrlForUpload(
|
||||
"test-doc.pdf",
|
||||
"ws-123",
|
||||
"application/pdf",
|
||||
"private" as TAccessType,
|
||||
1024 * 1024 * 10,
|
||||
["surveys", unsafeSegment]
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe(StorageErrorCode.InvalidInput);
|
||||
}
|
||||
expect(getSignedUploadUrl).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test("should properly sanitize filenames with special characters like # in URL", async () => {
|
||||
const mockSignedUrlResponse = {
|
||||
ok: true,
|
||||
|
||||
@@ -10,15 +10,18 @@ import {
|
||||
getSignedUploadUrl,
|
||||
} from "@formbricks/storage";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { TAccessType } from "@formbricks/types/storage";
|
||||
import { type TAccessType } from "@formbricks/types/storage";
|
||||
import { sanitizeFileName } from "./utils";
|
||||
|
||||
const SAFE_FILE_PATH_SEGMENT = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
export const getSignedUrlForUpload = async (
|
||||
fileName: string,
|
||||
workspaceId: string,
|
||||
fileType: string,
|
||||
accessType: TAccessType,
|
||||
maxFileUploadSize: number = 1024 * 1024 * 10 // 10MB
|
||||
maxFileUploadSize: number = 1024 * 1024 * 10, // 10MB
|
||||
filePathSegments: string[] = []
|
||||
): Promise<
|
||||
Result<
|
||||
{
|
||||
@@ -34,17 +37,19 @@ export const getSignedUrlForUpload = async (
|
||||
if (!safeFileName) {
|
||||
return err({ code: StorageErrorCode.InvalidInput });
|
||||
}
|
||||
|
||||
if (filePathSegments.some((segment) => !SAFE_FILE_PATH_SEGMENT.test(segment))) {
|
||||
return err({ code: StorageErrorCode.InvalidInput });
|
||||
}
|
||||
|
||||
const encodedFilePathSegments = filePathSegments.map((segment) => encodeURIComponent(segment));
|
||||
const fileNameWithoutExtension = safeFileName.split(".").slice(0, -1).join(".");
|
||||
const fileExtension = safeFileName.split(".").pop();
|
||||
|
||||
const updatedFileName = `${fileNameWithoutExtension}--fid--${randomUUID()}.${fileExtension}`;
|
||||
const filePath = [workspaceId, accessType, ...filePathSegments].join("/");
|
||||
|
||||
const signedUrlResult = await getSignedUploadUrl(
|
||||
updatedFileName,
|
||||
fileType,
|
||||
`${workspaceId}/${accessType}`,
|
||||
maxFileUploadSize
|
||||
);
|
||||
const signedUrlResult = await getSignedUploadUrl(updatedFileName, fileType, filePath, maxFileUploadSize);
|
||||
|
||||
if (!signedUrlResult.ok) {
|
||||
return signedUrlResult;
|
||||
@@ -54,7 +59,10 @@ export const getSignedUrlForUpload = async (
|
||||
return ok({
|
||||
signedUrl: signedUrlResult.data.signedUrl,
|
||||
presignedFields: signedUrlResult.data.presignedFields,
|
||||
fileUrl: `/storage/${workspaceId}/${accessType}/${encodeURIComponent(updatedFileName)}`,
|
||||
fileUrl: `/storage/${workspaceId}/${accessType}/${[
|
||||
...encodedFilePathSegments,
|
||||
encodeURIComponent(updatedFileName),
|
||||
].join("/")}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Error getting signed url for upload");
|
||||
|
||||
@@ -7,10 +7,12 @@ import { TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
isAllowedFileExtension,
|
||||
isValidImageFile,
|
||||
parseStorageFileUrl,
|
||||
resolveStorageUrl,
|
||||
resolveStorageUrlAuto,
|
||||
resolveStorageUrlsInObject,
|
||||
sanitizeFileName,
|
||||
validateClientFileUploads,
|
||||
validateFileUploads,
|
||||
validateSingleFile,
|
||||
validateSurveyAllowsFileUpload,
|
||||
@@ -369,7 +371,11 @@ describe("storage utils", () => {
|
||||
},
|
||||
] as unknown as TSurveyBlock[];
|
||||
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element1", blocks })
|
||||
).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should allow a matching extension from a legacy file upload question", () => {
|
||||
@@ -381,7 +387,9 @@ describe("storage utils", () => {
|
||||
},
|
||||
] as TSurveyQuestion[];
|
||||
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "image.png", questions })).toEqual({ ok: true });
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "image.png", questionId: "question1", questions })
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test("should allow any globally safe extension when a file upload has no survey restriction", () => {
|
||||
@@ -398,7 +406,11 @@ describe("storage utils", () => {
|
||||
},
|
||||
] as unknown as TSurveyBlock[];
|
||||
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element1", blocks })
|
||||
).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject surveys without file upload blocks or questions", () => {
|
||||
@@ -421,7 +433,9 @@ describe("storage utils", () => {
|
||||
},
|
||||
] as TSurveyQuestion[];
|
||||
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks, questions })).toEqual({
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "question1", blocks, questions })
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "no_file_upload_question",
|
||||
});
|
||||
@@ -447,7 +461,9 @@ describe("storage utils", () => {
|
||||
},
|
||||
] as unknown as TSurveyBlock[];
|
||||
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element2", blocks })
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "file_extension_not_allowed",
|
||||
});
|
||||
@@ -473,7 +489,11 @@ describe("storage utils", () => {
|
||||
},
|
||||
] as unknown as TSurveyBlock[];
|
||||
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true });
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element2", blocks })
|
||||
).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject files without a globally safe extension even when the survey has an unrestricted upload", () => {
|
||||
@@ -484,15 +504,148 @@ describe("storage utils", () => {
|
||||
},
|
||||
] as TSurveyQuestion[];
|
||||
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "report", questions })).toEqual({
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "report", questionId: "question1", questions })
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "file_extension_not_allowed",
|
||||
});
|
||||
expect(validateSurveyAllowsFileUpload({ fileName: "malware.exe", questions })).toEqual({
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "malware.exe", questionId: "question1", questions })
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "file_extension_not_allowed",
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject a question id that is not the file upload element", () => {
|
||||
const blocks = [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "element1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["pdf"],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as TSurveyBlock[];
|
||||
|
||||
expect(
|
||||
validateSurveyAllowsFileUpload({ fileName: "report.pdf", questionId: "element2", blocks })
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "file_upload_question_not_found",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateClientFileUploads", () => {
|
||||
const workspaceId = "clxworkspace123";
|
||||
const surveyId = "clxsurvey123";
|
||||
const questionId = "file_question";
|
||||
const blocks = [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: questionId,
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["pdf"],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as TSurveyBlock[];
|
||||
|
||||
test("should accept scoped private storage URLs for the matching survey and question", () => {
|
||||
const responseData = {
|
||||
[questionId]: [
|
||||
`/storage/${workspaceId}/private/surveys/${surveyId}/questions/${questionId}/report--fid--abc.pdf`,
|
||||
],
|
||||
};
|
||||
|
||||
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(true);
|
||||
});
|
||||
|
||||
test("should reject unscoped legacy storage URLs for new client submissions", () => {
|
||||
const responseData = {
|
||||
[questionId]: [`/storage/${workspaceId}/private/report--fid--abc.pdf`],
|
||||
};
|
||||
|
||||
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
|
||||
});
|
||||
|
||||
test("should reject scoped URLs for a different survey", () => {
|
||||
const responseData = {
|
||||
[questionId]: [
|
||||
`/storage/${workspaceId}/private/surveys/otherSurvey/questions/${questionId}/report--fid--abc.pdf`,
|
||||
],
|
||||
};
|
||||
|
||||
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
|
||||
});
|
||||
|
||||
test("should reject scoped URLs for a different question", () => {
|
||||
const responseData = {
|
||||
[questionId]: [
|
||||
`/storage/${workspaceId}/private/surveys/${surveyId}/questions/otherQuestion/report--fid--abc.pdf`,
|
||||
],
|
||||
};
|
||||
|
||||
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
|
||||
});
|
||||
|
||||
test("should reject external URLs", () => {
|
||||
const responseData = {
|
||||
[questionId]: ["https://example.com/report--fid--abc.pdf"],
|
||||
};
|
||||
|
||||
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
|
||||
});
|
||||
|
||||
test("should reject file extensions not allowed by the matching upload question", () => {
|
||||
const responseData = {
|
||||
[questionId]: [
|
||||
`/storage/${workspaceId}/private/surveys/${surveyId}/questions/${questionId}/image--fid--abc.png`,
|
||||
],
|
||||
};
|
||||
|
||||
expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseStorageFileUrl", () => {
|
||||
test("should parse nested relative storage URLs", () => {
|
||||
expect(
|
||||
parseStorageFileUrl(
|
||||
"/storage/workspace-123/private/surveys/survey-123/questions/question-123/report.pdf"
|
||||
)
|
||||
).toEqual({
|
||||
storageId: "workspace-123",
|
||||
accessType: "private",
|
||||
fileName: "surveys/survey-123/questions/question-123/report.pdf",
|
||||
});
|
||||
});
|
||||
|
||||
test("should parse absolute storage URLs", () => {
|
||||
expect(parseStorageFileUrl("https://example.com/storage/workspace-123/public/report.pdf")).toEqual({
|
||||
storageId: "workspace-123",
|
||||
accessType: "public",
|
||||
fileName: "report.pdf",
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
"https://example.com/not-storage/workspace-123/private/report.pdf",
|
||||
"/storage/workspace-123/internal/report.pdf",
|
||||
"/storage/workspace-123/private",
|
||||
"not a url",
|
||||
])("should reject invalid storage URL %s", (fileUrl) => {
|
||||
expect(parseStorageFileUrl(fileUrl)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidImageFile", () => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import "server-only";
|
||||
import { type StorageError, StorageErrorCode } from "@formbricks/storage";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage";
|
||||
import {
|
||||
type TAccessType,
|
||||
type TAllowedFileExtension,
|
||||
ZAllowedFileExtension,
|
||||
} from "@formbricks/types/storage";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElementTypeEnum, TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
@@ -120,7 +124,7 @@ export type TSurveyFileUploadPermissionResult =
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: "no_file_upload_question" | "file_extension_not_allowed";
|
||||
reason: "no_file_upload_question" | "file_upload_question_not_found" | "file_extension_not_allowed";
|
||||
};
|
||||
|
||||
const getAllowedFileExtensionFromFileName = (fileName: string): TAllowedFileExtension | null => {
|
||||
@@ -132,21 +136,33 @@ const getAllowedFileExtensionFromFileName = (fileName: string): TAllowedFileExte
|
||||
return extensionValidation.success ? extensionValidation.data : null;
|
||||
};
|
||||
|
||||
export const validateSurveyAllowsFileUpload = ({
|
||||
fileName,
|
||||
const getSurveyFileUploadConfigs = ({
|
||||
blocks,
|
||||
questions,
|
||||
}: {
|
||||
fileName: string;
|
||||
blocks?: TSurveyBlock[] | null;
|
||||
questions?: TSurveyQuestion[] | null;
|
||||
}): TSurveyFileUploadPermissionResult => {
|
||||
const fileUploadConfigs = [
|
||||
}): TSurveyFileUploadElement[] => {
|
||||
return [
|
||||
...(blocks ?? [])
|
||||
.flatMap((block) => block.elements)
|
||||
.filter((element) => element.type === TSurveyElementTypeEnum.FileUpload),
|
||||
...(questions ?? []).filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload),
|
||||
] as TSurveyFileUploadElement[];
|
||||
};
|
||||
|
||||
export const validateSurveyAllowsFileUpload = ({
|
||||
fileName,
|
||||
questionId,
|
||||
blocks,
|
||||
questions,
|
||||
}: {
|
||||
fileName: string;
|
||||
questionId: string;
|
||||
blocks?: TSurveyBlock[] | null;
|
||||
questions?: TSurveyQuestion[] | null;
|
||||
}): TSurveyFileUploadPermissionResult => {
|
||||
const fileUploadConfigs = getSurveyFileUploadConfigs({ blocks, questions });
|
||||
|
||||
if (fileUploadConfigs.length === 0) {
|
||||
return {
|
||||
@@ -155,6 +171,15 @@ export const validateSurveyAllowsFileUpload = ({
|
||||
};
|
||||
}
|
||||
|
||||
const fileUploadConfig = fileUploadConfigs.find((config) => config.id === questionId);
|
||||
|
||||
if (!fileUploadConfig) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "file_upload_question_not_found",
|
||||
};
|
||||
}
|
||||
|
||||
const fileExtension = getAllowedFileExtensionFromFileName(fileName);
|
||||
|
||||
if (!fileExtension) {
|
||||
@@ -164,11 +189,9 @@ export const validateSurveyAllowsFileUpload = ({
|
||||
};
|
||||
}
|
||||
|
||||
const isFileExtensionAllowed = fileUploadConfigs.some((fileUploadConfig) => {
|
||||
const { allowedFileExtensions } = fileUploadConfig;
|
||||
|
||||
return allowedFileExtensions === undefined || allowedFileExtensions.includes(fileExtension);
|
||||
});
|
||||
const { allowedFileExtensions } = fileUploadConfig;
|
||||
const isFileExtensionAllowed =
|
||||
allowedFileExtensions === undefined || allowedFileExtensions.includes(fileExtension);
|
||||
|
||||
return isFileExtensionAllowed
|
||||
? { ok: true }
|
||||
@@ -178,6 +201,127 @@ export const validateSurveyAllowsFileUpload = ({
|
||||
};
|
||||
};
|
||||
|
||||
const getStorageUrlPathSegments = (fileUrl: string): string[] | null => {
|
||||
if (!fileUrl.startsWith("/storage/")) return null;
|
||||
|
||||
const pathWithoutSearch = fileUrl.split(/[?#]/)[0];
|
||||
return pathWithoutSearch.split("/").filter(Boolean);
|
||||
};
|
||||
|
||||
type TParsedStorageFileUrl = {
|
||||
storageId: string;
|
||||
accessType: TAccessType;
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
export const parseStorageFileUrl = (fileUrl: string): TParsedStorageFileUrl | null => {
|
||||
let pathname: string;
|
||||
|
||||
try {
|
||||
pathname = fileUrl.startsWith("/storage/") ? fileUrl : new URL(fileUrl).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathWithoutSearch = pathname.split(/[?#]/)[0];
|
||||
if (!pathWithoutSearch.startsWith("/storage/")) return null;
|
||||
|
||||
const [storageSegment, storageId, accessType, ...fileNameSegments] = pathWithoutSearch
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
const fileName = fileNameSegments.join("/");
|
||||
|
||||
if (
|
||||
storageSegment !== "storage" ||
|
||||
!storageId ||
|
||||
!fileName ||
|
||||
(accessType !== "private" && accessType !== "public")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { storageId, accessType, fileName };
|
||||
};
|
||||
|
||||
const isScopedPrivateUploadUrl = ({
|
||||
fileUrl,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
questionId,
|
||||
}: {
|
||||
fileUrl: string;
|
||||
workspaceId: string;
|
||||
surveyId: string;
|
||||
questionId: string;
|
||||
}): boolean => {
|
||||
const segments = getStorageUrlPathSegments(fileUrl);
|
||||
|
||||
if (!segments || segments.length !== 8) return false;
|
||||
|
||||
const [
|
||||
storageSegment,
|
||||
storageWorkspaceId,
|
||||
accessType,
|
||||
surveysSegment,
|
||||
storageSurveyId,
|
||||
questionsSegment,
|
||||
storageQuestionId,
|
||||
fileName,
|
||||
] = segments;
|
||||
|
||||
return (
|
||||
storageSegment === "storage" &&
|
||||
storageWorkspaceId === workspaceId &&
|
||||
accessType === "private" &&
|
||||
surveysSegment === "surveys" &&
|
||||
storageSurveyId === surveyId &&
|
||||
questionsSegment === "questions" &&
|
||||
storageQuestionId === questionId &&
|
||||
Boolean(fileName)
|
||||
);
|
||||
};
|
||||
|
||||
export const validateClientFileUploads = ({
|
||||
data,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
blocks,
|
||||
questions,
|
||||
}: {
|
||||
data?: TResponseData;
|
||||
workspaceId: string;
|
||||
surveyId: string;
|
||||
blocks?: TSurveyBlock[] | null;
|
||||
questions?: TSurveyQuestion[] | null;
|
||||
}): boolean => {
|
||||
if (!data) return true;
|
||||
|
||||
const fileUploadConfigs = getSurveyFileUploadConfigs({ blocks, questions });
|
||||
|
||||
for (const fileUploadConfig of fileUploadConfigs) {
|
||||
const fileUrls = data[fileUploadConfig.id];
|
||||
|
||||
if (fileUrls === undefined) continue;
|
||||
if (!Array.isArray(fileUrls) || !fileUrls.every((url) => typeof url === "string")) return false;
|
||||
|
||||
for (const fileUrl of fileUrls) {
|
||||
if (!validateSingleFile(fileUrl, fileUploadConfig.allowedFileExtensions)) return false;
|
||||
if (
|
||||
!isScopedPrivateUploadUrl({
|
||||
fileUrl,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
questionId: fileUploadConfig.id,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const isValidImageFile = (fileUrl: string): boolean => {
|
||||
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
||||
if (!fileName || fileName.endsWith(".")) return false;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
|
||||
import { CreateSurveyParams, CreateSurveyWithLogicParams } from "@/playwright/utils/mock";
|
||||
|
||||
const MOCK_STORAGE_UPLOAD_PATH = "/__playwright__/mock-storage-upload";
|
||||
const MOCK_STORAGE_FILE_PATH = "/storage/playwright-mock";
|
||||
|
||||
type MockStorageFileFixture = {
|
||||
name: string;
|
||||
@@ -44,11 +43,19 @@ const DEFAULT_MOCK_STORAGE_FILE_FIXTURE: MockStorageFileFixture = {
|
||||
),
|
||||
};
|
||||
|
||||
const getMockStorageFileUrl = (
|
||||
appOrigin: string,
|
||||
fileName: string,
|
||||
accessType: "public" | "private"
|
||||
): string => {
|
||||
const getMockStorageFileUrl = ({
|
||||
appOrigin,
|
||||
fileName,
|
||||
accessType,
|
||||
storageId = "playwright-mock",
|
||||
filePathSegments = [],
|
||||
}: {
|
||||
appOrigin: string;
|
||||
fileName: string;
|
||||
accessType: "public" | "private";
|
||||
storageId?: string;
|
||||
filePathSegments?: string[];
|
||||
}): string => {
|
||||
if (accessType === "public") {
|
||||
const fixture = PLAYWRIGHT_STORAGE_FILE_FIXTURES.get(fileName);
|
||||
|
||||
@@ -57,7 +64,7 @@ const getMockStorageFileUrl = (
|
||||
}
|
||||
}
|
||||
|
||||
return `${MOCK_STORAGE_FILE_PATH}/${accessType}/${encodeURIComponent(fileName)}`;
|
||||
return `/storage/${storageId}/${accessType}/${[...filePathSegments, encodeURIComponent(fileName)].join("/")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -86,7 +93,7 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
|
||||
presignedFields: {
|
||||
key: fileName,
|
||||
},
|
||||
fileUrl: getMockStorageFileUrl(appOrigin, fileName, "public"),
|
||||
fileUrl: getMockStorageFileUrl({ appOrigin, fileName, accessType: "public" }),
|
||||
signingData: null,
|
||||
updatedFileName: fileName,
|
||||
},
|
||||
@@ -113,9 +120,17 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = route.request().postDataJSON() as { fileName?: string } | undefined;
|
||||
const payload = route.request().postDataJSON() as
|
||||
| { fileName?: string; surveyId?: string; questionId?: string }
|
||||
| undefined;
|
||||
const fileName = payload?.fileName ?? "uploaded-file.bin";
|
||||
const appOrigin = new URL(route.request().url()).origin;
|
||||
const requestUrl = new URL(route.request().url());
|
||||
const appOrigin = requestUrl.origin;
|
||||
const workspaceId = requestUrl.pathname.split("/").filter(Boolean)[3] ?? "playwright-mock";
|
||||
const filePathSegments =
|
||||
payload?.surveyId && payload?.questionId
|
||||
? ["surveys", payload.surveyId, "questions", payload.questionId]
|
||||
: [];
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -126,7 +141,13 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
|
||||
presignedFields: {
|
||||
key: fileName,
|
||||
},
|
||||
fileUrl: getMockStorageFileUrl(appOrigin, fileName, "private"),
|
||||
fileUrl: getMockStorageFileUrl({
|
||||
appOrigin,
|
||||
fileName,
|
||||
accessType: "private",
|
||||
storageId: workspaceId,
|
||||
filePathSegments,
|
||||
}),
|
||||
signingData: null,
|
||||
updatedFileName: fileName,
|
||||
},
|
||||
@@ -148,7 +169,7 @@ export const mockStorageUploads = async (page: Page): Promise<void> => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**${MOCK_STORAGE_FILE_PATH}/**`, async (route) => {
|
||||
await page.route("**/storage/**", async (route) => {
|
||||
if (!["GET", "HEAD"].includes(route.request().method())) {
|
||||
await route.fallback();
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface TUploadFileConfig {
|
||||
allowedFileExtensions?: string[] | undefined;
|
||||
surveyId?: string | undefined;
|
||||
questionId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface TUploadFileResponse {
|
||||
|
||||
@@ -259,6 +259,7 @@ export function FileUploadElement({
|
||||
{
|
||||
allowedFileExtensions: element.allowedFileExtensions,
|
||||
surveyId,
|
||||
questionId: element.id,
|
||||
}
|
||||
);
|
||||
return { name: file.name, url: uploadedUrl };
|
||||
|
||||
@@ -183,6 +183,45 @@ describe("ApiClient", () => {
|
||||
expect(fileUrl).toBe("https://fake-file-url.com");
|
||||
});
|
||||
|
||||
test("includes surveyId and questionId in the upload signing request", async () => {
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
signedUrl: "https://fake-s3-url.com",
|
||||
fileUrl: "/storage/ws-test/private/surveys/survey123/questions/question123/test.jpg",
|
||||
presignedFields: { policy: "test" },
|
||||
signingData: null,
|
||||
updatedFileName: "test.jpg",
|
||||
},
|
||||
}),
|
||||
} as unknown as Response)
|
||||
.mockResolvedValueOnce({ ok: true } as unknown as Response);
|
||||
|
||||
await client.uploadFile(
|
||||
{
|
||||
base64: "data:image/jpeg;base64,abcd",
|
||||
name: "test.jpg",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
{
|
||||
allowedFileExtensions: ["jpg"],
|
||||
surveyId: "survey123",
|
||||
questionId: "question123",
|
||||
}
|
||||
);
|
||||
|
||||
const requestInit = vi.mocked(global.fetch).mock.calls[0][1] as RequestInit;
|
||||
expect(JSON.parse(requestInit.body as string)).toEqual({
|
||||
fileName: "test.jpg",
|
||||
fileType: "image/jpeg",
|
||||
allowedFileExtensions: ["jpg"],
|
||||
surveyId: "survey123",
|
||||
questionId: "question123",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws an error if file is invalid", async () => {
|
||||
await expect(() => client.uploadFile({ base64: "", name: "", type: "" } as any)).rejects.toThrow(
|
||||
"Invalid file object"
|
||||
|
||||
@@ -116,7 +116,7 @@ export class ApiClient {
|
||||
name: string;
|
||||
base64: string;
|
||||
},
|
||||
{ allowedFileExtensions, surveyId }: TUploadFileConfig | undefined = {}
|
||||
{ allowedFileExtensions, surveyId, questionId }: TUploadFileConfig | undefined = {}
|
||||
): Promise<string> {
|
||||
if (!file.name || !file.type || !file.base64) {
|
||||
throw new Error(`Invalid file object`);
|
||||
@@ -127,6 +127,7 @@ export class ApiClient {
|
||||
fileType: file.type,
|
||||
allowedFileExtensions,
|
||||
surveyId,
|
||||
questionId,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.appUrl}/api/v1/client/${this.workspaceId}/storage`, {
|
||||
|
||||
@@ -114,6 +114,7 @@ export const ZDeleteFileRequest = ZDownloadFileRequest;
|
||||
export const ZUploadFileConfig = z.object({
|
||||
allowedFileExtensions: z.array(z.string()).optional(),
|
||||
surveyId: z.string().optional(),
|
||||
questionId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TUploadFileConfig = z.infer<typeof ZUploadFileConfig>;
|
||||
@@ -124,6 +125,14 @@ export const ZUploadPrivateFileRequest = z
|
||||
fileType: z.string().trim().min(1),
|
||||
allowedFileExtensions: z.array(ZAllowedFileExtension).optional(),
|
||||
surveyId: z.cuid2(),
|
||||
questionId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_-]+$/,
|
||||
"Question id must contain only alphanumeric characters, hyphens, or underscores"
|
||||
),
|
||||
workspaceId: z.cuid2(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
|
||||
Reference in New Issue
Block a user