Files
calendar/apps/web/app/api/verify-booking-token/route.ts
T
Volnei MunhozGitHubVolnei MunhozDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2ad24048b6 fix: use relative redirects in booking confirmation routes to fix localhost redirect issue (#25794)
* fix: use relative redirects in booking confirmation routes to fix localhost redirect issue

When Cal.com runs behind a reverse proxy, request.url may return the internal
server URL (localhost:3000) instead of the external URL. This causes users to
be redirected to localhost after confirming bookings via email links.

This fix changes the redirect URLs from using url.origin (which could be
localhost) to using relative URLs via new URL(path, request.url). This ensures
the browser resolves the redirect against whatever domain the user actually
requested, fixing the issue for self-hosted deployments behind proxies.

Fixes #20358

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* test: add tests for booking confirmation redirect URL construction

Add unit tests for verify-booking-token and link API routes to ensure:
- Redirect URLs preserve the request origin (not hardcoded localhost)
- Redirect URLs use the correct booking path (/booking/{uid})
- Error messages are properly encoded in query params
- POST handler returns correct 303 status code

These tests verify the fix for #20358 where users were redirected to
localhost:3000 instead of the proper production URL after confirming
bookings via email links.

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: add second argument to GET/POST calls in test files to fix type errors

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-12 19:02:54 +05:30

158 lines
5.0 KiB
TypeScript

import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { parseRequestData } from "app/api/parseRequestData";
import { headers, cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import prisma from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { createContext } from "@calcom/trpc/server/createContext";
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
import { createCallerFactory } from "@calcom/trpc/server/trpc";
import type { UserProfile } from "@calcom/types/UserProfile";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TRPCError } from "@trpc/server";
enum DirectAction {
ACCEPT = "accept",
REJECT = "reject",
}
const querySchema = z.object({
action: z.nativeEnum(DirectAction),
token: z.string(),
bookingUid: z.string(),
userId: z.string(),
});
async function getHandler(request: NextRequest) {
const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries());
try {
const { action, token, bookingUid, userId } = querySchema.parse(queryParams);
if (action === DirectAction.REJECT) {
// Rejections should use POST method
return NextResponse.redirect(
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Rejection requires POST method")}`, request.url)
);
}
return await handleBookingAction(action, token, bookingUid, userId, request, undefined);
} catch {
const bookingUid = queryParams.bookingUid || "";
return NextResponse.redirect(
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url)
);
}
}
async function postHandler(request: NextRequest) {
const queryParams = Object.fromEntries(request.nextUrl.searchParams.entries());
try {
const { action, token, bookingUid, userId } = querySchema.parse(queryParams);
const body = await parseRequestData(request).catch(() => ({}));
const { reason } = z.object({ reason: z.string().optional() }).parse(body || {});
return await handleBookingAction(action, token, bookingUid, userId, request, reason);
} catch {
const bookingUid = queryParams.bookingUid || "";
return NextResponse.redirect(
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
{ status: 303 }
);
}
}
async function handleBookingAction(
action: DirectAction,
token: string,
bookingUid: string,
userId: string,
request: NextRequest,
reason?: string
) {
const booking = await prisma.booking.findUnique({
where: { oneTimePassword: token },
});
if (!booking) {
return NextResponse.redirect(
new URL(`/booking/${bookingUid}?error=${encodeURIComponent("Error confirming booking")}`, request.url),
{ status: 303 }
);
}
const user = await prisma.user.findUniqueOrThrow({
where: { id: Number(userId) },
});
/** We shape the session as required by tRPC router */
async function sessionGetter() {
return {
user: {
id: Number(userId),
username: "" /* Not used in this context */,
role: UserPermissionRole.USER,
/* Not used in this context */
profile: {
id: null,
organizationId: null,
organization: null,
username: "",
upId: "",
} satisfies UserProfile,
},
upId: "",
hasValidLicense: true,
expires: "" /* Not used in this context */,
};
}
try {
/** @see https://trpc.io/docs/server-side-calls */
const createCaller = createCallerFactory(bookingsRouter);
// Use buildLegacyRequest to create a request object compatible with Pages Router
const legacyReq = request ? buildLegacyRequest(await headers(), await cookies()) : ({} as any);
const res = {} as any;
const ctx = await createContext({ req: legacyReq, res }, sessionGetter);
const caller = createCaller({
...ctx,
req: legacyReq,
res,
user: { ...user, locale: user?.locale ?? "en" },
});
await caller.confirm({
bookingId: booking.id,
recurringEventId: booking.recurringEventId || undefined,
confirmed: action === DirectAction.ACCEPT,
/** Ignored reason input unless we're rejecting */
reason: action === DirectAction.REJECT ? reason : undefined,
});
} catch (e) {
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),
{ status: 303 }
);
}
await prisma.booking.update({
where: { id: booking.id },
data: { oneTimePassword: null },
});
return NextResponse.redirect(new URL(`/booking/${booking.uid}`, request.url), { status: 303 });
}
export const GET = defaultResponderForAppDir(getHandler);
export const POST = defaultResponderForAppDir(postHandler);