Compare commits

..

2 Commits

Author SHA1 Message Date
Bhagya Amarasinghe a7f83c42a4 fix: harden scoped storage paths 2026-05-19 12:06:26 +05:30
Bhagya Amarasinghe 9e1a8e96c8 fix: bind storage uploads to survey questions 2026-05-19 00:22:25 +05:30
34 changed files with 945 additions and 1828 deletions
@@ -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());
@@ -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),
-11
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "vitest";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
@@ -119,13 +118,3 @@ describe("successResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
describe("noContentResponse", () => {
test("returns 204 without a body", async () => {
const res = noContentResponse({ requestId: "req-empty" });
expect(res.status).toBe(204);
expect(res.headers.get("X-Request-Id")).toBe("req-empty");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.text()).toBe("");
});
});
-15
View File
@@ -171,18 +171,3 @@ export function successResponse<T>(
}
);
}
export function noContentResponse(options?: { requestId?: string; cache?: string }): Response {
const headers: Record<string, string> = {
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return new Response(null, {
status: 204,
headers,
});
}
@@ -0,0 +1,318 @@
import { ApiKeyPermission } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { DELETE } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
const surveyId = "clxx1234567890123456789012";
const workspaceId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) {
headers["x-request-id"] = requestId;
}
return new NextRequest(url, {
method: "DELETE",
headers,
});
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: true },
},
workspacePermissions: [
{
workspaceId,
workspaceName: "W",
permission: ApiKeyPermission.write,
},
],
};
describe("DELETE /api/v3/surveys/[surveyId]", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(getSurvey).mockResolvedValue({
id: surveyId,
name: "Delete me",
workspaceId: workspaceId,
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "User" },
singleUse: null,
} as any);
vi.mocked(deleteSurvey).mockResolvedValue({
id: surveyId,
workspaceId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
organizationId: "org_1",
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(401);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 200 with session auth and deletes the survey", async () => {
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
"req-delete",
`/api/v3/surveys/${surveyId}`
);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(await res.json()).toEqual({
data: {
id: surveyId,
},
});
});
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const res = await DELETE(
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
"x-api-key": "fbk_test",
}),
{
params: Promise.resolve({ surveyId }),
} as never
);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
workspaceId,
"readWrite",
"req-api-key",
`/api/v3/surveys/${surveyId}`
);
});
test("returns 400 when surveyId is invalid", async () => {
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
params: Promise.resolve({ surveyId: "not-a-cuid" }),
} as never);
expect(res.status).toBe(400);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 403 when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
});
test("returns 403 when the user lacks readWrite workspace access", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-forbidden",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "unknown",
organizationId: "unknown",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: undefined,
})
);
});
test("returns 500 when survey deletion fails", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
test("queues an audit log with target, actor, organization, and old object", async () => {
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
});
+26 -153
View File
@@ -3,173 +3,41 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
noContentResponse,
problemBadRequest,
problemForbidden,
problemInternalError,
successResponse,
} from "@/app/api/v3/lib/response";
import {
V3SurveyLanguageError,
V3SurveyUnsupportedShapeError,
serializeV3SurveyResource,
} from "@/app/api/v3/surveys/serializers";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { parseV3SurveyLanguageQuery } from "../language";
const surveyParamsSchema = z.object({
surveyId: z.cuid2(),
});
const surveyQuerySchema = z
.object({
lang: z
.union([z.string(), z.array(z.string())])
.transform((value, ctx) => {
const parsedLanguageQuery = parseV3SurveyLanguageQuery(value);
if (!parsedLanguageQuery.ok) {
ctx.addIssue({
code: "custom",
message: parsedLanguageQuery.message,
});
return z.NEVER;
}
return parsedLanguageQuery.languages;
})
.optional(),
})
.strict();
async function getAuthorizedSurvey(params: {
surveyId: string;
authentication: Parameters<typeof requireV3WorkspaceAccess>[0];
access: "read" | "readWrite";
requestId: string;
instance: string;
}) {
const { surveyId, authentication, access, requestId, instance } = params;
const survey = await getSurvey(surveyId);
if (!survey) {
return {
survey: null,
authResult: null,
response: problemForbidden(requestId, "You are not authorized to access this resource", instance),
};
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
access,
requestId,
instance
);
if (authResult instanceof Response) {
return { survey: null, authResult: null, response: authResult };
}
return { survey, authResult, response: null };
}
export const GET = withV3ApiWrapper({
auth: "both",
schemas: {
params: surveyParamsSchema,
query: surveyQuerySchema,
},
handler: async ({ parsedInput, authentication, requestId, instance }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const { survey, response } = await getAuthorizedSurvey({
surveyId,
authentication,
access: "read",
requestId,
instance,
});
if (response) {
log.warn({ statusCode: response.status }, "Survey not found or not accessible");
return response;
}
try {
return successResponse(serializeV3SurveyResource(survey, { lang: parsedInput.query.lang }), {
requestId,
cache: "private, no-store",
});
} catch (error) {
if (error instanceof V3SurveyLanguageError) {
log.warn({ statusCode: 400, lang: parsedInput.query.lang }, "Invalid survey language selector");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "lang",
reason: error.message,
},
],
});
}
if (error instanceof V3SurveyUnsupportedShapeError) {
log.warn({ statusCode: 400 }, "Unsupported v3 survey shape");
return problemBadRequest(requestId, error.message, {
instance,
invalid_params: [
{
name: "survey",
reason: error.message,
},
],
});
}
throw error;
}
} catch (error) {
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey get unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: surveyParamsSchema,
params: z.object({
surveyId: z.cuid2(),
}),
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const { survey, authResult, response } = await getAuthorizedSurvey({
surveyId,
authentication,
access: "readWrite",
requestId,
instance,
});
const survey = await getSurvey(surveyId);
if (response) {
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return response;
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
if (auditLog) {
@@ -178,9 +46,14 @@ export const DELETE = withV3ApiWrapper({
auditLog.oldObject = survey;
}
await deleteSurvey(surveyId);
const deletedSurvey = await deleteSurvey(surveyId);
return noContentResponse({ requestId });
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
@@ -1,106 +0,0 @@
import { describe, expect, test } from "vitest";
import {
normalizeV3SurveyLanguageTag,
parseV3SurveyLanguageQuery,
resolveV3SurveyLanguageCode,
} from "./language";
const languages = [
{ code: "en-US", enabled: true },
{ code: "de-DE", enabled: true },
{ code: "fr-FR", enabled: false },
];
describe("normalizeV3SurveyLanguageTag", () => {
test.each([
["EN_us", "en-US"],
["en-us", "en-US"],
["de", "de"],
["zh_hans_cn", "zh-Hans-CN"],
])("normalizes %s to %s", (input, expected) => {
expect(normalizeV3SurveyLanguageTag(input)).toBe(expected);
});
test("returns null for invalid language tags", () => {
expect(normalizeV3SurveyLanguageTag("not a locale")).toBeNull();
});
});
describe("parseV3SurveyLanguageQuery", () => {
test("parses comma-separated language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE, pt_PT, EN_us")).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US"],
});
});
test("parses repeated language selectors", () => {
expect(parseV3SurveyLanguageQuery(["de-DE", "pt_PT,en_us"])).toEqual({
ok: true,
languages: ["de-DE", "pt-PT", "en-US"],
});
});
test("deduplicates language selectors case-insensitively", () => {
expect(parseV3SurveyLanguageQuery("de-DE,DE_de")).toEqual({
ok: true,
languages: ["de-DE"],
});
});
test("rejects empty language selectors", () => {
expect(parseV3SurveyLanguageQuery("de-DE,")).toEqual({
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
});
});
test("rejects invalid language selectors", () => {
expect(parseV3SurveyLanguageQuery("not a locale")).toEqual({
ok: false,
message: "Language 'not a locale' is not a valid locale code",
});
});
});
describe("resolveV3SurveyLanguageCode", () => {
test("matches configured languages case-insensitively and normalizes underscores", () => {
expect(resolveV3SurveyLanguageCode("DE_de", languages)).toEqual({ ok: true, code: "de-DE" });
});
test("resolves language-only tags when exactly one configured language matches", () => {
expect(resolveV3SurveyLanguageCode("de", languages)).toEqual({ ok: true, code: "de-DE" });
});
test("resolves disabled configured languages for management reads", () => {
expect(resolveV3SurveyLanguageCode("fr", languages)).toEqual({ ok: true, code: "fr-FR" });
});
test("returns ambiguous when language-only tags match multiple configured languages", () => {
expect(
resolveV3SurveyLanguageCode("pt", [
{ code: "pt-BR", enabled: true },
{ code: "pt-PT", enabled: true },
])
).toEqual({
ok: false,
reason: "ambiguous",
message: "Language 'pt' is ambiguous for this survey; use one of pt-BR, pt-PT",
});
});
test("returns unknown for languages not configured on the survey", () => {
expect(resolveV3SurveyLanguageCode("es-ES", languages)).toEqual({
ok: false,
reason: "unknown",
message: "Language 'es-ES' is not configured for this survey",
});
});
test("resolves the implicit default language for surveys without configured languages", () => {
expect(resolveV3SurveyLanguageCode("en", [{ code: "en-US", enabled: true }])).toEqual({
ok: true,
code: "en-US",
});
});
});
-112
View File
@@ -1,112 +0,0 @@
type TV3SurveyLanguageInput = {
code: string;
enabled: boolean;
};
type TV3SurveyLanguageQueryInput = string | string[];
type TResolveV3SurveyLanguageCodeResult =
| { ok: true; code: string }
| { ok: false; reason: "invalid" | "unknown" | "ambiguous"; message: string };
type TParseV3SurveyLanguageQueryResult = { ok: true; languages: string[] } | { ok: false; message: string };
export function normalizeV3SurveyLanguageTag(value: string): string | null {
const normalizedSeparators = value.trim().replaceAll("_", "-");
try {
return Intl.getCanonicalLocales(normalizedSeparators)[0] ?? null;
} catch {
return null;
}
}
export function parseV3SurveyLanguageQuery(
value: TV3SurveyLanguageQueryInput
): TParseV3SurveyLanguageQueryResult {
const requestedLanguages = (Array.isArray(value) ? value : [value])
.flatMap((entry) => entry.split(","))
.map((entry) => entry.trim());
if (requestedLanguages.some((entry) => entry.length === 0)) {
return {
ok: false,
message: "Language selector must contain valid comma-separated locale codes",
};
}
const normalizedLanguages: string[] = [];
for (const language of requestedLanguages) {
const normalizedLanguage = normalizeV3SurveyLanguageTag(language);
if (!normalizedLanguage) {
return {
ok: false,
message: `Language '${language}' is not a valid locale code`,
};
}
if (!normalizedLanguages.some((entry) => entry.toLowerCase() === normalizedLanguage.toLowerCase())) {
normalizedLanguages.push(normalizedLanguage);
}
}
return { ok: true, languages: normalizedLanguages };
}
function getLanguageSubtag(languageTag: string): string {
return languageTag.split("-")[0]?.toLowerCase() ?? languageTag.toLowerCase();
}
export function resolveV3SurveyLanguageCode(
requestedLanguage: string,
languages: TV3SurveyLanguageInput[]
): TResolveV3SurveyLanguageCodeResult {
const normalizedRequestedLanguage = normalizeV3SurveyLanguageTag(requestedLanguage);
if (!normalizedRequestedLanguage) {
return {
ok: false,
reason: "invalid",
message: `Language '${requestedLanguage}' is not a valid locale code`,
};
}
const normalizedLanguages = languages.map((language) => ({
...language,
code: normalizeV3SurveyLanguageTag(language.code) ?? language.code,
}));
const exactMatch = normalizedLanguages.find(
(language) => language.code.toLowerCase() === normalizedRequestedLanguage.toLowerCase()
);
if (exactMatch) {
return { ok: true, code: exactMatch.code };
}
const requestedSubtag = getLanguageSubtag(normalizedRequestedLanguage);
const hasRegionOrScript = normalizedRequestedLanguage.includes("-");
const matchingLanguages = hasRegionOrScript
? []
: normalizedLanguages.filter((language) => getLanguageSubtag(language.code) === requestedSubtag);
if (matchingLanguages.length > 1) {
return {
ok: false,
reason: "ambiguous",
message: `Language '${normalizedRequestedLanguage}' is ambiguous for this survey; use one of ${matchingLanguages.map((language) => language.code).join(", ")}`,
};
}
const languageMatch = matchingLanguages[0];
if (languageMatch) {
return { ok: true, code: languageMatch.code };
}
return {
ok: false,
reason: "unknown",
message: `Language '${normalizedRequestedLanguage}' is not configured for this survey`,
};
}
@@ -1,274 +0,0 @@
import { describe, expect, test } from "vitest";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { V3SurveyUnsupportedShapeError, serializeV3SurveyResource } from "./serializers";
const baseSurvey = {
id: "survey_1",
workspaceId: "workspace_1",
createdAt: new Date("2026-04-21T10:00:00.000Z"),
updatedAt: new Date("2026-04-21T11:00:00.000Z"),
name: "Product Feedback",
type: "link",
status: "draft",
metadata: { cx: "enterprise" },
languages: [
{
default: true,
enabled: true,
language: { id: "lang_1", code: "en-US", alias: "en", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: true,
language: { id: "lang_2", code: "de-DE", alias: "de", createdAt: new Date(), updatedAt: new Date() },
},
{
default: false,
enabled: false,
language: { id: "lang_3", code: "fr-FR", alias: "fr", createdAt: new Date(), updatedAt: new Date() },
},
],
questions: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome", "de-DE": "Willkommen", "fr-FR": "Bienvenue" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?", "de-DE": "Was sollen wir verbessern?" },
subheader: { default: "Tell us more" },
required: true,
},
],
},
],
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
} as unknown as TSurvey;
describe("serializeV3SurveyResource", () => {
test("returns canonical multilingual fields using real locale codes", () => {
const resource = serializeV3SurveyResource(baseSurvey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource).not.toHaveProperty("language");
expect(resource.languages).toEqual([
{ code: "en-US", default: true, enabled: true },
{ code: "de-DE", default: false, enabled: true },
{ code: "fr-FR", default: false, enabled: false },
]);
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
"fr-FR": "Bienvenue",
},
},
});
expect(resource).toMatchObject({
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("does not expose the internal default pseudo-locale for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
blocks: [
{
id: "block_1",
name: "Intro",
elements: [
{
id: "satisfaction",
type: "openText",
headline: { default: "What should we improve?" },
required: true,
},
],
},
],
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource.defaultLanguage).toBe("en-US");
expect(resource.languages).toEqual([{ code: "en-US", default: true, enabled: true }]);
expect(resource).toMatchObject({
welcomeCard: { headline: { "en-US": "Welcome" } },
blocks: [
{
elements: [
{
headline: { "en-US": "What should we improve?" },
},
],
},
],
});
});
test("filters the implicit default language for surveys without configured languages", () => {
const survey = {
...baseSurvey,
languages: [],
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey, { lang: ["en"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({ welcomeCard: { headline: { "en-US": "Welcome" } } });
});
test("preserves stored locale variants when their keys use non-canonical casing or separators", () => {
const survey = {
...baseSurvey,
welcomeCard: {
enabled: true,
headline: { default: "Welcome", de_de: "Willkommen" },
},
} as unknown as TSurvey;
const resource = serializeV3SurveyResource(survey);
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
});
});
test("filters fields for case-insensitive underscore language selectors while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["DE_de"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: { headline: { "de-DE": "Willkommen" } },
blocks: [
{
elements: [
{
headline: { "de-DE": "Was sollen wir verbessern?" },
subheader: { "de-DE": "Tell us more" },
},
],
},
],
});
});
test("resolves language-only selectors against configured survey languages", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["de"] });
expect(resource).toMatchObject({ welcomeCard: { headline: { "de-DE": "Willkommen" } } });
});
test("filters disabled configured languages for management reads", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["fr"] });
expect(resource).toMatchObject({ welcomeCard: { headline: { "fr-FR": "Bienvenue" } } });
});
test("filters multiple requested languages while preserving maps", () => {
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["en-US", "de"] });
expect(resource).not.toHaveProperty("language");
expect(resource).toMatchObject({
welcomeCard: {
headline: {
"en-US": "Welcome",
"de-DE": "Willkommen",
},
},
blocks: [
{
elements: [
{
headline: {
"en-US": "What should we improve?",
"de-DE": "Was sollen wir verbessern?",
},
},
],
},
],
});
});
test("rejects ambiguous language-only selectors", () => {
const survey = {
...baseSurvey,
languages: [
{
default: true,
enabled: true,
language: {
id: "lang_1",
code: "pt-BR",
alias: "br",
createdAt: new Date(),
updatedAt: new Date(),
},
},
{
default: false,
enabled: true,
language: {
id: "lang_2",
code: "pt-PT",
alias: "pt",
createdAt: new Date(),
updatedAt: new Date(),
},
},
],
} as unknown as TSurvey;
expect(() => serializeV3SurveyResource(survey, { lang: ["pt"] })).toThrow(
"Language 'pt' is ambiguous for this survey; use one of pt-BR, pt-PT"
);
});
test("rejects legacy question-based survey shapes instead of returning an incomplete block resource", () => {
const survey = {
...baseSurvey,
questions: [{ id: "legacy_question", type: "openText", headline: { default: "Legacy question" } }],
blocks: [],
} as unknown as TSurvey;
expect(() => serializeV3SurveyResource(survey)).toThrow(V3SurveyUnsupportedShapeError);
expect(() => serializeV3SurveyResource(survey)).toThrow(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
});
});
+3 -182
View File
@@ -1,192 +1,13 @@
import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types";
import type { TSurvey as TSurveyListRecord } from "@/modules/survey/list/types/surveys";
import { normalizeV3SurveyLanguageTag, resolveV3SurveyLanguageCode } from "./language";
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurveyListRecord, "singleUse">;
const DEFAULT_V3_SURVEY_LANGUAGE = "en-US";
type TV3SurveyLanguage = {
code: string;
default: boolean;
enabled: boolean;
};
type TSerializedValue =
| string
| number
| boolean
| null
| TSerializedValue[]
| { [key: string]: TSerializedValue };
export class V3SurveyLanguageError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyLanguageError";
}
}
export class V3SurveyUnsupportedShapeError extends Error {
constructor(message: string) {
super(message);
this.name = "V3SurveyUnsupportedShapeError";
}
}
export type TV3SurveyListItem = Omit<TSurvey, "singleUse">;
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Surveys are scoped by workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurveyListRecord): TV3SurveyListItem {
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { singleUse: _omitSingleUse, ...rest } = survey;
return rest;
}
function toIsoString(value: Date | string): string {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
function getSurveyLanguages(survey: TInternalSurvey): TV3SurveyLanguage[] {
const languages = (survey.languages ?? []).map((surveyLanguage) => ({
code: normalizeV3SurveyLanguageTag(surveyLanguage.language.code) ?? surveyLanguage.language.code,
default: surveyLanguage.default,
enabled: surveyLanguage.enabled,
}));
if (languages.length === 0) {
return [{ code: DEFAULT_V3_SURVEY_LANGUAGE, default: true, enabled: true }];
}
return languages;
}
function getDefaultLanguage(survey: TInternalSurvey): string {
const defaultLanguageCode = survey.languages?.find((surveyLanguage) => surveyLanguage.default)?.language
.code;
return defaultLanguageCode
? (normalizeV3SurveyLanguageTag(defaultLanguageCode) ?? defaultLanguageCode)
: DEFAULT_V3_SURVEY_LANGUAGE;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isI18nString(value: unknown): value is Record<string, string> {
return (
isPlainObject(value) &&
typeof value.default === "string" &&
Object.values(value).every((entry) => typeof entry === "string")
);
}
function getI18nValueForLanguage(value: Record<string, string>, languageCode: string): string | undefined {
if (typeof value[languageCode] === "string") {
return value[languageCode];
}
const matchingKey = Object.keys(value).find(
(key) => normalizeV3SurveyLanguageTag(key)?.toLowerCase() === languageCode.toLowerCase()
);
return matchingKey ? value[matchingKey] : undefined;
}
function serializeCanonicalValue(
value: unknown,
defaultLanguage: string,
languageCodes: Set<string>,
options?: { fallbackMissingTranslations?: boolean }
): TSerializedValue {
if (isI18nString(value)) {
const result: Record<string, string> = {
[defaultLanguage]: value.default,
};
for (const languageCode of languageCodes) {
const translatedValue = getI18nValueForLanguage(value, languageCode);
if (languageCode !== defaultLanguage) {
if (translatedValue !== undefined) {
result[languageCode] = translatedValue;
} else if (options?.fallbackMissingTranslations) {
result[languageCode] = value.default;
}
}
}
if (!languageCodes.has(defaultLanguage)) {
delete result[defaultLanguage];
}
return result;
}
if (Array.isArray(value)) {
return value.map((entry) => serializeCanonicalValue(entry, defaultLanguage, languageCodes, options));
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
serializeCanonicalValue(entry, defaultLanguage, languageCodes, options),
])
);
}
return value as TSerializedValue;
}
function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: string): string {
const result = resolveV3SurveyLanguageCode(language, languages);
if (!result.ok) {
throw new V3SurveyLanguageError(result.message);
}
return result.code;
}
function resolveRequestedLanguages(languages: TV3SurveyLanguage[], requestedLanguages?: string[]): string[] {
if (!requestedLanguages) {
return [];
}
return requestedLanguages.map((language) => resolveRequestedLanguage(languages, language));
}
export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { lang?: string[] }) {
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
throw new V3SurveyUnsupportedShapeError(
"Legacy question-based surveys are not supported by the v3 survey management API"
);
}
const defaultLanguage = getDefaultLanguage(survey);
const languages = getSurveyLanguages(survey);
const configuredLanguageCodes = new Set(languages.map((language) => language.code));
const requestedLanguages = resolveRequestedLanguages(languages, options?.lang);
const languageCodes = requestedLanguages.length > 0 ? new Set(requestedLanguages) : configuredLanguageCodes;
const serializeValue = (value: unknown) =>
serializeCanonicalValue(value, defaultLanguage, languageCodes, {
fallbackMissingTranslations: requestedLanguages.length > 0,
});
return {
id: survey.id,
workspaceId: survey.workspaceId,
createdAt: toIsoString(survey.createdAt),
updatedAt: toIsoString(survey.updatedAt),
name: survey.name,
type: survey.type,
status: survey.status,
metadata: survey.metadata,
defaultLanguage,
languages,
welcomeCard: serializeValue(survey.welcomeCard),
blocks: serializeValue(survey.blocks),
endings: serializeValue(survey.endings),
hiddenFields: survey.hiddenFields,
variables: survey.variables,
};
}
@@ -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");
+10 -6
View 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}`);
}
@@ -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");
}
+55
View 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,
+17 -9
View File
@@ -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");
+161 -8
View File
@@ -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", () => {
+156 -12
View File
@@ -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;
@@ -99,7 +99,12 @@ describe("useDeleteSurvey", () => {
0
);
resolveFetch?.(new Response(null, { status: 204 }));
resolveFetch?.(
new Response(JSON.stringify({ data: { id: "survey_1" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: surveyKeys.lists() });
@@ -1,10 +1,5 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { V3ApiError } from "@/modules/api/lib/v3-client";
import { buildSurveyListSearchParams, deleteSurvey } from "./v3-surveys-client";
afterEach(() => {
vi.unstubAllGlobals();
});
import { describe, expect, test } from "vitest";
import { buildSurveyListSearchParams } from "./v3-surveys-client";
describe("buildSurveyListSearchParams", () => {
test("emits only supported v3 params using normalized filter values", () => {
@@ -44,39 +39,3 @@ describe("buildSurveyListSearchParams", () => {
);
});
});
describe("deleteSurvey", () => {
test("treats 204 No Content as a successful delete", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
await expect(deleteSurvey("survey_1")).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith("/api/v3/surveys/survey_1", {
method: "DELETE",
cache: "no-store",
});
});
test("maps v3 problem responses to V3ApiError", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json(
{
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
},
{ status: 403 }
)
)
);
await expect(deleteSurvey("survey_1")).rejects.toMatchObject<V3ApiError>({
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
});
});
});
@@ -13,6 +13,12 @@ type TV3SurveyListResponse = {
meta: TSurveyListPage["meta"];
};
type TV3DeleteSurveyResponse = {
data: {
id: string;
};
};
export type TSurveyListPage = {
data: TSurveyListItem[];
meta: {
@@ -116,7 +122,7 @@ export async function listSurveys({
};
}
export async function deleteSurvey(surveyId: string): Promise<void> {
export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
const response = await fetch(`/api/v3/surveys/${surveyId}`, {
method: "DELETE",
cache: "no-store",
@@ -125,4 +131,7 @@ export async function deleteSurvey(surveyId: string): Promise<void> {
if (!response.ok) {
throw await parseV3ApiError(response);
}
const body = (await response.json()) as TV3DeleteSurveyResponse;
return body.data;
}
+33 -12
View File
@@ -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;
+13 -855
View File
@@ -1,11 +1,12 @@
# V3 API — Surveys (hand-maintained; not generated by generate-api-specs).
# Implementation: apps/web/app/api/v3/surveys/route.ts and apps/web/app/api/v3/surveys/[surveyId]/route.ts
# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context.
openapi: 3.1.0
info:
title: Formbricks API v3
description: |
**GET /api/v3/surveys**, **GET /api/v3/surveys/{surveyId}**, and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace).
**GET /api/v3/surveys** and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace).
**Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
@@ -33,7 +34,7 @@ info:
The v3-backed survey overview page intentionally removes actions that are not yet exposed by this contract: `Created by` filtering, `Duplicate`, `Copy...`, `Preview`, and `Copy link`.
**Next steps (out of scope for this spec)**
Additional v3 survey write endpoints, optional ETag/304, field selection, and survey version history.
Additional v3 survey endpoints, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
version: 0.1.0
x-implementation-notes:
route: apps/web/app/api/v3/surveys/route.ts
@@ -197,174 +198,6 @@ paths:
- sessionAuth: []
- apiKeyAuth: []
/api/v3/surveys/{surveyId}:
get:
operationId: getSurveyV3
summary: Retrieve a survey
description: |
Returns the public v3 survey management resource for one survey. By default, translatable
fields are returned as canonical multilingual maps keyed by real locale codes. Use `lang`
to filter those maps to one or more requested locale codes.
tags:
- V3 Surveys
parameters:
- in: path
name: surveyId
required: true
schema:
type: string
format: cuid2
description: Survey identifier.
- in: query
name: lang
required: false
style: form
explode: false
schema:
type: array
items:
type: string
examples:
- [de-DE]
- [de-DE, pt-PT]
description: |
Comma-separated locale code filter for translatable fields, for example `?lang=de-DE,pt-PT`.
The response shape stays stable: translatable fields are always maps keyed by locale code, never
strings. The parser is case-insensitive, accepts `_` or `-` separators, and normalizes to canonical
BCP 47 casing (`de_DE`, `DE-de` → `de-DE`). A language-only selector (`de`) resolves to the matching
configured survey language when exactly one exists; otherwise it returns `400`. Disabled-but-configured
languages are readable in the management API so unfinished translations can be completed. Aliases are
not accepted.
responses:
"200":
description: Survey retrieved successfully
headers:
X-Request-Id:
schema: { type: string }
description: Request correlation ID
Cache-Control:
schema: { type: string }
example: "private, no-store"
content:
application/json:
schema:
type: object
required: [data]
properties:
data:
$ref: "#/components/schemas/SurveyResource"
examples:
canonical:
summary: Canonical multilingual authoring resource
value:
data:
id: clseedsurveycsat000000
workspaceId: clseedworkspace000000000
createdAt: "2026-05-18T09:24:54.014Z"
updatedAt: "2026-05-18T09:24:54.014Z"
name: CSAT Survey
type: link
status: inProgress
metadata: {}
defaultLanguage: en-US
languages:
- code: en-US
default: true
enabled: true
- code: de-DE
default: false
enabled: false
welcomeCard:
enabled: false
blocks:
- id: e0tfwzqk63op37y14z95qq3k
name: Main Block
elements:
- id: nzte4cm8836hgjw63pesziht
type: rating
range: 5
scale: smiley
headline:
en-US: How satisfied are you with our product?
de-DE: Wie zufrieden sind Sie mit unserem Produkt?
required: true
endings: []
hiddenFields:
enabled: false
variables: []
filtered:
summary: Language-filtered projection with ?lang=de-DE
value:
data:
id: clseedsurveycsat000000
workspaceId: clseedworkspace000000000
createdAt: "2026-05-18T09:24:54.014Z"
updatedAt: "2026-05-18T09:24:54.014Z"
name: CSAT Survey
type: link
status: inProgress
metadata: {}
defaultLanguage: en-US
languages:
- code: en-US
default: true
enabled: true
- code: de-DE
default: false
enabled: false
welcomeCard:
enabled: false
blocks:
- id: e0tfwzqk63op37y14z95qq3k
name: Main Block
elements:
- id: nzte4cm8836hgjw63pesziht
type: rating
range: 5
scale: smiley
headline:
de-DE: Wie zufrieden sind Sie mit unserem Produkt?
required: true
endings: []
hiddenFields:
enabled: false
variables: []
"400":
description: Invalid survey id, unsupported query parameter, unknown language, or unsupported legacy survey shape
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"401":
description: Not authenticated (no valid session or API key)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"403":
description: Forbidden — no access, or survey does not exist (404 not used; avoids existence leak)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"429":
description: Rate limit exceeded
headers:
Retry-After:
schema: { type: integer }
description: Seconds until the current rate-limit window resets
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"500":
description: Internal Server Error
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
security:
- sessionAuth: []
- apiKeyAuth: []
delete:
operationId: deleteSurveyV3
summary: Delete a survey
@@ -380,7 +213,7 @@ paths:
format: cuid2
description: Survey identifier.
responses:
"204":
"200":
description: Survey deleted successfully
headers:
X-Request-Id:
@@ -389,6 +222,10 @@ paths:
Cache-Control:
schema: { type: string }
example: "private, no-store"
content:
application/json:
schema:
$ref: "#/components/schemas/SurveyDeleteResponse"
"400":
description: Bad Request
content:
@@ -467,694 +304,15 @@ components:
properties:
enabled: { type: boolean }
isEncrypted: { type: boolean }
TranslatableText:
allOf:
- $ref: "#/components/schemas/TranslatableTextMap"
description: |
Survey authoring text. `GET /api/v3/surveys/{surveyId}` always returns locale maps keyed by
real locale codes such as `en-US` and `de-DE`. Use `?lang=` to filter which locale keys are included.
The internal storage key `default` is never exposed by v3.
examples:
- en-US: What should we improve?
de-DE: Was sollten wir verbessern?
TranslatableTextMap:
SurveyDeleteResponse:
type: object
description: Canonical multilingual text map keyed by real locale codes.
propertyNames:
type: string
description: BCP 47 locale code, for example `en-US`, `de-DE`, or `pt-BR`.
additionalProperties:
type: string
SurveyLanguage:
type: object
description: |
Language configured for this survey. Aliases/display names are intentionally not exposed in v3.
Disabled languages can still be read by the management API so unfinished translations can be completed.
required: [code, default, enabled]
required: [data]
properties:
code:
type: string
description: Canonical locale code.
example: en-US
default:
type: boolean
description: Whether this is the default authoring language.
enabled:
type: boolean
description: Whether this language is enabled for respondent-facing delivery.
SurveyWelcomeCard:
type: object
description: Optional card shown before the first survey block.
required: [enabled]
properties:
enabled:
type: boolean
headline:
$ref: "#/components/schemas/TranslatableText"
subheader:
$ref: "#/components/schemas/TranslatableText"
buttonLabel:
$ref: "#/components/schemas/TranslatableText"
fileUrl:
type: string
videoUrl:
type: string
timeToFinish:
type: boolean
showResponseCount:
type: boolean
additionalProperties: true
SurveyHiddenFields:
type: object
description: |
Hidden fields, sometimes called embedded data in other survey products. Field ids are stable
public identifiers and may be referenced by logic, recall, quotas, integrations, and response data.
Use only letters, numbers, underscores, and hyphens; avoid spaces and reserved ids.
required: [enabled]
properties:
enabled:
type: boolean
fieldIds:
type: array
items:
type: string
pattern: "^[a-zA-Z0-9_-]+$"
uniqueItems: true
additionalProperties: false
SurveyVariable:
oneOf:
- $ref: "#/components/schemas/SurveyNumberVariable"
- $ref: "#/components/schemas/SurveyTextVariable"
description: |
Survey variable. Variable ids are stable references used by logic and calculation actions.
Variable names are human-readable labels and must be unique within the survey.
SurveyNumberVariable:
type: object
description: |
Number variable. Used by `calculate` logic actions with numeric operators such as `add`,
`subtract`, `multiply`, `divide`, or `assign`.
required: [id, name, type, value]
properties:
id:
type: string
format: cuid2
description: Stable variable id referenced from logic.
name:
type: string
pattern: "^[a-z0-9_]+$"
description: Unique variable name. Lowercase letters, numbers, and underscores only.
type:
type: string
enum: [number]
value:
type: number
description: Default numeric value.
additionalProperties: false
SurveyTextVariable:
type: object
description: |
Text variable. Used by `calculate` logic actions with text operators such as `assign` or `concat`.
required: [id, name, type, value]
properties:
id:
type: string
format: cuid2
description: Stable variable id referenced from logic.
name:
type: string
pattern: "^[a-z0-9_]+$"
description: Unique variable name. Lowercase letters, numbers, and underscores only.
type:
type: string
enum: [text]
value:
type: string
description: Default text value.
additionalProperties: false
SurveyEnding:
type: object
description: Ending reached after the last block or a jump action.
required: [id, type]
properties:
id:
type: string
format: cuid2
description: Stable ending id. `jumpToBlock.target` may point to this id.
type:
type: string
enum: [endScreen, redirectToUrl]
headline:
$ref: "#/components/schemas/TranslatableText"
subheader:
$ref: "#/components/schemas/TranslatableText"
buttonLabel:
$ref: "#/components/schemas/TranslatableText"
buttonLink:
type: string
imageUrl:
type: string
videoUrl:
type: string
url:
type: string
description: Redirect URL for `redirectToUrl` endings.
label:
type: string
description: Optional internal label for redirect endings.
additionalProperties: true
SurveyBlock:
type: object
description: |
Block-based survey section. Block ids are stable public identifiers. Logic and fallbacks can
jump to block ids or ending ids, so clients and agents should preserve ids unless intentionally
creating/deleting a block.
required: [id, name, elements]
properties:
id:
type: string
format: cuid2
description: Stable block id.
name:
type: string
minLength: 1
elements:
type: array
minItems: 1
items:
$ref: "#/components/schemas/SurveyElement"
logic:
type: array
items:
$ref: "#/components/schemas/SurveyBlockLogic"
logicFallback:
type: string
format: cuid2
description: Block or ending id used when no logic condition matches.
buttonLabel:
$ref: "#/components/schemas/TranslatableText"
backButtonLabel:
$ref: "#/components/schemas/TranslatableText"
additionalProperties: true
SurveyElement:
type: object
description: |
Survey element/question inside a block. Element ids are stable public identifiers used by
logic, recall strings, response data, quotas, integrations, and analysis. The schema lists
the fields used by all current element types; type-specific fields are present only when relevant.
required: [id, type, headline, required]
properties:
id:
type: string
pattern: "^[a-zA-Z0-9_-]+$"
description: Stable element id. Avoid spaces and reserved ids.
type:
type: string
enum:
- openText
- multipleChoiceSingle
- multipleChoiceMulti
- nps
- rating
- csat
- ces
- consent
- pictureSelection
- cta
- date
- fileUpload
- cal
- matrix
- address
- ranking
- contactInfo
headline:
$ref: "#/components/schemas/TranslatableText"
subheader:
$ref: "#/components/schemas/TranslatableText"
required:
type: boolean
imageUrl:
type: string
videoUrl:
type: string
isDraft:
type: boolean
description: Draft marker used by the editor and future update rules.
placeholder:
$ref: "#/components/schemas/TranslatableText"
longAnswer:
type: boolean
description: "`openText` only."
inputType:
type: string
enum: [text, email, url, number, phone]
description: "`openText` only."
charLimit:
data:
type: object
description: "`openText` character limit configuration."
required: [id]
properties:
enabled:
type: boolean
min:
type: number
max:
type: number
additionalProperties: false
choices:
type: array
description: Choice list for multiple choice, ranking, and picture selection elements.
items:
oneOf:
- $ref: "#/components/schemas/SurveyChoice"
- $ref: "#/components/schemas/SurveyPictureChoice"
shuffleOption:
type: string
enum: [none, all, exceptLast, reverseOrderOccasionally, reverseOrderExceptLast]
displayType:
type: string
enum: [list, dropdown]
description: Multiple choice display style.
otherOptionPlaceholder:
$ref: "#/components/schemas/TranslatableText"
lowerLabel:
$ref: "#/components/schemas/TranslatableText"
upperLabel:
$ref: "#/components/schemas/TranslatableText"
isColorCodingEnabled:
type: boolean
scale:
type: string
enum: [number, smiley, star]
description: Rating, CSAT, CES, or NPS scale display.
range:
type: integer
enum: [3, 4, 5, 6, 7, 10]
description: Rating range. CSAT is always 5; CES is 5 or 7.
label:
$ref: "#/components/schemas/TranslatableText"
description: Consent checkbox label.
allowMulti:
type: boolean
description: "`pictureSelection` only."
buttonExternal:
type: boolean
description: "`cta` only."
buttonUrl:
type: string
description: "`cta` only."
ctaButtonLabel:
$ref: "#/components/schemas/TranslatableText"
html:
$ref: "#/components/schemas/TranslatableText"
description: "`date` helper copy."
format:
type: string
enum: [M-d-y, d-M-y, y-M-d]
description: "`date` only."
allowMultipleFiles:
type: boolean
description: "`fileUpload` only."
maxSizeInMB:
type: number
description: "`fileUpload` only."
allowedFileExtensions:
type: array
items:
type: string
description: "`fileUpload` only."
calUserName:
type: string
description: "`cal` only."
calHost:
type: string
description: "`cal` only."
rows:
type: array
description: Matrix rows.
items:
$ref: "#/components/schemas/SurveyChoice"
columns:
type: array
description: Matrix columns.
items:
$ref: "#/components/schemas/SurveyChoice"
addressLine1:
$ref: "#/components/schemas/SurveyToggleInputConfig"
addressLine2:
$ref: "#/components/schemas/SurveyToggleInputConfig"
city:
$ref: "#/components/schemas/SurveyToggleInputConfig"
state:
$ref: "#/components/schemas/SurveyToggleInputConfig"
zip:
$ref: "#/components/schemas/SurveyToggleInputConfig"
country:
$ref: "#/components/schemas/SurveyToggleInputConfig"
firstName:
$ref: "#/components/schemas/SurveyToggleInputConfig"
lastName:
$ref: "#/components/schemas/SurveyToggleInputConfig"
email:
$ref: "#/components/schemas/SurveyToggleInputConfig"
phone:
$ref: "#/components/schemas/SurveyToggleInputConfig"
company:
$ref: "#/components/schemas/SurveyToggleInputConfig"
validation:
$ref: "#/components/schemas/SurveyValidation"
additionalProperties: true
SurveyChoice:
type: object
required: [id, label]
properties:
id:
type: string
description: Stable choice id.
label:
$ref: "#/components/schemas/TranslatableText"
additionalProperties: false
SurveyPictureChoice:
type: object
required: [id, imageUrl]
properties:
id:
type: string
description: Stable picture choice id.
imageUrl:
type: string
additionalProperties: false
SurveyToggleInputConfig:
type: object
description: Field config for address and contact info elements.
required: [show, required, placeholder]
properties:
show:
type: boolean
required:
type: boolean
placeholder:
$ref: "#/components/schemas/TranslatableText"
additionalProperties: false
SurveyValidation:
type: object
description: Optional element-level validation rules.
required: [rules]
properties:
logic:
type: string
enum: [and, or]
default: and
rules:
type: array
items:
$ref: "#/components/schemas/SurveyValidationRule"
additionalProperties: false
SurveyValidationRule:
type: object
required: [id, type, params]
properties:
id:
type: string
type:
type: string
enum:
- minLength
- maxLength
- pattern
- email
- url
- phone
- equals
- doesNotEqual
- contains
- doesNotContain
- minValue
- maxValue
- isGreaterThan
- isLessThan
- minSelections
- maxSelections
- minRanked
- rankAll
- minRowsAnswered
- answerAllRows
- isLaterThan
- isEarlierThan
- isBetween
- isNotBetween
- fileExtensionIs
- fileExtensionIsNot
params:
type: object
additionalProperties: true
field:
type: string
enum:
[
addressLine1,
addressLine2,
city,
state,
zip,
country,
firstName,
lastName,
email,
phone,
company,
]
additionalProperties: false
SurveyBlockLogic:
type: object
description: Conditional logic rule evaluated at block level.
required: [id, conditions, actions]
properties:
id:
type: string
format: cuid2
conditions:
$ref: "#/components/schemas/SurveyConditionGroup"
actions:
type: array
items:
$ref: "#/components/schemas/SurveyLogicAction"
additionalProperties: false
SurveyConditionGroup:
type: object
required: [id, connector, conditions]
properties:
id:
type: string
format: cuid2
connector:
type: string
enum: [and, or]
conditions:
type: array
items:
oneOf:
- $ref: "#/components/schemas/SurveyCondition"
- $ref: "#/components/schemas/SurveyConditionGroup"
additionalProperties: false
SurveyCondition:
type: object
description: |
Single condition. Operators such as `isSubmitted`, `isSkipped`, `isClicked`, `isAccepted`,
`isBooked`, `isSet`, and `isEmpty` do not use `rightOperand`; comparison operators do.
required: [id, leftOperand, operator]
properties:
id:
type: string
format: cuid2
leftOperand:
$ref: "#/components/schemas/SurveyDynamicReference"
operator:
type: string
enum:
- equals
- doesNotEqual
- contains
- doesNotContain
- startsWith
- doesNotStartWith
- endsWith
- doesNotEndWith
- isSubmitted
- isSkipped
- isGreaterThan
- isLessThan
- isGreaterThanOrEqual
- isLessThanOrEqual
- equalsOneOf
- includesAllOf
- includesOneOf
- doesNotIncludeOneOf
- doesNotIncludeAllOf
- isClicked
- isNotClicked
- isAccepted
- isBefore
- isAfter
- isBooked
- isPartiallySubmitted
- isCompletelySubmitted
- isSet
- isNotSet
- isEmpty
- isNotEmpty
- isAnyOf
rightOperand:
$ref: "#/components/schemas/SurveyLogicOperand"
additionalProperties: false
SurveyLogicOperand:
oneOf:
- type: object
required: [type, value]
properties:
type:
type: string
enum: [static]
value:
oneOf:
- type: string
- type: number
- type: array
items:
type: string
additionalProperties: false
- $ref: "#/components/schemas/SurveyDynamicReference"
SurveyDynamicReference:
type: object
description: Dynamic reference to another value in the survey document.
required: [type, value]
properties:
type:
type: string
enum: [element, variable, hiddenField]
value:
type: string
description: Element id, variable id, or hidden field id depending on `type`.
meta:
type: object
additionalProperties:
type: string
additionalProperties: false
SurveyLogicAction:
oneOf:
- $ref: "#/components/schemas/SurveyCalculateAction"
- $ref: "#/components/schemas/SurveyRequireAnswerAction"
- $ref: "#/components/schemas/SurveyJumpToBlockAction"
description: |
Logic action. Keep referenced ids stable: `calculate.variableId` points to a variable id,
`requireAnswer.target` points to an element id, and `jumpToBlock.target` points to a block id
or ending id.
SurveyCalculateAction:
type: object
description: Updates a survey variable when the logic rule matches.
required: [id, objective, variableId, operator, value]
properties:
id:
type: string
format: cuid2
objective:
type: string
enum: [calculate]
variableId:
type: string
format: cuid2
description: Variable id for `calculate`.
operator:
type: string
enum: [assign, concat, add, subtract, multiply, divide]
value:
$ref: "#/components/schemas/SurveyLogicOperand"
additionalProperties: false
SurveyRequireAnswerAction:
type: object
description: Requires an element/question to be answered before continuing.
required: [id, objective, target]
properties:
id:
type: string
format: cuid2
objective:
type: string
enum: [requireAnswer]
target:
type: string
description: Target element id.
additionalProperties: false
SurveyJumpToBlockAction:
type: object
description: Jumps to another block or ending when the logic rule matches.
required: [id, objective, target]
properties:
id:
type: string
format: cuid2
objective:
type: string
enum: [jumpToBlock]
target:
type: string
format: cuid2
description: Target block id or ending id.
additionalProperties: false
SurveyResource:
type: object
required:
- id
- workspaceId
- createdAt
- updatedAt
- name
- type
- status
- metadata
- defaultLanguage
- languages
- welcomeCard
- blocks
- endings
- hiddenFields
- variables
properties:
id: { type: string }
workspaceId: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
name: { type: string }
type: { type: string, enum: [link, app, website, web] }
status:
type: string
enum: [draft, inProgress, paused, completed]
metadata:
type: object
nullable: true
additionalProperties: true
defaultLanguage:
type: string
description: Real locale code for the survey default language. The internal `default` translation key is never exposed.
languages:
type: array
items:
$ref: "#/components/schemas/SurveyLanguage"
welcomeCard:
$ref: "#/components/schemas/SurveyWelcomeCard"
blocks:
type: array
items:
$ref: "#/components/schemas/SurveyBlock"
endings:
type: array
items:
$ref: "#/components/schemas/SurveyEnding"
hiddenFields:
$ref: "#/components/schemas/SurveyHiddenFields"
variables:
type: array
items:
$ref: "#/components/schemas/SurveyVariable"
id: { type: string }
Problem:
type: object
description: RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`.
+1
View File
@@ -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"
+2 -1
View File
@@ -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`, {
+9
View File
@@ -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) => {