first commit
This commit is contained in:
24
calcom/apps/api/v1/lib/helpers/addRequestid.ts
Normal file
24
calcom/apps/api/v1/lib/helpers/addRequestid.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
export const addRequestId: NextMiddleware = async (_req, res, next) => {
|
||||
// Apply header with unique ID to every request
|
||||
res.setHeader("Calcom-Response-ID", nanoid());
|
||||
// Add all headers here instead of next.config.js as it is throwing error( Cannot set headers after they are sent to the client) for OPTIONS method
|
||||
// It is known to happen only in Dev Mode.
|
||||
res.setHeader("Access-Control-Allow-Credentials", "true");
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS, PATCH, DELETE, POST, PUT");
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Content-Type, api_key, Authorization"
|
||||
);
|
||||
|
||||
// Ensure all OPTIONS request are automatically successful. Headers are already set above.
|
||||
if (_req.method === "OPTIONS") {
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
// Let remaining middleware and API route execute
|
||||
await next();
|
||||
};
|
||||
20
calcom/apps/api/v1/lib/helpers/captureErrors.ts
Normal file
20
calcom/apps/api/v1/lib/helpers/captureErrors.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { captureException as SentryCaptureException } from "@sentry/nextjs";
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { redactError } from "@calcom/lib/redactError";
|
||||
|
||||
export const captureErrors: NextMiddleware = async (_req, res, next) => {
|
||||
try {
|
||||
// Catch any errors that are thrown in remaining
|
||||
// middleware and the API route handler
|
||||
await next();
|
||||
} catch (error) {
|
||||
SentryCaptureException(error);
|
||||
const redactedError = redactError(error);
|
||||
if (redactedError instanceof Error) {
|
||||
res.status(400).json({ message: redactedError.message, error: redactedError });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ message: "Something went wrong", error });
|
||||
}
|
||||
};
|
||||
23
calcom/apps/api/v1/lib/helpers/checkIsInMaintenanceMode.ts
Normal file
23
calcom/apps/api/v1/lib/helpers/checkIsInMaintenanceMode.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { get } from "@vercel/edge-config";
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
const safeGet = async <T = unknown>(key: string): Promise<T | undefined> => {
|
||||
try {
|
||||
return get<T>(key);
|
||||
} catch (error) {
|
||||
// Don't crash if EDGE_CONFIG env var is missing
|
||||
}
|
||||
};
|
||||
|
||||
export const config = { matcher: "/:path*" };
|
||||
|
||||
export const checkIsInMaintenanceMode: NextMiddleware = async (req, res, next) => {
|
||||
const isInMaintenanceMode = await safeGet<boolean>("isInMaintenanceMode");
|
||||
if (isInMaintenanceMode) {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ message: "API is currently under maintenance. Please try again at a later time." });
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
9
calcom/apps/api/v1/lib/helpers/extendRequest.ts
Normal file
9
calcom/apps/api/v1/lib/helpers/extendRequest.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
export const extendRequest: NextMiddleware = async (req, res, next) => {
|
||||
req.pagination = {
|
||||
take: 100,
|
||||
skip: 0,
|
||||
};
|
||||
await next();
|
||||
};
|
||||
32
calcom/apps/api/v1/lib/helpers/httpMethods.ts
Normal file
32
calcom/apps/api/v1/lib/helpers/httpMethods.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
export const httpMethod = (allowedHttpMethod: "GET" | "POST" | "PATCH" | "DELETE"): NextMiddleware => {
|
||||
return async function (req, res, next) {
|
||||
if (req.method === allowedHttpMethod || req.method == "OPTIONS") {
|
||||
await next();
|
||||
} else {
|
||||
res.status(405).json({ message: `Only ${allowedHttpMethod} Method allowed` });
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
};
|
||||
// Made this so we can support several HTTP Methods in one route and use it there.
|
||||
// Could be further extracted into a third function or refactored into one.
|
||||
// that checks if it's just a string or an array and apply the correct logic to both cases.
|
||||
export const httpMethods = (allowedHttpMethod: string[]): NextMiddleware => {
|
||||
return async function (req, res, next) {
|
||||
if (allowedHttpMethod.some((method) => method === req.method || req.method == "OPTIONS")) {
|
||||
await next();
|
||||
} else {
|
||||
res.status(405).json({ message: `Only ${allowedHttpMethod} Method allowed` });
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const HTTP_POST = httpMethod("POST");
|
||||
export const HTTP_GET = httpMethod("GET");
|
||||
export const HTTP_PATCH = httpMethod("PATCH");
|
||||
export const HTTP_DELETE = httpMethod("DELETE");
|
||||
export const HTTP_GET_DELETE_PATCH = httpMethods(["GET", "DELETE", "PATCH"]);
|
||||
export const HTTP_GET_OR_POST = httpMethods(["GET", "POST"]);
|
||||
90
calcom/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts
Normal file
90
calcom/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiResponse, NextApiRequest } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
|
||||
import { rateLimitApiKey } from "~/lib/helpers/rateLimitApiKey";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
|
||||
vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
|
||||
checkRateLimitAndThrowError: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("rateLimitApiKey middleware", () => {
|
||||
it("should return 401 if no apiKey is provided", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "GET",
|
||||
query: {},
|
||||
});
|
||||
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(res._getStatusCode()).toBe(401);
|
||||
expect(res._getJSONData()).toEqual({ message: "No apiKey provided" });
|
||||
});
|
||||
|
||||
it("should call checkRateLimitAndThrowError with correct parameters", async () => {
|
||||
const { req, res } = createMocks({
|
||||
method: "GET",
|
||||
query: { apiKey: "test-key" },
|
||||
});
|
||||
|
||||
(checkRateLimitAndThrowError as any).mockResolvedValueOnce({
|
||||
limit: 100,
|
||||
remaining: 99,
|
||||
reset: Date.now(),
|
||||
});
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
|
||||
identifier: "test-key",
|
||||
rateLimitingType: "api",
|
||||
onRateLimiterResponse: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("should set rate limit headers correctly", async () => {
|
||||
const { req, res } = createMocks({
|
||||
method: "GET",
|
||||
query: { apiKey: "test-key" },
|
||||
});
|
||||
|
||||
const rateLimiterResponse = {
|
||||
limit: 100,
|
||||
remaining: 99,
|
||||
reset: Date.now(),
|
||||
};
|
||||
|
||||
(checkRateLimitAndThrowError as any).mockImplementationOnce(({ onRateLimiterResponse }) => {
|
||||
onRateLimiterResponse(rateLimiterResponse);
|
||||
});
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(res.getHeader("X-RateLimit-Limit")).toBe(rateLimiterResponse.limit);
|
||||
expect(res.getHeader("X-RateLimit-Remaining")).toBe(rateLimiterResponse.remaining);
|
||||
expect(res.getHeader("X-RateLimit-Reset")).toBe(rateLimiterResponse.reset);
|
||||
});
|
||||
|
||||
it("should return 429 if rate limit is exceeded", async () => {
|
||||
const { req, res } = createMocks({
|
||||
method: "GET",
|
||||
query: { apiKey: "test-key" },
|
||||
});
|
||||
|
||||
(checkRateLimitAndThrowError as any).mockRejectedValue(new Error("Rate limit exceeded"));
|
||||
|
||||
// @ts-expect-error weird typing between middleware and createMocks
|
||||
await rateLimitApiKey(req, res, vi.fn() as any);
|
||||
|
||||
expect(res._getStatusCode()).toBe(429);
|
||||
expect(res._getJSONData()).toEqual({ message: "Rate limit exceeded" });
|
||||
});
|
||||
});
|
||||
24
calcom/apps/api/v1/lib/helpers/rateLimitApiKey.ts
Normal file
24
calcom/apps/api/v1/lib/helpers/rateLimitApiKey.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
|
||||
export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
|
||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||
|
||||
// TODO: Add a way to add trusted api keys
|
||||
try {
|
||||
await checkRateLimitAndThrowError({
|
||||
identifier: req.query.apiKey as string,
|
||||
rateLimitingType: "api",
|
||||
onRateLimiterResponse: (response) => {
|
||||
res.setHeader("X-RateLimit-Limit", response.limit);
|
||||
res.setHeader("X-RateLimit-Remaining", response.remaining);
|
||||
res.setHeader("X-RateLimit-Reset", response.reset);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(429).json({ message: "Rate limit exceeded" });
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
14
calcom/apps/api/v1/lib/helpers/safeParseJSON.ts
Normal file
14
calcom/apps/api/v1/lib/helpers/safeParseJSON.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function parseJSONSafely(str: string) {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (e) {
|
||||
console.error((e as Error).message);
|
||||
if ((e as Error).message.includes("Unexpected token")) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid JSON in the body: ${(e as Error).message}`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
46
calcom/apps/api/v1/lib/helpers/verifyApiKey.ts
Normal file
46
calcom/apps/api/v1/lib/helpers/verifyApiKey.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { isAdminGuard } from "../utils/isAdmin";
|
||||
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
|
||||
|
||||
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
|
||||
export const dateNotInPast = function (date: Date) {
|
||||
const now = new Date();
|
||||
if (now.setHours(0, 0, 0, 0) > date.setHours(0, 0, 0, 0)) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// This verifies the apiKey and sets the user if it is valid.
|
||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
const hasValidLicense = await checkLicense(prisma);
|
||||
if (!hasValidLicense && IS_PRODUCTION)
|
||||
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||
// Check if the apiKey query param is provided.
|
||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||
// remove the prefix from the user provided api_key. If no env set default to "cal_"
|
||||
const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", "");
|
||||
// Hash the key again before matching against the database records.
|
||||
const hashedKey = hashAPIKey(strippedApiKey);
|
||||
// Check if the hashed api key exists in database.
|
||||
const apiKey = await prisma.apiKey.findUnique({ where: { hashedKey } });
|
||||
// If cannot find any api key. Throw a 401 Unauthorized.
|
||||
if (!apiKey) return res.status(401).json({ error: "Your apiKey is not valid" });
|
||||
if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) {
|
||||
return res.status(401).json({ error: "This apiKey is expired" });
|
||||
}
|
||||
if (!apiKey.userId) return res.status(404).json({ error: "No user found for this apiKey" });
|
||||
// save the user id in the request for later use
|
||||
req.userId = apiKey.userId;
|
||||
const { isAdmin, scope } = await isAdminGuard(req);
|
||||
|
||||
req.isSystemWideAdmin = isAdmin && scope === ScopeOfAdmin.SystemWide;
|
||||
req.isOrganizationOwnerOrAdmin = isAdmin && scope === ScopeOfAdmin.OrgOwnerOrAdmin;
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
export const verifyCredentialSyncEnabled: NextMiddleware = async (req, res, next) => {
|
||||
const { isSystemWideAdmin } = req;
|
||||
|
||||
if (!isSystemWideAdmin) {
|
||||
return res.status(403).json({ error: "Only admin API keys can access credential syncing endpoints" });
|
||||
}
|
||||
|
||||
if (!APP_CREDENTIAL_SHARING_ENABLED) {
|
||||
return res.status(501).json({ error: "Credential syncing is not enabled" });
|
||||
}
|
||||
|
||||
if (
|
||||
req.headers[process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME || "calcom-credential-sync-secret"] !==
|
||||
process.env.CALCOM_CREDENTIAL_SYNC_SECRET
|
||||
) {
|
||||
return res.status(401).json({ message: "Invalid credential sync secret" });
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
51
calcom/apps/api/v1/lib/helpers/withMiddleware.ts
Normal file
51
calcom/apps/api/v1/lib/helpers/withMiddleware.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { label } from "next-api-middleware";
|
||||
|
||||
import { addRequestId } from "./addRequestid";
|
||||
import { captureErrors } from "./captureErrors";
|
||||
import { checkIsInMaintenanceMode } from "./checkIsInMaintenanceMode";
|
||||
import { extendRequest } from "./extendRequest";
|
||||
import {
|
||||
HTTP_POST,
|
||||
HTTP_DELETE,
|
||||
HTTP_PATCH,
|
||||
HTTP_GET,
|
||||
HTTP_GET_OR_POST,
|
||||
HTTP_GET_DELETE_PATCH,
|
||||
} from "./httpMethods";
|
||||
import { rateLimitApiKey } from "./rateLimitApiKey";
|
||||
import { verifyApiKey } from "./verifyApiKey";
|
||||
import { verifyCredentialSyncEnabled } from "./verifyCredentialSyncEnabled";
|
||||
import { withPagination } from "./withPagination";
|
||||
|
||||
const middleware = {
|
||||
HTTP_GET_OR_POST,
|
||||
HTTP_GET_DELETE_PATCH,
|
||||
HTTP_GET,
|
||||
HTTP_PATCH,
|
||||
HTTP_POST,
|
||||
HTTP_DELETE,
|
||||
addRequestId,
|
||||
checkIsInMaintenanceMode,
|
||||
verifyApiKey,
|
||||
rateLimitApiKey,
|
||||
extendRequest,
|
||||
pagination: withPagination,
|
||||
captureErrors,
|
||||
verifyCredentialSyncEnabled,
|
||||
};
|
||||
|
||||
type Middleware = keyof typeof middleware;
|
||||
|
||||
const middlewareOrder = [
|
||||
// The order here, determines the order of execution
|
||||
"checkIsInMaintenanceMode",
|
||||
"extendRequest",
|
||||
"captureErrors",
|
||||
"verifyApiKey",
|
||||
"rateLimitApiKey",
|
||||
"addRequestId",
|
||||
] as Middleware[]; // <-- Provide a list of middleware to call automatically
|
||||
|
||||
const withMiddleware = label(middleware, middlewareOrder);
|
||||
|
||||
export { withMiddleware, middleware, middlewareOrder };
|
||||
17
calcom/apps/api/v1/lib/helpers/withPagination.ts
Normal file
17
calcom/apps/api/v1/lib/helpers/withPagination.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
import z from "zod";
|
||||
|
||||
const withPage = z.object({
|
||||
page: z.coerce.number().min(1).optional().default(1),
|
||||
take: z.coerce.number().min(1).optional().default(10),
|
||||
});
|
||||
|
||||
export const withPagination: NextMiddleware = async (req, _, next) => {
|
||||
const { page, take } = withPage.parse(req.query);
|
||||
const skip = (page - 1) * take;
|
||||
req.pagination = {
|
||||
take,
|
||||
skip,
|
||||
};
|
||||
await next();
|
||||
};
|
||||
Reference in New Issue
Block a user