diff --git a/apps/web/abTest/middlewareFactory.ts b/apps/web/abTest/middlewareFactory.ts deleted file mode 100644 index dc06d17ad58e81..00000000000000 --- a/apps/web/abTest/middlewareFactory.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { getBucket } from "abTest/utils"; -import type { NextMiddleware, NextRequest } from "next/server"; -import { NextResponse, URLPattern } from "next/server"; - -import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME } from "@calcom/lib/constants"; - -const ROUTES: [URLPattern, boolean][] = [ - ["/apps/:slug/setup", process.env.APP_ROUTER_APPS_SLUG_SETUP_ENABLED === "1"] as const, - ["/team", process.env.APP_ROUTER_TEAM_ENABLED === "1"] as const, -].map(([pathname, enabled]) => [ - new URLPattern({ - pathname, - }), - enabled, -]); - -export const abTestMiddlewareFactory = - (next: (req: NextRequest) => Promise>): NextMiddleware => - async (req: NextRequest) => { - const response = await next(req); - - const { pathname } = req.nextUrl; - - const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME); - - const route = ROUTES.find(([regExp]) => regExp.test(req.url)) ?? null; - const enabled = route !== null ? route[1] || override : false; - - if (pathname.includes("future") || !enabled) { - return response; - } - - const bucketValue = override ? "future" : req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value; - - if (!bucketValue || !["future", "legacy"].includes(bucketValue)) { - // cookie does not exist or it has incorrect value - const bucket = getBucket(); - - response.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, bucket, { - expires: Date.now() + 1000 * 60 * 30, - httpOnly: true, - }); // 30 min in ms - - if (bucket === "legacy") { - return response; - } - - const url = req.nextUrl.clone(); - url.pathname = `future${pathname}/`; - - return NextResponse.rewrite(url, response); - } - - if (bucketValue === "legacy") { - return response; - } - - const url = req.nextUrl.clone(); - url.pathname = `future${pathname}/`; - - return NextResponse.rewrite(url, response); - }; diff --git a/apps/web/abTest/utils.ts b/apps/web/abTest/utils.ts deleted file mode 100644 index ed40c9fca96f65..00000000000000 --- a/apps/web/abTest/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { AB_TEST_BUCKET_PROBABILITY } from "@calcom/lib/constants"; - -const cryptoRandom = () => { - return crypto.getRandomValues(new Uint8Array(1))[0] / 0xff; -}; - -export const getBucket = () => { - return cryptoRandom() * 100 < AB_TEST_BUCKET_PROBABILITY ? "future" : "legacy"; -}; diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx index e7d7ac50df6fa8..214916db93e109 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/availability/page.tsx @@ -12,7 +12,10 @@ import { ShellMainAppDir } from "../ShellMainAppDir"; export const generateMetadata = async () => { return await _generateMetadata( (t) => t("availability"), - (t) => t("configure_availability") + (t) => t("configure_availability"), + undefined, + undefined, + "/availability" ); }; diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx index 1355403f2743b7..398288742e3ce4 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx @@ -4,6 +4,8 @@ import { _generateMetadata, getTranslate } from "app/_utils"; import { redirect } from "next/navigation"; import { z } from "zod"; +import { decodeParams } from "@lib/buildLegacyCtx"; + import { validStatuses } from "~/bookings/lib/validStatuses"; import BookingsList from "~/bookings/views/bookings-listing-view"; @@ -11,14 +13,17 @@ const querySchema = z.object({ status: z.enum(validStatuses), }); -export const generateMetadata = async () => +export const generateMetadata = async ({ params }: PageProps) => await _generateMetadata( (t) => t("bookings"), - (t) => t("bookings_description") + (t) => t("bookings_description"), + undefined, + undefined, + `/bookings/${decodeParams(params).status}` ); const Page = async ({ params }: PageProps) => { - const parsed = querySchema.safeParse(params); + const parsed = querySchema.safeParse(decodeParams(params)); if (!parsed.success) { redirect("/bookings/upcoming"); } diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/event-types/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/event-types/page.tsx index d975c7de607d3e..a713c3adc326f1 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/event-types/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/event-types/page.tsx @@ -15,7 +15,10 @@ import EventTypes, { EventTypesCTA } from "~/event-types/views/event-types-listi export const generateMetadata = async () => await _generateMetadata( (t) => t("event_types_page_title"), - (t) => t("event_types_page_subtitle") + (t) => t("event_types_page_subtitle"), + undefined, + undefined, + "/event-types" ); const Page = async ({ params, searchParams }: PageProps) => { diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx index a5828cd67e6f26..c6c45f67f04106 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/teams/page.tsx @@ -13,7 +13,10 @@ import TeamsView, { TeamsCTA } from "~/teams/teams-view"; export const generateMetadata = async () => await _generateMetadata( (t) => t("teams"), - (t) => t("create_manage_teams_collaborative") + (t) => t("create_manage_teams_collaborative"), + undefined, + undefined, + "/teams" ); const getData = withAppDirSsr(getServerSideProps); diff --git a/apps/web/app/(use-page-wrapper)/403/page.tsx b/apps/web/app/(use-page-wrapper)/403/page.tsx index f7aed69ca647f1..7f2ba6d1773356 100644 --- a/apps/web/app/(use-page-wrapper)/403/page.tsx +++ b/apps/web/app/(use-page-wrapper)/403/page.tsx @@ -6,7 +6,10 @@ import { Button } from "@calcom/ui"; export const generateMetadata = () => _generateMetadata( (t) => `${t("access_denied")} | ${APP_NAME}`, - () => "" + () => "", + undefined, + undefined, + "/403" ); async function Error403() { diff --git a/apps/web/app/(use-page-wrapper)/500/page.tsx b/apps/web/app/(use-page-wrapper)/500/page.tsx index 32239cd1663374..d018688a44501a 100644 --- a/apps/web/app/(use-page-wrapper)/500/page.tsx +++ b/apps/web/app/(use-page-wrapper)/500/page.tsx @@ -8,7 +8,10 @@ import CopyButton from "./copy-button"; export const generateMetadata = () => _generateMetadata( (t) => `${t("something_unexpected_occurred")} | ${APP_NAME}`, - () => "" + () => "", + undefined, + undefined, + "/500" ); async function Error500({ searchParams }: { searchParams: { error?: string } }) { diff --git a/apps/web/app/(use-page-wrapper)/[user]/[type]/page.tsx b/apps/web/app/(use-page-wrapper)/[user]/[type]/page.tsx index 1e19cd0d50c806..bb0f0f63b49d88 100644 --- a/apps/web/app/(use-page-wrapper)/[user]/[type]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/[user]/[type]/page.tsx @@ -5,7 +5,7 @@ import { headers, cookies } from "next/headers"; import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; -import { buildLegacyCtx } from "@lib/buildLegacyCtx"; +import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx"; import { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps"; @@ -31,12 +31,14 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => { })), ], }; + const decodedParams = decodeParams(params); const metadata = await generateMeetingMetadata( meeting, (t) => `${rescheduleUid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`, (t) => `${rescheduleUid ? t("reschedule") : ""} ${title}`, isBrandingHidden, - getOrgFullOrigin(eventData?.entity.orgSlug ?? null) + getOrgFullOrigin(eventData?.entity.orgSlug ?? null), + `/${decodedParams.user}/${decodedParams.type}` ); return { diff --git a/apps/web/app/(use-page-wrapper)/[user]/page.tsx b/apps/web/app/(use-page-wrapper)/[user]/page.tsx index 8bc97b02b428e6..49a9c4c614dbc4 100644 --- a/apps/web/app/(use-page-wrapper)/[user]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/[user]/page.tsx @@ -5,7 +5,7 @@ import { headers, cookies } from "next/headers"; import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; -import { buildLegacyCtx } from "@lib/buildLegacyCtx"; +import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx"; import { getServerSideProps } from "@server/lib/[user]/getServerSideProps"; @@ -25,12 +25,14 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => { profile: { name: `${profile.name}`, image: profile.image }, users: [{ username: `${profile.username}`, name: `${profile.name}` }], }; + const metadata = await generateMeetingMetadata( meeting, () => profile.name, () => markdownStrippedBio, false, - getOrgFullOrigin(entity.orgSlug ?? null) + getOrgFullOrigin(entity.orgSlug ?? null), + `/${decodeParams(params).user}` ); return { diff --git a/apps/web/app/(use-page-wrapper)/apps/[slug]/page.tsx b/apps/web/app/(use-page-wrapper)/apps/[slug]/page.tsx index 94b15fd4983a25..b8f45cb73ba500 100644 --- a/apps/web/app/(use-page-wrapper)/apps/[slug]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/apps/[slug]/page.tsx @@ -4,6 +4,7 @@ import { notFound } from "next/navigation"; import { z } from "zod"; import { getStaticProps } from "@lib/apps/[slug]/getStaticProps"; +import { decodeParams } from "@lib/buildLegacyCtx"; import AppView from "~/apps/[slug]/slug-view"; @@ -12,7 +13,7 @@ const paramsSchema = z.object({ }); export const generateMetadata = async ({ params }: _PageProps) => { - const p = paramsSchema.safeParse(params); + const p = paramsSchema.safeParse(decodeParams(params)); if (!p.success) { return notFound(); @@ -28,12 +29,15 @@ export const generateMetadata = async ({ params }: _PageProps) => { return await generateAppMetadata( { slug: logo, name, description }, () => name, - () => description + () => description, + undefined, + undefined, + `/apps/${p.data.slug}` ); }; async function Page({ params }: _PageProps) { - const p = paramsSchema.safeParse(params); + const p = paramsSchema.safeParse(decodeParams(params)); if (!p.success) { return notFound(); diff --git a/apps/web/app/(use-page-wrapper)/apps/installed/[category]/page.tsx b/apps/web/app/(use-page-wrapper)/apps/installed/[category]/page.tsx index ff6d563bc92e52..06d398f0d8916f 100644 --- a/apps/web/app/(use-page-wrapper)/apps/installed/[category]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/apps/installed/[category]/page.tsx @@ -5,6 +5,8 @@ import { z } from "zod"; import { AppCategories } from "@calcom/prisma/enums"; +import { decodeParams } from "@lib/buildLegacyCtx"; + import InstalledApps from "~/apps/installed/[category]/installed-category-view"; const querySchema = z.object({ @@ -19,7 +21,7 @@ export const generateMetadata = async () => { }; const InstalledAppsWrapper = async ({ params }: PageProps) => { - const parsedParams = querySchema.safeParse(params); + const parsedParams = querySchema.safeParse(decodeParams(params)); if (!parsedParams.success) { redirect("/apps/installed/calendar"); diff --git a/apps/web/app/(use-page-wrapper)/apps/page.tsx b/apps/web/app/(use-page-wrapper)/apps/page.tsx index 0117c797c9e9ba..871506f583f8ba 100644 --- a/apps/web/app/(use-page-wrapper)/apps/page.tsx +++ b/apps/web/app/(use-page-wrapper)/apps/page.tsx @@ -11,7 +11,10 @@ import AppsPage from "~/apps/apps-view"; export const generateMetadata = async () => { return await _generateMetadata( (t) => t("app_store"), - (t) => t("app_store_description") + (t) => t("app_store_description"), + undefined, + undefined, + "/apps" ); }; diff --git a/apps/web/app/_utils.tsx b/apps/web/app/_utils.tsx index acda1dd5207ab3..a9cc40af66bdfe 100644 --- a/apps/web/app/_utils.tsx +++ b/apps/web/app/_utils.tsx @@ -1,8 +1,9 @@ import { type TFunction } from "i18next"; import i18next from "i18next"; import { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import { headers } from "next/headers"; +import { cookies, headers } from "next/headers"; +import { getLocale } from "@calcom/features/auth/lib/getLocale"; import type { AppImageProps, MeetingImageProps } from "@calcom/lib/OgImages"; import { constructAppImage, constructGenericImage, constructMeetingImage } from "@calcom/lib/OgImages"; import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG, CAL_URL } from "@calcom/lib/constants"; @@ -11,6 +12,8 @@ import { truncateOnWord } from "@calcom/lib/text"; //@ts-expect-error no type definitions import config from "@calcom/web/next-i18next.config"; +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + const i18nInstanceCache: Record = {}; const createI18nInstance = async (locale: string, ns: string) => { @@ -44,7 +47,7 @@ export const getTranslate = async () => { const headersList = await headers(); // If "x-locale" does not exist in header, // ensure that config.matcher in middleware includes the page you are testing - const locale = headersList.get("x-locale"); + const locale = await getLocale(buildLegacyRequest(headersList, cookies())); const t = await getTranslationWithCache(locale ?? "en"); return t; }; @@ -53,12 +56,13 @@ const _generateMetadataWithoutImage = async ( getTitle: (t: TFunction) => string, getDescription: (t: TFunction) => string, hideBranding?: boolean, - origin?: string + origin?: string, + pathname?: string ) => { const h = headers(); - const pathname = h.get("x-pathname") ?? ""; - const canonical = buildCanonical({ path: pathname, origin: origin ?? CAL_URL }); - const locale = h.get("x-locale") ?? "en"; + const _pathname = pathname ?? ""; + const canonical = buildCanonical({ path: _pathname, origin: origin ?? CAL_URL }); + const locale = (await getLocale(buildLegacyRequest(h, cookies()))) ?? "en"; const t = await getTranslationWithCache(locale); const title = getTitle(t); @@ -86,9 +90,16 @@ export const _generateMetadata = async ( getTitle: (t: TFunction) => string, getDescription: (t: TFunction) => string, hideBranding?: boolean, - origin?: string + origin?: string, + pathname?: string ) => { - const metadata = await _generateMetadataWithoutImage(getTitle, getDescription, hideBranding, origin); + const metadata = await _generateMetadataWithoutImage( + getTitle, + getDescription, + hideBranding, + origin, + pathname + ); const image = SEO_IMG_OGIMG + constructGenericImage({ @@ -110,9 +121,16 @@ export const generateMeetingMetadata = async ( getTitle: (t: TFunction) => string, getDescription: (t: TFunction) => string, hideBranding?: boolean, - origin?: string + origin?: string, + pathname?: string ) => { - const metadata = await _generateMetadataWithoutImage(getTitle, getDescription, hideBranding, origin); + const metadata = await _generateMetadataWithoutImage( + getTitle, + getDescription, + hideBranding, + origin, + pathname + ); const image = SEO_IMG_OGIMG + constructMeetingImage(meeting); return { @@ -129,9 +147,16 @@ export const generateAppMetadata = async ( getTitle: (t: TFunction) => string, getDescription: (t: TFunction) => string, hideBranding?: boolean, - origin?: string + origin?: string, + pathname?: string ) => { - const metadata = await _generateMetadataWithoutImage(getTitle, getDescription, hideBranding, origin); + const metadata = await _generateMetadataWithoutImage( + getTitle, + getDescription, + hideBranding, + origin, + pathname + ); const image = SEO_IMG_OGIMG + constructAppImage({ ...app, description: metadata.description }); diff --git a/apps/web/lib/buildLegacyCtx.tsx b/apps/web/lib/buildLegacyCtx.tsx index 834a36a2016dd3..f26f8a4e094ad7 100644 --- a/apps/web/lib/buildLegacyCtx.tsx +++ b/apps/web/lib/buildLegacyCtx.tsx @@ -26,7 +26,7 @@ const buildLegacyCookies = (cookies: ReadonlyRequestCookies) => { return createProxifiedObject(cookiesObject); }; -function decodeParams(params: Params): Params { +export function decodeParams(params: Params): Params { return Object.entries(params).reduce((acc, [key, value]) => { // Handle array values if (Array.isArray(value)) { diff --git a/apps/web/middleware.test.ts b/apps/web/middleware.test.ts new file mode 100644 index 00000000000000..9f91d699469cb8 --- /dev/null +++ b/apps/web/middleware.test.ts @@ -0,0 +1,55 @@ +import { NextRequest } from "next/server"; +import { describe, it, expect } from "vitest"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; + +import { checkPostMethod, POST_METHODS_ALLOWED_APP_ROUTES } from "./middleware"; + +describe("Middleware - POST requests restriction", () => { + const createRequest = (path: string, method: string) => { + return new NextRequest( + new Request(`${WEBAPP_URL}${path}`, { + method, + }) + ); + }; + + it("should allow POST requests to /api routes", async () => { + const req1 = createRequest("/api/auth/signup", "POST"); + const res1 = checkPostMethod(req1); + expect(res1).toBeNull(); + + const req2 = createRequest("/api/trpc/book/event", "POST"); + const res2 = checkPostMethod(req2); + expect(res2).toBeNull(); + }); + + it("should allow POST requests to allowed app routes", async () => { + POST_METHODS_ALLOWED_APP_ROUTES.forEach(async (route) => { + const req = createRequest(route, "POST"); + const res = checkPostMethod(req); + expect(res).toBeNull(); + }); + }); + + it("should block POST requests to not-allowed app routes", async () => { + const req = createRequest("/team/xyz", "POST"); + const res = checkPostMethod(req); + expect(res).not.toBeNull(); + expect(res?.status).toBe(405); + expect(res?.statusText).toBe("Method Not Allowed"); + expect(res?.headers.get("Allow")).toBe("GET"); + }); + + it("should allow GET requests to app routes", async () => { + const req = createRequest("/team/xyz", "GET"); + const res = checkPostMethod(req); + expect(res).toBeNull(); + }); + + it("should allow GET requests to /api routes", async () => { + const req = createRequest("/api/auth/signup", "GET"); + const res = checkPostMethod(req); + expect(res).toBeNull(); + }); +}); diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 5f748b579eb56c..04c53dc10573a6 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -3,12 +3,9 @@ import { collectEvents } from "next-collect/server"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { getLocale } from "@calcom/features/auth/lib/getLocale"; import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry"; -import { csp } from "@lib/csp"; - -import { abTestMiddlewareFactory } from "./abTest/middlewareFactory"; +import { csp } from "./lib/csp"; const safeGet = async (key: string): Promise => { try { @@ -18,7 +15,33 @@ const safeGet = async (key: string): Promise => { } }; +export const POST_METHODS_ALLOWED_API_ROUTES = ["/api/auth/signup", "/api/trpc"]; +// Some app routes are allowed because "revalidatePath()" is used to revalidate the cache for them +export const POST_METHODS_ALLOWED_APP_ROUTES = ["/settings/my-account/general"]; + +export function checkPostMethod(req: NextRequest) { + const pathname = req.nextUrl.pathname; + if ( + ![...POST_METHODS_ALLOWED_API_ROUTES, ...POST_METHODS_ALLOWED_APP_ROUTES].some((route) => + pathname.startsWith(route) + ) && + req.method === "POST" + ) { + return new NextResponse(null, { + status: 405, + statusText: "Method Not Allowed", + headers: { + Allow: "GET", + }, + }); + } + return null; +} + const middleware = async (req: NextRequest): Promise> => { + const postCheckResult = checkPostMethod(req); + if (postCheckResult) return postCheckResult; + const url = req.nextUrl; const requestHeaders = new Headers(req.headers); requestHeaders.set("x-url", req.url); @@ -72,12 +95,6 @@ const middleware = async (req: NextRequest): Promise> => { } } - requestHeaders.set("x-pathname", url.pathname); - - const locale = await getLocale(req); - - requestHeaders.set("x-locale", locale); - const res = NextResponse.next({ request: { headers: requestHeaders, @@ -140,56 +157,22 @@ export const config = { // Next.js Doesn't support spread operator in config matcher, so, we must list all paths explicitly here. // https://github.com/vercel/next.js/discussions/42458 matcher: [ - "/", - "/403", - "/500", - "/icons", - "/d/:path*", - "/more/:path*", - "/maintenance/:path*", - "/enterprise/:path*", - "/upgrade/:path*", - "/connect-and-join/:path*", - "/insights/:path*", "/:path*/embed", "/api/auth/signup", "/api/trpc/:path*", "/login", - "/auth/:path*", + "/auth/login", + "/auth/logout", /** * Paths required by routingForms.handle */ "/apps/routing_forms/:path*", - - "/event-types/:path*", "/apps/installed/:category/", - "/apps/installation/:path*", - "/apps/:slug/", - "/apps/:slug/setup/", - "/apps/categories/", - "/apps/categories/:category/", - "/workflows/:path*", - "/getting-started/:path*", - "/apps", - "/bookings/:path*", - "/video/:path*", - "/teams/:path*", - "/signup/:path*", - "/settings/:path*", - "/reschedule/:path*", - "/availability/:path*", - "/booking/:path*", - "/payment/:path*", - "/routing-forms/:path*", - "/team/:path*", - "/org/:path*", - "/:user/:type/", - "/:user/", ], }; export default collectEvents({ - middleware: abTestMiddlewareFactory(middleware), + middleware, ...nextCollectBasicSettings, cookieName: "__clnds", extend: extendEventData,