2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View 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();
};

View 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 });
}
};

View 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();
};

View 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();
};

View 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"]);

View 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" });
});
});

View 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();
};

View 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 {};
}
}

View 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();
};

View File

@@ -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();
};

View 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 };

View 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();
};