Skip to content

Commit

Permalink
Merge branch 'main' into redirect-on-success-for-platform
Browse files Browse the repository at this point in the history
  • Loading branch information
Ryukemeister authored Jan 20, 2025
2 parents 3007d08 + 4424b2a commit 13dd50e
Show file tree
Hide file tree
Showing 58 changed files with 909 additions and 268 deletions.
2 changes: 0 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -367,9 +367,7 @@ APP_ROUTER_TEAM_ENABLED=0
APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED=0
APP_ROUTER_AUTH_LOGIN_ENABLED=0
APP_ROUTER_AUTH_LOGOUT_ENABLED=0
APP_ROUTER_AUTH_NEW_ENABLED=0
APP_ROUTER_AUTH_SAML_ENABLED=0
APP_ROUTER_AUTH_ERROR_ENABLED=0
APP_ROUTER_AUTH_PLATFORM_ENABLED=0
APP_ROUTER_AUTH_OAUTH2_ENABLED=0

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
diff --git a/index.cjs b/index.cjs
index c83f700ae9998cd87b4c2d66ecbb2ad3d7b4603c..76a2200b57f0b9243e2c61464d578b67746ad5a4 100644
index c83f700..da6fc7e 100644
--- a/index.cjs
+++ b/index.cjs
@@ -13,8 +13,8 @@ function withMetadataArgument(func, _arguments) {
Expand All @@ -12,4 +12,4 @@ index c83f700ae9998cd87b4c2d66ecbb2ad3d7b4603c..76a2200b57f0b9243e2c61464d578b67
+// exports['default'] = min.parsePhoneNumberFromString

// `parsePhoneNumberFromString()` named export is now considered legacy:
// it has been promoted to a default export due to being too verbose.
// it has been promoted to a default export due to being too verbose.
7 changes: 7 additions & 0 deletions .yarn/versions/c2e72c83.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
149 changes: 145 additions & 4 deletions apps/api/v1/lib/helpers/rateLimitApiKey.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { RatelimitResponse } from "@unkey/ratelimit";
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 { handleAutoLock } from "@calcom/lib/autoLock";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { HttpError } from "@calcom/lib/http-error";

import { rateLimitApiKey } from "~/lib/helpers/rateLimitApiKey";

Expand All @@ -14,6 +17,10 @@ vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
checkRateLimitAndThrowError: vi.fn(),
}));

vi.mock("@calcom/lib/autoLock", () => ({
handleAutoLock: vi.fn(),
}));

describe("rateLimitApiKey middleware", () => {
it("should return 401 if no apiKey is provided", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
Expand Down Expand Up @@ -55,17 +62,20 @@ describe("rateLimitApiKey middleware", () => {
query: { apiKey: "test-key" },
});

const rateLimiterResponse = {
const rateLimiterResponse: RatelimitResponse = {
limit: 100,
remaining: 99,
reset: Date.now(),
success: true,
};

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(checkRateLimitAndThrowError as any).mockImplementationOnce(({ onRateLimiterResponse }) => {
onRateLimiterResponse(rateLimiterResponse);
});
(checkRateLimitAndThrowError as any).mockImplementationOnce(
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
onRateLimiterResponse(rateLimiterResponse);
}
);

// @ts-expect-error weird typing between middleware and createMocks
await rateLimitApiKey(req, res, vi.fn() as any);
Expand All @@ -89,4 +99,135 @@ describe("rateLimitApiKey middleware", () => {
expect(res._getStatusCode()).toBe(429);
expect(res._getJSONData()).toEqual({ message: "Rate limit exceeded" });
});

it("should lock API key when rate limit is repeatedly exceeded", async () => {
const { req, res } = createMocks({
method: "GET",
query: { apiKey: "test-key" },
});

const rateLimiterResponse: RatelimitResponse = {
success: false,
remaining: 0,
limit: 100,
reset: Date.now(),
};

// Mock rate limiter to trigger the onRateLimiterResponse callback
(checkRateLimitAndThrowError as any).mockImplementationOnce(
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
onRateLimiterResponse(rateLimiterResponse);
}
);

// Mock handleAutoLock to indicate the key was locked
vi.mocked(handleAutoLock).mockResolvedValueOnce(true);

// @ts-expect-error weird typing between middleware and createMocks
await rateLimitApiKey(req, res, vi.fn() as any);

expect(handleAutoLock).toHaveBeenCalledWith({
identifier: "test-key",
identifierType: "apiKey",
rateLimitResponse: rateLimiterResponse,
});

expect(res._getStatusCode()).toBe(429);
expect(res._getJSONData()).toEqual({ message: "Too many requests" });
});

it("should handle API key not found error during auto-lock", async () => {
const { req, res } = createMocks({
method: "GET",
query: { apiKey: "test-key" },
});

const rateLimiterResponse: RatelimitResponse = {
success: false,
remaining: 0,
limit: 100,
reset: Date.now(),
};

// Mock rate limiter to trigger the onRateLimiterResponse callback
(checkRateLimitAndThrowError as any).mockImplementationOnce(
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
onRateLimiterResponse(rateLimiterResponse);
}
);

// Mock handleAutoLock to throw a "No user found" error
vi.mocked(handleAutoLock).mockRejectedValueOnce(new Error("No user found for this API key."));

// @ts-expect-error weird typing between middleware and createMocks
await rateLimitApiKey(req, res, vi.fn() as any);

expect(handleAutoLock).toHaveBeenCalledWith({
identifier: "test-key",
identifierType: "apiKey",
rateLimitResponse: rateLimiterResponse,
});

expect(res._getStatusCode()).toBe(401);
expect(res._getJSONData()).toEqual({ message: "No user found for this API key." });
});

it("should continue if auto-lock returns false (not locked)", async () => {
const { req, res } = createMocks({
method: "GET",
query: { apiKey: "test-key" },
});

const rateLimiterResponse: RatelimitResponse = {
success: false,
remaining: 0,
limit: 100,
reset: Date.now(),
};

// Mock rate limiter to trigger the onRateLimiterResponse callback
(checkRateLimitAndThrowError as any).mockImplementationOnce(
({ onRateLimiterResponse }: { onRateLimiterResponse: (response: RatelimitResponse) => void }) => {
onRateLimiterResponse(rateLimiterResponse);
}
);

// Mock handleAutoLock to indicate the key was not locked
vi.mocked(handleAutoLock).mockResolvedValueOnce(false);

const next = vi.fn();
// @ts-expect-error weird typing between middleware and createMocks
await rateLimitApiKey(req, res, next);

expect(handleAutoLock).toHaveBeenCalledWith({
identifier: "test-key",
identifierType: "apiKey",
rateLimitResponse: rateLimiterResponse,
});

// Verify headers were set but request continued
expect(res.getHeader("X-RateLimit-Limit")).toBe(rateLimiterResponse.limit);
expect(next).toHaveBeenCalled();
});

it("should handle HttpError during rate limiting", async () => {
const { req, res } = createMocks({
method: "GET",
query: { apiKey: "test-key" },
});

// Mock checkRateLimitAndThrowError to throw HttpError
vi.mocked(checkRateLimitAndThrowError).mockRejectedValueOnce(
new HttpError({
statusCode: 429,
message: "Custom rate limit error",
})
);

// @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: "Custom rate limit error" });
});
});
26 changes: 24 additions & 2 deletions apps/api/v1/lib/helpers/rateLimitApiKey.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { NextMiddleware } from "next-api-middleware";

import { handleAutoLock } from "@calcom/lib/autoLock";
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" });
Expand All @@ -10,14 +12,34 @@ export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
await checkRateLimitAndThrowError({
identifier: req.query.apiKey as string,
rateLimitingType: "api",
onRateLimiterResponse: (response) => {
onRateLimiterResponse: async (response) => {
res.setHeader("X-RateLimit-Limit", response.limit);
res.setHeader("X-RateLimit-Remaining", response.remaining);
res.setHeader("X-RateLimit-Reset", response.reset);

try {
const didLock = await handleAutoLock({
identifier: req.query.apiKey as string, // Casting as this is verified in another middleware
identifierType: "apiKey",
rateLimitResponse: response,
});

if (didLock) {
return res.status(429).json({ message: "Too many requests" });
}
} catch (error) {
if (error instanceof Error && error.message === "No user found for this API key.") {
return res.status(401).json({ message: error.message });
}
throw error;
}
},
});
} catch (error) {
res.status(429).json({ message: "Rate limit exceeded" });
if (error instanceof HttpError) {
return res.status(error.statusCode).json({ message: error.message });
}
return res.status(429).json({ message: "Rate limit exceeded" });
}

await next();
Expand Down
20 changes: 18 additions & 2 deletions apps/api/v1/pages/api/teams/_post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { NextApiRequest } from "next";

import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getDubCustomer } from "@calcom/features/auth/lib/dub";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
Expand Down Expand Up @@ -192,11 +193,26 @@ const generateTeamCheckoutSession = async ({
pendingPaymentTeamId: number;
ownerId: number;
}) => {
const customer = await getStripeCustomerIdFromUserId(ownerId);
const [customer, dubCustomer] = await Promise.all([
getStripeCustomerIdFromUserId(ownerId),
getDubCustomer(ownerId.toString()),
]);

const session = await stripe.checkout.sessions.create({
customer,
mode: "subscription",
allow_promotion_codes: true,
...(dubCustomer?.discount?.couponId
? {
discounts: [
{
coupon:
process.env.NODE_ENV !== "production" && dubCustomer.discount.couponTestId
? dubCustomer.discount.couponTestId
: dubCustomer.discount.couponId,
},
],
}
: { allow_promotion_codes: true }),
success_url: `${WEBAPP_URL}/api/teams/api/create?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${WEBAPP_URL}/settings/my-account/profile`,
line_items: [
Expand Down
2 changes: 0 additions & 2 deletions apps/web/abTest/middlewareFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ const ROUTES: [URLPattern, boolean][] = [
["/auth/forgot-password/:path*", process.env.APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED === "1"] as const,
["/auth/login", process.env.APP_ROUTER_AUTH_LOGIN_ENABLED === "1"] as const,
["/auth/logout", process.env.APP_ROUTER_AUTH_LOGOUT_ENABLED === "1"] as const,
["/auth/new", process.env.APP_ROUTER_AUTH_NEW_ENABLED === "1"] as const,
["/auth/saml-idp", process.env.APP_ROUTER_AUTH_SAML_ENABLED === "1"] as const,
["/auth/error", process.env.APP_ROUTER_AUTH_ERROR_ENABLED === "1"] as const,
["/auth/platform/:path*", process.env.APP_ROUTER_AUTH_PLATFORM_ENABLED === "1"] as const,
["/auth/oauth2/:path*", process.env.APP_ROUTER_AUTH_OAUTH2_ENABLED === "1"] as const,
["/team", process.env.APP_ROUTER_TEAM_ENABLED === "1"] as const,
Expand Down
47 changes: 47 additions & 0 deletions apps/web/app/auth/error/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import Link from "next/link";
import { z } from "zod";

import { Button, Icon } from "@calcom/ui";

import AuthContainer from "@components/ui/AuthContainer";

export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("error"),
() => ""
);
};

const querySchema = z.object({
error: z.string().optional(),
});

const ServerPage = async ({ searchParams }: PageProps) => {
const t = await getTranslate();
const { error } = querySchema.parse({ error: searchParams?.error || undefined });
const errorMsg = error || t("error_during_login");
return (
<AuthContainer title="" description="" isAppDir={true}>
<div>
<div className="bg-error mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<Icon name="x" className="h-6 w-6 text-red-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-emphasis text-lg font-medium leading-6" id="modal-title">
{errorMsg}
</h3>
</div>
</div>
<div className="mt-5 sm:mt-6">
<Link href="/auth/login">
<Button className="flex w-full justify-center">{t("go_back_login")}</Button>
</Link>
</div>
</AuthContainer>
);
};

export default WithLayout({ ServerPage })<"P">;
File renamed without changes.
24 changes: 20 additions & 4 deletions apps/web/app/event-types/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";

import { getServerSideProps } from "@lib/event-types/getServerSideProps";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";

import { buildLegacyCtx } from "@lib/buildLegacyCtx";

import { ssrInit } from "@server/lib/ssr";

import EventTypes from "~/event-types/views/event-types-listing-view";

Expand All @@ -12,6 +18,16 @@ export const generateMetadata = async () =>
(t) => t("event_types_page_subtitle")
);

const getData = withAppDirSsr(getServerSideProps);
const Page = async ({ params, searchParams }: PageProps) => {
const context = buildLegacyCtx(headers(), cookies(), params, searchParams);
const session = await getServerSessionForAppDir();
if (!session?.user?.id) {
redirect("/auth/login");
}

await ssrInit(context);

return <EventTypes />;
};

export default WithLayout({ getLayout: null, getData, Page: EventTypes })<"P">;
export default WithLayout({ ServerPage: Page })<"P">;
Loading

0 comments on commit 13dd50e

Please sign in to comment.