feat: Add rate limiting to API V1 (#6355)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Victor Hugo dos Santos
2025-08-11 16:10:45 +07:00
committed by GitHub
parent 9d84bc0c8d
commit 43628caa3b
48 changed files with 3613 additions and 2177 deletions

View File

@@ -1,10 +1,11 @@
import { responses } from "@/app/lib/api/response";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { NextRequest } from "next/server";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) return null;

View File

@@ -5,6 +5,7 @@ import { getSyncSurveys } from "@/app/api/v1/client/[environmentId]/app/sync/lib
import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getActionClasses } from "@/lib/actionClass/service";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnvironment, updateEnvironment } from "@/lib/environment/service";
@@ -21,168 +22,180 @@ import { COLOR_DEFAULTS } from "@/lib/styling/constants";
import { NextRequest, userAgent } from "next/server";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TJsPeopleUserIdInput, ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys/types";
const validateInput = (
environmentId: string,
userId: string
): { isValid: true; data: TJsPeopleUserIdInput } | { isValid: false; error: Response } => {
const inputValidation = ZJsPeopleUserIdInput.safeParse({ environmentId, userId });
if (!inputValidation.success) {
return {
isValid: false,
error: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
return { isValid: true, data: inputValidation.data };
};
const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
if (!IS_FORMBRICKS_CLOUD) return false;
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
logger.error({ environmentId }, "Organization does not exist");
// fail closed if the organization does not exist
return true;
}
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
if (isLimitReached) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: { responses: monthlyResponseLimit, miu: null },
},
});
} catch (error) {
logger.error({ error }, `Error sending plan limits reached event to Posthog`);
}
}
return isLimitReached;
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = async (
request: NextRequest,
props: {
params: Promise<{
environmentId: string;
userId: string;
}>;
}
): Promise<Response> => {
const params = await props.params;
try {
const { device } = userAgent(request);
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ environmentId: string; userId: string }> };
}) => {
const params = await props.params;
try {
const { device } = userAgent(req);
// validate using zod
const inputValidation = ZJsPeopleUserIdInput.safeParse({
environmentId: params.environmentId,
userId: params.userId,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId, userId } = inputValidation.data;
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
const project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new Error("Project not found");
}
if (!environment.appSetupCompleted) {
await Promise.all([
updateEnvironment(environment.id, { appSetupCompleted: true }),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
}
// check organization subscriptions
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error("Organization does not exist");
}
// check if response limit is reached
let isAppSurveyResponseLimitReached = false;
if (IS_FORMBRICKS_CLOUD) {
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
isAppSurveyResponseLimitReached =
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
if (isAppSurveyResponseLimitReached) {
try {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
plan: organization.billing.plan,
limits: {
projects: null,
monthly: {
responses: monthlyResponseLimit,
miu: null,
},
},
});
} catch (error) {
logger.error({ error, url: request.url }, `Error sending plan limits reached event to Posthog`);
}
// validate using zod
const validation = validateInput(params.environmentId, params.userId);
if (!validation.isValid) {
return { response: validation.error };
}
}
let contact = await getContactByUserId(environmentId, userId);
if (!contact) {
contact = await prisma.contact.create({
data: {
attributes: {
create: {
attributeKey: {
connect: {
key_environmentId: {
key: "userId",
environmentId,
const { environmentId, userId } = validation.data;
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
const project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new Error("Project not found");
}
if (!environment.appSetupCompleted) {
await Promise.all([
updateEnvironment(environment.id, { appSetupCompleted: true }),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]);
}
// check organization subscriptions and response limits
const isAppSurveyResponseLimitReached = await checkResponseLimit(environmentId);
let contact = await getContactByUserId(environmentId, userId);
if (!contact) {
contact = await prisma.contact.create({
data: {
attributes: {
create: {
attributeKey: {
connect: {
key_environmentId: {
key: "userId",
environmentId,
},
},
},
value: userId,
},
value: userId,
},
environment: { connect: { id: environmentId } },
},
environment: { connect: { id: environmentId } },
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
}
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
acc[attribute.attributeKey.key] = attribute.value;
return acc;
}, {}) as Record<string, string>;
const [surveys, actionClasses] = await Promise.all([
getSyncSurveys(
environmentId,
contact.id,
contactAttributes,
device.type === "mobile" ? "phone" : "desktop"
),
getActionClasses(environmentId),
]);
const updatedProject: any = {
...project,
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(project.styling.highlightBorderColor?.light && {
highlightBorderColor: project.styling.highlightBorderColor.light,
}),
};
const language = contactAttributes["language"];
// Scenario 1: Multi language and updated trigger action classes supported.
// Use the surveys as they are.
let transformedSurveys: TSurvey[] = surveys;
// creating state object
let state = {
surveys: !isAppSurveyResponseLimitReached
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
: [],
actionClasses,
language,
project: updatedProject,
};
return {
response: responses.successResponse({ ...state }, true),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/client/[environmentId]/app/sync/[userId]");
return {
response: responses.internalServerErrorResponse(
"Unable to handle the request: " + error.message,
true
),
};
}
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
acc[attribute.attributeKey.key] = attribute.value;
return acc;
}, {}) as Record<string, string>;
const [surveys, actionClasses] = await Promise.all([
getSyncSurveys(
environmentId,
contact.id,
contactAttributes,
device.type === "mobile" ? "phone" : "desktop"
),
getActionClasses(environmentId),
]);
if (!project) {
throw new Error("Project not found");
}
const updatedProject: any = {
...project,
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(project.styling.highlightBorderColor?.light && {
highlightBorderColor: project.styling.highlightBorderColor.light,
}),
};
const language = contactAttributes["language"];
// Scenario 1: Multi language and updated trigger action classes supported.
// Use the surveys as they are.
let transformedSurveys: TSurvey[] = surveys;
// creating state object
let state = {
surveys: !isAppSurveyResponseLimitReached
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
: [],
actionClasses,
language,
project: updatedProject,
};
return responses.successResponse({ ...state }, true);
} catch (error) {
logger.error(
{ error, url: request.url },
"Error in GET /api/v1/client/[environmentId]/app/sync/[userId]"
);
return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true);
}
};
},
});

View File

@@ -1,7 +1,9 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -23,40 +25,55 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const jsonInput = await request.json();
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
environmentId: params.environmentId,
});
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
const jsonInput = await req.json();
const inputValidation = ZDisplayCreateInput.safeParse({
...jsonInput,
environmentId: params.environmentId,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
if (inputValidation.data.userId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
}
try {
const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Survey", inputValidation.data.surveyId);
} else {
logger.error({ error, url: request.url }, "Error in POST /api/v1/client/[environmentId]/displays");
return responses.internalServerErrorResponse("Something went wrong. Please try again.");
if (inputValidation.data.userId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"User identification is only available for enterprise users.",
true
),
};
}
}
}
};
try {
const response = await createDisplay(inputValidation.data);
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Survey", inputValidation.data.surveyId),
};
} else {
logger.error({ error, url: req.url }, "Error in POST /api/v1/client/[environmentId]/displays");
return {
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
};
}
}
},
});

View File

@@ -1,5 +1,6 @@
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -16,60 +17,69 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
export const GET = async (
request: NextRequest,
props: {
params: Promise<{
environmentId: string;
}>;
}
): Promise<Response> => {
const params = await props.params;
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ environmentId: string }> };
}) => {
const params = await props.params;
try {
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
if (!params.environmentId || typeof params.environmentId !== "string") {
return responses.badRequestResponse("Environment ID is required", undefined, true);
}
try {
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
if (typeof params.environmentId !== "string") {
return {
response: responses.badRequestResponse("Environment ID is required", undefined, true),
};
}
// Use optimized environment state fetcher with new caching approach
const environmentState = await getEnvironmentState(params.environmentId);
const { data } = environmentState;
// Use optimized environment state fetcher with new caching approach
const environmentState = await getEnvironmentState(params.environmentId);
const { data } = environmentState;
return responses.successResponse(
{
data,
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
},
true,
// Optimized cache headers for Cloudflare CDN and browser caching
// max-age=3600: 1hr browser cache (per guidelines)
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
// stale-while-revalidate=1800: 30min stale serving during revalidation
// stale-if-error=3600: 1hr stale serving on origin errors
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
);
} catch (err) {
if (err instanceof ResourceNotFoundError) {
logger.warn(
return {
response: responses.successResponse(
{
data,
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
},
true,
// Optimized cache headers for Cloudflare CDN and browser caching
// max-age=3600: 1hr browser cache (per guidelines)
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
// stale-while-revalidate=1800: 30min stale serving during revalidation
// stale-if-error=3600: 1hr stale serving on origin errors
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
),
};
} catch (err) {
if (err instanceof ResourceNotFoundError) {
logger.warn(
{
environmentId: params.environmentId,
resourceType: err.resourceType,
resourceId: err.resourceId,
},
"Resource not found in environment endpoint"
);
return {
response: responses.notFoundResponse(err.resourceType, err.resourceId),
};
}
logger.error(
{
error: err,
url: req.url,
environmentId: params.environmentId,
resourceType: err.resourceType,
resourceId: err.resourceId,
},
"Resource not found in environment endpoint"
"Error in GET /api/v1/client/[environmentId]/environment"
);
return responses.notFoundResponse(err.resourceType, err.resourceId);
return {
response: responses.internalServerErrorResponse(err.message, true),
};
}
logger.error(
{
error: err,
url: request.url,
environmentId: params.environmentId,
},
"Error in GET /api/v1/client/[environmentId]/environment"
);
return responses.internalServerErrorResponse(err.message, true);
}
};
},
});

View File

@@ -1,10 +1,12 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
@@ -27,108 +29,135 @@ const handleDatabaseError = (error: Error, url: string, endpoint: string, respon
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
export const PUT = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
): Promise<Response> => {
const params = await props.params;
const { responseId } = params;
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ responseId: string }> };
}) => {
const params = await props.params;
const { responseId } = params;
if (!responseId) {
return responses.badRequestResponse("Response ID is missing", undefined, true);
}
const responseUpdate = await request.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
let response;
try {
response = await getResponse(responseId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return handleDatabaseError(error, request.url, endpoint, responseId);
}
if (response.finished) {
return responses.badRequestResponse("Response is already finished", undefined, true);
}
// get survey to get environmentId
let survey;
try {
survey = await getSurvey(response.surveyId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return handleDatabaseError(error, request.url, endpoint, responseId);
}
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response", undefined, true);
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: inputValidation.data.data,
surveyQuestions: survey.questions,
responseLanguage: inputValidation.data.language,
});
if (otherResponseInvalidQuestionId) {
return responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
);
}
// update response
let updatedResponse;
try {
updatedResponse = await updateResponse(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
if (!responseId) {
return {
response: responses.badRequestResponse("Response ID is missing", undefined, true),
};
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: request.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return responses.internalServerErrorResponse(error.message);
}
}
// send response update to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response: updatedResponse,
});
const responseUpdate = await req.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (updatedResponse.finished) {
// send response to pipeline
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
let response;
try {
response = await getResponse(responseId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(error, req.url, endpoint, responseId),
};
}
if (response.finished) {
return {
response: responses.badRequestResponse("Response is already finished", undefined, true),
};
}
// get survey to get environmentId
let survey;
try {
survey = await getSurvey(response.surveyId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(error, req.url, endpoint, responseId),
};
}
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
};
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: inputValidation.data.data,
surveyQuestions: survey.questions,
responseLanguage: inputValidation.data.language,
});
if (otherResponseInvalidQuestionId) {
return {
response: responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
),
};
}
// update response
let updatedResponse;
try {
updatedResponse = await updateResponse(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse(error.message),
};
}
}
// send response update to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseFinished",
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response: updatedResponse,
});
}
return responses.successResponse({}, true);
};
if (updatedResponse.finished) {
// send response to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: updatedResponse,
});
}
return {
response: responses.successResponse({}, true),
};
},
});

View File

@@ -1,11 +1,13 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
@@ -29,121 +31,150 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await request.json();
} catch (error) {
return responses.badRequestResponse("Invalid JSON in request body", { error: error.message }, true);
}
const { environmentId } = params;
const environmentIdValidation = ZId.safeParse(environmentId);
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
);
}
if (!responseInputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(responseInputValidation.error),
true
);
}
const userAgent = request.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
if (responseInputData.userId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await req.json();
} catch (error) {
return {
response: responses.badRequestResponse(
"Invalid JSON in request body",
{ error: error.message },
true
),
};
}
}
// get and check survey
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
}
if (survey.environmentId !== environmentId) {
return responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
);
}
const { environmentId } = params;
const environmentIdValidation = ZId.safeParse(environmentId);
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!validateFileUploads(responseInputData.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
let response: TResponse;
try {
const meta: TResponseInput["meta"] = {
source: responseInputData?.meta?.source,
url: responseInputData?.meta?.url,
userAgent: {
browser: agent.getBrowser().name,
device: agent.getDevice().type || "desktop",
os: agent.getOS().name,
},
country: country,
action: responseInputData?.meta?.action,
};
response = await createResponse({
...responseInputData,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
logger.error({ error, url: request.url }, "Error creating response");
return responses.internalServerErrorResponse(error.message);
if (!environmentIdValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
),
};
}
}
sendToPipeline({
event: "responseCreated",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
if (!responseInputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(responseInputValidation.error),
true
),
};
}
const userAgent = req.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
if (responseInputData.userId) {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"User identification is only available for enterprise users.",
true
),
};
}
}
// get and check survey
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return {
response: responses.notFoundResponse("Survey", responseInputData.surveyId, true),
};
}
if (survey.environmentId !== environmentId) {
return {
response: responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
),
};
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
};
}
let response: TResponse;
try {
const meta: TResponseInput["meta"] = {
source: responseInputData?.meta?.source,
url: responseInputData?.meta?.url,
userAgent: {
browser: agent.getBrowser().name,
device: agent.getDevice().type || "desktop",
os: agent.getOS().name,
},
country: country,
action: responseInputData?.meta?.action,
};
response = await createResponse({
...responseInputData,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
} else {
logger.error({ error, url: req.url }, "Error creating response");
return {
response: responses.internalServerErrorResponse(error.message),
};
}
}
if (responseInput.finished) {
sendToPipeline({
event: "responseFinished",
event: "responseCreated",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
}
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
surveyId: response.surveyId,
surveyType: survey.type,
});
if (responseInput.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: response.surveyId,
response: response,
});
}
return responses.successResponse({ id: response.id }, true);
};
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
surveyId: response.surveyId,
surveyType: survey.type,
});
return {
response: responses.successResponse({ id: response.id }, true),
};
},
});

View File

@@ -2,6 +2,7 @@
// body -> should be a valid file object (buffer)
// method -> PUT (to be the same as the signedUrl method)
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
import { validateLocalSignedUrl } from "@/lib/crypto";
import { validateFile } from "@/lib/fileValidation";
@@ -28,115 +29,150 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
export const POST = async (req: NextRequest, context: Context): Promise<Response> => {
if (!ENCRYPTION_KEY) {
return responses.internalServerErrorResponse("Encryption key is not set");
}
const params = await context.params;
const environmentId = params.environmentId;
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
if (!ENCRYPTION_KEY) {
return {
response: responses.internalServerErrorResponse("Encryption key is not set"),
};
}
const params = await props.params;
const environmentId = params.environmentId;
const accessType = "private"; // private files are accessible only by authorized users
const accessType = "private"; // private files are accessible only by authorized users
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const surveyId = jsonInput.surveyId as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const surveyId = jsonInput.surveyId as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
if (!fileType) {
return responses.badRequestResponse("contentType is required");
}
if (!fileType) {
return {
response: responses.badRequestResponse("contentType is required"),
};
}
if (!encodedFileName) {
return responses.badRequestResponse("fileName is required");
}
if (!encodedFileName) {
return {
response: responses.badRequestResponse("fileName is required"),
};
}
if (!surveyId) {
return responses.badRequestResponse("surveyId is required");
}
if (!surveyId) {
return {
response: responses.badRequestResponse("surveyId is required"),
};
}
if (!signedSignature) {
return responses.unauthorizedResponse();
}
if (!signedSignature) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!signedUuid) {
return responses.unauthorizedResponse();
}
if (!signedUuid) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!signedTimestamp) {
return responses.unauthorizedResponse();
}
if (!signedTimestamp) {
return {
response: responses.unauthorizedResponse(),
};
}
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),
]);
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),
]);
if (!survey) {
return responses.notFoundResponse("Survey", surveyId);
}
if (!survey) {
return {
response: responses.notFoundResponse("Survey", surveyId),
};
}
if (!organization) {
return responses.notFoundResponse("OrganizationByEnvironmentId", environmentId);
}
if (!organization) {
return {
response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId),
};
}
const fileName = decodeURIComponent(encodedFileName);
const fileName = decodeURIComponent(encodedFileName);
// Perform server-side file validation again
// This is crucial as attackers could bypass the initial validation and directly call this endpoint
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType });
}
// Perform server-side file validation again
// This is crucial as attackers could bypass the initial validation and directly call this endpoint
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file", {
fileName,
fileType,
}),
};
}
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,
environmentId,
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
);
if (!validated) {
return responses.unauthorizedResponse();
}
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return responses.badRequestResponse("fileBuffer is required");
}
try {
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
const bytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(bytes);
await putFileToLocalStorage(
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,
fileBuffer,
accessType,
environmentId,
UPLOADS_DIR,
isBiggerFileUploadAllowed
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
);
return responses.successResponse({
message: "File uploaded successfully",
});
} catch (err) {
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload");
if (err.name === "FileTooLargeError") {
return responses.badRequestResponse(err.message);
if (!validated) {
return {
response: responses.unauthorizedResponse(),
};
}
return responses.internalServerErrorResponse("File upload failed");
}
};
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return {
response: responses.badRequestResponse("fileBuffer is required"),
};
}
try {
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
const bytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(bytes);
await putFileToLocalStorage(
fileName,
fileBuffer,
accessType,
environmentId,
UPLOADS_DIR,
isBiggerFileUploadAllowed
);
return {
response: responses.successResponse({
message: "File uploaded successfully",
}),
};
} catch (err) {
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload");
if (err.name === "FileTooLargeError") {
return {
response: responses.badRequestResponse(err.message),
};
}
return {
response: responses.internalServerErrorResponse("File upload failed"),
};
}
},
});

View File

@@ -1,5 +1,6 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFile } from "@/lib/fileValidation";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
@@ -30,46 +31,62 @@ export const OPTIONS = async (): Promise<Response> => {
// use this to let users upload files to a survey for example
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
export const POST = async (req: NextRequest, context: Context): Promise<Response> => {
const params = await context.params;
const environmentId = params.environmentId;
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
const environmentId = params.environmentId;
const jsonInput = await req.json();
const inputValidation = ZUploadFileRequest.safeParse({
...jsonInput,
environmentId,
});
const jsonInput = await req.json();
const inputValidation = ZUploadFileRequest.safeParse({
...jsonInput,
environmentId,
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Invalid request",
transformErrorToDetails(inputValidation.error),
true
);
}
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Invalid request",
transformErrorToDetails(inputValidation.error),
true
),
};
}
const { fileName, fileType, surveyId } = inputValidation.data;
const { fileName, fileType, surveyId } = inputValidation.data;
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType }, true);
}
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(
fileValidation.error ?? "Invalid file",
{ fileName, fileType },
true
),
};
}
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),
]);
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),
]);
if (!survey) {
return responses.notFoundResponse("Survey", surveyId);
}
if (!survey) {
return {
response: responses.notFoundResponse("Survey", surveyId),
};
}
if (!organization) {
return responses.notFoundResponse("OrganizationByEnvironmentId", environmentId);
}
if (!organization) {
return {
response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId),
};
}
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
return await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed);
};
return {
response: await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed),
};
},
});

View File

@@ -1,10 +1,9 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import * as z from "zod";
import { logger } from "@formbricks/logger";
@@ -21,65 +20,83 @@ const getEmail = async (token: string) => {
return z.string().parse(res_?.email);
};
export const GET = async (req: NextRequest) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const session = await getServerSession(authOptions);
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
if (!environmentId) {
return responses.badRequestResponse("Invalid environmentId");
}
if (!session) {
return responses.notAuthenticatedResponse();
}
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
const code_verifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64");
if (!client_id) return responses.internalServerErrorResponse("Airtable client id is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Airtable redirect url is missing");
const formData = {
grant_type: "authorization_code",
code,
redirect_uri,
client_id,
code_verifier,
};
try {
const key = await fetchAirtableAuthToken(formData);
if (!key) {
return responses.notFoundResponse("airtable auth token", key);
if (!environmentId) {
return {
response: responses.badRequestResponse("Invalid environmentId"),
};
}
const email = await getEmail(key.access_token);
const airtableIntegrationInput = {
type: "airtable" as "airtable",
environment: environmentId,
config: {
key,
data: [],
email,
},
if (!code) {
return {
response: responses.badRequestResponse("`code` is missing"),
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
const code_verifier = Buffer.from(environmentId + authentication.user.id + environmentId).toString(
"base64"
);
if (!client_id)
return {
response: responses.internalServerErrorResponse("Airtable client id is missing"),
};
const formData = {
grant_type: "authorization_code",
code,
redirect_uri,
client_id,
code_verifier,
};
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`);
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
responses.internalServerErrorResponse(error);
}
responses.badRequestResponse("unknown error occurred");
};
try {
const key = await fetchAirtableAuthToken(formData);
if (!key) {
return {
response: responses.notFoundResponse("airtable auth token", key),
};
}
const email = await getEmail(key.access_token);
const airtableIntegrationInput = {
type: "airtable" as "airtable",
environment: environmentId,
config: {
key,
data: [],
email,
},
};
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback");
return {
response: responses.internalServerErrorResponse(error),
};
}
},
});

View File

@@ -1,54 +1,66 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import crypto from "crypto";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
const scope = `data.records:read data.records:write schema.bases:read schema.bases:write user.email:read`;
export const GET = async (req: NextRequest) => {
const environmentId = req.headers.get("environmentId");
const session = await getServerSession(authOptions);
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const environmentId = req.headers.get("environmentId");
if (!environmentId) {
return responses.badRequestResponse("environmentId is missing");
}
if (!environmentId) {
return {
response: responses.badRequestResponse("environmentId is missing"),
};
}
if (!session) {
return responses.notAuthenticatedResponse();
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
if (!client_id)
return {
response: responses.internalServerErrorResponse("Airtable client id is missing"),
};
const codeVerifier = Buffer.from(environmentId + authentication.user.id + environmentId).toString(
"base64"
);
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
if (!client_id) return responses.internalServerErrorResponse("Airtable client id is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Airtable redirect url is missing");
const codeVerifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64");
const codeChallengeMethod = "S256";
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier) // hash the code verifier with the sha256 algorithm
.digest("base64") // base64 encode, needs to be transformed to base64url
.replace(/=/g, "") // remove =
.replace(/\+/g, "-") // replace + with -
.replace(/\//g, "_"); // replace / with _ now base64url encoded
const codeChallengeMethod = "S256";
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier) // hash the code verifier with the sha256 algorithm
.digest("base64") // base64 encode, needs to be transformed to base64url
.replace(/=/g, "") // remove =
.replace(/\+/g, "-") // replace + with -
.replace(/\//g, "_"); // replace / with _ now base64url encoded
const authUrl = new URL("https://airtable.com/oauth2/v1/authorize");
const authUrl = new URL("https://airtable.com/oauth2/v1/authorize");
authUrl.searchParams.append("client_id", client_id);
authUrl.searchParams.append("redirect_uri", redirect_uri);
authUrl.searchParams.append("state", environmentId);
authUrl.searchParams.append("scope", scope);
authUrl.searchParams.append("response_type", "code");
authUrl.searchParams.append("code_challenge_method", codeChallengeMethod);
authUrl.searchParams.append("code_challenge", codeChallenge);
authUrl.searchParams.append("client_id", client_id);
authUrl.searchParams.append("redirect_uri", redirect_uri);
authUrl.searchParams.append("state", environmentId);
authUrl.searchParams.append("scope", scope);
authUrl.searchParams.append("response_type", "code");
authUrl.searchParams.append("code_challenge_method", codeChallengeMethod);
authUrl.searchParams.append("code_challenge", codeChallenge);
return responses.successResponse({ authUrl: authUrl.toString() });
};
return {
response: responses.successResponse({ authUrl: authUrl.toString() }),
};
},
});

View File

@@ -1,43 +1,55 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getTables } from "@/lib/airtable/service";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getIntegrationByType } from "@/lib/integration/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import * as z from "zod";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
export const GET = async (req: NextRequest) => {
const url = req.url;
const environmentId = req.headers.get("environmentId");
const queryParams = new URLSearchParams(url.split("?")[1]);
const session = await getServerSession(authOptions);
const baseId = z.string().safeParse(queryParams.get("baseId"));
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const url = req.url;
const environmentId = req.headers.get("environmentId");
const queryParams = new URLSearchParams(url.split("?")[1]);
const baseId = z.string().safeParse(queryParams.get("baseId"));
if (!baseId.success) {
return responses.badRequestResponse("Base Id is Required");
}
if (!baseId.success) {
return {
response: responses.badRequestResponse("Base Id is Required"),
};
}
if (!session) {
return responses.notAuthenticatedResponse();
}
if (!environmentId) {
return {
response: responses.badRequestResponse("environmentId is missing"),
};
}
if (!environmentId) {
return responses.badRequestResponse("environmentId is missing");
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment || !environmentId) {
return responses.unauthorizedResponse();
}
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
if (!integration) {
return {
response: responses.notFoundResponse("Integration not found", environmentId),
};
}
if (!integration) {
return responses.notFoundResponse("Integration not found", environmentId);
}
const tables = await getTables(integration.config.key, baseId.data);
return responses.successResponse(tables);
};
const tables = await getTables(integration.config.key, baseId.data);
return {
response: responses.successResponse(tables),
};
},
});

View File

@@ -1,4 +1,5 @@
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import {
ENCRYPTION_KEY,
NOTION_OAUTH_CLIENT_ID,
@@ -11,70 +12,93 @@ import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integrati
import { NextRequest } from "next/server";
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
export const GET = async (req: NextRequest) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const error = queryParams.get("error");
export const GET = withV1ApiWrapper({
handler: async ({ req }: { req: NextRequest }) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const error = queryParams.get("error");
if (!environmentId) {
return responses.badRequestResponse("Invalid environmentId");
}
if (!environmentId) {
return {
response: responses.badRequestResponse("Invalid environmentId"),
};
}
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
}
if (code && typeof code !== "string") {
return {
response: responses.badRequestResponse("`code` must be a string"),
};
}
const client_id = NOTION_OAUTH_CLIENT_ID;
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
const redirect_uri = NOTION_REDIRECT_URI;
if (!client_id) return responses.internalServerErrorResponse("Notion client id is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Notion redirect url is missing");
if (!client_secret) return responses.internalServerErrorResponse("Notion client secret is missing");
if (code) {
// encode in base 64
const encoded = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
const client_id = NOTION_OAUTH_CLIENT_ID;
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
const redirect_uri = NOTION_REDIRECT_URI;
if (!client_id)
return {
response: responses.internalServerErrorResponse("Notion client id is missing"),
};
if (!redirect_uri)
return {
response: responses.internalServerErrorResponse("Notion redirect url is missing"),
};
if (!client_secret)
return {
response: responses.internalServerErrorResponse("Notion client secret is missing"),
};
if (code) {
// encode in base 64
const encoded = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
const response = await fetch("https://api.notion.com/v1/oauth/token", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Basic ${encoded}`,
},
body: JSON.stringify({
grant_type: "authorization_code",
code: code,
redirect_uri: redirect_uri,
}),
});
const response = await fetch("https://api.notion.com/v1/oauth/token", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Basic ${encoded}`,
},
body: JSON.stringify({
grant_type: "authorization_code",
code: code,
redirect_uri: redirect_uri,
}),
});
const tokenData = await response.json();
const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY!);
tokenData.access_token = encryptedAccessToken;
const tokenData = await response.json();
const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY);
tokenData.access_token = encryptedAccessToken;
const notionIntegration: TIntegrationNotionInput = {
type: "notion" as "notion",
config: {
key: tokenData,
data: [],
},
const notionIntegration: TIntegrationNotionInput = {
type: "notion" as "notion",
config: {
key: tokenData,
data: [],
},
};
const existingIntegration = await getIntegrationByType(environmentId, "notion");
if (existingIntegration) {
notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[];
}
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
if (result) {
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`),
};
}
} else if (error) {
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}`
),
};
}
return {
response: responses.badRequestResponse("Missing code or error parameter"),
};
const existingIntegration = await getIntegrationByType(environmentId, "notion");
if (existingIntegration) {
notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[];
}
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
if (result) {
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`);
}
} else if (error) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}`
);
}
};
},
});

View File

@@ -1,4 +1,5 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import {
NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID,
@@ -6,35 +7,54 @@ import {
NOTION_REDIRECT_URI,
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
export const GET = async (req: NextRequest) => {
const environmentId = req.headers.get("environmentId");
const session = await getServerSession(authOptions);
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const environmentId = req.headers.get("environmentId");
if (!environmentId) {
return responses.badRequestResponse("environmentId is missing");
}
if (!environmentId) {
return {
response: responses.badRequestResponse("environmentId is missing"),
};
}
if (!session) {
return responses.notAuthenticatedResponse();
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
const client_id = NOTION_OAUTH_CLIENT_ID;
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
const auth_url = NOTION_AUTH_URL;
const redirect_uri = NOTION_REDIRECT_URI;
if (!client_id)
return {
response: responses.internalServerErrorResponse("Notion client id is missing"),
};
if (!redirect_uri)
return {
response: responses.internalServerErrorResponse("Notion redirect url is missing"),
};
if (!client_secret)
return {
response: responses.internalServerErrorResponse("Notion client secret is missing"),
};
if (!auth_url)
return {
response: responses.internalServerErrorResponse("Notion auth url is missing"),
};
const client_id = NOTION_OAUTH_CLIENT_ID;
const client_secret = NOTION_OAUTH_CLIENT_SECRET;
const auth_url = NOTION_AUTH_URL;
const redirect_uri = NOTION_REDIRECT_URI;
if (!client_id) return responses.internalServerErrorResponse("Notion client id is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Notion redirect url is missing");
if (!client_secret) return responses.internalServerErrorResponse("Notion client secret is missing");
if (!auth_url) return responses.internalServerErrorResponse("Notion auth url is missing");
return responses.successResponse({ authUrl: `${auth_url}&state=${environmentId}` });
};
return {
response: responses.successResponse({ authUrl: `${auth_url}&state=${environmentId}` }),
};
},
});

View File

@@ -1,4 +1,5 @@
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { NextRequest } from "next/server";
@@ -8,79 +9,103 @@ import {
TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack";
export const GET = async (req: NextRequest) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const error = queryParams.get("error");
export const GET = withV1ApiWrapper({
handler: async ({ req }: { req: NextRequest }) => {
const url = req.url;
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
const code = queryParams.get("code");
const error = queryParams.get("error");
if (!environmentId) {
return responses.badRequestResponse("Invalid environmentId");
}
if (code && typeof code !== "string") {
return responses.badRequestResponse("`code` must be a string");
}
if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing");
if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing");
const formData = {
code,
client_id: SLACK_CLIENT_ID,
client_secret: SLACK_CLIENT_SECRET,
};
const formBody: string[] = [];
for (const property in formData) {
const encodedKey = encodeURIComponent(property);
const encodedValue = encodeURIComponent(formData[property]);
formBody.push(encodedKey + "=" + encodedValue);
}
const bodyString = formBody.join("&");
if (code) {
const response = await fetch("https://slack.com/api/oauth.v2.access", {
method: "POST",
body: bodyString,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const data = await response.json();
if (!data.ok) {
return responses.badRequestResponse(data.error);
if (!environmentId) {
return {
response: responses.badRequestResponse("Invalid environmentId"),
};
}
const slackCredentials: TIntegrationSlackCredential = {
app_id: data.app_id,
authed_user: data.authed_user,
token_type: data.token_type,
access_token: data.access_token,
bot_user_id: data.bot_user_id,
team: data.team,
};
const slackIntegration = await getIntegrationByType(environmentId, "slack");
const slackConfiguration: TIntegrationSlackConfig = {
data: (slackIntegration?.config.data as TIntegrationSlackConfigData[]) ?? [],
key: slackCredentials,
};
const integration = {
type: "slack" as "slack",
environment: environmentId,
config: slackConfiguration,
};
const result = await createOrUpdateIntegration(environmentId, integration);
if (result) {
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`);
if (code && typeof code !== "string") {
return {
response: responses.badRequestResponse("`code` must be a string"),
};
}
} else if (error) {
return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`);
}
};
if (!SLACK_CLIENT_ID)
return {
response: responses.internalServerErrorResponse("Slack client id is missing"),
};
if (!SLACK_CLIENT_SECRET)
return {
response: responses.internalServerErrorResponse("Slack client secret is missing"),
};
const formData = {
code,
client_id: SLACK_CLIENT_ID,
client_secret: SLACK_CLIENT_SECRET,
};
const formBody: string[] = [];
for (const property in formData) {
const encodedKey = encodeURIComponent(property);
const encodedValue = encodeURIComponent(formData[property]);
formBody.push(encodedKey + "=" + encodedValue);
}
const bodyString = formBody.join("&");
if (code) {
const response = await fetch("https://slack.com/api/oauth.v2.access", {
method: "POST",
body: bodyString,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const data = await response.json();
if (!data.ok) {
return {
response: responses.badRequestResponse(data.error),
};
}
const slackCredentials: TIntegrationSlackCredential = {
app_id: data.app_id,
authed_user: data.authed_user,
token_type: data.token_type,
access_token: data.access_token,
bot_user_id: data.bot_user_id,
team: data.team,
};
const slackIntegration = await getIntegrationByType(environmentId, "slack");
const slackConfiguration: TIntegrationSlackConfig = {
data: (slackIntegration?.config.data as TIntegrationSlackConfigData[]) ?? [],
key: slackCredentials,
};
const integration = {
type: "slack" as "slack",
environment: environmentId,
config: slackConfiguration,
};
const result = await createOrUpdateIntegration(environmentId, integration);
if (result) {
return {
response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`),
};
}
} else if (error) {
return {
response: Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`
),
};
}
return {
response: responses.badRequestResponse("Missing code or error parameter"),
};
},
});

View File

@@ -1,30 +1,47 @@
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_AUTH_URL, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
export const GET = async (req: NextRequest) => {
const environmentId = req.headers.get("environmentId");
const session = await getServerSession(authOptions);
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TSessionAuthentication>;
}) => {
const environmentId = req.headers.get("environmentId");
if (!environmentId) {
return responses.badRequestResponse("environmentId is missing");
}
if (!environmentId) {
return {
response: responses.badRequestResponse("environmentId is missing"),
};
}
if (!session) {
return responses.notAuthenticatedResponse();
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!canUserAccessEnvironment) {
return {
response: responses.unauthorizedResponse(),
};
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return responses.unauthorizedResponse();
}
if (!SLACK_CLIENT_ID)
return {
response: responses.internalServerErrorResponse("Slack client id is missing"),
};
if (!SLACK_CLIENT_SECRET)
return {
response: responses.internalServerErrorResponse("Slack client secret is missing"),
};
if (!SLACK_AUTH_URL)
return {
response: responses.internalServerErrorResponse("Slack auth url is missing"),
};
if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing");
if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing");
if (!SLACK_AUTH_URL) return responses.internalServerErrorResponse("Slack auth url is missing");
return responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${environmentId}` });
};
return {
response: responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${environmentId}` }),
};
},
});

View File

@@ -1,9 +1,10 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
@@ -27,36 +28,49 @@ const fetchAndAuthorizeActionClass = async (
return actionClass;
};
export const GET = async (
request: Request,
props: { params: Promise<{ actionClassId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
if (actionClass) {
return responses.successResponse(actionClass);
}
return responses.notFoundResponse("Action Class", params.actionClassId);
} catch (error) {
return handleErrorResponse(error);
}
};
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ actionClassId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
if (actionClass) {
return {
response: responses.notAuthenticatedResponse(),
response: responses.successResponse(actionClass),
};
}
auditLog.userId = authentication.apiKeyId;
return {
response: responses.notFoundResponse("Action Class", params.actionClassId),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
},
});
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ actionClassId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
try {
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
if (!actionClass) {
return {
@@ -64,13 +78,12 @@ export const PUT = withApiLogging(
};
}
auditLog.oldObject = actionClass;
auditLog.organizationId = authentication.organizationId;
let actionClassUpdate;
try {
actionClassUpdate = await request.json();
actionClassUpdate = await req.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -105,24 +118,25 @@ export const PUT = withApiLogging(
};
}
},
"updated",
"actionClass"
);
action: "updated",
targetType: "actionClass",
});
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
export const DELETE = withV1ApiWrapper({
handler: async ({
props,
auditLog,
authentication,
}: {
props: { params: Promise<{ actionClassId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
auditLog.targetId = params.actionClassId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
if (!actionClass) {
return {
@@ -131,7 +145,6 @@ export const DELETE = withApiLogging(
}
auditLog.oldObject = actionClass;
auditLog.organizationId = authentication.organizationId;
const deletedActionClass = await deleteActionClass(params.actionClassId);
return {
@@ -143,6 +156,6 @@ export const DELETE = withApiLogging(
};
}
},
"deleted",
"actionClass"
);
action: "deleted",
targetType: "actionClass",
});

View File

@@ -1,51 +1,53 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { createActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { getActionClasses } from "./lib/action-classes";
export const GET = async (request: Request) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const actionClasses = await getActionClasses(environmentIds);
return responses.successResponse(actionClasses);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
export const GET = withV1ApiWrapper({
handler: async ({ authentication }: { authentication: NonNullable<TApiKeyAuthentication> }) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const actionClasses = await getActionClasses(environmentIds);
return {
response: responses.successResponse(actionClasses),
};
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.notAuthenticatedResponse(),
response: responses.badRequestResponse(error.message),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
throw error;
}
},
});
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
try {
let actionClassInput;
try {
actionClassInput = await request.json();
actionClassInput = await req.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -85,6 +87,6 @@ export const POST = withApiLogging(
throw error;
}
},
"created",
"actionClass"
);
action: "created",
targetType: "actionClass",
});

View File

@@ -1,5 +1,8 @@
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { responses } from "@/app/lib/api/response";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
@@ -9,9 +12,11 @@ export const GET = async () => {
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
if (apiKey) {
const hashedApiKey = hashApiKey(apiKey);
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey: hashApiKey(apiKey),
hashedKey: hashedApiKey,
},
select: {
apiKeyEnvironments: {
@@ -39,9 +44,13 @@ export const GET = async () => {
});
if (!apiKeyData) {
return new Response("Not authenticated", {
status: 401,
});
return responses.notAuthenticatedResponse();
}
try {
await applyRateLimit(rateLimitConfigs.api.v1, hashedApiKey);
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
}
if (
@@ -60,16 +69,18 @@ export const GET = async () => {
},
});
} else {
return new Response("You can't use this method with this API key", {
status: 400,
});
return responses.badRequestResponse("You can't use this method with this API key");
}
} else {
const sessionUser = await getSessionUser();
if (!sessionUser) {
return new Response("Not authenticated", {
status: 401,
});
return responses.notAuthenticatedResponse();
}
try {
await applyRateLimit(rateLimitConfigs.api.v1, sessionUser.id);
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
}
const user = await prisma.user.findUnique({

View File

@@ -1,19 +1,24 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
async function fetchAndAuthorizeResponse(
responseId: string,
authentication: any,
authentication: TApiKeyAuthentication,
requiredPermission: "GET" | "PUT" | "DELETE"
) {
if (!authentication) {
return { error: responses.notAuthenticatedResponse() };
}
const response = await getResponse(responseId);
if (!response) {
return { error: responses.notFoundResponse("Response", responseId) };
@@ -31,38 +36,47 @@ async function fetchAndAuthorizeResponse(
return { response, survey };
}
export const GET = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ responseId: string }> };
authentication: TApiKeyAuthentication;
}) => {
const params = await props.params;
try {
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
if (result.error) {
return {
response: result.error,
};
}
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
if (result.error) return result.error;
return {
response: responses.successResponse(result.response),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
},
});
return responses.successResponse(result.response);
} catch (error) {
return handleErrorResponse(error);
}
};
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
export const DELETE = withV1ApiWrapper({
handler: async ({
props,
auditLog,
authentication,
}: {
props: { params: Promise<{ responseId: string }> };
auditLog: TApiAuditLog;
authentication: TApiKeyAuthentication;
}) => {
const params = await props.params;
auditLog.targetId = params.responseId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
if (result.error) {
return {
@@ -81,24 +95,25 @@ export const DELETE = withApiLogging(
};
}
},
"deleted",
"response"
);
action: "deleted",
targetType: "response",
});
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ responseId: string }> };
auditLog: TApiAuditLog;
authentication: TApiKeyAuthentication;
}) => {
const params = await props.params;
auditLog.targetId = params.responseId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (result.error) {
return {
@@ -109,9 +124,9 @@ export const PUT = withApiLogging(
let responseUpdate;
try {
responseUpdate = await request.json();
responseUpdate = await req.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -144,6 +159,6 @@ export const PUT = withApiLogging(
};
}
},
"updated",
"response"
);
action: "updated",
targetType: "response",
});

View File

@@ -1,7 +1,6 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -12,42 +11,56 @@ import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
export const GET = async (request: NextRequest) => {
const searchParams = request.nextUrl.searchParams;
const surveyId = searchParams.get("surveyId");
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.get("skip") ? Number(searchParams.get("skip")) : undefined;
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const searchParams = req.nextUrl.searchParams;
const surveyId = searchParams.get("surveyId");
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.get("skip") ? Number(searchParams.get("skip")) : undefined;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
let allResponses: TResponse[] = [];
try {
let allResponses: TResponse[] = [];
if (surveyId) {
const survey = await getSurvey(surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", surveyId, true);
if (surveyId) {
const survey = await getSurvey(surveyId);
if (!survey) {
return {
response: responses.notFoundResponse("Survey", surveyId, true),
};
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return {
response: responses.unauthorizedResponse(),
};
}
const surveyResponses = await getResponses(surveyId, limit, offset);
allResponses.push(...surveyResponses);
} else {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
allResponses.push(...environmentResponses);
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return responses.unauthorizedResponse();
return {
response: responses.successResponse(allResponses),
};
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
const surveyResponses = await getResponses(surveyId, limit, offset);
allResponses.push(...surveyResponses);
} else {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
allResponses.push(...environmentResponses);
throw error;
}
return responses.successResponse(allResponses);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
},
});
const validateInput = async (request: Request) => {
let jsonInput;
@@ -92,19 +105,18 @@ const validateSurvey = async (responseInput: TResponseInput, environmentId: stri
return { survey };
};
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const inputResult = await validateInput(request);
const inputResult = await validateInput(req);
if (inputResult.error) {
return {
response: inputResult.error,
@@ -145,16 +157,14 @@ export const POST = withApiLogging(
response: responses.successResponse(response, true),
};
} catch (error) {
logger.error({ error, url: req.url }, "Error in POST /api/v1/management/responses");
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
} else if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return {
response: responses.internalServerErrorResponse(error.message),
};
@@ -168,6 +178,6 @@ export const POST = withApiLogging(
throw error;
}
},
"created",
"response"
);
action: "created",
targetType: "response",
});

View File

@@ -1,24 +1,16 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { Session } from "next-auth";
import { NextRequest } from "next/server";
import { describe, expect, test } from "vitest";
import { vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { checkForRequiredFields } from "./utils";
import { checkAuth } from "./utils";
import { checkAuth, checkForRequiredFields } from "./utils";
// Create mock response objects
const mockBadRequestResponse = new Response("Bad Request", { status: 400 });
const mockNotAuthenticatedResponse = new Response("Not authenticated", { status: 401 });
const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 });
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: vi.fn(),
}));
vi.mock("@/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
}));
@@ -80,19 +72,22 @@ describe("checkForRequiredFields", () => {
describe("checkAuth", () => {
const environmentId = "env-123";
const mockRequest = new NextRequest("http://localhost:3000/api/test");
test("returns notAuthenticatedResponse when no session and no authentication", async () => {
vi.mocked(authenticateRequest).mockResolvedValue(null);
test("returns notAuthenticatedResponse when authentication is null", async () => {
const result = await checkAuth(null, environmentId);
const result = await checkAuth(null, environmentId, mockRequest);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
});
test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => {
test("returns notAuthenticatedResponse when authentication is undefined", async () => {
const result = await checkAuth(undefined as any, environmentId);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
});
test("returns unauthorizedResponse when API key authentication lacks POST permission", async () => {
const mockAuthentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: [
@@ -112,12 +107,10 @@ describe("checkAuth", () => {
},
};
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
vi.mocked(hasPermission).mockReturnValue(false);
const result = await checkAuth(null, environmentId, mockRequest);
const result = await checkAuth(mockAuthentication, environmentId);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(hasPermission).toHaveBeenCalledWith(
mockAuthentication.environmentPermissions,
environmentId,
@@ -127,7 +120,7 @@ describe("checkAuth", () => {
expect(result).toBe(mockUnauthorizedResponse);
});
test("returns undefined when no session and authentication has POST permission", async () => {
test("returns undefined when API key authentication has POST permission", async () => {
const mockAuthentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: [
@@ -147,12 +140,10 @@ describe("checkAuth", () => {
},
};
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
vi.mocked(hasPermission).mockReturnValue(true);
const result = await checkAuth(null, environmentId, mockRequest);
const result = await checkAuth(mockAuthentication, environmentId);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(hasPermission).toHaveBeenCalledWith(
mockAuthentication.environmentPermissions,
environmentId,
@@ -171,7 +162,7 @@ describe("checkAuth", () => {
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false);
const result = await checkAuth(mockSession, environmentId, mockRequest);
const result = await checkAuth(mockSession, environmentId);
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(responses.unauthorizedResponse).toHaveBeenCalled();
@@ -188,25 +179,18 @@ describe("checkAuth", () => {
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
const result = await checkAuth(mockSession, environmentId, mockRequest);
const result = await checkAuth(mockSession, environmentId);
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(result).toBeUndefined();
});
test("does not call authenticateRequest when session exists", async () => {
const mockSession: Session = {
user: {
id: "user-123",
},
expires: "2024-12-31T23:59:59.999Z",
};
test("returns notAuthenticatedResponse when authentication object is neither session nor API key", async () => {
const invalidAuth = { someProperty: "value" } as any;
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
const result = await checkAuth(invalidAuth, environmentId);
await checkAuth(mockSession, environmentId, mockRequest);
expect(authenticateRequest).not.toHaveBeenCalled();
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
});
});

View File

@@ -1,9 +1,7 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { TApiV1Authentication } from "@/app/lib/api/with-api-logging";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { Session } from "next-auth";
import { NextRequest } from "next/server";
export const checkForRequiredFields = (
environmentId: string,
@@ -23,19 +21,21 @@ export const checkForRequiredFields = (
}
};
export const checkAuth = async (session: Session | null, environmentId: string, request: NextRequest) => {
if (!session) {
//check whether its using API key
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
export const checkAuth = async (authentication: TApiV1Authentication, environmentId: string) => {
if (!authentication) {
return responses.notAuthenticatedResponse();
}
if ("user" in authentication) {
const isUserAuthorized = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
} else if ("hashedApiKey" in authentication) {
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
} else {
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
return responses.notAuthenticatedResponse();
}
};

View File

@@ -3,88 +3,107 @@
// method -> PUT (to be the same as the signedUrl method)
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
import { validateLocalSignedUrl } from "@/lib/crypto";
import { validateFile } from "@/lib/fileValidation";
import { putFileToLocalStorage } from "@/lib/storage/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
export const POST = async (req: NextRequest): Promise<Response> => {
if (!ENCRYPTION_KEY) {
return responses.internalServerErrorResponse("Encryption key is not set");
}
const accessType = "public"; // public files are accessible by anyone
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
const environmentId = jsonInput.environmentId as string;
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, encodedFileName);
if (requiredFieldResponse) return requiredFieldResponse;
if (!signedSignature || !signedUuid || !signedTimestamp) {
return responses.unauthorizedResponse();
}
const session = await getServerSession(authOptions);
const authResponse = await checkAuth(session, environmentId, req);
if (authResponse) return authResponse;
const fileName = decodeURIComponent(encodedFileName);
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file");
}
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,
environmentId,
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
);
if (!validated) {
return responses.unauthorizedResponse();
}
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return responses.badRequestResponse("fileBuffer is required");
}
try {
const bytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(bytes);
await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR);
return responses.successResponse({
message: "File uploaded successfully",
});
} catch (err) {
logger.error(err, "Error uploading file");
if (err.name === "FileTooLargeError") {
return responses.badRequestResponse(err.message);
export const POST = withV1ApiWrapper({
handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => {
if (!ENCRYPTION_KEY) {
return {
response: responses.internalServerErrorResponse("Encryption key is not set"),
};
}
return responses.internalServerErrorResponse("File upload failed");
}
};
const accessType = "public"; // public files are accessible by anyone
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
const environmentId = jsonInput.environmentId as string;
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, encodedFileName);
if (requiredFieldResponse) {
return {
response: requiredFieldResponse,
};
}
if (!signedSignature || !signedUuid || !signedTimestamp) {
return {
response: responses.unauthorizedResponse(),
};
}
const authResponse = await checkAuth(authentication, environmentId);
if (authResponse) return { response: authResponse };
const fileName = decodeURIComponent(encodedFileName);
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file"),
};
}
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,
environmentId,
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
);
if (!validated) {
return {
response: responses.unauthorizedResponse(),
};
}
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return {
response: responses.badRequestResponse("fileBuffer is required"),
};
}
try {
const bytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(bytes);
await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR);
return {
response: responses.successResponse({
message: "File uploaded successfully",
}),
};
} catch (err) {
logger.error(err, "Error uploading file");
if (err.name === "FileTooLargeError") {
return {
response: responses.badRequestResponse(err.message),
};
}
return {
response: responses.internalServerErrorResponse("File upload failed"),
};
}
},
});

View File

@@ -1,8 +1,7 @@
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFile } from "@/lib/fileValidation";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
@@ -13,40 +12,57 @@ import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
// use this to upload files for a specific resource, e.g. a user profile picture or a survey
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
export const POST = async (request: NextRequest): Promise<Response> => {
let storageInput;
export const POST = withV1ApiWrapper({
handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => {
let storageInput;
try {
storageInput = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput;
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName);
if (requiredFieldResponse) return requiredFieldResponse;
const session = await getServerSession(authOptions);
const authResponse = await checkAuth(session, environmentId, request);
if (authResponse) return authResponse;
// Perform server-side file validation first to block dangerous file types
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file type");
}
// Also perform client-specified allowed file extensions validation if provided
if (allowedFileExtensions?.length) {
const fileExtension = fileName.split(".").pop()?.toLowerCase();
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
return responses.badRequestResponse(
`File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}`
);
try {
storageInput = await req.json();
} catch (error) {
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
}
return await getSignedUrlForPublicFile(fileName, environmentId, fileType);
};
const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput;
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName);
if (requiredFieldResponse) {
return {
response: requiredFieldResponse,
};
}
const authResponse = await checkAuth(authentication, environmentId);
if (authResponse) {
return {
response: authResponse,
};
}
// Perform server-side file validation first to block dangerous file types
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file type"),
};
}
// Also perform client-specified allowed file extensions validation if provided
if (allowedFileExtensions?.length) {
const fileExtension = fileName.split(".").pop()?.toLowerCase();
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
return {
response: responses.badRequestResponse(
`File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}`
),
};
}
}
return {
response: await getSignedUrlForPublicFile(fileName, environmentId, fileType),
};
},
});

View File

@@ -1,12 +1,13 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
@@ -27,36 +28,47 @@ const fetchAndAuthorizeSurvey = async (
return { survey };
};
export const GET = async (
request: Request,
props: { params: Promise<{ surveyId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET");
if (result.error) return result.error;
return responses.successResponse(result.survey);
} catch (error) {
return handleErrorResponse(error);
}
};
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ surveyId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
try {
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET");
if (result.error) {
return {
response: result.error,
};
}
return {
response: responses.successResponse(result.survey),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
},
});
export const DELETE = withV1ApiWrapper({
handler: async ({
props,
auditLog,
authentication,
}: {
props: { params: Promise<{ surveyId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
auditLog.targetId = params.surveyId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
if (result.error) {
return {
@@ -75,23 +87,25 @@ export const DELETE = withApiLogging(
};
}
},
"deleted",
"survey"
);
action: "deleted",
targetType: "survey",
});
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ surveyId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
auditLog.targetId = params.surveyId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
if (result.error) {
return {
@@ -106,13 +120,12 @@ export const PUT = withApiLogging(
response: responses.notFoundResponse("Organization", null),
};
}
auditLog.organizationId = organization.id;
let surveyUpdate;
try {
surveyUpdate = await request.json();
surveyUpdate = await req.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -146,18 +159,16 @@ export const PUT = withApiLogging(
response: responses.successResponse(updatedSurvey),
};
} catch (error) {
auditLog.status = "failure";
return {
response: handleErrorResponse(error),
};
}
} catch (error) {
auditLog.status = "failure";
return {
response: handleErrorResponse(error),
};
}
},
"updated",
"survey"
);
action: "updated",
targetType: "survey",
});

View File

@@ -1,55 +1,77 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
export const GET = async (
request: NextRequest,
props: { params: Promise<{ surveyId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const survey = await getSurvey(params.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId);
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ surveyId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
try {
const params = await props.params;
const survey = await getSurvey(params.surveyId);
if (!survey) {
return {
response: responses.notFoundResponse("Survey", params.surveyId),
};
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return {
response: responses.unauthorizedResponse(),
};
}
if (survey.type !== "link") {
return {
response: responses.badRequestResponse("Single use links are only available for link surveys"),
};
}
if (!survey.singleUse || !survey.singleUse.enabled) {
return {
response: responses.badRequestResponse("Single use links are not enabled for this survey"),
};
}
const searchParams = req.nextUrl.searchParams;
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : 10;
if (limit < 1) {
return {
response: responses.badRequestResponse("Limit cannot be less than 1"),
};
}
if (limit > 5000) {
return {
response: responses.badRequestResponse("Limit cannot be more than 5000"),
};
}
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
const publicDomain = getPublicDomain();
// map single use ids to survey links
const surveyLinks = singleUseIds.map(
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
);
return {
response: responses.successResponse(surveyLinks),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return responses.unauthorizedResponse();
}
if (survey.type !== "link") {
return responses.badRequestResponse("Single use links are only available for link surveys");
}
if (!survey.singleUse || !survey.singleUse.enabled) {
return responses.badRequestResponse("Single use links are not enabled for this survey");
}
const searchParams = request.nextUrl.searchParams;
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : 10;
if (limit < 1) {
return responses.badRequestResponse("Limit cannot be less than 1");
}
if (limit > 5000) {
return responses.badRequestResponse("Limit cannot be more than 5000");
}
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
const publicDomain = getPublicDomain();
// map single use ids to survey links
const surveyLinks = singleUseIds.map(
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
);
return responses.successResponse(surveyLinks);
} catch (error) {
return handleErrorResponse(error);
}
};
},
});

View File

@@ -1,55 +1,64 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
import { getSurveys } from "./lib/surveys";
export const GET = async (request: Request) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const searchParams = new URL(request.url).searchParams;
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const surveys = await getSurveys(environmentIds, limit, offset);
return responses.successResponse(surveys);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
export const GET = withV1ApiWrapper({
handler: async ({
req,
authentication,
}: {
req: NextRequest;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
const searchParams = new URL(req.url).searchParams;
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const surveys = await getSurveys(environmentIds, limit, offset);
return {
response: responses.successResponse(surveys),
};
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.notAuthenticatedResponse(),
response: responses.badRequestResponse(error.message),
};
}
auditLog.userId = authentication.apiKeyId;
throw error;
}
},
});
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
try {
let surveyInput;
try {
surveyInput = await request.json();
surveyInput = await req.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
logger.error({ error, url: req.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -80,7 +89,6 @@ export const POST = withApiLogging(
response: responses.notFoundResponse("Organization", null),
};
}
auditLog.organizationId = organization.id;
const surveyData = { ...inputValidation.data, environmentId };
@@ -94,18 +102,19 @@ export const POST = withApiLogging(
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
auditLog.targetId = survey.id;
auditLog.newObject = survey;
return {
response: responses.successResponse(survey),
};
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
response: responses.internalServerErrorResponse(error.message),
};
}
throw error;
}
},
"created",
"survey"
);
action: "created",
targetType: "survey",
});

View File

@@ -1,53 +1,52 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
import { responses } from "@/app/lib/api/response";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
export const GET = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => {
const params = await props.params;
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const authentication = await authenticateRequest(request);
if (!authentication) {
return responses.notAuthenticatedResponse();
}
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ webhookId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
// add webhook to database
const webhook = await getWebhook(params.webhookId);
if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId);
}
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) {
return responses.unauthorizedResponse();
}
return responses.successResponse(webhook);
};
const webhook = await getWebhook(params.webhookId);
if (!webhook) {
return {
response: responses.notFoundResponse("Webhook", params.webhookId),
};
}
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) {
return {
response: responses.unauthorizedResponse(),
};
}
return {
response: responses.successResponse(webhook),
};
},
});
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ webhookId: string }> }, auditLog: ApiAuditLog) => {
export const DELETE = withV1ApiWrapper({
handler: async ({
req,
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ webhookId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
auditLog.targetId = params.webhookId;
const headersList = headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return {
response: responses.notAuthenticatedResponse(),
};
}
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
// check if webhook exists
const webhook = await getWebhook(params.webhookId);
if (!webhook) {
@@ -71,12 +70,12 @@ export const DELETE = withApiLogging(
};
} catch (e) {
auditLog.status = "failure";
logger.error({ error: e, url: request.url }, "Error deleting webhook");
logger.error({ error: e, url: req.url }, "Error deleting webhook");
return {
response: responses.notFoundResponse("Webhook", params.webhookId),
};
}
},
"deleted",
"webhook"
);
action: "deleted",
targetType: "webhook",
});

View File

@@ -1,43 +1,44 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
export const GET = async (request: Request) => {
const authentication = await authenticateRequest(request);
if (!authentication) {
return responses.notAuthenticatedResponse();
}
try {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const webhooks = await getWebhooks(environmentIds);
return responses.successResponse(webhooks);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.internalServerErrorResponse(error.message);
}
throw error;
}
};
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
const authentication = await authenticateRequest(request);
if (!authentication) {
export const GET = withV1ApiWrapper({
handler: async ({ authentication }: { authentication: NonNullable<TApiKeyAuthentication> }) => {
try {
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const webhooks = await getWebhooks(environmentIds);
return {
response: responses.notAuthenticatedResponse(),
response: responses.successResponse(webhooks),
};
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.internalServerErrorResponse(error.message),
};
}
throw error;
}
},
});
auditLog.organizationId = authentication.organizationId;
auditLog.userId = authentication.apiKeyId;
const webhookInput = await request.json();
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const webhookInput = await req.json();
const inputValidation = ZWebhookInput.safeParse(webhookInput);
if (!inputValidation.success) {
@@ -85,6 +86,6 @@ export const POST = withApiLogging(
throw error;
}
},
"created",
"webhook"
);
action: "created",
targetType: "webhook",
});

View File

@@ -1,11 +1,12 @@
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import * as Sentry from "@sentry/nextjs";
import { NextRequest } from "next/server";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { responses } from "./response";
import { ApiAuditLog } from "./with-api-logging";
// Mocks
// This top-level mock is crucial for the SUT (withApiLogging.ts)
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
__esModule: true,
queueAuditEvent: vi.fn(),
@@ -29,7 +30,6 @@ vi.mock("@formbricks/logger", () => {
return {
logger: {
withContext: mockWithContextInstance,
// These are for direct calls like logger.error(), logger.warn()
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
@@ -37,35 +37,70 @@ vi.mock("@formbricks/logger", () => {
};
});
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/middleware/endpoint-validator", async () => {
const original = await vi.importActual("@/app/middleware/endpoint-validator");
return {
...original,
isClientSideApiRoute: vi.fn().mockReturnValue({ isClientSideApi: false, isRateLimited: true }),
isManagementApiRoute: vi.fn().mockReturnValue({ isManagementApi: false, authenticationMethod: "apiKey" }),
isIntegrationRoute: vi.fn().mockReturnValue(false),
isSyncWithUserIdentificationEndpoint: vi.fn().mockReturnValue(null),
};
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
applyRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
client: { windowMs: 60000, max: 100 },
v1: { windowMs: 60000, max: 1000 },
syncUserIdentification: { windowMs: 60000, max: 50 },
},
},
}));
function createMockRequest({ method = "GET", url = "https://api.test/endpoint", headers = new Map() } = {}) {
// Parse the URL to get the pathname
const parsedUrl = url.startsWith("/") ? new URL(url, "http://localhost:3000") : new URL(url);
return {
method,
url,
headers: {
get: (key: string) => headers.get(key),
},
} as unknown as Request;
nextUrl: {
pathname: parsedUrl.pathname,
},
} as unknown as NextRequest;
}
// Minimal valid ApiAuditLog
const baseAudit: ApiAuditLog = {
action: "created",
targetType: "survey",
userId: "user-1",
targetId: "target-1",
const mockApiAuthentication = {
hashedApiKey: "test-api-key",
apiKeyId: "api-key-1",
organizationId: "org-1",
status: "failure",
userType: "api",
};
} as TAuthenticationApiKey;
describe("withApiLogging", () => {
describe("withV1ApiWrapper", () => {
beforeEach(() => {
vi.resetModules(); // Reset SUT and other potentially cached modules
// vi.doMock for constants if a specific test needs to override it
// The top-level mocks for audit-logs, sentry, logger should be re-applied implicitly
// or are already in place due to vi.mock hoisting.
vi.resetModules();
// Restore the mock for constants to its default for most tests
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: true,
IS_PRODUCTION: true,
@@ -74,34 +109,45 @@ describe("withApiLogging", () => {
REDIS_URL: "redis://localhost:6379",
}));
vi.clearAllMocks(); // Clear call counts etc. for all vi.fn()
vi.clearAllMocks();
});
test("logs and audits on error response", async () => {
test("logs and audits on error response with API key authentication", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn().mockImplementation(async ({ auditLog }) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.internalServerErrorResponse("fail"),
};
});
const req = createMockRequest({ headers: new Map([["x-request-id", "abc-123"]]) });
const { withApiLogging } = await import("./with-api-logging"); // SUT dynamically imported
const wrapped = withApiLogging(handler, "created", "survey");
const req = createMockRequest({
url: "https://api.test/v1/management/surveys",
headers: new Map([["x-request-id", "abc-123"]]),
});
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
await wrapped(req, undefined);
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventId: "abc-123",
@@ -110,7 +156,7 @@ describe("withApiLogging", () => {
action: "created",
status: "failure",
targetType: "survey",
userId: "user-1",
userId: "api-key-1",
targetId: "target-1",
organizationId: "org-1",
})
@@ -125,28 +171,36 @@ describe("withApiLogging", () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn().mockImplementation(async ({ auditLog }) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.badRequestResponse("bad req"),
};
});
const req = createMockRequest();
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
await wrapped(req, undefined);
expect(Sentry.captureException).not.toHaveBeenCalled();
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
userType: "api",
@@ -154,7 +208,7 @@ describe("withApiLogging", () => {
action: "created",
status: "failure",
targetType: "survey",
userId: "user-1",
userId: "api-key-1",
targetId: "target-1",
organizationId: "org-1",
})
@@ -165,21 +219,34 @@ describe("withApiLogging", () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn().mockImplementation(async ({ auditLog }) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
throw new Error("fail!");
});
const req = createMockRequest({ headers: new Map([["x-request-id", "err-1"]]) });
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
const req = createMockRequest({
url: "https://api.test/v1/management/surveys",
headers: new Map([["x-request-id", "err-1"]]),
});
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
const res = await wrapped(req, undefined);
expect(res.status).toBe(500);
const body = await res.json();
expect(body).toEqual({
@@ -189,8 +256,6 @@ describe("withApiLogging", () => {
});
expect(logger.withContext).toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
eventId: "err-1",
@@ -199,7 +264,7 @@ describe("withApiLogging", () => {
action: "created",
status: "failure",
targetType: "survey",
userId: "user-1",
userId: "api-key-1",
targetId: "target-1",
organizationId: "org-1",
})
@@ -210,31 +275,39 @@ describe("withApiLogging", () => {
);
});
test("does not log/audit on success response", async () => {
test("does not log on success response but still audits", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const handler = vi.fn().mockImplementation(async (req, _props, auditLog) => {
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn().mockImplementation(async ({ auditLog }) => {
if (auditLog) {
auditLog.action = "created";
auditLog.targetType = "survey";
auditLog.userId = "user-1";
auditLog.targetId = "target-1";
auditLog.organizationId = "org-1";
auditLog.userType = "api";
}
return {
response: responses.successResponse({ ok: true }),
};
});
const req = createMockRequest();
const { withApiLogging } = await import("./with-api-logging");
const wrapped = withApiLogging(handler, "created", "survey");
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
await wrapped(req, undefined);
expect(logger.withContext).not.toHaveBeenCalled();
expect(mockContextualLoggerError).not.toHaveBeenCalled();
expect(mockContextualLoggerWarn).not.toHaveBeenCalled();
expect(mockContextualLoggerInfo).not.toHaveBeenCalled();
expect(mockedQueueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
userType: "api",
@@ -242,7 +315,7 @@ describe("withApiLogging", () => {
action: "created",
status: "success",
targetType: "survey",
userId: "user-1",
userId: "api-key-1",
targetId: "target-1",
organizationId: "org-1",
})
@@ -251,7 +324,6 @@ describe("withApiLogging", () => {
});
test("does not call audit if AUDIT_LOG_ENABLED is false", async () => {
// For this specific test, we override the AUDIT_LOG_ENABLED constant
vi.doMock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: false,
IS_PRODUCTION: true,
@@ -263,15 +335,209 @@ describe("withApiLogging", () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { withApiLogging } = await import("./with-api-logging");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { withV1ApiWrapper } = await import("./with-api-logging");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn().mockResolvedValue({
response: responses.internalServerErrorResponse("fail"),
audit: { ...baseAudit },
});
const req = createMockRequest();
const wrapped = withApiLogging(handler, "created", "survey");
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
await wrapped(req, undefined);
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
});
test("handles client-side API routes without authentication", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: "/api/v1/client/displays" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(handler).toHaveBeenCalledWith({
req,
props: undefined,
auditLog: undefined,
authentication: null,
});
});
test("returns authentication error for non-client routes without auth", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
});
test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const rateLimitError = new Error("Rate limit exceeded");
rateLimitError.message = "Rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
});
test("handles sync user identification rate limiting", async () => {
const { applyRateLimit, applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
const {
isClientSideApiRoute,
isManagementApiRoute,
isIntegrationRoute,
isSyncWithUserIdentificationEndpoint,
} = await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(isSyncWithUserIdentificationEndpoint).mockReturnValue({
userId: "user-123",
environmentId: "env-123",
});
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
const rateLimitError = new Error("Sync rate limit exceeded");
rateLimitError.message = "Sync rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ url: "/api/v1/client/env-123/app/sync/user-123" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(429);
expect(applyRateLimit).toHaveBeenCalledWith(
expect.objectContaining({ windowMs: 60000, max: 50 }),
"user-123"
);
});
test("skips audit log creation when no action/targetType provided", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
await wrapped(req, undefined);
expect(handler).toHaveBeenCalledWith({
req,
props: undefined,
auditLog: undefined,
authentication: mockApiAuthentication,
});
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
});
});
describe("buildAuditLogBaseObject", () => {
test("creates audit log base object with correct structure", async () => {
const { buildAuditLogBaseObject } = await import("./with-api-logging");
const result = buildAuditLogBaseObject("created", "survey", "https://api.test/v1/management/surveys");
expect(result).toEqual({
action: "created",
targetType: "survey",
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl: "https://api.test/v1/management/surveys",
});
});
});

View File

@@ -1,84 +1,328 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import {
AuthenticationMethod,
isClientSideApiRoute,
isIntegrationRoute,
isManagementApiRoute,
isSyncWithUserIdentificationEndpoint,
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { Session, getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
export type ApiAuditLog = Parameters<typeof queueAuditEvent>[0];
export type TApiAuditLog = Parameters<typeof queueAuditEvent>[0];
export type TApiV1Authentication = TAuthenticationApiKey | Session | null;
export type TApiKeyAuthentication = TAuthenticationApiKey | null;
export type TSessionAuthentication = Session | null;
// Interface for handler function parameters
export interface THandlerParams<TProps = unknown> {
req?: NextRequest;
props?: TProps;
auditLog?: TApiAuditLog;
authentication?: TApiKeyAuthentication | TSessionAuthentication;
}
// Interface for wrapper function parameters
export interface TWithV1ApiWrapperParams<TResult extends { response: Response }, TProps = unknown> {
handler: (params: THandlerParams<TProps>) => Promise<TResult>;
action?: TAuditAction;
targetType?: TAuditTarget;
}
enum ApiV1RouteTypeEnum {
Client = "client",
General = "general",
Integration = "integration",
}
/**
* withApiLogging wraps an V1 API handler to provide unified error/audit/system logging.
* - Handler must return { response }.
* - If not a successResponse, calls audit log, system log, and Sentry as needed.
* - System and Sentry logs are always called for non-success responses.
* Apply client-side API rate limiting (IP-based or sync-specific)
*/
export const withApiLogging = <TResult extends { response: Response }>(
handler: (req: Request, props?: any, auditLog?: ApiAuditLog) => Promise<TResult>,
action: TAuditAction,
targetType: TAuditTarget
) => {
return async function (req: Request, props: any): Promise<Response> {
const auditLog = buildAuditLogBaseObject(action, targetType, req.url);
const applyClientRateLimit = async (url: string): Promise<void> => {
const syncEndpoint = isSyncWithUserIdentificationEndpoint(url);
if (syncEndpoint) {
const syncRateLimitConfig = rateLimitConfigs.api.syncUserIdentification;
await applyRateLimit(syncRateLimitConfig, syncEndpoint.userId);
} else {
await applyIPRateLimit(rateLimitConfigs.api.client);
}
};
let result: { response: Response };
let error: any = undefined;
try {
result = await handler(req, props, auditLog);
} catch (err) {
error = err;
result = {
response: responses.internalServerErrorResponse("An unexpected error occurred."),
};
/**
* Handle rate limiting based on authentication and API type
*/
const handleRateLimiting = async (
url: string,
authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum
): Promise<Response | null> => {
try {
if (authentication) {
if ("user" in authentication) {
// Session-based authentication for integration routes
await applyRateLimit(rateLimitConfigs.api.v1, authentication.user.id);
} else if ("hashedApiKey" in authentication) {
// API key authentication for general routes
await applyRateLimit(rateLimitConfigs.api.v1, authentication.hashedApiKey);
} else {
logger.error({ authentication }, "Unknown authentication type");
return responses.internalServerErrorResponse("Invalid authentication configuration");
}
}
const res = result.response;
// Try to parse the response as JSON to check if it's a success or error
let isSuccess = false;
let parsed: any = undefined;
try {
parsed = await res.clone().json();
isSuccess = parsed && typeof parsed === "object" && "data" in parsed;
} catch {
isSuccess = false;
if (routeType === ApiV1RouteTypeEnum.Client) {
await applyClientRateLimit(url);
}
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
}
const correlationId = req.headers.get("x-request-id") ?? "";
return null;
};
if (!isSuccess) {
if (auditLog) {
auditLog.eventId = correlationId;
}
/**
* Execute handler with error handling
*/
const executeHandler = async <TResult extends { response: Response }, TProps>(
handler: (params: THandlerParams<TProps>) => Promise<TResult>,
req: NextRequest,
props: TProps,
auditLog: TApiAuditLog | undefined,
authentication: TApiV1Authentication
): Promise<{ result: TResult; error?: unknown }> => {
try {
const result = await handler({ req, props, auditLog, authentication });
return { result };
} catch (err) {
const result = {
response: responses.internalServerErrorResponse("An unexpected error occurred."),
} as TResult;
return { result, error: err };
}
};
// System log
const logContext: any = {
correlationId,
method: req.method,
path: req.url,
status: res.status,
};
if (error) {
logContext.error = error;
}
logger.withContext(logContext).error("API Error Details");
// Sentry log
if (SENTRY_DSN && IS_PRODUCTION && res.status === 500) {
const err = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(err, {
extra: {
error,
correlationId,
},
});
}
} else {
/**
* Set up audit log with authentication details
*/
const setupAuditLog = (
authentication: TApiV1Authentication,
auditLog: TApiAuditLog | undefined,
routeType: ApiV1RouteTypeEnum
): void => {
if (
authentication &&
auditLog &&
routeType === ApiV1RouteTypeEnum.General &&
"apiKeyId" in authentication
) {
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
}
if (authentication && auditLog && "user" in authentication) {
auditLog.userId = authentication.user.id;
}
};
/**
* Handle authentication based on method
*/
const handleAuthentication = async (
authenticationMethod: AuthenticationMethod,
req: NextRequest
): Promise<TApiV1Authentication> => {
switch (authenticationMethod) {
case AuthenticationMethod.ApiKey:
return await authenticateRequest(req);
case AuthenticationMethod.Session:
return await getServerSession(authOptions);
case AuthenticationMethod.Both: {
const session = await getServerSession(authOptions);
return session ?? (await authenticateRequest(req));
}
case AuthenticationMethod.None:
return null;
}
};
/**
* Log error details to system logger and Sentry
*/
const logErrorDetails = (res: Response, req: NextRequest, correlationId: string, error?: any): void => {
const logContext = {
correlationId,
method: req.method,
path: req.url,
status: res.status,
...(error && { error }),
};
logger.withContext(logContext).error("V1 API Error Details");
if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) {
const err = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(err, { extra: { error, correlationId } });
}
};
/**
* Handle response processing and logging
*/
const processResponse = async (
res: Response,
req: NextRequest,
auditLog?: TApiAuditLog,
error?: any
): Promise<void> => {
const correlationId = req.headers.get("x-request-id") ?? "";
// Handle audit logging
if (auditLog) {
if (res.ok) {
auditLog.status = "success";
} else {
auditLog.eventId = correlationId;
}
}
// Handle error logging
if (!res.ok) {
logErrorDetails(res, req, correlationId, error);
}
// Queue audit event if enabled and audit log exists
if (AUDIT_LOG_ENABLED && auditLog) {
queueAuditEvent(auditLog);
}
};
const getRouteType = (
req: NextRequest
): { routeType: ApiV1RouteTypeEnum; isRateLimited: boolean; authenticationMethod: AuthenticationMethod } => {
const pathname = req.nextUrl.pathname;
const { isClientSideApi, isRateLimited } = isClientSideApiRoute(pathname);
const { isManagementApi, authenticationMethod } = isManagementApiRoute(pathname);
const isIntegration = isIntegrationRoute(pathname);
if (isClientSideApi)
return {
routeType: ApiV1RouteTypeEnum.Client,
isRateLimited,
authenticationMethod: AuthenticationMethod.None,
};
if (isManagementApi)
return { routeType: ApiV1RouteTypeEnum.General, isRateLimited: true, authenticationMethod };
if (isIntegration)
return {
routeType: ApiV1RouteTypeEnum.Integration,
isRateLimited: true,
authenticationMethod: AuthenticationMethod.Session,
};
throw new Error(`Unknown route type: ${pathname}`);
};
/**
* withV1ApiWrapper wraps a V1 API handler to provide unified authentication, rate limiting, and optional audit/system logging.
*
* Features:
* - Performs authentication once and passes result to handler
* - Applies API key-based rate limiting with differentiated limits for client vs management APIs
* - Includes additional sync user identification rate limiting for client-side sync endpoints
* - Sets userId and organizationId in audit log automatically when audit logging is enabled
* - System and Sentry logs are always called for non-success responses
* - Uses function overloads to provide type safety without requiring type guards
*
* @param params - Configuration object for the wrapper
* @param params.handler - The API handler function that processes the request, receives an object with:
* - req: The incoming HTTP request object
* - props: Optional route parameters (e.g., { params: { id: string } })
* - auditLog: Optional audit log object for tracking API actions (only present when action/targetType provided)
* - authentication: Authentication result (type determined by route - API key for general, session for integration)
* @param params.action - Optional audit action type (e.g., "created", "updated", "deleted"). Required for audit logging
* @param params.targetType - Optional audit target type (e.g., "webhook", "survey", "response"). Required for audit logging
* @returns Wrapped handler function that returns the final HTTP response
*
*/
export const withV1ApiWrapper: {
<TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> & {
handler: (
params: THandlerParams<TProps> & { authentication?: TApiKeyAuthentication }
) => Promise<TResult>;
}
): (req: NextRequest, props: TProps) => Promise<Response>;
<TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> & {
handler: (
params: THandlerParams<TProps> & { authentication?: TSessionAuthentication }
) => Promise<TResult>;
}
): (req: NextRequest, props: TProps) => Promise<Response>;
<TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> & {
handler: (
params: THandlerParams<TProps> & { authentication?: TApiV1Authentication }
) => Promise<TResult>;
}
): (req: NextRequest, props: TProps) => Promise<Response>;
} = <TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
// === Audit Log Setup ===
const saveAuditLog = action && targetType;
const auditLog = saveAuditLog ? buildAuditLogBaseObject(action, targetType, req.url) : undefined;
let routeType: ApiV1RouteTypeEnum;
let isRateLimited: boolean;
let authenticationMethod: AuthenticationMethod;
// === Route Classification ===
try {
({ routeType, isRateLimited, authenticationMethod } = getRouteType(req));
} catch (error) {
logger.error({ error }, "Error getting route type");
return responses.internalServerErrorResponse("An unexpected error occurred.");
}
if (AUDIT_LOG_ENABLED && auditLog) {
queueAuditEvent(auditLog);
// === Authentication ===
const authentication = await handleAuthentication(authenticationMethod, req);
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
return responses.notAuthenticatedResponse();
}
// === Audit Log Enhancement ===
setupAuditLog(authentication, auditLog, routeType);
// === Rate Limiting ===
if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting(req.nextUrl.pathname, authentication, routeType);
if (rateLimitResponse) return rateLimitResponse;
}
// === Handler Execution ===
const { result, error } = await executeHandler(handler, req, props, auditLog, authentication);
const res = result.response;
// === Response Processing & Logging ===
await processResponse(res, req, auditLog, error);
return res;
};
};
@@ -87,7 +331,7 @@ export const buildAuditLogBaseObject = (
action: TAuditAction,
targetType: TAuditTarget,
apiUrl: string
): ApiAuditLog => {
): TApiAuditLog => {
return {
action,
targetType,

View File

@@ -1,39 +0,0 @@
import * as constants from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Mock } from "vitest";
vi.mock("@/lib/utils/rate-limit", () => ({ rateLimit: vi.fn() }));
describe("bucket middleware rate limiters", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
const mockedRateLimit = rateLimit as unknown as Mock;
mockedRateLimit.mockImplementation((config) => config);
});
test("clientSideApiEndpointsLimiter uses CLIENT_SIDE_API_RATE_LIMIT settings", async () => {
const { clientSideApiEndpointsLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
expect(clientSideApiEndpointsLimiter).toEqual({
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
});
test("syncUserIdentificationLimiter uses SYNC_USER_IDENTIFICATION_RATE_LIMIT settings", async () => {
const { syncUserIdentificationLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});
expect(syncUserIdentificationLimiter).toEqual({
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});
});
});

View File

@@ -1,12 +0,0 @@
import { CLIENT_SIDE_API_RATE_LIMIT, SYNC_USER_IDENTIFICATION_RATE_LIMIT } from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
export const clientSideApiEndpointsLimiter = rateLimit({
interval: CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
export const syncUserIdentificationLimiter = rateLimit({
interval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});

View File

@@ -1,8 +1,10 @@
import { describe, expect, test } from "vitest";
import {
AuthenticationMethod,
isAdminDomainRoute,
isAuthProtectedRoute,
isClientSideApiRoute,
isIntegrationRoute,
isManagementApiRoute,
isPublicDomainRoute,
isRouteAllowedForDomain,
@@ -10,34 +12,230 @@ import {
} from "./endpoint-validator";
describe("endpoint-validator", () => {
describe("AuthenticationMethod enum", () => {
test("should have correct values", () => {
expect(AuthenticationMethod.ApiKey).toBe("apiKey");
expect(AuthenticationMethod.Session).toBe("session");
expect(AuthenticationMethod.Both).toBe("both");
expect(AuthenticationMethod.None).toBe("none");
});
});
describe("isClientSideApiRoute", () => {
test("should return true for client-side API routes", () => {
expect(isClientSideApiRoute("/api/v1/js/actions")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/storage")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/other")).toBe(true);
expect(isClientSideApiRoute("/api/v2/client/other")).toBe(true);
test("should return correct object for client-side API routes with rate limiting", () => {
expect(isClientSideApiRoute("/api/v1/client/storage")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/v1/client/other")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/v2/client/other")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/v3/client/test")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
});
test("should return correct object for OG route (client-side but not rate limited)", () => {
expect(isClientSideApiRoute("/api/v1/client/og")).toEqual({
isClientSideApi: true,
isRateLimited: false,
});
expect(isClientSideApiRoute("/api/v1/client/og/image")).toEqual({
isClientSideApi: true,
isRateLimited: false,
});
});
test("should return false for non-client-side API routes", () => {
expect(isClientSideApiRoute("/api/v1/management/something")).toBe(false);
expect(isClientSideApiRoute("/api/something")).toBe(false);
expect(isClientSideApiRoute("/auth/login")).toBe(false);
expect(isClientSideApiRoute("/api/v1/management/something")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/v1/js/actions")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/something")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
expect(isClientSideApiRoute("/auth/login")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/v1/integrations/webhook")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
});
// exception for open graph image generation route, it should not be rate limited
expect(isClientSideApiRoute("/api/v1/client/og")).toBe(false);
test("should handle edge cases", () => {
expect(isClientSideApiRoute("/api/v1/client")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/v1/client/")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
expect(isClientSideApiRoute("/api/client/test")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
});
});
describe("isManagementApiRoute", () => {
test("should return true for management API routes", () => {
expect(isManagementApiRoute("/api/v1/management/something")).toBe(true);
expect(isManagementApiRoute("/api/v2/management/other")).toBe(true);
test("should return correct object for management API routes with API key authentication", () => {
expect(isManagementApiRoute("/api/v1/management/something")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v2/management/other")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v1/management/surveys")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v3/management/users")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
test("should return false for non-management API routes", () => {
expect(isManagementApiRoute("/api/v1/client/something")).toBe(false);
expect(isManagementApiRoute("/api/something")).toBe(false);
expect(isManagementApiRoute("/auth/login")).toBe(false);
test("should return correct object for storage management routes with both authentication methods", () => {
expect(isManagementApiRoute("/api/v1/management/storage")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
expect(isManagementApiRoute("/api/v1/management/storage/files")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
expect(isManagementApiRoute("/api/v1/management/storage/upload")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
});
test("should return correct object for webhooks routes with API key authentication", () => {
expect(isManagementApiRoute("/api/v1/webhooks")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v1/webhooks/123")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v1/webhooks/webhook-id/config")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
test("should return correct object for non-v1 storage management routes (only v1 supports both auth methods)", () => {
expect(isManagementApiRoute("/api/v2/management/storage")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v3/management/storage/upload")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
test("should return correct object for non-v1 webhooks routes (falls back to management regex)", () => {
expect(isManagementApiRoute("/api/v2/webhooks")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v3/webhooks/123")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v2/management/webhooks")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
test("should return correct object for non-management API routes", () => {
expect(isManagementApiRoute("/api/v1/client/something")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/something")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/auth/login")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v1/integrations/webhook")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
test("should handle edge cases", () => {
expect(isManagementApiRoute("/api/v1/management")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v1/management/")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/management/test")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
test("should handle webhooks edge cases", () => {
expect(isManagementApiRoute("/api/v1/webhook")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/webhooks")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/webhooks/api/v1")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
});
describe("isIntegrationRoute", () => {
test("should return true for integration API routes", () => {
expect(isIntegrationRoute("/api/v1/integrations/webhook")).toBe(true);
expect(isIntegrationRoute("/api/v2/integrations/slack")).toBe(true);
expect(isIntegrationRoute("/api/v1/integrations/zapier")).toBe(true);
expect(isIntegrationRoute("/api/v3/integrations/other")).toBe(true);
});
test("should return false for non-integration API routes", () => {
expect(isIntegrationRoute("/api/v1/client/something")).toBe(false);
expect(isIntegrationRoute("/api/v1/management/something")).toBe(false);
expect(isIntegrationRoute("/api/something")).toBe(false);
expect(isIntegrationRoute("/auth/login")).toBe(false);
expect(isIntegrationRoute("/integrations/webhook")).toBe(false);
});
test("should handle edge cases", () => {
expect(isIntegrationRoute("/api/v1/integrations")).toBe(false);
expect(isIntegrationRoute("/api/v1/integrations/")).toBe(true);
expect(isIntegrationRoute("/api/integrations/test")).toBe(false);
});
});
@@ -45,15 +243,30 @@ describe("endpoint-validator", () => {
test("should return true for protected routes", () => {
expect(isAuthProtectedRoute("/environments")).toBe(true);
expect(isAuthProtectedRoute("/environments/something")).toBe(true);
expect(isAuthProtectedRoute("/environments/123/surveys")).toBe(true);
expect(isAuthProtectedRoute("/setup/organization")).toBe(true);
expect(isAuthProtectedRoute("/setup/organization/create")).toBe(true);
expect(isAuthProtectedRoute("/organizations")).toBe(true);
expect(isAuthProtectedRoute("/organizations/something")).toBe(true);
expect(isAuthProtectedRoute("/organizations/123/settings")).toBe(true);
});
test("should return false for non-protected routes", () => {
expect(isAuthProtectedRoute("/auth/login")).toBe(false);
expect(isAuthProtectedRoute("/auth/signup")).toBe(false);
expect(isAuthProtectedRoute("/api/something")).toBe(false);
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
expect(isAuthProtectedRoute("/")).toBe(false);
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
expect(isAuthProtectedRoute("/health")).toBe(false);
});
test("should handle edge cases", () => {
expect(isAuthProtectedRoute("/environment")).toBe(false); // partial match should not work
expect(isAuthProtectedRoute("/organization")).toBe(false); // partial match should not work
expect(isAuthProtectedRoute("/setup/team")).toBe(false); // not in protected routes
expect(isAuthProtectedRoute("/setup")).toBe(false); // partial match should not work
});
});
@@ -70,12 +283,42 @@ describe("endpoint-validator", () => {
environmentId: "abc-123",
userId: "xyz-789",
});
const result3 = isSyncWithUserIdentificationEndpoint(
"/api/v1/client/env_123_test/app/sync/user_456_test"
);
expect(result3).toEqual({
environmentId: "env_123_test",
userId: "user_456_test",
});
});
test("should handle optional trailing slash", () => {
// Test both with and without trailing slash
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
expect(result1).toEqual({
environmentId: "env123",
userId: "user456",
});
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456/");
expect(result2).toEqual({
environmentId: "env123",
userId: "user456",
});
});
test("should return false for invalid sync URLs", () => {
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/something")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/other/user456")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v2/client/env123/app/sync/user456")).toBe(false); // only v1 supported
});
test("should handle empty or malformed IDs", () => {
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client//app/sync/user456")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/")).toBe(false);
});
});
@@ -84,21 +327,57 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/health")).toBe(true);
});
test("should return true for public storage routes", () => {
expect(isPublicDomainRoute("/storage/env123/public/file.jpg")).toBe(true);
expect(isPublicDomainRoute("/storage/abc-456/public/document.pdf")).toBe(true);
expect(isPublicDomainRoute("/storage/env123/public/folder/image.png")).toBe(true);
});
test("should return false for private storage routes", () => {
expect(isPublicDomainRoute("/storage/env123/private/file.jpg")).toBe(false);
expect(isPublicDomainRoute("/storage/env123")).toBe(false);
expect(isPublicDomainRoute("/storage")).toBe(false);
});
// Static assets are not handled by domain routing - middleware doesn't run on them
test("should return true for survey routes", () => {
expect(isPublicDomainRoute("/s/survey123")).toBe(true);
expect(isPublicDomainRoute("/s/survey-id-with-dashes")).toBe(true);
expect(isPublicDomainRoute("/s/survey_id_with_underscores")).toBe(true);
expect(isPublicDomainRoute("/s/abc123def456")).toBe(true);
});
test("should return false for malformed survey routes", () => {
expect(isPublicDomainRoute("/s/")).toBe(false);
expect(isPublicDomainRoute("/s")).toBe(false);
expect(isPublicDomainRoute("/survey/123")).toBe(false);
});
test("should return true for contact survey routes", () => {
expect(isPublicDomainRoute("/c/jwt-token")).toBe(true);
expect(isPublicDomainRoute("/c/very-long-jwt-token-123")).toBe(true);
expect(isPublicDomainRoute("/c/token.with.dots")).toBe(true);
});
test("should return false for malformed contact survey routes", () => {
expect(isPublicDomainRoute("/c/")).toBe(false);
expect(isPublicDomainRoute("/c")).toBe(false);
expect(isPublicDomainRoute("/contact/token")).toBe(false);
});
test("should return true for client API routes", () => {
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/env/actions")).toBe(true);
expect(isPublicDomainRoute("/api/v2/client/env/responses")).toBe(true);
});
test("should return false for non-client API routes", () => {
expect(isPublicDomainRoute("/api/v3/client/something")).toBe(false); // only v1 and v2 supported
expect(isPublicDomainRoute("/api/client/something")).toBe(false);
expect(isPublicDomainRoute("/api/v1/management/users")).toBe(false);
expect(isPublicDomainRoute("/api/v1/integrations/webhook")).toBe(false);
});
test("should return false for admin-only routes", () => {
@@ -116,7 +395,11 @@ describe("endpoint-validator", () => {
describe("isAdminDomainRoute", () => {
test("should return true for health endpoint (backward compatibility)", () => {
expect(isAdminDomainRoute("/health")).toBe(true);
expect(isAdminDomainRoute("/health")).toBe(true);
});
test("should return true for public storage routes (backward compatibility)", () => {
expect(isAdminDomainRoute("/storage/env123/public/file.jpg")).toBe(true);
expect(isAdminDomainRoute("/storage/abc-456/public/document.pdf")).toBe(true);
});
// Static assets are not handled by domain routing - middleware doesn't run on them
@@ -135,73 +418,261 @@ describe("endpoint-validator", () => {
expect(isAdminDomainRoute("/product/features")).toBe(true);
expect(isAdminDomainRoute("/api/v1/management/users")).toBe(true);
expect(isAdminDomainRoute("/api/v2/management/surveys")).toBe(true);
expect(isAdminDomainRoute("/api/v1/integrations/webhook")).toBe(true);
expect(isAdminDomainRoute("/pipeline/jobs")).toBe(true);
expect(isAdminDomainRoute("/cron/tasks")).toBe(true);
expect(isAdminDomainRoute("/random/route")).toBe(true);
});
test("should return false for public-only routes", () => {
expect(isAdminDomainRoute("/s/survey123")).toBe(false);
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
});
test("should handle edge cases", () => {
expect(isAdminDomainRoute("")).toBe(true);
expect(isAdminDomainRoute("/unknown/path")).toBe(true); // unknown routes default to admin
});
});
describe("isRouteAllowedForDomain", () => {
test("should allow public routes on public domain", () => {
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
// Static assets not tested - middleware doesn't run on them
describe("public domain routing", () => {
test("should allow public routes on public domain", () => {
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
expect(isRouteAllowedForDomain("/storage/env123/public/file.jpg", true)).toBe(true);
// Static assets not tested - middleware doesn't run on them
});
test("should block admin routes on public domain", () => {
expect(isRouteAllowedForDomain("/", true)).toBe(false);
expect(isRouteAllowedForDomain("/environments/123", true)).toBe(false);
expect(isRouteAllowedForDomain("/auth/login", true)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/management/users", true)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/integrations/webhook", true)).toBe(false);
expect(isRouteAllowedForDomain("/organizations/123", true)).toBe(false);
expect(isRouteAllowedForDomain("/setup/organization", true)).toBe(false);
});
});
test("should block admin routes on public domain", () => {
expect(isRouteAllowedForDomain("/", true)).toBe(false);
expect(isRouteAllowedForDomain("/environments/123", true)).toBe(false);
expect(isRouteAllowedForDomain("/auth/login", true)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/management/users", true)).toBe(false);
describe("admin domain routing", () => {
test("should allow admin routes on admin domain", () => {
expect(isRouteAllowedForDomain("/", false)).toBe(true);
expect(isRouteAllowedForDomain("/environments/123", false)).toBe(true);
expect(isRouteAllowedForDomain("/auth/login", false)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/management/users", false)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/integrations/webhook", false)).toBe(true);
expect(isRouteAllowedForDomain("/health", false)).toBe(true);
expect(isRouteAllowedForDomain("/storage/env123/public/file.jpg", false)).toBe(true);
expect(isRouteAllowedForDomain("/pipeline/jobs", false)).toBe(true);
expect(isRouteAllowedForDomain("/cron/tasks", false)).toBe(true);
expect(isRouteAllowedForDomain("/unknown/route", false)).toBe(true);
});
test("should block public-only routes on admin domain when PUBLIC_URL is configured", () => {
expect(isRouteAllowedForDomain("/s/survey123", false)).toBe(false);
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
});
});
test("should block public routes on admin domain when PUBLIC_URL is configured", () => {
// Admin routes should be allowed
expect(isRouteAllowedForDomain("/", false)).toBe(true);
expect(isRouteAllowedForDomain("/environments/123", false)).toBe(true);
expect(isRouteAllowedForDomain("/auth/login", false)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/management/users", false)).toBe(true);
expect(isRouteAllowedForDomain("/health", false)).toBe(true);
expect(isRouteAllowedForDomain("/pipeline/jobs", false)).toBe(true);
expect(isRouteAllowedForDomain("/cron/tasks", false)).toBe(true);
describe("edge cases", () => {
test("should handle empty paths", () => {
expect(isRouteAllowedForDomain("", true)).toBe(false);
expect(isRouteAllowedForDomain("", false)).toBe(true);
});
// Public routes should be blocked on admin domain
expect(isRouteAllowedForDomain("/s/survey123", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
test("should handle paths with query parameters and fragments", () => {
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
});
});
});
describe("edge cases", () => {
test("should handle empty paths", () => {
expect(isPublicDomainRoute("")).toBe(false);
expect(isAdminDomainRoute("")).toBe(true);
expect(isAdminDomainRoute("")).toBe(true);
describe("comprehensive integration tests", () => {
describe("URL parsing edge cases", () => {
test("should handle paths with query parameters", () => {
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
});
test("should handle paths with fragments", () => {
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
});
test("should handle trailing slashes", () => {
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isIntegrationRoute("/api/v1/integrations/webhook/")).toBe(true);
});
});
test("should handle paths with query parameters", () => {
expect(isPublicDomainRoute("/s/survey123?param=value")).toBe(true);
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
describe("nested route handling", () => {
test("should handle nested survey routes", () => {
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
});
test("should handle nested client API routes", () => {
expect(isPublicDomainRoute("/api/v1/client/env123/actions")).toBe(true);
expect(isPublicDomainRoute("/api/v2/client/env456/responses")).toBe(true);
expect(isPublicDomainRoute("/api/v1/client/env789/surveys/123")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/env123/actions")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
});
test("should handle deeply nested admin routes", () => {
expect(isAuthProtectedRoute("/environments/123/surveys/456/settings")).toBe(true);
expect(isAuthProtectedRoute("/organizations/789/members/123/roles")).toBe(true);
expect(isAuthProtectedRoute("/setup/organization/team/invites")).toBe(true);
});
});
test("should handle paths with fragments", () => {
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
describe("version handling", () => {
test("should handle different API versions correctly", () => {
// Client API - only v1 and v2 supported in public routes
expect(isPublicDomainRoute("/api/v1/client/test")).toBe(true);
expect(isPublicDomainRoute("/api/v2/client/test")).toBe(true);
expect(isPublicDomainRoute("/api/v3/client/test")).toBe(false);
// Management API - all versions supported
expect(isManagementApiRoute("/api/v1/management/test")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v2/management/test")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isManagementApiRoute("/api/v3/management/test")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
// Integration API - all versions supported
expect(isIntegrationRoute("/api/v1/integrations/test")).toBe(true);
expect(isIntegrationRoute("/api/v2/integrations/test")).toBe(true);
expect(isIntegrationRoute("/api/v3/integrations/test")).toBe(true);
});
});
test("should handle nested survey routes", () => {
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
describe("special characters in routes", () => {
test("should handle special characters in survey IDs", () => {
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
expect(
isSyncWithUserIdentificationEndpoint("/api/v1/client/env-123_test/app/sync/user-456_test")
).toEqual({
environmentId: "env-123_test",
userId: "user-456_test",
});
});
});
test("should handle nested client API routes", () => {
expect(isPublicDomainRoute("/api/v1/client/env123/actions")).toBe(true);
expect(isPublicDomainRoute("/api/v2/client/env456/responses")).toBe(true);
describe("security considerations", () => {
test("should properly validate malicious or injection-like URLs", () => {
// SQL injection-like attempts
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
// Path traversal attempts
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
// XSS-like attempts
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
isClientSideApi: true,
isRateLimited: true,
});
});
test("should handle URL encoding", () => {
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
});
});
describe("performance considerations", () => {
test("should handle very long URLs efficiently", () => {
const longSurveyId = "a".repeat(1000);
const longPath = `s/${longSurveyId}`;
expect(isPublicDomainRoute(`/${longPath}`)).toBe(true);
const longEnvironmentId = "env" + "a".repeat(1000);
const longUserId = "user" + "b".repeat(1000);
expect(
isSyncWithUserIdentificationEndpoint(`/api/v1/client/${longEnvironmentId}/app/sync/${longUserId}`)
).toEqual({
environmentId: longEnvironmentId,
userId: longUserId,
});
});
test("should handle empty and minimal inputs", () => {
expect(isPublicDomainRoute("")).toBe(false);
expect(isClientSideApiRoute("")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
expect(isManagementApiRoute("")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isIntegrationRoute("")).toBe(false);
expect(isAuthProtectedRoute("")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("")).toBe(false);
});
});
describe("case sensitivity", () => {
test("should be case sensitive for route patterns", () => {
// These should not match due to case sensitivity
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
isClientSideApi: false,
isRateLimited: true,
});
expect(isManagementApiRoute("/API/V1/MANAGEMENT/test")).toEqual({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.ApiKey,
});
expect(isIntegrationRoute("/API/V1/INTEGRATIONS/test")).toBe(false);
expect(isAuthProtectedRoute("/ENVIRONMENTS/123")).toBe(false);
});
});
});
});

View File

@@ -4,18 +4,35 @@ import {
matchesAnyPattern,
} from "./route-config";
export const isClientSideApiRoute = (url: string): boolean => {
// Open Graph image generation route is a client side API route but it should not be rate limited
if (url.includes("/api/v1/client/og")) return false;
export enum AuthenticationMethod {
ApiKey = "apiKey",
Session = "session",
Both = "both",
None = "none",
}
export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; isRateLimited: boolean } => {
// Open Graph image generation route is a client side API route but it should not be rate limited
if (url.includes("/api/v1/client/og")) return { isClientSideApi: true, isRateLimited: false };
if (url.includes("/api/v1/js/actions")) return true;
if (url.includes("/api/v1/client/storage")) return true;
const regex = /^\/api\/v\d+\/client\//;
return regex.test(url);
return { isClientSideApi: regex.test(url), isRateLimited: true };
};
export const isManagementApiRoute = (url: string): boolean => {
export const isManagementApiRoute = (
url: string
): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => {
if (url.includes("/api/v1/management/storage"))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/webhooks"))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.ApiKey };
const regex = /^\/api\/v\d+\/management\//;
return { isManagementApi: regex.test(url), authenticationMethod: AuthenticationMethod.ApiKey };
};
export const isIntegrationRoute = (url: string): boolean => {
const regex = /^\/api\/v\d+\/integrations\//;
return regex.test(url);
};

View File

@@ -153,20 +153,6 @@ export const SURVEY_BG_COLORS = [
"#CDFAD5",
];
// Rate Limiting
export const CLIENT_SIDE_API_RATE_LIMIT = {
interval: 60, // 1 minute
allowedPerInterval: 100,
};
export const MANAGEMENT_API_RATE_LIMIT = {
interval: 60, // 1 minute
allowedPerInterval: 100,
};
export const SYNC_USER_IDENTIFICATION_RATE_LIMIT = {
interval: 60, // 1 minute
allowedPerInterval: 5,
};
export const DEBUG = env.DEBUG === "1";
// Enterprise License constant

View File

@@ -1,16 +1,7 @@
import { clientSideApiEndpointsLimiter, syncUserIdentificationLimiter } from "@/app/middleware/bucket";
import { isPublicDomainConfigured, isRequestFromPublicDomain } from "@/app/middleware/domain-utils";
import {
isAuthProtectedRoute,
isClientSideApiRoute,
isRouteAllowedForDomain,
isSyncWithUserIdentificationEndpoint,
} from "@/app/middleware/endpoint-validator";
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, WEBAPP_URL } from "@/lib/constants";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { isAuthProtectedRoute, isRouteAllowedForDomain } from "@/app/middleware/endpoint-validator";
import { WEBAPP_URL } from "@/lib/constants";
import { isValidCallbackUrl } from "@/lib/utils/url";
import { logApiErrorEdge } from "@/modules/api/v2/lib/utils-edge";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
@@ -37,17 +28,6 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
return null;
};
const applyRateLimiting = async (request: NextRequest, ip: string) => {
if (isClientSideApiRoute(request.nextUrl.pathname)) {
await clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
if (envIdAndUserId) {
const { environmentId, userId } = envIdAndUserId;
await syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
}
}
};
/**
* Handle domain-aware routing based on PUBLIC_URL and WEBAPP_URL
*/
@@ -100,27 +80,6 @@ export const middleware = async (originalRequest: NextRequest) => {
const authResponse = await handleAuth(request);
if (authResponse) return authResponse;
const ip = await getClientIpFromHeaders();
if (!IS_PRODUCTION || RATE_LIMITING_DISABLED) {
return nextResponseWithCustomHeader;
}
if (ip) {
try {
await applyRateLimiting(request, ip);
return nextResponseWithCustomHeader;
} catch (e) {
logger.error(e, "Error applying rate limiting");
const apiError: ApiErrorResponseV2 = {
type: "too_many_requests",
details: [{ field: "", issue: "Too many requests. Please try again later." }],
};
logApiErrorEdge(request, apiError);
return NextResponse.json(apiError, { status: 429 });
}
}
return nextResponseWithCustomHeader;
};

View File

@@ -1,4 +1,4 @@
import { ApiAuditLog } from "@/app/lib/api/with-api-logging";
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";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
@@ -15,7 +15,7 @@ export type HandlerFn<TInput = Record<string, unknown>> = ({
authentication: TAuthenticationApiKey;
parsedInput: TInput;
request: Request;
auditLog?: ApiAuditLog;
auditLog?: TApiAuditLog;
}) => Promise<Response>;
export type ExtendedSchemas = {
@@ -52,7 +52,7 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
externalParams?: Promise<Record<string, any>>;
rateLimit?: boolean;
handler: HandlerFn<ParsedSchemas<S>>;
auditLog?: ApiAuditLog;
auditLog?: TApiAuditLog;
}): Promise<Response> => {
const authentication = await authenticateRequest(request);
if (!authentication.ok) {

View File

@@ -66,7 +66,7 @@ describe("rateLimitConfigs", () => {
test("should have all API configurations", () => {
const apiConfigs = Object.keys(rateLimitConfigs.api);
expect(apiConfigs).toEqual(["v1", "v2", "client"]);
expect(apiConfigs).toEqual(["v1", "v2", "client", "syncUserIdentification"]);
});
test("should have all action configurations", () => {
@@ -83,9 +83,9 @@ describe("rateLimitConfigs", () => {
...Object.values(rateLimitConfigs.actions),
];
allConfigs.forEach((config) => {
for (const config of allConfigs) {
expect(() => ZRateLimitConfig.parse(config)).not.toThrow();
});
}
});
});
@@ -129,29 +129,50 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.auth.signup, identifier: "user-signup" },
{ config: rateLimitConfigs.api.v1, identifier: "api-v1-key" },
{ config: rateLimitConfigs.api.v2, identifier: "api-v2-key" },
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
{ config: rateLimitConfigs.api.syncUserIdentification, identifier: "sync-user-id" },
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
];
const testAllowedRequest = async (config: any, identifier: string) => {
for (const { config, identifier } of testCases) {
// Test allowed request
mockEval.mockClear();
mockEval.mockResolvedValue([1, 1]);
const result = await checkRateLimit(config, identifier);
expect(result.ok).toBe(true);
expect((result as any).data.allowed).toBe(true);
};
const allowedResult = await checkRateLimit(config, identifier);
expect(allowedResult.ok).toBe(true);
expect((allowedResult as any).data.allowed).toBe(true);
const testExceededLimit = async (config: any, identifier: string) => {
// When limit is exceeded, remaining should be 0
// Test exceeded limit
mockEval.mockClear();
mockEval.mockResolvedValue([config.allowedPerInterval + 1, 0]);
const result = await checkRateLimit(config, identifier);
expect(result.ok).toBe(true);
expect((result as any).data.allowed).toBe(false);
};
const exceededResult = await checkRateLimit(config, identifier);
expect(exceededResult.ok).toBe(true);
expect((exceededResult as any).data.allowed).toBe(false);
}
});
for (const { config, identifier } of testCases) {
await testAllowedRequest(config, identifier);
await testExceededLimit(config, identifier);
test("should properly configure syncUserIdentification rate limit", async () => {
const config = rateLimitConfigs.api.syncUserIdentification;
// Verify configuration values
expect(config.interval).toBe(60); // 1 minute
expect(config.allowedPerInterval).toBe(5); // 5 requests per minute
expect(config.namespace).toBe("api:sync-user-identification");
// Test with allowed request
mockEval.mockResolvedValue([1, 1]); // 1 request used, allowed (1 = true)
const allowedResult = await checkRateLimit(config, "env-user-123");
expect(allowedResult.ok).toBe(true);
if (allowedResult.ok) {
expect(allowedResult.data.allowed).toBe(true);
}
// Test when limit is exceeded
mockEval.mockResolvedValue([6, 0]); // 6 requests used (exceeds limit of 5), not allowed (0 = false)
const exceededResult = await checkRateLimit(config, "env-user-123");
expect(exceededResult.ok).toBe(true);
if (exceededResult.ok) {
expect(exceededResult.data.allowed).toBe(false);
}
});
});

View File

@@ -9,9 +9,14 @@ export const rateLimitConfigs = {
// API endpoints - higher limits for legitimate usage
api: {
v1: { interval: 60, allowedPerInterval: 100, namespace: "api:v1" }, // 100 per minute
v1: { interval: 60, allowedPerInterval: 100, namespace: "api:v1" }, // 100 per minute (Management API)
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" }, // 100 per minute
client: { interval: 60, allowedPerInterval: 100, namespace: "api:client" }, // 100 per minute
client: { interval: 60, allowedPerInterval: 100, namespace: "api:client" }, // 100 per minute (Client API)
syncUserIdentification: {
interval: 60,
allowedPerInterval: 5,
namespace: "api:sync-user-identification",
}, // 5 per minute per environment-user pair
},
// Server actions - varies by action type

View File

@@ -1,5 +1,6 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { NextRequest } from "next/server";
@@ -8,97 +9,129 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZJsContactsUpdateAttributeInput } from "@formbricks/types/js";
import { getContactByUserIdWithAttributes } from "./lib/contact";
const validateParams = (
environmentId: string,
userId: string
): { isValid: true } | { isValid: false; error: Response } => {
if (!environmentId) {
return {
isValid: false,
error: responses.badRequestResponse("environmentId is required", { environmentId }, true),
};
}
if (!userId) {
return { isValid: false, error: responses.badRequestResponse("userId is required", { userId }, true) };
}
return { isValid: true };
};
const checkIfAttributesNeedUpdate = (contact: any, updatedAttributes: Record<string, string>) => {
const oldAttributes = new Map(contact.attributes.map((attr: any) => [attr.attributeKey.key, attr.value]));
for (const [key, value] of Object.entries(updatedAttributes)) {
if (value !== oldAttributes.get(key)) {
return false; // needs update
}
}
return true; // up to date
};
export const OPTIONS = async () => {
// cors headers
return responses.successResponse({}, true);
};
export const PUT = async (
req: NextRequest,
context: { params: Promise<{ environmentId: string; userId: string }> }
) => {
try {
const params = await context.params;
const environmentId = params.environmentId;
if (!environmentId) {
return responses.badRequestResponse("environmentId is required", { environmentId }, true);
}
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ environmentId: string; userId: string }> };
}) => {
try {
const params = await props.params;
const { environmentId, userId } = params;
const userId = params.userId;
if (!userId) {
return responses.badRequestResponse("userId is required", { userId }, true);
}
const jsonInput = await req.json();
const parsedInput = ZJsContactsUpdateAttributeInput.safeParse(jsonInput);
if (!parsedInput.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(parsedInput.error),
true
);
}
// check for ee license:
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
// ignore userId and id
const { userId: userIdAttr, id: idAttr, ...updatedAttributes } = parsedInput.data.attributes;
const contact = await getContactByUserIdWithAttributes(environmentId, userId, updatedAttributes);
if (!contact) {
return responses.notFoundResponse("contact", userId, true);
}
const oldAttributes = new Map(contact.attributes.map((attr) => [attr.attributeKey.key, attr.value]));
let isUpToDate = true;
for (const [key, value] of Object.entries(updatedAttributes)) {
if (value !== oldAttributes.get(key)) {
isUpToDate = false;
break;
// Validate required parameters
const paramValidation = validateParams(environmentId, userId);
if (!paramValidation.isValid) {
return { response: paramValidation.error };
}
// Parse and validate input
const jsonInput = await req.json();
const parsedInput = ZJsContactsUpdateAttributeInput.safeParse(jsonInput);
if (!parsedInput.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(parsedInput.error),
true
),
};
}
// Check enterprise license
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"User identification is only available for enterprise users.",
true
),
};
}
// Process attributes (ignore userId and id)
const { userId: userIdAttr, id: idAttr, ...updatedAttributes } = parsedInput.data.attributes;
const contact = await getContactByUserIdWithAttributes(environmentId, userId, updatedAttributes);
if (!contact) {
return { response: responses.notFoundResponse("contact", userId, true) };
}
// Check if update is needed
const isUpToDate = checkIfAttributesNeedUpdate(contact, updatedAttributes);
if (isUpToDate) {
return {
response: responses.successResponse(
{ changed: false, message: "No updates were necessary; the person is already up to date." },
true
),
};
}
// Perform update
const { messages } = await updateAttributes(contact.id, userId, environmentId, updatedAttributes);
return {
response: responses.successResponse(
{
changed: true,
message: "The person was successfully updated.",
...(messages && messages.length > 0 ? { messages } : {}),
},
true
),
};
} catch (err) {
logger.error({ err, url: req.url }, "Error updating attributes");
if (err.statusCode === 403) {
return {
response: responses.forbiddenResponse(err.message || "Forbidden", true, { ignore: true }),
};
}
if (err instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse(err.resourceType, err.resourceId, true),
};
}
return {
response: responses.internalServerErrorResponse("Something went wrong", true),
};
}
if (isUpToDate) {
return responses.successResponse(
{
changed: false,
message: "No updates were necessary; the person is already up to date.",
},
true
);
}
const { messages } = await updateAttributes(contact.id, userId, environmentId, updatedAttributes);
return responses.successResponse(
{
changed: true,
message: "The person was successfully updated.",
...(messages && messages.length > 0
? {
messages,
}
: {}),
},
true
);
} catch (err) {
logger.error({ err, url: req.url }, "Error updating attributes");
if (err.statusCode === 403) {
return responses.forbiddenResponse(err.message || "Forbidden", true, { ignore: true });
}
if (err instanceof ResourceNotFoundError) {
return responses.notFoundResponse(err.resourceType, err.resourceId, true);
}
return responses.internalServerErrorResponse("Something went wrong", true);
}
};
},
});

View File

@@ -1,5 +1,6 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { NextRequest, userAgent } from "next/server";
import { logger } from "@formbricks/logger";
@@ -11,54 +12,80 @@ export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = async (
request: NextRequest,
props: { params: Promise<{ environmentId: string; userId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const { environmentId, userId } = params;
// Validate input
const syncInputValidation = ZJsUserIdentifyInput.safeParse({
environmentId,
userId,
});
if (!syncInputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(syncInputValidation.error),
true
);
}
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
const { device } = userAgent(request);
const deviceType = device ? "phone" : "desktop";
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ environmentId: string; userId: string }> };
}) => {
const params = await props.params;
try {
const personState = await getPersonState({
const { environmentId, userId } = params;
// Validate input
const syncInputValidation = ZJsUserIdentifyInput.safeParse({
environmentId,
userId,
device: deviceType,
});
return responses.successResponse(personState.state, true);
} catch (err) {
if (err instanceof ResourceNotFoundError) {
return responses.notFoundResponse(err.resourceType, err.resourceId);
if (!syncInputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(syncInputValidation.error),
true
),
};
}
logger.error({ err, url: request.url }, "Error fetching person state");
return responses.internalServerErrorResponse(err.message ?? "Unable to fetch person state", true);
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"User identification is only available for enterprise users.",
true
),
};
}
const { device } = userAgent(req);
const deviceType = device ? "phone" : "desktop";
try {
const personState = await getPersonState({
environmentId,
userId,
device: deviceType,
});
return {
response: responses.successResponse(personState.state, true),
};
} catch (err) {
if (err instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse(err.resourceType, err.resourceId),
};
}
logger.error({ err, url: req.url }, "Error fetching person state");
return {
response: responses.internalServerErrorResponse(
err.message ?? "Unable to fetch person state",
true
),
};
}
} catch (error) {
logger.error({ error, url: req.url }, "Error fetching person state");
return {
response: responses.internalServerErrorResponse(
`Unable to complete response: ${error.message}`,
true
),
};
}
} catch (error) {
logger.error({ error, url: request.url }, "Error fetching person state");
return responses.internalServerErrorResponse(`Unable to complete response: ${error.message}`, true);
}
};
},
});

View File

@@ -1,4 +1,5 @@
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { NextRequest, userAgent } from "next/server";
import { logger } from "@formbricks/logger";
@@ -17,77 +18,99 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
export const POST = async (
request: NextRequest,
props: { params: Promise<{ environmentId: string }> }
): Promise<Response> => {
const params = await props.params;
export const POST = withV1ApiWrapper({
handler: async ({
req,
props,
}: {
req: NextRequest;
props: { params: Promise<{ environmentId: string }> };
}) => {
const params = await props.params;
try {
const { environmentId } = params;
try {
const { environmentId } = params;
// Simple validation (faster than Zod for high-frequency endpoint)
if (!environmentId || typeof environmentId !== "string") {
return responses.badRequestResponse("Environment ID is required", undefined, true);
}
const jsonInput = await request.json();
// Basic input validation without Zod overhead
if (
!jsonInput ||
typeof jsonInput !== "object" ||
!jsonInput.userId ||
typeof jsonInput.userId !== "string"
) {
return responses.badRequestResponse("userId is required and must be a string", undefined, true);
}
// Simple email validation if present (avoid Zod)
if (jsonInput.attributes?.email) {
const email = jsonInput.attributes.email;
if (typeof email !== "string" || !email.includes("@") || email.length < 3) {
return responses.badRequestResponse("Invalid email format", undefined, true);
// Simple validation (faster than Zod for high-frequency endpoint)
if (!environmentId || typeof environmentId !== "string") {
return {
response: responses.badRequestResponse("Environment ID is required", undefined, true),
};
}
const jsonInput = await req.json();
// Basic input validation without Zod overhead
if (
!jsonInput ||
typeof jsonInput !== "object" ||
!jsonInput.userId ||
typeof jsonInput.userId !== "string"
) {
return {
response: responses.badRequestResponse("userId is required and must be a string", undefined, true),
};
}
// Simple email validation if present (avoid Zod)
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),
};
}
}
const { userId, attributes } = jsonInput;
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"User identification is only available for enterprise users.",
true
),
};
}
let attributeUpdatesToSend: TContactAttributes | null = null;
if (attributes) {
// remove userId and id from attributes
const { userId: userIdAttr, id: idAttr, ...updatedAttributes } = attributes;
attributeUpdatesToSend = updatedAttributes;
}
const { device } = userAgent(req);
const deviceType = device ? "phone" : "desktop";
const { state: userState, messages } = await updateUser(
environmentId,
userId,
deviceType,
attributeUpdatesToSend ?? undefined
);
// Build response (simplified structure)
const responseJson: { state: TJsPersonState; messages?: string[] } = {
state: userState,
...(messages && messages.length > 0 && { messages }),
};
return {
response: responses.successResponse(responseJson, true),
};
} catch (err) {
if (err instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse(err.resourceType, err.resourceId),
};
}
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/user");
return {
response: responses.internalServerErrorResponse(err.message ?? "Unable to fetch person state", true),
};
}
const { userId, attributes } = jsonInput;
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
let attributeUpdatesToSend: TContactAttributes | null = null;
if (attributes) {
// remove userId and id from attributes
const { userId: userIdAttr, id: idAttr, ...updatedAttributes } = attributes;
attributeUpdatesToSend = updatedAttributes;
}
const { device } = userAgent(request);
const deviceType = device ? "phone" : "desktop";
const { state: userState, messages } = await updateUser(
environmentId,
userId,
deviceType,
attributeUpdatesToSend ?? undefined
);
// Build response (simplified structure)
const responseJson: { state: TJsPersonState; messages?: string[] } = {
state: userState,
...(messages && messages.length > 0 && { messages }),
};
return responses.successResponse(responseJson, true);
} catch (err) {
if (err instanceof ResourceNotFoundError) {
return responses.notFoundResponse(err.resourceType, err.resourceId);
}
logger.error({ error: err, url: request.url }, "Error in POST /api/v1/client/[environmentId]/user");
return responses.internalServerErrorResponse(err.message ?? "Unable to fetch person state", true);
}
};
},
});

View File

@@ -1,10 +1,10 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import {
deleteContactAttributeKey,
getContactAttributeKey,
@@ -14,7 +14,7 @@ import { ZContactAttributeKeyUpdateInput } from "./types/contact-attribute-keys"
async function fetchAndAuthorizeContactAttributeKey(
attributeKeyId: string,
authentication: TAuthenticationApiKey,
environmentPermissions: NonNullable<TApiKeyAuthentication>["environmentPermissions"],
requiredPermission: "GET" | "PUT" | "DELETE"
) {
const attributeKey = await getContactAttributeKey(attributeKeyId);
@@ -22,60 +22,69 @@ async function fetchAndAuthorizeContactAttributeKey(
return { error: responses.notFoundResponse("Attribute Key", attributeKeyId) };
}
if (!hasPermission(authentication.environmentPermissions, attributeKey.environmentId, requiredPermission)) {
if (!hasPermission(environmentPermissions, attributeKey.environmentId, requiredPermission)) {
return { error: responses.unauthorizedResponse() };
}
return { attributeKey };
}
export const GET = async (
request: Request,
{ params: paramsPromise }: { params: Promise<{ contactAttributeKeyId: string }> }
): Promise<Response> => {
try {
const params = await paramsPromise;
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeContactAttributeKey(
params.contactAttributeKeyId,
authentication,
"GET"
);
if (result.error) return result.error;
return responses.successResponse(result.attributeKey);
} catch (error) {
if (
error instanceof Error &&
error.message === "Contacts are only enabled for Enterprise Edition, please upgrade."
) {
return responses.forbiddenResponse(error.message);
}
return handleErrorResponse(error);
}
};
export const DELETE = withApiLogging(
async (
request: Request,
{ params: paramsPromise }: { params: Promise<{ contactAttributeKeyId: string }> },
auditLog: ApiAuditLog
) => {
const params = await paramsPromise;
auditLog.targetId = params.contactAttributeKeyId;
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ contactAttributeKeyId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const params = await props.params;
const result = await fetchAndAuthorizeContactAttributeKey(
params.contactAttributeKeyId,
authentication,
authentication.environmentPermissions,
"GET"
);
if (result.error) {
return {
response: result.error,
};
}
return {
response: responses.successResponse(result.attributeKey),
};
} catch (error) {
if (
error instanceof Error &&
error.message === "Contacts are only enabled for Enterprise Edition, please upgrade."
) {
return {
response: responses.forbiddenResponse(error.message),
};
}
return {
response: handleErrorResponse(error),
};
}
},
});
export const DELETE = withV1ApiWrapper({
handler: async ({
props,
auditLog,
authentication,
}: {
props: { params: Promise<{ contactAttributeKeyId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
auditLog.targetId = params.contactAttributeKeyId;
try {
const result = await fetchAndAuthorizeContactAttributeKey(
params.contactAttributeKeyId,
authentication.environmentPermissions,
"DELETE"
);
@@ -85,7 +94,6 @@ export const DELETE = withApiLogging(
};
}
auditLog.oldObject = result.attributeKey;
auditLog.organizationId = authentication.organizationId;
if (result.attributeKey.type === "default") {
return {
response: responses.badRequestResponse("Default Contact Attribute Keys cannot be deleted"),
@@ -101,30 +109,28 @@ export const DELETE = withApiLogging(
};
}
},
"deleted",
"contactAttributeKey"
);
action: "deleted",
targetType: "contactAttributeKey",
});
export const PUT = withApiLogging(
async (
request: Request,
{ params: paramsPromise }: { params: Promise<{ contactAttributeKeyId: string }> },
auditLog: ApiAuditLog
) => {
const params = await paramsPromise;
export const PUT = withV1ApiWrapper({
handler: async ({
req,
props,
auditLog,
authentication,
}: {
req: NextRequest;
props: { params: Promise<{ contactAttributeKeyId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
auditLog.targetId = params.contactAttributeKeyId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const result = await fetchAndAuthorizeContactAttributeKey(
params.contactAttributeKeyId,
authentication,
authentication.environmentPermissions,
"PUT"
);
if (result.error) {
@@ -133,13 +139,12 @@ export const PUT = withApiLogging(
};
}
auditLog.oldObject = result.attributeKey;
auditLog.organizationId = authentication.organizationId;
let contactAttributeKeyUpdate;
try {
contactAttributeKeyUpdate = await request.json();
contactAttributeKeyUpdate = await req.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
@@ -175,6 +180,6 @@ export const PUT = withApiLogging(
};
}
},
"updated",
"contactAttributeKey"
);
action: "updated",
targetType: "contactAttributeKey",
});

View File

@@ -1,50 +1,17 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { ZContactAttributeKeyCreateInput } from "./[contactAttributeKeyId]/types/contact-attribute-keys";
import { createContactAttributeKey, getContactAttributeKeys } from "./lib/contact-attribute-keys";
export const GET = async (request: Request) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("Contacts are only enabled for Enterprise Edition, please upgrade.");
}
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const contactAttributeKeys = await getContactAttributeKeys(environmentIds);
return responses.successResponse(contactAttributeKeys);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
export const GET = withV1ApiWrapper({
handler: async ({ authentication }: { authentication: NonNullable<TApiKeyAuthentication> }) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return {
@@ -54,17 +21,57 @@ export const POST = withApiLogging(
};
}
let contactAttibuteKeyInput;
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const contactAttributeKeys = await getContactAttributeKeys(environmentIds);
return {
response: responses.successResponse(contactAttributeKeys),
};
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
throw error;
}
},
});
export const POST = withV1ApiWrapper({
handler: async ({
req,
auditLog,
authentication,
}: {
req: NextRequest;
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
try {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"Contacts are only enabled for Enterprise Edition, please upgrade."
),
};
}
let contactAttributeKeyInput;
try {
contactAttibuteKeyInput = await request.json();
contactAttributeKeyInput = await req.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZContactAttributeKeyCreateInput.safeParse(contactAttibuteKeyInput);
const inputValidation = ZContactAttributeKeyCreateInput.safeParse(contactAttributeKeyInput);
if (!inputValidation.success) {
return {
@@ -76,7 +83,6 @@ export const POST = withApiLogging(
};
}
const environmentId = inputValidation.data.environmentId;
auditLog.organizationId = authentication.organizationId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
@@ -105,6 +111,6 @@ export const POST = withApiLogging(
throw error;
}
},
"created",
"contactAttributeKey"
);
action: "created",
targetType: "contactAttributeKey",
});

View File

@@ -1,29 +1,36 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { DatabaseError } from "@formbricks/types/errors";
import { getContactAttributes } from "./lib/contact-attributes";
export const GET = async (request: Request) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
export const GET = withV1ApiWrapper({
handler: async ({ authentication }: { authentication: NonNullable<TApiKeyAuthentication> }) => {
try {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"Contacts are only enabled for Enterprise Edition, please upgrade."
),
};
}
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("Contacts are only enabled for Enterprise Edition, please upgrade.");
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const attributes = await getContactAttributes(environmentIds);
return {
response: responses.successResponse(attributes),
};
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
throw error;
}
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const attributes = await getContactAttributes(environmentIds);
return responses.successResponse(attributes);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
},
});

View File

@@ -1,16 +1,15 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { deleteContact, getContact } from "./lib/contact";
// Please use the methods provided by the client API to update a person
const fetchAndAuthorizeContact = async (
contactId: string,
authentication: TAuthenticationApiKey,
environmentPermissions: NonNullable<TApiKeyAuthentication>["environmentPermissions"],
requiredPermission: "GET" | "PUT" | "DELETE"
) => {
const contact = await getContact(contactId);
@@ -19,54 +18,23 @@ const fetchAndAuthorizeContact = async (
return { error: responses.notFoundResponse("Contact", contactId) };
}
if (!hasPermission(authentication.environmentPermissions, contact.environmentId, requiredPermission)) {
if (!hasPermission(environmentPermissions, contact.environmentId, requiredPermission)) {
return { error: responses.unauthorizedResponse() };
}
return { contact };
};
export const GET = async (
request: Request,
{ params: paramsPromise }: { params: Promise<{ contactId: string }> }
): Promise<Response> => {
try {
const params = await paramsPromise;
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("Contacts are only enabled for Enterprise Edition, please upgrade.");
}
const result = await fetchAndAuthorizeContact(params.contactId, authentication, "GET");
if (result.error) return result.error;
return responses.successResponse(result.contact);
} catch (error) {
return handleErrorResponse(error);
}
};
export const DELETE = withApiLogging(
async (
request: Request,
{ params: paramsPromise }: { params: Promise<{ contactId: string }> },
auditLog: ApiAuditLog
) => {
const params = await paramsPromise;
auditLog.targetId = params.contactId;
export const GET = withV1ApiWrapper({
handler: async ({
props,
authentication,
}: {
props: { params: Promise<{ contactId: string }> };
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const params = await props.params;
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
@@ -77,7 +45,56 @@ export const DELETE = withApiLogging(
};
}
const result = await fetchAndAuthorizeContact(params.contactId, authentication, "DELETE");
const result = await fetchAndAuthorizeContact(
params.contactId,
authentication.environmentPermissions,
"GET"
);
if (result.error) {
return {
response: result.error,
};
}
return {
response: responses.successResponse(result.contact),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
},
});
export const DELETE = withV1ApiWrapper({
handler: async ({
props,
auditLog,
authentication,
}: {
props: { params: Promise<{ contactId: string }> };
auditLog: TApiAuditLog;
authentication: NonNullable<TApiKeyAuthentication>;
}) => {
const params = await props.params;
auditLog.targetId = params.contactId;
try {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"Contacts are only enabled for Enterprise Edition, please upgrade."
),
};
}
const result = await fetchAndAuthorizeContact(
params.contactId,
authentication.environmentPermissions,
"DELETE"
);
if (result.error) {
return {
response: result.error,
@@ -95,6 +112,6 @@ export const DELETE = withApiLogging(
};
}
},
"deleted",
"contact"
);
action: "deleted",
targetType: "contact",
});

View File

@@ -1,32 +1,39 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { DatabaseError } from "@formbricks/types/errors";
import { getContacts } from "./lib/contacts";
export const GET = async (request: Request) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
export const GET = withV1ApiWrapper({
handler: async ({ authentication }: { authentication: NonNullable<TApiKeyAuthentication> }) => {
try {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return {
response: responses.forbiddenResponse(
"Contacts are only enabled for Enterprise Edition, please upgrade."
),
};
}
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return responses.forbiddenResponse("Contacts are only enabled for Enterprise Edition, please upgrade.");
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const contacts = await getContacts(environmentIds);
return {
response: responses.successResponse(contacts),
};
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
throw error;
}
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const contacts = await getContacts(environmentIds);
return responses.successResponse(contacts);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
},
});
// Please use the client API to create a new contact