mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-21 10:08:34 -06:00
feat: Add rate limiting to API V1 (#6355)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
committed by
GitHub
parent
9d84bc0c8d
commit
43628caa3b
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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."),
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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() }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}` }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}` }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user