mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 09:50:10 -06:00
382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
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";
|
|
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 { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
|
|
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
|
import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
|
|
|
export type TApiAuditLog = Parameters<typeof queueAuditEvent>[0];
|
|
export type TApiV1Authentication = TAuthenticationApiKey | Session | null;
|
|
export type TApiKeyAuthentication = TAuthenticationApiKey | null;
|
|
export type TSessionAuthentication = Session | null;
|
|
|
|
// Helper type to properly narrow NonNullable<TApiKeyAuthentication> to TAuthenticationApiKey
|
|
// This ensures TypeScript properly infers nested properties like environmentPermissions
|
|
export type TNonNullableApiKeyAuthentication = NonNullable<TApiKeyAuthentication> & TAuthenticationApiKey;
|
|
|
|
// 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;
|
|
customRateLimitConfig?: TRateLimitConfig;
|
|
}
|
|
|
|
enum ApiV1RouteTypeEnum {
|
|
Client = "client",
|
|
General = "general",
|
|
Integration = "integration",
|
|
}
|
|
|
|
/**
|
|
* Apply client-side API rate limiting (IP-based or sync-specific)
|
|
*/
|
|
const applyClientRateLimit = async (url: string, customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
|
|
const syncEndpoint = isSyncWithUserIdentificationEndpoint(url);
|
|
if (syncEndpoint) {
|
|
const syncRateLimitConfig = rateLimitConfigs.api.syncUserIdentification;
|
|
await applyRateLimit(syncRateLimitConfig, syncEndpoint.userId);
|
|
} else {
|
|
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle rate limiting based on authentication and API type
|
|
*/
|
|
const handleRateLimiting = async (
|
|
url: string,
|
|
authentication: TApiV1Authentication,
|
|
routeType: ApiV1RouteTypeEnum,
|
|
customRateLimitConfig?: TRateLimitConfig
|
|
): Promise<Response | null> => {
|
|
try {
|
|
if (authentication) {
|
|
if ("user" in authentication) {
|
|
// Session-based authentication for integration routes
|
|
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.user.id);
|
|
} else if ("apiKeyId" in authentication) {
|
|
// API key authentication for general routes
|
|
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.apiKeyId);
|
|
} else {
|
|
logger.error({ authentication }, "Unknown authentication type");
|
|
return responses.internalServerErrorResponse("Invalid authentication configuration");
|
|
}
|
|
}
|
|
|
|
if (routeType === ApiV1RouteTypeEnum.Client) {
|
|
await applyClientRateLimit(url, customRateLimitConfig);
|
|
}
|
|
} catch (error) {
|
|
return responses.tooManyRequestsResponse(error.message);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* 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 };
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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) {
|
|
// Set correlation ID as a tag for easy filtering
|
|
Sentry.withScope((scope) => {
|
|
scope.setTag("correlationId", correlationId);
|
|
scope.setLevel("error");
|
|
|
|
// If we have an actual error, capture it with full stacktrace
|
|
// Otherwise, create a generic error with context
|
|
if (error instanceof Error) {
|
|
Sentry.captureException(error);
|
|
} else {
|
|
scope.setExtra("originalError", error);
|
|
const genericError = new Error(`API V1 error, id: ${correlationId}`);
|
|
Sentry.captureException(genericError);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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: {
|
|
// More specific overload for TAuthenticationApiKey (non-null) - must come first for proper type inference
|
|
<TResult extends { response: Response }, TProps = unknown>(
|
|
params: TWithV1ApiWrapperParams<TResult, TProps> & {
|
|
handler: (
|
|
params: THandlerParams<TProps> & { authentication: TAuthenticationApiKey }
|
|
) => Promise<TResult>;
|
|
}
|
|
): (req: NextRequest, props: TProps) => Promise<Response>;
|
|
|
|
<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, customRateLimitConfig } = 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.");
|
|
}
|
|
|
|
// === 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,
|
|
customRateLimitConfig
|
|
);
|
|
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;
|
|
};
|
|
};
|
|
|
|
export const buildAuditLogBaseObject = (
|
|
action: TAuditAction,
|
|
targetType: TAuditTarget,
|
|
apiUrl: string
|
|
): TApiAuditLog => {
|
|
return {
|
|
action,
|
|
targetType,
|
|
userId: UNKNOWN_DATA,
|
|
targetId: UNKNOWN_DATA,
|
|
organizationId: UNKNOWN_DATA,
|
|
status: "failure",
|
|
oldObject: undefined,
|
|
newObject: undefined,
|
|
userType: "api",
|
|
apiUrl,
|
|
};
|
|
};
|