From 518cfbc03755a94e917bf6933daac9e29219dce9 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Fri, 8 Dec 2023 02:38:51 +0530 Subject: [PATCH] feat: daily webhooks (#12273) Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> --- .env.appStore.example | 1 + apps/web/pages/api/recorded-daily-video.ts | 153 +++++++++------------ apps/web/pages/video/[uid].tsx | 28 ---- turbo.json | 1 + 4 files changed, 68 insertions(+), 115 deletions(-) diff --git a/.env.appStore.example b/.env.appStore.example index 2719175e4a..1b51680e41 100644 --- a/.env.appStore.example +++ b/.env.appStore.example @@ -37,6 +37,7 @@ BASECAMP3_USER_AGENT= DAILY_API_KEY= DAILY_SCALE_PLAN='' +DAILY_WEBHOOK_SECRET='' # - GOOGLE CALENDAR/MEET/LOGIN # Needed to enable Google Calendar integration and Login with Google diff --git a/apps/web/pages/api/recorded-daily-video.ts b/apps/web/pages/api/recorded-daily-video.ts index c35a9d5c7f..9886fc6863 100644 --- a/apps/web/pages/api/recorded-daily-video.ts +++ b/apps/web/pages/api/recorded-daily-video.ts @@ -1,24 +1,38 @@ import type { WebhookTriggerEvents } from "@prisma/client"; +import { createHmac } from "crypto"; import type { NextApiRequest, NextApiResponse } from "next"; import { z } from "zod"; import { DailyLocationType } from "@calcom/app-store/locations"; import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient"; import { sendDailyVideoRecordingEmails } from "@calcom/emails"; -import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; -import { IS_SELF_HOSTED } from "@calcom/lib/constants"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import { defaultHandler } from "@calcom/lib/server"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import type { CalendarEvent } from "@calcom/types/Calendar"; -const schema = z.object({ - recordingId: z.string(), - bookingUID: z.string(), -}); +const schema = z + .object({ + version: z.string(), + type: z.string(), + id: z.string(), + payload: z.object({ + recording_id: z.string(), + end_ts: z.number(), + room_name: z.string(), + start_ts: z.number(), + status: z.string(), + + max_participants: z.number(), + duration: z.number(), + s3_key: z.string(), + }), + event_ts: z.number(), + }) + .passthrough(); const downloadLinkSchema = z.object({ download_link: z.string(), @@ -39,8 +53,8 @@ const triggerWebhook = async ({ }; }) => { const eventTrigger: WebhookTriggerEvents = "RECORDING_READY"; - // Send Webhook call if hooked to BOOKING.RECORDING_READY + // Send Webhook call if hooked to BOOKING.RECORDING_READY const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId); const subscriberOptions = { @@ -62,71 +76,62 @@ const triggerWebhook = async ({ await Promise.all(promises); }; -const checkIfUserIsPartOfTheSameTeam = async ( - teamId: number | undefined | null, - userId: number, - userEmail: string | undefined | null -) => { - if (!teamId) return false; - - const getUserQuery = () => { - if (!!userEmail) { - return { - OR: [ - { - id: userId, - }, - { - email: userEmail, - }, - ], - }; - } else { - return { - id: userId, - }; - } - }; - - const team = await prisma.team.findFirst({ - where: { - id: teamId, - members: { - some: { - user: getUserQuery(), - }, - }, - }, - }); - - return !!team; -}; +const testRequestSchema = z.object({ + test: z.enum(["test"]), +}); async function handler(req: NextApiRequest, res: NextApiResponse) { if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) { return res.status(405).json({ message: "No SendGrid API key or email" }); } - const response = schema.safeParse(JSON.parse(req.body)); - if (!response.success) { + if (testRequestSchema.safeParse(req.body).success) { + return res.status(200).json({ message: "Test request successful" }); + } + + const hmacSecret = process.env.DAILY_WEBHOOK_SECRET; + if (!hmacSecret) { + return res.status(405).json({ message: "No Daily Webhook Secret" }); + } + + const signature = `${req.headers["x-webhook-timestamp"]}.${JSON.stringify(req.body)}`; + const base64DecodedSecret = Buffer.from(hmacSecret, "base64"); + const hmac = createHmac("sha256", base64DecodedSecret); + const computed_signature = hmac.update(signature).digest("base64"); + + if (req.headers["x-webhook-signature"] !== computed_signature) { + return res.status(403).json({ message: "Signature does not match" }); + } + + const response = schema.safeParse(req.body); + + if (!response.success || response.data.type !== "recording.ready-to-download") { return res.status(400).send({ message: "Invalid Payload", }); } - const { recordingId, bookingUID } = response.data; - const session = await getServerSession({ req, res }); + const { room_name, recording_id, status } = response.data.payload; - if (!session?.user) { - return res.status(401).send({ - message: "User not logged in", + if (status !== "finished") { + return res.status(400).send({ + message: "Recording not finished", }); } try { - const booking = await prisma.booking.findFirst({ + const bookingReference = await prisma.bookingReference.findFirst({ + where: { type: "daily_video", uid: room_name, meetingId: room_name }, + select: { bookingId: true }, + }); + + if (!bookingReference || !bookingReference.bookingId) { + return res.status(404).send({ message: "Booking reference not found" }); + } + + const booking = await prisma.booking.findUniqueOrThrow({ where: { - uid: bookingUID, + id: bookingReference.bookingId, }, select: { ...bookingMinimalSelect, @@ -153,9 +158,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }, }); - if (!booking || booking.location !== DailyLocationType) { + if (!booking || !(booking.location === DailyLocationType || booking?.location?.trim() === "")) { return res.status(404).send({ - message: `Booking of uid ${bookingUID} does not exist or does not contain daily video as location`, + message: `Booking of room_name ${room_name} does not exist or does not contain daily video as location`, }); } @@ -175,26 +180,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const attendeesList = await Promise.all(attendeesListPromises); - const isUserAttendeeOrOrganiser = - booking?.user?.id === session.user.id || - attendeesList.find( - (attendee) => attendee.id === session.user.id || attendee.email === session.user.email - ); - - if (!isUserAttendeeOrOrganiser) { - const isUserMemberOfTheTeam = checkIfUserIsPartOfTheSameTeam( - booking?.eventType?.teamId, - session.user.id, - session.user.email - ); - - if (!isUserMemberOfTheTeam) { - return res.status(403).send({ - message: "Unauthorised", - }); - } - } - await prisma.booking.update({ where: { uid: booking.uid, @@ -204,7 +189,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }, }); - const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId); + const response = await getDownloadLinkOfCalVideoByRecordingId(recording_id); const downloadLinkResponse = downloadLinkSchema.parse(response); const downloadLink = downloadLinkResponse.download_link; @@ -242,17 +227,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }, }); - const isSendingEmailsAllowed = IS_SELF_HOSTED || session?.user?.belongsToActiveTeam; - // send emails to all attendees only when user has team plan - if (isSendingEmailsAllowed) { - await sendDailyVideoRecordingEmails(evt, downloadLink); - return res.status(200).json({ message: "Success" }); - } - - return res.status(403).json({ message: "User does not have team plan to send out emails" }); + await sendDailyVideoRecordingEmails(evt, downloadLink); + return res.status(200).json({ message: "Success" }); } catch (err) { - console.warn("Error in /recorded-daily-video", err); + console.error("Error in /recorded-daily-video", err); return res.status(500).json({ message: "something went wrong" }); } } diff --git a/apps/web/pages/video/[uid].tsx b/apps/web/pages/video/[uid].tsx index 0f8340501c..5661ec9b62 100644 --- a/apps/web/pages/video/[uid].tsx +++ b/apps/web/pages/video/[uid].tsx @@ -1,10 +1,8 @@ -import type { DailyEventObjectRecordingStarted } from "@daily-co/daily-js"; import DailyIframe from "@daily-co/daily-js"; import MarkdownIt from "markdown-it"; import type { GetServerSidePropsContext } from "next"; import Head from "next/head"; import { useState, useEffect, useRef } from "react"; -import z from "zod"; import dayjs from "@calcom/dayjs"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; @@ -21,19 +19,12 @@ import PageWrapper from "@components/PageWrapper"; import { ssrInit } from "@server/lib/ssr"; -const recordingStartedEventResponse = z - .object({ - recordingId: z.string(), - }) - .passthrough(); - export type JoinCallPageProps = inferSSRProps; const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true }); export default function JoinCall(props: JoinCallPageProps) { const { t } = useLocale(); const { meetingUrl, meetingPassword, booking } = props; - const recordingId = useRef(null); useEffect(() => { const callFrame = DailyIframe.createFrame({ @@ -61,31 +52,12 @@ export default function JoinCall(props: JoinCallPageProps) { ...(typeof meetingPassword === "string" && { token: meetingPassword }), }); callFrame.join(); - callFrame.on("recording-started", onRecordingStarted).on("recording-stopped", onRecordingStopped); return () => { callFrame.destroy(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const onRecordingStopped = () => { - const data = { recordingId: recordingId.current, bookingUID: booking.uid }; - - fetch("/api/recorded-daily-video", { - method: "POST", - body: JSON.stringify(data), - }).catch((err) => { - console.log(err); - }); - - recordingId.current = null; - }; - - const onRecordingStarted = (event?: DailyEventObjectRecordingStarted | undefined) => { - const response = recordingStartedEventResponse.parse(event); - recordingId.current = response.recordingId; - }; - const title = `${APP_NAME} Video`; return ( <> diff --git a/turbo.json b/turbo.json index f33f3941f7..a91fc56c0d 100644 --- a/turbo.json +++ b/turbo.json @@ -222,6 +222,7 @@ "CRON_ENABLE_APP_SYNC", "DAILY_API_KEY", "DAILY_SCALE_PLAN", + "DAILY_WEBHOOK_SECRET", "DEBUG", "E2E_TEST_APPLE_CALENDAR_EMAIL", "E2E_TEST_APPLE_CALENDAR_PASSWORD",