Compare commits
3 Commits
main
...
bugfix/cha
Author | SHA1 | Date | |
---|---|---|---|
4e82266d59 | |||
6a28725f6c | |||
f528de44ed |
|
@ -1,7 +1,7 @@
|
|||
import * as Sentry from "@sentry/nextjs";
|
||||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { redactError } from "@calcom/lib/redactError";
|
||||
import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown";
|
||||
|
||||
export const captureErrors: NextMiddleware = async (_req, res, next) => {
|
||||
try {
|
||||
|
@ -10,11 +10,10 @@ export const captureErrors: NextMiddleware = async (_req, res, next) => {
|
|||
await next();
|
||||
} catch (error) {
|
||||
Sentry.captureException(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 });
|
||||
|
||||
console.error(error);
|
||||
const serverError = getServerErrorFromUnknown(error);
|
||||
|
||||
res.status(serverError.statusCode).json({ message: serverError.message });
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { apiKeySchema } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
|
||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||
|
||||
export const rateLimitApiKey: NextMiddleware = async (req, _res, next) => {
|
||||
const result = apiKeySchema.safeParse(req.query.apiKey);
|
||||
// Use basic parsing of the API key to ensure we don't rate limit needlessly
|
||||
if (!result.success) {
|
||||
throw new HttpError({ statusCode: 401, message: result.error.issues[0].message });
|
||||
}
|
||||
// TODO: Add a way to add trusted api keys
|
||||
await checkRateLimitAndThrowError({
|
||||
identifier: req.query.apiKey as string,
|
||||
identifier: result.data,
|
||||
rateLimitingType: "api",
|
||||
});
|
||||
|
||||
|
|
|
@ -1,32 +1,38 @@
|
|||
import type { NextMiddleware } from "next-api-middleware";
|
||||
|
||||
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import { apiKeySchema, 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 { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
import { isAdminGuard } from "../utils/isAdmin";
|
||||
|
||||
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
|
||||
export const dateNotInPast = function (date: Date) {
|
||||
export const isExpired = function (date: Date): boolean {
|
||||
const now = new Date();
|
||||
if (now.setHours(0, 0, 0, 0) > date.setHours(0, 0, 0, 0)) {
|
||||
return true;
|
||||
}
|
||||
now.setHours(0, 0, 0, 0);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return now > date;
|
||||
};
|
||||
|
||||
// This verifies the apiKey and sets the user if it is valid.
|
||||
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
const { prisma, isCustomPrisma, isAdmin } = req;
|
||||
const hasValidLicense = await checkLicense(prisma);
|
||||
if (!hasValidLicense && IS_PRODUCTION)
|
||||
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||
if (!hasValidLicense && IS_PRODUCTION) {
|
||||
// Internal Error when the CALCOM_LICENSE_KEY is invalid or missing
|
||||
throw new Error("Invalid or missing CALCOM_LICENSE_KEY environment variable");
|
||||
}
|
||||
// If the user is an admin and using a license key (from customPrisma), skip the apiKey check.
|
||||
if (isCustomPrisma && isAdmin) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Check if the apiKey query param is provided.
|
||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||
const result = apiKeySchema.safeParse(req.query.apiKey);
|
||||
// Use basic parsing of the API key to ensure we don't rate limit needlessly
|
||||
if (!result.success) {
|
||||
throw new HttpError({ statusCode: 401, message: result.error.issues[0].message });
|
||||
}
|
||||
// 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.
|
||||
|
@ -35,7 +41,7 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
|||
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)) {
|
||||
if (apiKey.expiresAt && isExpired(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" });
|
||||
|
|
|
@ -34,17 +34,18 @@ const middleware = {
|
|||
|
||||
type Middleware = keyof typeof middleware;
|
||||
|
||||
const middlewareOrder =
|
||||
// The order here, determines the order of execution
|
||||
[
|
||||
"extendRequest",
|
||||
"captureErrors",
|
||||
// - Put customPrismaClient before verifyApiKey always.
|
||||
"customPrismaClient",
|
||||
"verifyApiKey",
|
||||
"rateLimitApiKey",
|
||||
"addRequestId",
|
||||
] as Middleware[]; // <-- Provide a list of middleware to call automatically
|
||||
// The order here, determines the order of execution
|
||||
const middlewareOrder = [
|
||||
// register captureErrors first, most important.
|
||||
"captureErrors",
|
||||
"extendRequest",
|
||||
// Rate limit api key without verification
|
||||
"rateLimitApiKey",
|
||||
// - Put customPrismaClient before verifyApiKey always.
|
||||
"customPrismaClient",
|
||||
"verifyApiKey",
|
||||
"addRequestId",
|
||||
] satisfies Middleware[];
|
||||
|
||||
const withMiddleware = label(middleware, middlewareOrder);
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import { createMocks } from "node-mocks-http";
|
|||
import { describe, vi, it, expect, afterEach } from "vitest";
|
||||
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
||||
|
||||
|
@ -46,10 +47,11 @@ describe("Verify API key", () => {
|
|||
|
||||
const middlewareSpy = vi.spyOn(middleware, "fn");
|
||||
|
||||
await middleware.fn(req, res, serverNext);
|
||||
await expect(middleware.fn(req, res, serverNext)).rejects.toThrow(HttpError);
|
||||
await expect(middleware.fn(req, res, serverNext)).rejects.toHaveProperty("statusCode", 401);
|
||||
await expect(middleware.fn(req, res, serverNext)).rejects.toHaveProperty("message", "No apiKey provided");
|
||||
|
||||
expect(middlewareSpy).toBeCalled();
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
it("It should thow an error if no api key is provided", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
|
@ -68,9 +70,10 @@ describe("Verify API key", () => {
|
|||
|
||||
const middlewareSpy = vi.spyOn(middleware, "fn");
|
||||
|
||||
await middleware.fn(req, res, serverNext);
|
||||
await expect(middleware.fn(req, res, serverNext)).rejects.toThrow(HttpError);
|
||||
await expect(middleware.fn(req, res, serverNext)).rejects.toHaveProperty("statusCode", 401);
|
||||
await expect(middleware.fn(req, res, serverNext)).rejects.toHaveProperty("message", "No apiKey provided");
|
||||
|
||||
expect(middlewareSpy).toBeCalled();
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -39,7 +39,8 @@ const availabilitySchema = z
|
|||
duration: z.number().optional(),
|
||||
withSource: z.boolean().optional(),
|
||||
})
|
||||
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
|
||||
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.")
|
||||
.refine((data) => data.dateFrom.isValid() && data.dateTo.isValid(), "Invalid time range given.");
|
||||
|
||||
const getEventType = async (id: number) => {
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
|
@ -154,10 +155,6 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
|
|||
const { username, userId, dateFrom, dateTo, eventTypeId, afterEventBuffer, beforeEventBuffer, duration } =
|
||||
availabilitySchema.parse(query);
|
||||
|
||||
if (!dateFrom.isValid() || !dateTo.isValid()) {
|
||||
throw new HttpError({ statusCode: 400, message: "Invalid time range given." });
|
||||
}
|
||||
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
if (username) where.username = username;
|
||||
if (userId) where.id = userId;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { randomBytes, createHash } from "crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
// Hash the API key to check against when veriying it. so we don't have to store the key in plain text.
|
||||
export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex");
|
||||
|
@ -8,3 +9,20 @@ export const generateUniqueAPIKey = (apiKey = randomBytes(16).toString("hex")) =
|
|||
hashAPIKey(apiKey),
|
||||
apiKey,
|
||||
];
|
||||
|
||||
export const apiKeySchema = z.string({ required_error: "No apiKey provided" }).refine(
|
||||
(value) => {
|
||||
// Check if it starts with process.env.API_KEY_PREFIX
|
||||
if (!value.startsWith(process.env.API_KEY_PREFIX || "cal_")) {
|
||||
return false;
|
||||
}
|
||||
// Extract the hash part and validate its format
|
||||
const hashPart = value.slice((process.env.API_KEY_PREFIX || "cal_").length); // Remove prefix
|
||||
const hashRegex = /^[a-f0-9]{32}$/i; // Regex for a 32-character hexadecimal hash
|
||||
|
||||
return hashRegex.test(hashPart);
|
||||
},
|
||||
{
|
||||
message: "Your apiKey is not valid",
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
import { TRPCError } from "@calcom/trpc/server";
|
||||
|
||||
import { httpError } from "./http-error";
|
||||
import type { RateLimitHelper } from "./rateLimit";
|
||||
import { rateLimiter } from "./rateLimit";
|
||||
|
||||
const rateLimitExceededError = (secondsToWait: number) =>
|
||||
httpError({
|
||||
statusCode: 429,
|
||||
message: `Rate limit exceeded. Try again in ${secondsToWait} seconds.`,
|
||||
cause: new Error("TOO_MANY_REQUESTS"),
|
||||
});
|
||||
|
||||
export async function checkRateLimitAndThrowError({
|
||||
rateLimitingType = "core",
|
||||
identifier,
|
||||
|
@ -12,9 +18,6 @@ export async function checkRateLimitAndThrowError({
|
|||
if (remaining < 1) {
|
||||
const convertToSeconds = (ms: number) => Math.floor(ms / 1000);
|
||||
const secondsToWait = convertToSeconds(reset - Date.now());
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: `Rate limit exceeded. Try again in ${secondsToWait} seconds.`,
|
||||
});
|
||||
throw rateLimitExceededError(secondsToWait);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { HttpError } from "./http-error";
|
||||
import { createFromRequest } from "./http-error";
|
||||
|
||||
async function http<T>(path: string, config: RequestInit): Promise<T> {
|
||||
const request = new Request(path, config);
|
||||
|
@ -6,7 +6,7 @@ async function http<T>(path: string, config: RequestInit): Promise<T> {
|
|||
|
||||
if (!response.ok) {
|
||||
const errJson = await response.json();
|
||||
const err = HttpError.fromRequest(request, {
|
||||
const err = createFromRequest(request, {
|
||||
...response,
|
||||
statusText: errJson.message || response.statusText,
|
||||
});
|
||||
|
|
|
@ -1,3 +1,11 @@
|
|||
type HttpErrorOptions<TCode extends number = number> = {
|
||||
url?: string;
|
||||
method?: string;
|
||||
message?: string;
|
||||
statusCode: TCode;
|
||||
cause?: Error;
|
||||
};
|
||||
|
||||
export class HttpError<TCode extends number = number> extends Error {
|
||||
public readonly cause?: Error;
|
||||
public readonly statusCode: TCode;
|
||||
|
@ -5,7 +13,7 @@ export class HttpError<TCode extends number = number> extends Error {
|
|||
public readonly url: string | undefined;
|
||||
public readonly method: string | undefined;
|
||||
|
||||
constructor(opts: { url?: string; method?: string; message?: string; statusCode: TCode; cause?: Error }) {
|
||||
constructor(opts: HttpErrorOptions<TCode>) {
|
||||
super(opts.message ?? `HTTP Error ${opts.statusCode} `);
|
||||
|
||||
Object.setPrototypeOf(this, HttpError.prototype);
|
||||
|
@ -21,13 +29,14 @@ export class HttpError<TCode extends number = number> extends Error {
|
|||
this.stack = opts.cause.stack;
|
||||
}
|
||||
}
|
||||
|
||||
public static fromRequest(request: Request, response: Response) {
|
||||
return new HttpError({
|
||||
message: response.statusText,
|
||||
url: response.url,
|
||||
method: request.method,
|
||||
statusCode: response.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const createFromRequest = (request: Request, response: Response) =>
|
||||
new HttpError({
|
||||
message: response.statusText,
|
||||
url: response.url,
|
||||
method: request.method,
|
||||
statusCode: response.status,
|
||||
});
|
||||
|
||||
export const httpError = (props: HttpErrorOptions) => new HttpError(props);
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { httpError } from "../http-error";
|
||||
|
||||
type Handlers = {
|
||||
[method in "GET" | "POST" | "PATCH" | "PUT" | "DELETE"]?: Promise<{ default: NextApiHandler }>;
|
||||
};
|
||||
|
@ -7,18 +9,11 @@ type Handlers = {
|
|||
/** Allows us to split big API handlers by method */
|
||||
export const defaultHandler = (handlers: Handlers) => async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const handler = (await handlers[req.method as keyof typeof handlers])?.default;
|
||||
// auto catch unsupported methods.
|
||||
if (!handler) {
|
||||
return res
|
||||
.status(405)
|
||||
.json({ message: `Method Not Allowed (Allow: ${Object.keys(handlers).join(",")})` });
|
||||
}
|
||||
|
||||
try {
|
||||
await handler(req, res);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ message: "Something went wrong" });
|
||||
throw httpError({
|
||||
statusCode: 405,
|
||||
message: `Method Not Allowed (Allow: ${Object.keys(handlers).join(",")})`,
|
||||
});
|
||||
}
|
||||
await handler(req, res);
|
||||
};
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getServerErrorFromUnknown } from "./getServerErrorFromUnknown";
|
||||
import { performance } from "./perfObserver";
|
||||
|
||||
type Handle<T> = (req: NextApiRequest, res: NextApiResponse) => Promise<T>;
|
||||
|
@ -15,10 +14,7 @@ export function defaultResponder<T>(f: Handle<T>) {
|
|||
ok = true;
|
||||
if (result) res.json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = getServerErrorFromUnknown(err);
|
||||
res.statusCode = error.statusCode;
|
||||
res.json({ message: error.message });
|
||||
throw err;
|
||||
} finally {
|
||||
performance.mark("End");
|
||||
performance.measure(`[${ok ? "OK" : "ERROR"}][$1] ${req.method} '${req.url}'`, "Start", "End");
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { PrismaClientKnownRequestError, NotFoundError } from "@prisma/client/runtime/library";
|
||||
import Stripe from "stripe";
|
||||
import type { ZodIssue } from "zod";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { HttpError } from "../http-error";
|
||||
import { HttpError, httpError } from "../http-error";
|
||||
import { redactError } from "../redactError";
|
||||
|
||||
function hasName(cause: unknown): cause is { name: string } {
|
||||
|
@ -28,54 +27,45 @@ function parseZodErrorIssues(issues: ZodIssue[]): string {
|
|||
|
||||
export function getServerErrorFromUnknown(cause: unknown): HttpError {
|
||||
if (isZodError(cause)) {
|
||||
console.log("cause", cause);
|
||||
return new HttpError({
|
||||
return httpError({
|
||||
statusCode: 400,
|
||||
message: parseZodErrorIssues(cause.issues),
|
||||
cause,
|
||||
});
|
||||
}
|
||||
|
||||
if (cause instanceof SyntaxError) {
|
||||
return new HttpError({
|
||||
return httpError({
|
||||
statusCode: 500,
|
||||
message: "Unexpected error, please reach out for our customer support.",
|
||||
});
|
||||
}
|
||||
|
||||
if (cause instanceof PrismaClientKnownRequestError) {
|
||||
return getHttpError({ statusCode: 400, cause });
|
||||
return createHttpError({ statusCode: 400, cause });
|
||||
}
|
||||
if (cause instanceof NotFoundError) {
|
||||
return getHttpError({ statusCode: 404, cause });
|
||||
}
|
||||
if (cause instanceof Stripe.errors.StripeInvalidRequestError) {
|
||||
return getHttpError({ statusCode: 400, cause });
|
||||
return createHttpError({ statusCode: 404, cause });
|
||||
}
|
||||
|
||||
if (cause instanceof HttpError) {
|
||||
const redactedCause = redactError(cause);
|
||||
return {
|
||||
...redactedCause,
|
||||
cause: cause.cause,
|
||||
url: cause.url,
|
||||
statusCode: cause.statusCode,
|
||||
method: cause.method,
|
||||
};
|
||||
return createHttpError({ statusCode: cause.statusCode, cause });
|
||||
}
|
||||
if (cause instanceof Error) {
|
||||
return getHttpError({ statusCode: 500, cause });
|
||||
return createHttpError({ statusCode: 500, cause });
|
||||
}
|
||||
if (typeof cause === "string") {
|
||||
// @ts-expect-error https://github.com/tc39/proposal-error-cause
|
||||
return new Error(cause, { cause });
|
||||
}
|
||||
|
||||
return new HttpError({
|
||||
return httpError({
|
||||
statusCode: 500,
|
||||
message: `Unhandled error of type '${typeof cause}'. Please reach out for our customer support.`,
|
||||
});
|
||||
}
|
||||
|
||||
function getHttpError<T extends Error>({ statusCode, cause }: { statusCode: number; cause: T }) {
|
||||
function createHttpError<T extends Error>({ statusCode, cause }: { statusCode: number; cause: T }) {
|
||||
const redacted = redactError(cause);
|
||||
return new HttpError({ statusCode, message: redacted.message, cause: redacted });
|
||||
return httpError({ statusCode, message: redacted.message, cause: redacted });
|
||||
}
|
||||
|
|
|
@ -1,11 +1,29 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const iso8601Schema = z.string().refine(
|
||||
(value) => {
|
||||
// Attempt to parse the string as a date
|
||||
const date = new Date(value);
|
||||
// Check if the date is valid and the string is in ISO 8601 format
|
||||
return !isNaN(date.getTime()) && value === date.toISOString();
|
||||
},
|
||||
{
|
||||
message: "The string must be a valid ISO 8601 date string",
|
||||
}
|
||||
);
|
||||
|
||||
const isStartTimeBeforeEndTime = (startTime: string, endTime: string) => {
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(endTime);
|
||||
return start < end;
|
||||
};
|
||||
|
||||
export const getScheduleSchema = z
|
||||
.object({
|
||||
// startTime ISOString
|
||||
startTime: z.string(),
|
||||
// endTime ISOString
|
||||
endTime: z.string(),
|
||||
// startTime as ISO 8601 string
|
||||
startTime: iso8601Schema,
|
||||
// endTime as ISO 8601 string
|
||||
endTime: iso8601Schema,
|
||||
// Event type ID
|
||||
eventTypeId: z.coerce.number().int().optional(),
|
||||
// Event type slug
|
||||
|
@ -34,6 +52,10 @@ export const getScheduleSchema = z
|
|||
.refine(
|
||||
(data) => !!data.eventTypeId || (!!data.usernameList && !!data.eventTypeSlug),
|
||||
"You need to either pass an eventTypeId OR an usernameList/eventTypeSlug combination"
|
||||
)
|
||||
.refine(
|
||||
(data) => isStartTimeBeforeEndTime(data.startTime, data.endTime),
|
||||
"Start time must be before end time."
|
||||
);
|
||||
|
||||
export const reserveSlotSchema = z
|
||||
|
|
|
@ -302,9 +302,6 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
|
|||
const endTime =
|
||||
input.timeZone === "Etc/GMT" ? dayjs.utc(input.endTime) : dayjs(input.endTime).utc().tz(input.timeZone);
|
||||
|
||||
if (!startTime.isValid() || !endTime.isValid()) {
|
||||
throw new TRPCError({ message: "Invalid time range given.", code: "BAD_REQUEST" });
|
||||
}
|
||||
let currentSeats: CurrentSeats | undefined;
|
||||
|
||||
let usersWithCredentials = eventType.users.map((user) => ({
|
||||
|
|
Loading…
Reference in New Issue
Block a user