Hash my url (#2484)

* disposable link model added

* disposable model updated

* added disposable slug availability page

* added disposable book page

* added disposable slug hook

* added disposable link booking flow

* updated schema

* checktype fix

* added checkfix and schema generated

* create link API added

* added one time link view on event type list

* adjusted schema

* fixed disposable visual indicator

* expired check and visual indicator added

* updated slug for disposable event type

* revised schema

* WIP

* revert desc

* revert --WIP

* rework based on change of plans

* further adjustments

* added eventtype option for hashed link

* added refresh and delete on update

* fixed update call conditions

* cleanup

* code improvement

* clean up

* Potential fix for 404

* backward compat for booking page

* fixes regular booking for user and team

* typefix

* updated path for Booking import

* checkfix

* e2e wip

* link err fix

* workaround for banner issue in event type update-test

* added regenerate hash check

* fixed test according to new testID

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Syed Ali Shahbaz 2022-04-28 21:14:26 +05:30 committed by GitHub
parent 8e956893ca
commit 59a1db9068
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 627 additions and 3 deletions

View File

@ -49,6 +49,7 @@ import AvatarGroup from "@components/ui/AvatarGroup";
import type PhoneInputType from "@components/ui/form/PhoneInput";
import { BookPageProps } from "../../../pages/[user]/book";
import { HashLinkPageProps } from "../../../pages/d/[link]/book";
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
/** These are like 40kb that not every user needs */
@ -56,7 +57,7 @@ const PhoneInput = dynamic(
() => import("@components/ui/form/PhoneInput")
) as unknown as typeof PhoneInputType;
type BookingPageProps = BookPageProps | TeamBookingPageProps;
type BookingPageProps = BookPageProps | TeamBookingPageProps | HashLinkPageProps;
type BookingFormValues = {
name: string;
@ -76,6 +77,8 @@ const BookingPage = ({
profile,
isDynamicGroupBooking,
locationLabels,
hasHashedBookingLink,
hashedLink,
}: BookingPageProps) => {
const { t, i18n } = useLocale();
const isEmbed = useIsEmbed();
@ -290,6 +293,8 @@ const BookingPage = ({
label: eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
value: booking.customInputs![inputId],
})),
hasHashedBookingLink,
hashedLink,
});
};

View File

@ -27,6 +27,8 @@ export type BookingCreateBody = {
metadata: {
[key: string]: string;
};
hasHashedBookingLink: boolean;
hashedLink?: string | null;
};
export type BookingResponse = Booking & {

View File

@ -213,6 +213,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
booking,
trpcState: ssr.dehydrate(),
isDynamicGroupBooking,
hasHashedBookingLink: false,
hashedLink: null,
},
};
}

View File

@ -233,6 +233,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const dynamicUserList = Array.isArray(reqBody.user)
? getGroupName(req.body.user)
: getUsernameList(reqBody.user as string);
const hasHashedBookingLink = reqBody.hasHashedBookingLink;
const eventTypeSlug = reqBody.eventTypeSlug;
const eventTypeId = reqBody.eventTypeId;
const tAttendees = await getTranslation(reqBody.language ?? "en", "common");
@ -780,6 +781,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
},
});
// refresh hashed link if used
const urlSeed = `${users[0].username}:${dayjs(req.body.start).utc().format()}`;
const hashedUid = translator.fromUUID(uuidv5(urlSeed, uuidv5.URL));
if (hasHashedBookingLink) {
await prisma.hashedLink.update({
where: {
link: reqBody.hashedLink as string,
},
data: {
link: hashedUid,
},
});
}
// booking successful
return res.status(201).json(booking);

View File

@ -0,0 +1,182 @@
import { Prisma } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import { GetBookingType } from "@lib/getBooking";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import AvailabilityPage from "@components/booking/pages/AvailabilityPage";
import { ssrInit } from "@server/lib/ssr";
export type AvailabilityPageProps = inferSSRProps<typeof getServerSideProps>;
export default function Type(props: AvailabilityPageProps) {
return <AvailabilityPage {...props} />;
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
const link = asStringOrNull(context.query.link) || "";
const slug = asStringOrNull(context.query.slug) || "";
const dateParam = asStringOrNull(context.query.date);
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true,
title: true,
availability: true,
description: true,
length: true,
price: true,
currency: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
schedulingType: true,
userId: true,
schedule: {
select: {
availability: true,
timeZone: true,
},
},
hidden: true,
slug: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
timeZone: true,
metadata: true,
slotInterval: true,
users: {
select: {
id: true,
avatar: true,
name: true,
username: true,
hideBranding: true,
plan: true,
timeZone: true,
},
},
});
const hashedLink = await prisma.hashedLink.findUnique({
where: {
link,
},
select: {
eventTypeId: true,
eventType: {
select: eventTypeSelect,
},
},
});
const userId = hashedLink?.eventType.userId || hashedLink?.eventType.users[0]?.id;
if (!userId)
return {
notFound: true,
};
if (hashedLink?.eventType.slug !== slug)
return {
notFound: true,
};
const users = await prisma.user.findMany({
where: {
id: userId,
},
select: {
id: true,
username: true,
name: true,
email: true,
bio: true,
avatar: true,
startTime: true,
endTime: true,
timeZone: true,
weekStart: true,
availability: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
defaultScheduleId: true,
allowDynamicBooking: true,
away: true,
schedules: {
select: {
availability: true,
timeZone: true,
id: true,
},
},
theme: true,
plan: true,
},
});
if (!users || !users.length) {
return {
notFound: true,
};
}
const [user] = users;
const eventTypeObject = Object.assign({}, hashedLink.eventType, {
metadata: {} as JSONObject,
periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null,
periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null,
slug,
});
const schedule = {
...user.schedules.filter(
(schedule) => !user.defaultScheduleId || schedule.id === user.defaultScheduleId
)[0],
};
const timeZone = schedule.timeZone || user.timeZone;
const workingHours = getWorkingHours(
{
timeZone,
},
schedule.availability || user.availability
);
eventTypeObject.schedule = null;
eventTypeObject.availability = [];
let booking: GetBookingType | null = null;
const profile = {
name: user.name || user.username,
image: user.avatar,
slug: user.username,
theme: user.theme,
weekStart: user.weekStart,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
};
return {
props: {
away: user.away,
isDynamicGroup: false,
profile,
plan: user.plan,
date: dateParam,
eventType: eventTypeObject,
workingHours,
trpcState: ssr.dehydrate(),
previousPage: context.req.headers.referer ?? null,
booking,
},
};
};

View File

@ -0,0 +1,183 @@
import { Prisma } from "@prisma/client";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { getLocationLabels } from "@calcom/app-store/utils";
import { asStringOrThrow } from "@lib/asStringOrNull";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import BookingPage from "@components/booking/pages/BookingPage";
import { getTranslation } from "@server/lib/i18n";
import { ssrInit } from "@server/lib/ssr";
dayjs.extend(utc);
dayjs.extend(timezone);
export type HashLinkPageProps = inferSSRProps<typeof getServerSideProps>;
export default function Book(props: HashLinkPageProps) {
return <BookingPage {...props} />;
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const link = asStringOrThrow(context.query.link as string);
const slug = context.query.slug as string;
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
id: true,
title: true,
slug: true,
description: true,
length: true,
locations: true,
customInputs: true,
periodType: true,
periodDays: true,
periodStartDate: true,
periodEndDate: true,
metadata: true,
periodCountCalendarDays: true,
price: true,
currency: true,
disableGuests: true,
userId: true,
users: {
select: {
id: true,
username: true,
name: true,
email: true,
bio: true,
avatar: true,
theme: true,
},
},
});
const hashedLink = await prisma.hashedLink.findUnique({
where: {
link,
},
select: {
eventTypeId: true,
eventType: {
select: eventTypeSelect,
},
},
});
const userId = hashedLink?.eventType.userId || hashedLink?.eventType.users[0]?.id;
if (!userId)
return {
notFound: true,
};
const users = await prisma.user.findMany({
where: {
id: userId,
},
select: {
id: true,
username: true,
name: true,
email: true,
bio: true,
avatar: true,
theme: true,
brandColor: true,
darkBrandColor: true,
allowDynamicBooking: true,
},
});
if (!users.length) return { notFound: true };
const [user] = users;
const eventTypeRaw = hashedLink?.eventType;
if (!eventTypeRaw) return { notFound: true };
const credentials = await prisma.credential.findMany({
where: {
userId: {
in: users.map((user) => user.id),
},
},
select: {
id: true,
type: true,
key: true,
},
});
const web3Credentials = credentials.find((credential) => credential.type.includes("_web3"));
const eventType = {
...eventTypeRaw,
metadata: (eventTypeRaw.metadata || {}) as JSONObject,
isWeb3Active:
web3Credentials && web3Credentials.key
? (((web3Credentials.key as JSONObject).isWeb3Active || false) as boolean)
: false,
};
const eventTypeObject = [eventType].map((e) => {
return {
...e,
periodStartDate: e.periodStartDate?.toString() ?? null,
periodEndDate: e.periodEndDate?.toString() ?? null,
};
})[0];
async function getBooking() {
return prisma.booking.findFirst({
where: {
uid: asStringOrThrow(context.query.rescheduleUid),
},
select: {
description: true,
attendees: {
select: {
email: true,
name: true,
},
},
},
});
}
type Booking = Prisma.PromiseReturnType<typeof getBooking>;
let booking: Booking | null = null;
const profile = {
name: user.name || user.username,
image: user.avatar,
slug: user.username,
theme: user.theme,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
eventName: null,
};
const t = await getTranslation(context.locale ?? "en", "common");
return {
props: {
locationLabels: getLocationLabels(t),
profile,
eventType: eventTypeObject,
booking,
trpcState: ssr.dehydrate(),
isDynamicGroupBooking: false,
hasHashedBookingLink: true,
hashedLink: link,
},
};
}

View File

@ -2,6 +2,7 @@ import { GlobeAltIcon, PhoneIcon, XIcon } from "@heroicons/react/outline";
import {
ChevronRightIcon,
ClockIcon,
DocumentDuplicateIcon,
DocumentIcon,
ExternalLinkIcon,
LinkIcon,
@ -52,6 +53,7 @@ import { ClientSuspense } from "@components/ClientSuspense";
import DestinationCalendarSelector from "@components/DestinationCalendarSelector";
import Loader from "@components/Loader";
import Shell from "@components/Shell";
import { Tooltip } from "@components/Tooltip";
import { UpgradeToProDialog } from "@components/UpgradeToProDialog";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
@ -262,6 +264,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
const [advancedSettingsVisible, setAdvancedSettingsVisible] = useState(false);
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
useEffect(() => {
const fetchTokens = async () => {
@ -442,6 +445,10 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
team ? `team/${team.slug}` : eventType.users[0].username
}/${eventType.slug}`;
const placeholderHashedLink = `${process.env.NEXT_PUBLIC_WEBSITE_URL}/d/${
eventType.hashedLink ? eventType.hashedLink.link : "xxxxxxxxxxxxxxxxx"
}/${eventType.slug}`;
const mapUserToValue = ({
id,
name,
@ -471,6 +478,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
currency: string;
hidden: boolean;
hideCalendarNotes: boolean;
hashedLink: boolean;
locations: { type: LocationType; address?: string; link?: string }[];
customInputs: EventTypeCustomInput[];
users: string[];
@ -1333,6 +1341,65 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
)}
/>
<Controller
name="hashedLink"
control={formMethods.control}
defaultValue={eventType.hashedLink ? true : false}
render={() => (
<>
<CheckboxField
id="hashedLink"
name="hashedLink"
label={t("hashed_link")}
description={t("hashed_link_description")}
defaultChecked={eventType.hashedLink ? true : false}
onChange={(e) => {
setHashedLinkVisible(e?.target.checked);
formMethods.setValue("hashedLink", e?.target.checked);
}}
/>
{hashedLinkVisible && (
<div className="block items-center sm:flex">
<div className="min-w-48 mb-4 sm:mb-0"></div>
<div className="w-full">
<div className="relative mt-1 flex w-full">
<input
disabled
data-testid="generated-hash-url"
type="text"
className=" grow select-none border-gray-300 bg-gray-50 text-sm text-gray-500 ltr:rounded-l-sm rtl:rounded-r-sm"
defaultValue={placeholderHashedLink}
/>
<Tooltip
content={
eventType.hashedLink
? t("copy_to_clipboard")
: t("enabled_after_update")
}>
<Button
color="minimal"
onClick={() => {
if (eventType.hashedLink) {
navigator.clipboard.writeText(placeholderHashedLink);
showToast("Link copied!", "success");
}
}}
type="button"
className="text-md flex items-center border border-gray-300 px-2 py-1 text-sm font-medium text-gray-700 ltr:rounded-r-sm ltr:border-l-0 rtl:rounded-l-sm rtl:border-r-0">
<DocumentDuplicateIcon className="w-6 p-1 text-neutral-500" />
</Button>
</Tooltip>
</div>
<span className="text-xs text-gray-500">
The URL will regenerate after each use
</span>
</div>
</div>
)}
</>
)}
/>
<hr className="my-2 border-neutral-200" />
<Controller
name="minimumBookingNotice"
@ -1944,6 +2011,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
beforeEventBuffer: true,
afterEventBuffer: true,
slotInterval: true,
hashedLink: true,
successRedirectUrl: true,
team: {
select: {

View File

@ -98,6 +98,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
eventType: eventTypeObject,
booking,
isDynamicGroupBooking: false,
hasHashedBookingLink: false,
hashedLink: null,
},
};
}

View File

@ -0,0 +1,75 @@
import { expect, test } from "@playwright/test";
import { deleteAllBookingsByEmail } from "./lib/teardown";
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe("hash my url", () => {
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
let $url = "";
test.beforeEach(async ({ page }) => {
await deleteAllBookingsByEmail("pro@example.com");
await page.goto("/event-types");
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
});
test.afterAll(async () => {
// delete test bookings
await deleteAllBookingsByEmail("pro@example.com");
});
test("generate url hash", async ({ page }) => {
// await page.pause();
await page.goto("/event-types");
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
await page.click('//ul[@data-testid="event-types"]/li[1]');
// We wait for the page to load
await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
await page.click('//*[@data-testid="show-advanced-settings"]');
// we wait for the hashedLink setting to load
await page.waitForSelector('//*[@id="hashedLink"]');
await page.click('//*[@id="hashedLink"]');
// click update
await page.focus('//button[@type="submit"]');
await page.keyboard.press("Enter");
});
test("book using generated url hash", async ({ page }) => {
// await page.pause();
await page.goto("/event-types");
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
await page.click('//ul[@data-testid="event-types"]/li[1]');
// We wait for the page to load
await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
await page.click('//*[@data-testid="show-advanced-settings"]');
// we wait for the hashedLink setting to load
await page.waitForSelector('//*[@data-testid="generated-hash-url"]');
$url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
await page.goto($url);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
});
test("hash regenerates after successful booking", async ({ page }) => {
await page.goto("/event-types");
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
await page.click('//ul[@data-testid="event-types"]/li[1]');
// We wait for the page to load
await page.waitForSelector('//*[@data-testid="show-advanced-settings"]');
await page.click('//*[@data-testid="show-advanced-settings"]');
// we wait for the hashedLink setting to load
await page.waitForSelector('//*[@data-testid="generated-hash-url"]');
const $newUrl = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
expect($url !== $newUrl).toBeTruthy();
});
});

View File

@ -500,6 +500,7 @@
"url": "URL",
"hidden": "Hidden",
"readonly": "Readonly",
"one_time_link": "One-time link",
"plan_description": "You're currently on the {{plan}} plan.",
"plan_upgrade_invitation": "Upgrade your account to the pro plan to unlock all of the features we have to offer.",
"plan_upgrade": "You need to upgrade your plan to have more than one active event type.",
@ -583,6 +584,8 @@
"opt_in_booking_description": "The booking needs to be manually confirmed before it is pushed to the integrations and a confirmation mail is sent.",
"disable_guests": "Disable Guests",
"disable_guests_description": "Disable adding additional guests while booking.",
"hashed_link": "Generate hashed URL",
"hashed_link_description": "Generate a hashed URL to share without exposing your Cal username",
"invitees_can_schedule": "Invitees can schedule",
"date_range": "Date Range",
"calendar_days": "calendar days",
@ -745,6 +748,7 @@
"success_api_key_created_bold_tagline": "Save this API key somewhere safe.",
"you_will_only_view_it_once": "You will not be able to view it again once you close this modal.",
"copy_to_clipboard": "Copy to clipboard",
"enabled_after_update": "Enabled after update",
"confirm_delete_api_key": "Revoke this API key",
"revoke_api_key": "Revoke API key",
"api_key_copied": "API key copied!",

View File

@ -133,6 +133,7 @@ const loggedInViewerRouter = createProtectedRouter()
currency: true,
position: true,
successRedirectUrl: true,
hashedLink: true,
users: {
select: {
id: true,

View File

@ -1,4 +1,6 @@
import { EventTypeCustomInput, MembershipRole, PeriodType, Prisma } from "@prisma/client";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { z } from "zod";
import {
@ -89,6 +91,7 @@ const EventTypeUpdateInput = _EventTypeModel
}),
users: z.array(stringOrNumber).optional(),
schedule: z.number().optional(),
hashedLink: z.boolean(),
})
.partial()
.merge(
@ -214,8 +217,17 @@ export const eventTypesRouter = createProtectedRouter()
.mutation("update", {
input: EventTypeUpdateInput.strict(),
async resolve({ ctx, input }) {
const { schedule, periodType, locations, destinationCalendar, customInputs, users, id, ...rest } =
input;
const {
schedule,
periodType,
locations,
destinationCalendar,
customInputs,
users,
id,
hashedLink,
...rest
} = input;
assertValidUrl(input.successRedirectUrl);
const data: Prisma.EventTypeUpdateInput = rest;
data.locations = locations ?? undefined;
@ -250,6 +262,48 @@ export const eventTypesRouter = createProtectedRouter()
};
}
const connectedLink = await ctx.prisma.hashedLink.findFirst({
where: {
eventTypeId: input.id,
},
select: {
id: true,
},
});
if (hashedLink) {
// check if hashed connection existed. If it did, do nothing. If it didn't, add a new connection
if (!connectedLink) {
const translator = short();
const seed = `${input.eventName}:${input.id}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
// create a hashed link
await ctx.prisma.hashedLink.upsert({
where: {
eventTypeId: input.id,
},
update: {
link: uid,
},
create: {
link: uid,
eventType: {
connect: { id: input.id },
},
},
});
}
} else {
// check if hashed connection exists. If it does, disconnect
if (connectedLink) {
await ctx.prisma.hashedLink.delete({
where: {
eventTypeId: input.id,
},
});
}
}
const eventType = await ctx.prisma.eventType.update({
where: { id },
data,

View File

@ -0,0 +1,23 @@
-- DropForeignKey
ALTER TABLE "BookingReference" DROP CONSTRAINT "BookingReference_bookingId_fkey";
-- CreateTable
CREATE TABLE "HashedLink" (
"id" SERIAL NOT NULL,
"link" TEXT NOT NULL,
"eventTypeId" INTEGER NOT NULL,
CONSTRAINT "HashedLink_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "HashedLink_link_key" ON "HashedLink"("link");
-- CreateIndex
CREATE UNIQUE INDEX "HashedLink_eventTypeId_key" ON "HashedLink"("eventTypeId");
-- AddForeignKey
ALTER TABLE "BookingReference" ADD CONSTRAINT "BookingReference_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HashedLink" ADD CONSTRAINT "HashedLink_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -45,6 +45,7 @@ model EventType {
userId Int?
team Team? @relation(fields: [teamId], references: [id])
teamId Int?
hashedLink HashedLink?
bookings Booking[]
availability Availability[]
webhooks Webhook[]
@ -406,6 +407,13 @@ model ApiKey {
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model HashedLink {
id Int @id @default(autoincrement())
link String @unique()
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
eventTypeId Int @unique
}
model Account {
id String @id @default(cuid())
userId Int