Compare commits

..

5 Commits

Author SHA1 Message Date
Bhagya Amarasinghe 0e65278af7 fix: wire Cube API secret into Helm defaults 2026-05-20 19:15:49 +05:30
Johannes 13c9677edd fix: correct settings sidebar back navigation behavior (#8052)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:18:12 +00:00
Johannes c0bf2ab7cc fix: enforce billing-only settings access (#8053)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 11:14:43 +00:00
Johannes 65d0f4ac0e fix: add CSAT and CES summary filter icons (#8056)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-20 09:44:10 +00:00
Matti Nannt 655c0b5e47 fix: strip client-provided timestamps in client response API (ENG-828) (#8047)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 06:53:42 +00:00
51 changed files with 223 additions and 615 deletions
@@ -194,7 +194,7 @@ export const MainNavigation = ({
const settingsNavigationItem = useMemo(
() => ({
name: t("common.settings"),
href: `/workspaces/${workspace.id}/settings`,
href: `/workspaces/${workspace.id}/settings/workspace/general`,
icon: SettingsIcon,
isActive: isSettingsMode,
disabled: isMembershipPending || isBilling,
@@ -467,7 +467,7 @@ export const MainNavigation = ({
{isSettingsMode ? (
<div className="flex flex-col overflow-hidden">
<div className="mb-2 px-3">
<GoBackButton />
<GoBackButton url={`/workspaces/${workspace.id}/surveys`} />
</div>
{/* Settings sidebar content */}
@@ -335,6 +335,7 @@ export const SettingsSidebarContent = ({
href: `${basePath}/organization/feedback-directories`,
icon: <FoldersIcon className={iconClassName} />,
hidden: isMember,
disabled: !isOwnerOrManager,
},
{
id: "org-api-keys",
@@ -373,12 +374,14 @@ export const SettingsSidebarContent = ({
label: t("common.your_profile"),
href: `${basePath}/account/profile`,
icon: <UserCircleIcon className={iconClassName} />,
disabled: isBilling,
},
{
id: "notifications",
label: t("common.notifications"),
href: `${basePath}/account/notifications`,
icon: <BellIcon className={iconClassName} />,
disabled: isBilling,
},
];
@@ -1,4 +1,11 @@
const AccountSettingsLayout = (props: { children: React.ReactNode }) => {
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const AccountSettingsLayout = async (props: Readonly<{
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return <>{props.children}</>;
};
@@ -0,0 +1,54 @@
import { redirect } from "next/navigation";
import { describe, expect, test, vi } from "vitest";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { redirectBillingRoleFromRestrictedSettings } from "./redirect-billing-role";
const mocks = vi.hoisted(() => ({
getBillingFallbackPath: vi.fn(),
getWorkspaceAuth: vi.fn(),
isFormbricksCloud: false,
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud,
}));
vi.mock("@/lib/membership/navigation", () => ({
getBillingFallbackPath: mocks.getBillingFallbackPath,
}));
vi.mock("@/modules/workspaces/lib/utils", () => ({
getWorkspaceAuth: mocks.getWorkspaceAuth,
}));
const workspaceId = "workspace-1";
const billingFallbackPath = `/workspaces/${workspaceId}/settings/organization/billing`;
const getWorkspaceAuthResponse = (isBilling: boolean) =>
({
isBilling,
}) as Awaited<ReturnType<typeof getWorkspaceAuth>>;
describe("redirectBillingRoleFromRestrictedSettings", () => {
test("does not redirect non-billing workspace members", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(false));
await expect(redirectBillingRoleFromRestrictedSettings(workspaceId)).resolves.toBeUndefined();
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).not.toHaveBeenCalled();
expect(redirect).not.toHaveBeenCalled();
});
test("redirects billing users to the billing fallback path", async () => {
vi.mocked(getWorkspaceAuth).mockResolvedValue(getWorkspaceAuthResponse(true));
vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath);
await redirectBillingRoleFromRestrictedSettings(workspaceId);
expect(getWorkspaceAuth).toHaveBeenCalledWith(workspaceId);
expect(getBillingFallbackPath).toHaveBeenCalledWith(workspaceId, mocks.isFormbricksCloud);
expect(redirect).toHaveBeenCalledWith(billingFallbackPath);
});
});
@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export const redirectBillingRoleFromRestrictedSettings = async (workspaceId: string): Promise<void> => {
const { isBilling } = await getWorkspaceAuth(workspaceId);
if (isBilling) {
redirect(getBillingFallbackPath(workspaceId, IS_FORMBRICKS_CLOUD));
}
};
@@ -1,3 +1,11 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
export default APIKeysPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return APIKeysPage(props);
};
export default Page;
@@ -1,3 +1,18 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { PricingPage } from "@/modules/ee/billing/page";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
export default PricingPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const { isBilling } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && !IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
return PricingPage(props);
};
export default Page;
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/domain/components/pretty-urls-table";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
@@ -12,8 +13,9 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
@@ -1,9 +1,10 @@
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { Button } from "@/modules/ui/components/button";
@@ -11,15 +12,19 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
const t = await getTranslate();
const { isBilling, isMember } = await getWorkspaceAuth(params.workspaceId);
if (isBilling && IS_FORMBRICKS_CLOUD) {
redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
if (IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { isMember } = await getWorkspaceAuth(params.workspaceId);
const isPricingDisabled = isMember;
if (isPricingDisabled) {
@@ -1 +1,11 @@
export { FeedbackDirectoriesPage as default } from "@/modules/ee/feedback-directory/page";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { FeedbackDirectoriesPage } from "@/modules/ee/feedback-directory/page";
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return FeedbackDirectoriesPage(props);
};
export default Page;
@@ -1,3 +1,4 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
@@ -26,8 +27,9 @@ import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
const t = await getTranslate();
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
@@ -1,3 +1,11 @@
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
import { TeamsPage } from "@/modules/organization/settings/teams/page";
export default TeamsPage;
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return TeamsPage(props);
};
export default Page;
@@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
import { redirectBillingRoleFromRestrictedSettings } from "@/app/(app)/workspaces/[workspaceId]/settings/lib/redirect-billing-role";
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const Page = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const params = await props.params;
await redirectBillingRoleFromRestrictedSettings(params.workspaceId);
return redirect(`/workspaces/${params.workspaceId}/settings/workspace/general`);
};
@@ -11,6 +11,7 @@ import {
ContactIcon,
EyeOff,
FlagIcon,
GaugeIcon,
GlobeIcon,
GridIcon,
HashIcon,
@@ -25,6 +26,7 @@ import {
NetworkIcon,
PieChartIcon,
Rows3Icon,
SmilePlusIcon,
SmartphoneIcon,
StarIcon,
User,
@@ -103,6 +105,8 @@ const elementIcons = {
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
[TSurveyElementTypeEnum.Matrix]: GridIcon,
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyElementTypeEnum.CSAT]: SmilePlusIcon,
[TSurveyElementTypeEnum.CES]: GaugeIcon,
[TSurveyElementTypeEnum.Address]: HomeIcon,
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
@@ -1,7 +1,6 @@
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -33,19 +32,7 @@ export const POST = withV1ApiWrapper({
}
const { workspaceId } = resolved;
let jsonInput;
try {
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
throw error;
}
const jsonInput = await req.json();
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
workspaceId,
@@ -104,7 +104,11 @@ export const createResponse = async (
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
const prismaData = buildPrismaResponseData(responseInput, contact, ttc);
const prismaData = buildPrismaResponseData(
{ ...responseInput, createdAt: undefined, updatedAt: undefined },
contact,
ttc
);
const prismaClient = tx ?? prisma;
@@ -6,7 +6,6 @@ import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateSingleUseResponseInput } from "@/app/api/client/[workspaceId]/responses/lib/single-use";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -57,14 +56,8 @@ export const POST = withV1ApiWrapper({
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
responseInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
return {
response: responses.badRequestResponse(
"Invalid JSON in request body",
@@ -218,7 +211,7 @@ export const POST = withV1ApiWrapper({
response: responseData,
});
if (responseInputData.finished) {
if (responseInput.finished) {
await sendToPipeline({
event: "responseFinished",
workspaceId,
@@ -3,7 +3,6 @@ import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classe
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -85,14 +84,8 @@ export const PUT = withV1ApiWrapper({
let actionClassUpdate;
try {
actionClassUpdate = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
actionClassUpdate = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,7 +2,6 @@ import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -46,14 +45,8 @@ export const POST = withV1ApiWrapper({
try {
let actionClassInput;
try {
actionClassInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
actionClassInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -1,7 +1,6 @@
import { logger } from "@formbricks/logger";
import { TResponseData, ZResponseUpdateInput } from "@formbricks/types/responses";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiV1Authentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -13,11 +12,6 @@ import { hasPermission } from "@/modules/organization/settings/api-keys/lib/util
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
type TUncheckedResponseUpdate = Record<string, unknown> & {
data: TResponseData;
language?: string;
};
async function fetchAndAuthorizeResponse(
responseId: string,
authentication: TApiV1Authentication | undefined,
@@ -126,16 +120,10 @@ export const PUT = withV1ApiWrapper({
auditLog.oldObject = result.response;
}
let responseUpdate: TUncheckedResponseUpdate;
let responseUpdate;
try {
responseUpdate = await parseJsonBodyWithLimit<TUncheckedResponseUpdate>(req);
responseUpdate = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,7 +2,6 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -92,14 +91,8 @@ export const POST = withV1ApiWrapper({
try {
let jsonInput;
try {
jsonInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
jsonInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -2,7 +2,6 @@ import { logger } from "@formbricks/logger";
import { ZUploadPublicFileRequest } from "@formbricks/types/storage";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { checkAuth } from "@/app/api/v1/management/storage/lib/utils";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -20,14 +19,8 @@ export const POST = withV1ApiWrapper({
let storageInput;
try {
storageInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
storageInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -9,7 +9,6 @@ import {
addLegacyProjectOverwrites,
normaliseProjectOverwritesToWorkspace,
} from "@/app/lib/api/api-backwards-compat";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import {
transformBlocksToQuestions,
@@ -23,12 +22,6 @@ import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
type TSurveyUpdateBody = Record<string, unknown> & {
blocks?: Parameters<typeof validateSurveyInput>[0]["blocks"];
endings?: Parameters<typeof transformQuestionsToBlocks>[1];
questions?: Parameters<typeof transformQuestionsToBlocks>[0];
};
const fetchAndAuthorizeSurvey = async (
surveyId: string,
authentication: TAuthenticationApiKey,
@@ -171,16 +164,10 @@ export const PUT = withV1ApiWrapper({
};
}
let surveyUpdate: TSurveyUpdateBody;
let surveyUpdate;
try {
surveyUpdate = await parseJsonBodyWithLimit<TSurveyUpdateBody>(req);
surveyUpdate = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -201,7 +188,7 @@ export const PUT = withV1ApiWrapper({
if (hasQuestions) {
surveyUpdate.blocks = transformQuestionsToBlocks(
surveyUpdate.questions!,
surveyUpdate.questions,
surveyUpdate.endings || result.survey.endings
);
surveyUpdate.questions = [];
@@ -221,11 +208,7 @@ export const PUT = withV1ApiWrapper({
};
}
const featureCheckResult = await checkFeaturePermissions(
surveyUpdate as Parameters<typeof checkFeaturePermissions>[0],
organization,
result.survey
);
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization, result.survey);
if (featureCheckResult) {
return {
response: featureCheckResult,
@@ -8,7 +8,6 @@ import {
addLegacyProjectOverwritesToList,
normaliseProjectOverwritesToWorkspace,
} from "@/app/lib/api/api-backwards-compat";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import {
transformBlocksToQuestions,
@@ -85,14 +84,8 @@ export const POST = withV1ApiWrapper({
try {
let surveyInput;
try {
surveyInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
surveyInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
+2 -9
View File
@@ -2,7 +2,6 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -41,14 +40,8 @@ export const POST = withV1ApiWrapper({
let webhookInput;
try {
webhookInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
webhookInput = await req.json();
} catch {
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -49,18 +49,7 @@ const buildPrismaResponseData = (
contact: { id: string; attributes: TContactAttributes } | null,
ttc: Record<string, number>
): Prisma.ResponseCreateInput => {
const {
surveyId,
displayId,
finished,
data,
language,
meta,
singleUseId,
variables,
createdAt,
updatedAt,
} = responseInput;
const { surveyId, displayId, finished, data, language, meta, singleUseId, variables } = responseInput;
return {
survey: {
@@ -84,8 +73,6 @@ const buildPrismaResponseData = (
singleUseId,
...(variables && { variables }),
ttc: ttc,
createdAt,
updatedAt,
};
};
@@ -2,7 +2,6 @@ import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body";
import { withV3ApiWrapper } from "./api-wrapper";
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
@@ -415,44 +414,6 @@ describe("withV3ApiWrapper", () => {
]);
});
test("returns 413 problem response for oversized JSON input", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
body: "{}",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
"Content-Type": "application/json",
"x-request-id": "req-payload-too-large",
},
}),
{} as never
);
expect(response.status).toBe(413);
expect(handler).not.toHaveBeenCalled();
await expect(response.json()).resolves.toEqual(
expect.objectContaining({
code: "payload_too_large",
detail: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
requestId: "req-payload-too-large",
status: 413,
title: "Payload Too Large",
})
);
});
test("returns 400 problem response for invalid route params", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
+2 -11
View File
@@ -4,7 +4,6 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -17,7 +16,6 @@ import {
type InvalidParam,
problemBadRequest,
problemInternalError,
problemPayloadTooLarge,
problemTooManyRequests,
problemUnauthorized,
} from "./response";
@@ -172,15 +170,8 @@ async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
let bodyData: unknown;
try {
bodyData = await parseJsonBodyWithLimit(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
ok: false,
response: problemPayloadTooLarge(requestId, error.message, instance),
};
}
bodyData = await req.json();
} catch {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
-11
View File
@@ -71,17 +71,6 @@ export function problemBadRequest(
});
}
export function problemPayloadTooLarge(
requestId: string,
detail: string = "Payload Too Large",
instance?: string
): Response {
return problemResponse(413, "Payload Too Large", detail, requestId, {
code: "payload_too_large",
instance,
});
}
export function problemUnauthorized(
requestId: string,
detail: string = "Not authenticated",
@@ -1,7 +1,6 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import { parseAndValidateJsonBody } from "./parse-and-validate-json-body";
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "./request-body";
describe("parseAndValidateJsonBody", () => {
test("returns a malformed JSON response when request parsing fails", async () => {
@@ -40,40 +39,6 @@ describe("parseAndValidateJsonBody", () => {
});
});
test("returns a payload too large response when the request body exceeds the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
"Content-Type": "application/json",
},
body: "{}",
});
const result = await parseAndValidateJsonBody({
request,
schema: z.object({
finished: z.boolean(),
}),
});
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.issue).toBe("payload_too_large");
expect(result.response.status).toBe(413);
await expect(result.response.json()).resolves.toEqual({
code: "payload_too_large",
message: "Payload Too Large",
details: {
error: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
},
});
});
test("returns a validation response when the parsed JSON does not match the schema", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
@@ -1,9 +1,8 @@
import { z } from "zod";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body" | "payload_too_large";
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body";
type TJsonBodyValidationError = {
details: Record<string, string> | { error: string };
@@ -45,18 +44,10 @@ export const parseAndValidateJsonBody = async <TSchema extends z.ZodTypeAny>({
let jsonInput: unknown;
try {
jsonInput = await parseJsonBodyWithLimit(request);
jsonInput = await request.json();
} catch (error) {
const details = { error: getErrorMessage(error) };
if (error instanceof RequestBodyTooLargeError) {
return {
details,
issue: "payload_too_large",
response: responses.payloadTooLargeResponse("Payload Too Large", details, true),
};
}
return {
details,
issue: "invalid_json",
-76
View File
@@ -1,76 +0,0 @@
import { describe, expect, test } from "vitest";
import {
DEFAULT_REQUEST_BODY_LIMIT_BYTES,
RequestBodyTooLargeError,
parseJsonBodyWithLimit,
readRequestBodyWithLimit,
} from "./request-body";
const createStreamingRequest = (chunks: string[]): Request =>
new Request("http://localhost/api/test", {
method: "POST",
body: new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
}),
duplex: "half",
} as RequestInit & { duplex: "half" });
describe("request body parsing", () => {
test("rejects a request when content-length exceeds the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
},
body: "{}",
});
await expect(readRequestBodyWithLimit(request)).rejects.toMatchObject({
actualBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1,
limitBytes: DEFAULT_REQUEST_BODY_LIMIT_BYTES,
name: "RequestBodyTooLargeError",
});
});
test("rejects a streamed request when the actual body exceeds the body limit", async () => {
const request = createStreamingRequest(["a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES), "b"]);
await expect(readRequestBodyWithLimit(request)).rejects.toBeInstanceOf(RequestBodyTooLargeError);
});
test("allows a body exactly at the body limit", async () => {
const rawBody = "a".repeat(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
const request = new Request("http://localhost/api/test", {
method: "POST",
body: rawBody,
});
const body = await readRequestBodyWithLimit(request);
expect(body).toHaveLength(DEFAULT_REQUEST_BODY_LIMIT_BYTES);
expect(body).toBe(rawBody);
});
test("preserves JSON parse errors for malformed bodies under the body limit", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
body: "{invalid-json",
});
await expect(parseJsonBodyWithLimit(request)).rejects.toBeInstanceOf(SyntaxError);
});
test("returns an empty string for requests without a body", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
});
await expect(readRequestBodyWithLimit(request)).resolves.toBe("");
});
});
-90
View File
@@ -1,90 +0,0 @@
export const DEFAULT_REQUEST_BODY_LIMIT_BYTES = 2 * 1024 * 1024;
export class RequestBodyTooLargeError extends Error {
readonly actualBytes: number | null;
readonly limitBytes: number;
constructor(limitBytes: number, actualBytes: number | null = null) {
super(`Request body must not exceed ${limitBytes} bytes`);
this.name = "RequestBodyTooLargeError";
this.limitBytes = limitBytes;
this.actualBytes = actualBytes;
}
}
const textDecoder = new TextDecoder();
const getContentLength = (headers: Headers): number | null => {
const contentLength = headers.get("content-length");
if (!contentLength) {
return null;
}
const parsedContentLength = Number(contentLength);
if (!Number.isSafeInteger(parsedContentLength) || parsedContentLength < 0) {
return null;
}
return parsedContentLength;
};
const assertBodySize = (actualBytes: number, limitBytes: number): void => {
if (actualBytes > limitBytes) {
throw new RequestBodyTooLargeError(limitBytes, actualBytes);
}
};
export const readRequestBodyWithLimit = async (
request: Request,
limitBytes: number = DEFAULT_REQUEST_BODY_LIMIT_BYTES
): Promise<string> => {
const contentLength = getContentLength(request.headers);
if (contentLength !== null) {
assertBodySize(contentLength, limitBytes);
}
if (!request.body) {
return "";
}
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let receivedBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
receivedBytes += value.byteLength;
if (receivedBytes > limitBytes) {
await reader.cancel().catch(() => undefined);
throw new RequestBodyTooLargeError(limitBytes, receivedBytes);
}
chunks.push(value);
}
if (chunks.length === 0) {
return "";
}
if (chunks.length === 1) {
return textDecoder.decode(chunks[0]);
}
const body = new Uint8Array(receivedBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return textDecoder.decode(body);
};
export const parseJsonBodyWithLimit = async <TJson = unknown>(
request: Request,
limitBytes: number = DEFAULT_REQUEST_BODY_LIMIT_BYTES
): Promise<TJson> => JSON.parse(await readRequestBodyWithLimit(request, limitBytes)) as TJson;
+1 -27
View File
@@ -17,8 +17,7 @@ interface ApiErrorResponse {
| "not_authenticated"
| "forbidden"
| "too_many_requests"
| "conflict"
| "payload_too_large";
| "conflict";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -81,30 +80,6 @@ const badRequestResponse = (
);
};
const payloadTooLargeResponse = (
message: string = "Payload Too Large",
details: ApiErrorResponse["details"] = {},
cors: boolean = false,
cache: string = "private, no-store"
) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
code: "payload_too_large",
message,
details,
} as ApiErrorResponse,
{
status: 413,
headers,
}
);
};
const methodNotAllowedResponse = (
res: CustomNextApiResponse,
allowedMethods: string[],
@@ -319,7 +294,6 @@ export const responses = {
unauthorizedResponse,
notFoundResponse,
successResponse,
payloadTooLargeResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
+2 -15
View File
@@ -1,7 +1,6 @@
import { ZodRawShape, z } from "zod";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
@@ -74,22 +73,10 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
if (schemas?.body) {
let bodyData: Record<string, unknown>;
let bodyData;
try {
bodyData = await parseJsonBodyWithLimit<Record<string, unknown>>(request);
bodyData = await request.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return handleApiError(request, {
type: "payload_too_large",
details: [
{
field: "body",
issue: error.message,
},
],
});
}
logger.error({ error, url: request.url }, "Error parsing JSON input");
return handleApiError(request, {
type: "bad_request",
@@ -1,7 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { err, ok } from "@formbricks/types/error-handlers";
import { DEFAULT_REQUEST_BODY_LIMIT_BYTES } from "@/app/lib/api/request-body";
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
import { handleApiError } from "@/modules/api/v2/lib/utils";
@@ -165,42 +164,6 @@ describe("apiWrapper", () => {
});
});
test("should handle oversized JSON input in request body", async () => {
const request = new Request("http://localhost", {
method: "POST",
body: "{}",
headers: {
"Content-Length": String(DEFAULT_REQUEST_BODY_LIMIT_BYTES + 1),
"Content-Type": "application/json",
},
});
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 413 }));
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn();
const response = await apiWrapper({
request,
schemas: { body: bodySchema },
rateLimit: false,
handler,
});
expect(response.status).toBe(413);
expect(handler).not.toHaveBeenCalled();
expect(handleApiError).toHaveBeenCalledWith(request, {
type: "payload_too_large",
details: [
{
field: "body",
issue: `Request body must not exceed ${DEFAULT_REQUEST_BODY_LIMIT_BYTES} bytes`,
},
],
});
});
test("should handle empty body when body schema is provided", async () => {
const request = new Request("http://localhost", {
method: "POST",
-30
View File
@@ -148,35 +148,6 @@ const conflictResponse = ({
);
};
const payloadTooLargeResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 413,
message: "Payload Too Large",
details,
},
},
{
status: 413,
headers,
}
);
};
const unprocessableEntityResponse = ({
details = [],
cors = false,
@@ -380,7 +351,6 @@ export const responses = {
forbiddenResponse,
notFoundResponse,
conflictResponse,
payloadTooLargeResponse,
unprocessableEntityResponse,
tooManyRequestsResponse,
internalServerErrorResponse,
-2
View File
@@ -28,8 +28,6 @@ export const handleApiError = (
return responses.notFoundResponse({ details: err.details });
case "conflict":
return responses.conflictResponse({ details: err.details });
case "payload_too_large":
return responses.payloadTooLargeResponse({ details: err.details });
case "unprocessable_entity":
return responses.unprocessableEntityResponse({ details: err.details });
case "too_many_requests":
+1 -7
View File
@@ -10,13 +10,7 @@ export type ApiErrorDetails = {
export type ApiErrorResponseV2 =
| {
type:
| "unauthorized"
| "forbidden"
| "conflict"
| "payload_too_large"
| "too_many_requests"
| "internal_server_error";
type: "unauthorized" | "forbidden" | "conflict" | "too_many_requests" | "internal_server_error";
details?: ApiErrorDetails;
}
| {
+1 -6
View File
@@ -1,12 +1,11 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { logger } from "@formbricks/logger";
import { RequestBodyTooLargeError, readRequestBodyWithLimit } from "@/app/lib/api/request-body";
import { webhookHandler } from "@/modules/ee/billing/api/lib/stripe-webhook";
export const POST = async (request: Request) => {
try {
const body = await readRequestBodyWithLimit(request);
const body = await request.text();
const requestHeaders = await headers(); // Corrected: headers() is async
const signature = requestHeaders.get("stripe-signature");
@@ -27,10 +26,6 @@ export const POST = async (request: Request) => {
return NextResponse.json(result.message || { received: true }, { status: 200 });
} catch (error: any) {
if (error instanceof RequestBodyTooLargeError) {
return NextResponse.json({ message: "Payload Too Large" }, { status: 413 });
}
logger.error(error, `Unhandled error in Stripe webhook POST handler: ${error.message}`);
return NextResponse.json({ message: "Internal server error" }, { status: 500 });
}
@@ -4,7 +4,6 @@ import { ZId } from "@formbricks/types/common";
import { TContactAttributesInput } from "@formbricks/types/contact-attribute";
import { ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { TJsPersonState } from "@formbricks/types/js";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
@@ -28,11 +27,6 @@ const handleError = (err: unknown, url: string): { response: Response; error?: u
};
};
type TContactUserRequestBody = Record<string, unknown> & {
attributes?: Record<string, unknown>;
userId?: unknown;
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -82,18 +76,7 @@ export const POST = withV1ApiWrapper({
}
const { workspaceId } = resolved;
let jsonInput: TContactUserRequestBody;
try {
jsonInput = await parseJsonBodyWithLimit<TContactUserRequestBody>(req);
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }, true),
};
}
throw error;
}
const jsonInput = await req.json();
// Basic input validation without Zod overhead
if (
@@ -108,13 +91,8 @@ export const POST = withV1ApiWrapper({
}
// Simple email validation if present (avoid Zod)
const attributes =
typeof jsonInput.attributes === "object" && jsonInput.attributes !== null
? jsonInput.attributes
: undefined;
if (attributes?.email) {
const email = attributes.email;
if (jsonInput.attributes?.email) {
const email = jsonInput.attributes.email;
if (typeof email !== "string" || !email.includes("@") || email.length < 3) {
return {
response: responses.badRequestResponse("Invalid email format", undefined, true),
@@ -122,7 +100,7 @@ export const POST = withV1ApiWrapper({
}
}
const userId = jsonInput.userId;
const { userId, attributes } = jsonInput;
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
@@ -1,6 +1,5 @@
import { logger } from "@formbricks/logger";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiKeyAuthentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -150,14 +149,8 @@ export const PUT = withV1ApiWrapper({
let contactAttributeKeyUpdate;
try {
contactAttributeKeyUpdate = await parseJsonBodyWithLimit(req);
contactAttributeKeyUpdate = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -1,7 +1,6 @@
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -64,14 +63,8 @@ export const POST = withV1ApiWrapper({
let contactAttributeKeyInput;
try {
contactAttributeKeyInput = await parseJsonBodyWithLimit<Record<string, unknown>>(req);
contactAttributeKeyInput = await req.json();
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return {
response: responses.payloadTooLargeResponse("Payload Too Large", { error: error.message }),
};
}
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
@@ -6,7 +6,6 @@ import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ZId } from "@formbricks/types/common";
import { AuthorizationError } from "@formbricks/types/errors";
import { RequestBodyTooLargeError, readRequestBodyWithLimit } from "@/app/lib/api/request-body";
import { verifyFeedbackRecordsGatewayToken } from "@/lib/jwt";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getBearerTokenFromHeaders } from "@/modules/api/lib/api-key-auth";
@@ -148,35 +147,17 @@ const parseTenantId = (tenantId: string | null): string | null => {
return ZId.safeParse(tenantId).success ? tenantId : null;
};
const parseJsonBody = async (
request: NextRequest
): Promise<
| {
ok: true;
body: Record<string, unknown> | null;
}
| {
ok: false;
response: Response;
}
> => {
const parseJsonBody = async (request: NextRequest): Promise<Record<string, unknown> | null> => {
try {
const rawBody = await readRequestBodyWithLimit(request);
const rawBody = await request.text();
if (!rawBody.trim()) {
return { ok: true, body: null };
return null;
}
const parsedBody = JSON.parse(rawBody);
return {
ok: true,
body: parsedBody && typeof parsedBody === "object" ? (parsedBody as Record<string, unknown>) : null,
};
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
return { ok: false, response: buildGatewayStatusResponse(413, "Payload Too Large") };
}
return { ok: true, body: null };
return parsedBody && typeof parsedBody === "object" ? (parsedBody as Record<string, unknown>) : null;
} catch {
return null;
}
};
@@ -227,12 +208,7 @@ const resolveTenantId = async (
}
if (route.tenantSource === "body") {
const parseResult = await parseJsonBody(request);
if (!parseResult.ok) {
return { errorResponse: parseResult.response };
}
const body = parseResult.body;
const body = await parseJsonBody(request);
const tenantId = parseTenantId(typeof body?.tenant_id === "string" ? body.tenant_id : null);
if (!tenantId) {
return {
@@ -5,9 +5,14 @@ import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
export const GoBackButton = ({ url }: { url?: string }) => {
interface GoBackButtonProps {
url?: string;
}
export const GoBackButton = ({ url }: Readonly<GoBackButtonProps>) => {
const router = useRouter();
const { t } = useTranslation();
return (
<Button
size="sm"
@@ -17,6 +22,7 @@ export const GoBackButton = ({ url }: { url?: string }) => {
router.push(url);
return;
}
router.back();
}}>
<ArrowLeftIcon />
+2 -1
View File
@@ -55,7 +55,8 @@ Cube is part of the baseline Formbricks v5 stack and is deployed by this chart b
when using the default release name.
- For an external Cube, set `cube.enabled: false` and point `deployment.env.CUBEJS_API_URL` at your
endpoint.
- Provide `CUBEJS_API_SECRET` through your existing secret management flow, such as the generated app secret override or `deployment.envFrom`.
- The generated app secret supplies `CUBEJS_API_SECRET` by default. If you disable generated secrets,
provide it through your existing secret management flow.
- Provide `CUBEJS_DB_*` connection variables to the Cube deployment through `cube.envFrom` or `cube.env`.
- Keep `cube.replicas=1` while `cube.env.CUBEJS_CACHE_AND_QUEUE_DRIVER` is `memory`. Configure Cube Store before running multiple Cube replicas.
- Keep Hub enabled. Cube should point at the same feedback records database that Hub writes to, unless you intentionally split that storage.
+9
View File
@@ -289,6 +289,15 @@ true
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.cubejsApiSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- if and $secret (index $secret.data "CUBEJS_API_SECRET") }}
{{- index $secret.data "CUBEJS_API_SECRET" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.envoy.gatewayClassName" -}}
{{- if .Values.envoy.formbricks.gatewayClass.name -}}
{{- .Values.envoy.formbricks.gatewayClass.name | trunc 63 | trimSuffix "-" -}}
@@ -70,8 +70,12 @@ spec:
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.cube.envFrom }}
{{- if or .Values.cube.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }}
envFrom:
{{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }}
- secretRef:
name: {{ template "formbricks.name" . }}-app-secrets
{{- end }}
{{- range $value := .Values.cube.envFrom }}
{{- if (eq .type "configmap") }}
- configMapRef:
+2
View File
@@ -5,6 +5,7 @@
{{- $redisPassword := include "formbricks.redisPassword" . }}
{{- $webappUrl := required "formbricks.webappUrl is required. Set it to your Formbricks instance URL (e.g., https://formbricks.example.com)" .Values.formbricks.webappUrl }}
{{- $hubApiKey := include "formbricks.hubApiKey" . }}
{{- $cubejsApiSecret := include "formbricks.cubejsApiSecret" . }}
---
apiVersion: v1
kind: Secret
@@ -31,6 +32,7 @@ data:
{{- end }}
HUB_API_KEY: {{ $hubApiKey | b64enc }}
credential: {{ printf "Bearer %s" $hubApiKey | b64enc }}
CUBEJS_API_SECRET: {{ $cubejsApiSecret | b64enc }}
CRON_SECRET: {{ include "formbricks.cronSecret" . | b64enc }}
ENCRYPTION_KEY: {{ include "formbricks.encryptionKey" . | b64enc }}
NEXTAUTH_SECRET: {{ include "formbricks.nextAuthSecret" . | b64enc }}
+3 -2
View File
@@ -580,8 +580,9 @@ cube:
type: ClusterIP
port: 4000
# Secret values such as CUBEJS_API_SECRET and CUBEJS_DB_* should be supplied
# through envFrom or another secret-management flow.
# The generated app secret supplies CUBEJS_API_SECRET when secret.enabled=true.
# Secret values such as CUBEJS_DB_* should be supplied through envFrom or another secret-management
# flow.
envFrom: []
env:
+4 -3
View File
@@ -116,7 +116,8 @@ Cube is part of the baseline Formbricks v5 stack and is bundled with the chart b
- set `cube.enabled: false` to skip the bundled Cube deployment
- point the app at your external endpoint via `deployment.env.CUBEJS_API_URL`
- supply `CUBEJS_API_SECRET` via `deployment.env` or `deployment.envFrom`
- supply `CUBEJS_API_SECRET` via `deployment.env` or `deployment.envFrom` if you disable generated
secrets
## 4. Upgrade The Deployment
@@ -134,8 +135,8 @@ For a Formbricks 4.x to 5.0 migration, confirm the following before running the
- `HUB_API_KEY` is present
- your edge rate-limiting plan is in place
- any required `AI_*` variables are added
- `CUBEJS_API_SECRET` is configured (Cube is bundled by default; provide an external endpoint if you set
`cube.enabled: false`)
- `CUBEJS_API_SECRET` is configured (the generated app secret supplies it by default; provide an external
endpoint if you set `cube.enabled: false`)
## 5. Key Values