fix: use WEBAPP_URL for booking confirmation redirects to fix localhost behind proxy (#28144)
Replace request.url with WEBAPP_URL from @calcom/lib/constants as the base URL for NextResponse.redirect() in booking confirmation API routes. Behind a reverse proxy, request.url resolves to http://localhost:3000 instead of the public domain. Fixes #20358 Co-Authored-By: unknown <> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
unknown
Devin AI
parent
0a84ce5c73
commit
8238d4ffcd
@@ -1,7 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { Mock } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockConfirmHandler = confirmHandler as unknown as Mock<typeof confirmHandler>;
|
||||
|
||||
@@ -82,9 +82,9 @@ vi.mock("@calcom/features/booking-audit/lib/makeActor", () => ({
|
||||
makeUserActor: vi.fn().mockReturnValue({ type: "user", id: "test-uuid" }),
|
||||
}));
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
// Import after mocks are set up
|
||||
import { GET } from "../route";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const createMockRequest = (url: string): NextRequest => {
|
||||
const urlObj = new URL(url);
|
||||
@@ -97,13 +97,16 @@ const createMockRequest = (url: string): NextRequest => {
|
||||
} as unknown as NextRequest;
|
||||
};
|
||||
|
||||
// Vitest sets NEXT_PUBLIC_WEBAPP_URL to http://app.cal.local:3000 (see vitest.config.mts)
|
||||
const EXPECTED_REDIRECT_ORIGIN = "http://app.cal.local:3000";
|
||||
|
||||
describe("link route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET handler - redirect URL construction", () => {
|
||||
it("should redirect to booking page with the same origin as the request", async () => {
|
||||
it("should redirect to booking page using WEBAPP_URL (fixes localhost redirect when behind proxy)", async () => {
|
||||
const baseUrl = "https://app.example.com/api/link?action=accept&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
@@ -113,11 +116,11 @@ describe("link route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
|
||||
});
|
||||
|
||||
it("should preserve custom domain origin in redirect URL", async () => {
|
||||
it("should use WEBAPP_URL for redirects, not request.url (avoids localhost when proxy sends localhost)", async () => {
|
||||
const baseUrl = "https://custom-domain.company.com/api/link?action=accept&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
@@ -127,11 +130,11 @@ describe("link route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://custom-domain.company.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
|
||||
it("should preserve self-hosted domain origin in redirect URL", async () => {
|
||||
it("should use WEBAPP_URL for self-hosted deployments", async () => {
|
||||
const baseUrl = "https://calcom.internal.company.net/api/link?action=reject&token=encrypted-token";
|
||||
const req = createMockRequest(baseUrl);
|
||||
|
||||
@@ -141,11 +144,11 @@ describe("link route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://calcom.internal.company.net");
|
||||
expect(location).not.toContain("localhost");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
|
||||
});
|
||||
|
||||
it("should construct redirect URLs relative to the request URL for various origins", async () => {
|
||||
it("should construct redirect URLs using WEBAPP_URL regardless of request origin", async () => {
|
||||
const testOrigins = [
|
||||
"https://app.cal.com",
|
||||
"https://acme.cal.com",
|
||||
@@ -164,7 +167,7 @@ describe("link route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe(origin);
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
|
||||
}
|
||||
});
|
||||
@@ -174,7 +177,6 @@ describe("link route", () => {
|
||||
it("should redirect with error message when confirmHandler throws a TRPCError", async () => {
|
||||
const { TRPCError } = await import("@trpc/server");
|
||||
|
||||
// Mock confirmHandler to throw a TRPCError
|
||||
mockConfirmHandler.mockRejectedValueOnce(
|
||||
new TRPCError({ code: "BAD_REQUEST", message: "Custom error" })
|
||||
);
|
||||
@@ -188,15 +190,14 @@ describe("link route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-booking-uid");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Custom error");
|
||||
});
|
||||
|
||||
it("should preserve origin in error redirect URL", async () => {
|
||||
it("should use WEBAPP_URL for error redirects (not localhost when behind proxy)", async () => {
|
||||
const { TRPCError } = await import("@trpc/server");
|
||||
|
||||
// Mock confirmHandler to throw a TRPCError
|
||||
mockConfirmHandler.mockRejectedValueOnce(new TRPCError({ code: "INTERNAL_SERVER_ERROR" }));
|
||||
|
||||
const baseUrl = "https://self-hosted.company.org/api/link?action=accept&token=encrypted-token";
|
||||
@@ -208,7 +209,7 @@ describe("link route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://self-hosted.company.org");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import process from "node:process";
|
||||
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { distributedTracing } from "@calcom/lib/tracing/factory";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
enum DirectAction {
|
||||
ACCEPT = "accept",
|
||||
@@ -99,11 +100,11 @@ async function handler(request: NextRequest) {
|
||||
let message = "Error confirming booking";
|
||||
if (e instanceof TRPCError) message = (e as TRPCError).message;
|
||||
return NextResponse.redirect(
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, request.url)
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`, WEBAPP_URL)
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL(`/booking/${bookingUid}`, request.url));
|
||||
return NextResponse.redirect(new URL(`/booking/${bookingUid}`, WEBAPP_URL));
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(handler);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { Mock } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockConfirmHandler = confirmHandler as unknown as Mock<typeof confirmHandler>;
|
||||
|
||||
vi.mock("app/api/defaultResponderForAppDir", () => ({
|
||||
@@ -80,6 +81,9 @@ function setMockRequestBody(body: Record<string, unknown>) {
|
||||
mockRequestBody = body;
|
||||
}
|
||||
|
||||
// Vitest sets NEXT_PUBLIC_WEBAPP_URL to http://app.cal.local:3000 (see vitest.config.mts)
|
||||
const EXPECTED_REDIRECT_ORIGIN = "http://app.cal.local:3000";
|
||||
|
||||
function expectErrorRedirect(res: Response, path: string, error: string) {
|
||||
const location = res.headers.get("location");
|
||||
expect(location).toBeTruthy();
|
||||
@@ -88,8 +92,10 @@ function expectErrorRedirect(res: Response, path: string, error: string) {
|
||||
expect(redirectUrl.searchParams.get("error")).toBe(error);
|
||||
}
|
||||
|
||||
import process from "node:process";
|
||||
// Import after mocks are set up
|
||||
import { GET, POST } from "../route";
|
||||
|
||||
const DB = {
|
||||
bookings: {} as Record<
|
||||
string,
|
||||
@@ -204,7 +210,7 @@ describe("verify-booking-token route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/abc123");
|
||||
expect(redirectUrl.searchParams.get("error")).toBeNull();
|
||||
expect(mockConfirmHandler).toHaveBeenCalledWith(
|
||||
@@ -229,7 +235,7 @@ describe("verify-booking-token route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/abc123");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
|
||||
});
|
||||
@@ -245,12 +251,12 @@ describe("verify-booking-token route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/abc123");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
|
||||
});
|
||||
|
||||
it("should preserve the request origin in redirect URL (not hardcode localhost)", async () => {
|
||||
it("should use WEBAPP_URL for redirects (fixes localhost when behind proxy)", async () => {
|
||||
const baseUrl =
|
||||
"https://custom-domain.example.org/api/verify-booking-token?action=reject&token=t&bookingUid=booking-uid&userId=1";
|
||||
const req = createMockRequest(baseUrl, "GET");
|
||||
@@ -261,7 +267,7 @@ describe("verify-booking-token route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://custom-domain.example.org");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
|
||||
@@ -294,12 +300,12 @@ describe("verify-booking-token route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://app.example.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/abc123");
|
||||
expect(redirectUrl.searchParams.get("error")).toBe("Error confirming booking");
|
||||
});
|
||||
|
||||
it("should preserve the request origin in POST redirect URL", async () => {
|
||||
it("should use WEBAPP_URL for POST redirects (fixes localhost when behind proxy)", async () => {
|
||||
const baseUrl =
|
||||
"https://self-hosted.company.com/api/verify-booking-token?bookingUid=uid123&token=t&userId=1&action=reject";
|
||||
const req = createMockRequest(baseUrl, "POST");
|
||||
@@ -310,13 +316,13 @@ describe("verify-booking-token route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe("https://self-hosted.company.com");
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(location).not.toContain("localhost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redirect URL construction", () => {
|
||||
it("should construct redirect URLs relative to the request URL, not hardcoded origins", async () => {
|
||||
it("should construct redirect URLs using WEBAPP_URL regardless of request origin", async () => {
|
||||
const testOrigins = [
|
||||
"https://app.cal.com",
|
||||
"https://acme.cal.com",
|
||||
@@ -335,7 +341,7 @@ describe("verify-booking-token route", () => {
|
||||
expect(location).toBeTruthy();
|
||||
const redirectUrl = new URL(location!);
|
||||
|
||||
expect(redirectUrl.origin).toBe(origin);
|
||||
expect(redirectUrl.origin).toBe(EXPECTED_REDIRECT_ORIGIN);
|
||||
expect(redirectUrl.pathname).toBe("/booking/test-uid");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { makeUserActor } from "@calcom/features/booking-audit/lib/makeActor";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { distributedTracing } from "@calcom/lib/tracing/factory";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { confirmHandler } from "@calcom/trpc/server/routers/viewer/bookings/confirm.handler";
|
||||
@@ -31,7 +32,7 @@ async function getHandler(request: NextRequest) {
|
||||
} catch {
|
||||
const bookingUid = queryParams.bookingUid || "";
|
||||
return NextResponse.redirect(
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url)
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,7 +49,7 @@ async function postHandler(request: NextRequest) {
|
||||
} catch {
|
||||
const bookingUid = queryParams.bookingUid || "";
|
||||
return NextResponse.redirect(
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL),
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
@@ -59,7 +60,7 @@ async function handleBookingAction(
|
||||
token: string,
|
||||
bookingUid: string,
|
||||
userId: string,
|
||||
request: NextRequest,
|
||||
_request: NextRequest,
|
||||
reason?: string
|
||||
) {
|
||||
const booking = await prisma.booking.findUnique({
|
||||
@@ -68,7 +69,7 @@ async function handleBookingAction(
|
||||
|
||||
if (!booking) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
|
||||
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, WEBAPP_URL),
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
@@ -113,7 +114,7 @@ async function handleBookingAction(
|
||||
let message = "Error confirming booking";
|
||||
if (e instanceof TRPCError) message = (e as TRPCError).message;
|
||||
return NextResponse.redirect(
|
||||
new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, request.url),
|
||||
new URL(`/booking/${booking.uid}?error=${encodeURIComponent(message)}`, WEBAPP_URL),
|
||||
{ status: 303 }
|
||||
);
|
||||
}
|
||||
@@ -123,7 +124,7 @@ async function handleBookingAction(
|
||||
data: { oneTimePassword: null },
|
||||
});
|
||||
|
||||
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, request.url), { status: 303 });
|
||||
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, WEBAPP_URL), { status: 303 });
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
|
||||
Reference in New Issue
Block a user