mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-18 23:28:32 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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),
|
||||
|
||||
+16
-8
@@ -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)
|
||||
@@ -139,7 +147,7 @@ export const DELETE = async (
|
||||
idParam
|
||||
);
|
||||
|
||||
if (!deleteResult.ok) {
|
||||
if (!deleteResult.ok && "error" in deleteResult) {
|
||||
const { error } = deleteResult;
|
||||
|
||||
logger.error({ error }, "Error deleting file");
|
||||
@@ -589,8 +589,9 @@ 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 pathname = fileUrl.startsWith("/storage/") ? fileUrl : new URL(fileUrl).pathname;
|
||||
const [, storageId, accessType, ...fileNameSegments] = pathname.split("/").filter(Boolean);
|
||||
const fileName = fileNameSegments.join("/");
|
||||
|
||||
if (!storageId || !accessType || !fileName) {
|
||||
throw new Error(`Invalid file path: ${pathname}`);
|
||||
|
||||
@@ -24,8 +24,9 @@ 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 pathname = fileUrl.startsWith("/storage/") ? fileUrl : new URL(fileUrl).pathname;
|
||||
const [, storageId, accessType, ...fileNameSegments] = pathname.split("/").filter(Boolean);
|
||||
const fileName = fileNameSegments.join("/");
|
||||
|
||||
if (!storageId || !accessType || !fileName) {
|
||||
throw new Error(`Invalid file path: ${pathname}`);
|
||||
|
||||
@@ -117,6 +117,41 @@ 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("should properly sanitize filenames with special characters like # in URL", async () => {
|
||||
const mockSignedUrlResponse = {
|
||||
ok: true,
|
||||
|
||||
@@ -18,7 +18,8 @@ export const getSignedUrlForUpload = async (
|
||||
workspaceId: string,
|
||||
fileType: string,
|
||||
accessType: TAccessType,
|
||||
maxFileUploadSize: number = 1024 * 1024 * 10 // 10MB
|
||||
maxFileUploadSize: number = 1024 * 1024 * 10, // 10MB
|
||||
filePathSegments: string[] = []
|
||||
): Promise<
|
||||
Result<
|
||||
{
|
||||
@@ -34,17 +35,18 @@ export const getSignedUrlForUpload = async (
|
||||
if (!safeFileName) {
|
||||
return err({ code: StorageErrorCode.InvalidInput });
|
||||
}
|
||||
|
||||
if (filePathSegments.some((segment) => !segment || segment.includes("/") || segment.includes("\\"))) {
|
||||
return err({ code: StorageErrorCode.InvalidInput });
|
||||
}
|
||||
|
||||
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 +56,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}/${[
|
||||
...filePathSegments,
|
||||
encodeURIComponent(updatedFileName),
|
||||
].join("/")}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Error getting signed url for upload");
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveStorageUrlAuto,
|
||||
resolveStorageUrlsInObject,
|
||||
sanitizeFileName,
|
||||
validateClientFileUploads,
|
||||
validateFileUploads,
|
||||
validateSingleFile,
|
||||
validateSurveyAllowsFileUpload,
|
||||
@@ -369,7 +370,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 +386,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 +405,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 +432,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 +460,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 +488,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 +503,117 @@ 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("isValidImageFile", () => {
|
||||
|
||||
@@ -120,7 +120,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 +132,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 +167,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 +185,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 +197,92 @@ 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);
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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