From d9df9b3d01d72910643fbe473586bf57b851ea97 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Fri, 23 May 2025 17:31:33 +0530 Subject: [PATCH] feat: recording url with 6 months expiry time (#18707) * feat: store cal video recording on s3 * feat: create new route to fetch download link * tests: add unit test * fix: unit test * fix: type error * chore: add env var * fix: open in new tab * fix: type err * chore: update test * fix: import * chore: use cal video recording token secret --------- Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> --- .env.example | 4 + .../web/app/api/recorded-daily-video/route.ts | 15 ++-- .../video/recording/__tests__/route.test.ts | 75 +++++++++++++++++++ apps/web/app/api/video/recording/route.ts | 29 +++++++ .../tests/recorded-daily-video.test.ts | 12 +-- .../ee/video/ViewRecordingsDialog.tsx | 8 +- packages/lib/videoTokens.ts | 35 +++++++++ turbo.json | 3 +- 8 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 apps/web/app/api/video/recording/__tests__/route.test.ts create mode 100644 apps/web/app/api/video/recording/route.ts create mode 100644 packages/lib/videoTokens.ts diff --git a/.env.example b/.env.example index 383623bd4d..8df31c4c61 100644 --- a/.env.example +++ b/.env.example @@ -445,6 +445,10 @@ NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM=0 # Used to enable quick availability checks for x% of all visitors NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10 +# Used to generate token for cal video recordings expiry token +# You can use: `openssl rand -base64 32` to generate one +CAL_VIDEO_RECORDING_TOKEN_SECRET= + # Deprecated. Use NEXT_PUBLIC_BODY_SCRIPTS instead. NEXT_PUBLIC_HEAD_SCRIPTS are now injected into the body only. NEXT_PUBLIC_HEAD_SCRIPTS= NEXT_PUBLIC_BODY_SCRIPTS= diff --git a/apps/web/app/api/recorded-daily-video/route.ts b/apps/web/app/api/recorded-daily-video/route.ts index a95858a6d0..2737e7c175 100644 --- a/apps/web/app/api/recorded-daily-video/route.ts +++ b/apps/web/app/api/recorded-daily-video/route.ts @@ -9,15 +9,16 @@ import { sendDailyVideoRecordingEmails, sendDailyVideoTranscriptEmails, } from "@calcom/emails/daily-video-emails"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getAllTranscriptsAccessLinkFromMeetingId, - getDownloadLinkOfCalVideoByRecordingId, submitBatchProcessorTranscriptionJob, } from "@calcom/lib/videoClient"; +import { generateVideoToken } from "@calcom/lib/videoTokens"; import prisma from "@calcom/prisma"; import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking"; import { getBookingReference } from "@calcom/web/lib/daily-webhook/getBookingReference"; @@ -26,7 +27,6 @@ import { meetingEndedSchema, recordingReadySchema, batchProcessorJobFinishedSchema, - downloadLinkSchema, testRequestSchema, } from "@calcom/web/lib/daily-webhook/schema"; import { @@ -44,10 +44,9 @@ const computeSignature = (hmacSecret: string, reqBody: any, webhookTimestampHead return computed_signature; }; -const getDownloadLinkOfCalVideo = async (recordingId: string) => { - const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId); - const downloadLinkResponse = downloadLinkSchema.parse(response); - const downloadLink = downloadLinkResponse.download_link; +const getProxyDownloadLinkOfCalVideo = async (recordingId: string) => { + const token = generateVideoToken(recordingId); + const downloadLink = `${WEBAPP_URL}/api/video/recording?token=${token}`; return downloadLink; }; @@ -110,7 +109,7 @@ export async function postHandler(request: NextRequest) { }, }); - const downloadLink = await getDownloadLinkOfCalVideo(recording_id); + const downloadLink = await getProxyDownloadLinkOfCalVideo(recording_id); const teamId = await getTeamIdFromEventType({ eventType: { @@ -192,7 +191,7 @@ export async function postHandler(request: NextRequest) { const evt = await getCalendarEvent(booking); - const recording = await getDownloadLinkOfCalVideo(input.recordingId); + const recording = await getProxyDownloadLinkOfCalVideo(input.recordingId); const batchProcessorJobAccessLink = await getBatchProcessorJobAccessLink(id); await triggerTranscriptionGeneratedWebhook({ diff --git a/apps/web/app/api/video/recording/__tests__/route.test.ts b/apps/web/app/api/video/recording/__tests__/route.test.ts new file mode 100644 index 0000000000..85c9310914 --- /dev/null +++ b/apps/web/app/api/video/recording/__tests__/route.test.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; +import { describe, expect, test, vi, afterEach } from "vitest"; + +import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient"; +import { verifyVideoToken } from "@calcom/lib/videoTokens"; + +import { GET } from "../route"; + +vi.mock("@calcom/lib/videoClient", () => ({ + getDownloadLinkOfCalVideoByRecordingId: vi.fn(), +})); + +vi.mock("@calcom/lib/videoTokens", () => ({ + verifyVideoToken: vi.fn(), +})); + +describe("GET /api/video/recording", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + test("returns 401 when token is missing", async () => { + const request = new Request("http://example.com/api/video/recording"); + const response = await GET(request); + + expect(response.status).toBe(401); + expect(await response.text()).toBe("Missing token"); + }); + + test("returns 401 when token is invalid", async () => { + vi.mocked(verifyVideoToken).mockReturnValue({ valid: false }); + + const request = new Request("http://example.com/api/video/recording?token=invalid_token"); + const response = await GET(request); + + expect(response.status).toBe(401); + expect(await response.text()).toBe("Invalid or expired token"); + }); + + test("returns 404 when recording is not found", async () => { + vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" }); + vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue(undefined); + + const request = new Request("http://example.com/api/video/recording?token=valid_token"); + const response = await GET(request); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Recording not found"); + }); + + test("redirects to download link when recording exists", async () => { + vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" }); + vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({ + download_link: "https://example.com/download", + }); + + const request = new Request("http://example.com/api/video/recording?token=valid_token"); + const response = await GET(request); + + expect(response instanceof NextResponse).toBe(true); + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("https://example.com/download"); + }); + + test("returns 500 when getting recording fails", async () => { + vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" }); + vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockRejectedValue(new Error("Failed to get recording")); + + const request = new Request("http://example.com/api/video/recording?token=valid_token"); + const response = await GET(request); + + expect(response.status).toBe(500); + expect(await response.text()).toBe("Failed to get recording"); + }); +}); diff --git a/apps/web/app/api/video/recording/route.ts b/apps/web/app/api/video/recording/route.ts new file mode 100644 index 0000000000..ad9c18b173 --- /dev/null +++ b/apps/web/app/api/video/recording/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; + +import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient"; +import { verifyVideoToken } from "@calcom/lib/videoTokens"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const token = searchParams.get("token"); + + if (!token) { + return new Response("Missing token", { status: 401 }); + } + + const verification = verifyVideoToken(token); + if (!verification.valid || !verification.recordingId) { + return new Response("Invalid or expired token", { status: 401 }); + } + + try { + const recordingLink = await getDownloadLinkOfCalVideoByRecordingId(verification.recordingId); + if (!recordingLink) { + return new Response("Recording not found", { status: 404 }); + } + return NextResponse.redirect(recordingLink.download_link, { status: 302 }); + } catch (error) { + console.error("Failed to get recording:", error); + return new Response("Failed to get recording", { status: 500 }); + } +} diff --git a/apps/web/lib/daily-webhook/tests/recorded-daily-video.test.ts b/apps/web/lib/daily-webhook/tests/recorded-daily-video.test.ts index f55cc06ebd..24528075ca 100644 --- a/apps/web/lib/daily-webhook/tests/recorded-daily-video.test.ts +++ b/apps/web/lib/daily-webhook/tests/recorded-daily-video.test.ts @@ -15,7 +15,7 @@ import { describe, afterEach, test, vi, beforeEach, beforeAll, expect } from "vi import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib"; -import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import prisma from "@calcom/prisma"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -70,9 +70,9 @@ vi.mock("@calcom/app-store/dailyvideo/lib", () => { }; }); -vi.mock("@calcom/lib/videoClient", () => { +vi.mock("@calcom/lib/videoTokens", () => { return { - getDownloadLinkOfCalVideoByRecordingId: vi.fn(), + generateVideoToken: vi.fn().mockReturnValue("MOCK_TOKEN"), }; }); @@ -189,7 +189,6 @@ describe("Handler: /api/recorded-daily-video", () => { const bookingUid = "n5Wv3eHgconAED2j4gcVhP"; const iCalUID = `${bookingUid}@Cal.com`; const subscriberUrl = "http://my-webhook.example.com"; - const recordingDownloadLink = "https://download-link.com"; const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); const booker = getBooker({ @@ -262,9 +261,6 @@ describe("Handler: /api/recorded-daily-video", () => { vi.mocked(getRoomNameFromRecordingId).mockResolvedValue("MOCK_ID"); vi.mocked(getBatchProcessorJobAccessLink).mockResolvedValue(TRANSCRIPTION_ACCESS_LINK); - vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({ - download_link: recordingDownloadLink, - }); const { req } = createMocks({ method: "POST", @@ -291,7 +287,7 @@ describe("Handler: /api/recorded-daily-video", () => { uid: bookingUid, downloadLinks: { transcription: TRANSCRIPTION_ACCESS_LINK.transcription, - recording: recordingDownloadLink, + recording: `${WEBAPP_URL}/api/video/recording?token=MOCK_TOKEN`, }, organizer: { email: organizer.email, diff --git a/packages/features/ee/video/ViewRecordingsDialog.tsx b/packages/features/ee/video/ViewRecordingsDialog.tsx index 1aaae63eb1..534f257092 100644 --- a/packages/features/ee/video/ViewRecordingsDialog.tsx +++ b/packages/features/ee/video/ViewRecordingsDialog.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useRouter } from "next/navigation"; import { Suspense, useEffect, useState } from "react"; @@ -74,21 +76,19 @@ const useRecordingDownload = () => { useEffect( function refactorMeWithoutEffect() { if (data && data.download_link) { - window.location.href = data.download_link; + window.open(data.download_link, "_blank", "noopener,noreferrer"); } }, [data] ); return { setRecordingId: (newRecordingId: string) => { - // may be a way to do this by default, but this is easy enough. if (recordingId === newRecordingId && data) { - window.location.href = data.download_link; + window.open(data.download_link, "_blank", "noopener,noreferrer"); } if (!isFetching) { setRecordingId(newRecordingId); } - // assume it is still fetching, do nothing. }, isFetching, recordingId, diff --git a/packages/lib/videoTokens.ts b/packages/lib/videoTokens.ts new file mode 100644 index 0000000000..7ef3e41b03 --- /dev/null +++ b/packages/lib/videoTokens.ts @@ -0,0 +1,35 @@ +import { createHmac } from "crypto"; + +// 262992 minutes is 6 months +export function generateVideoToken(recordingId: string, expiresInMinutes = 262992) { + const secret = process.env.CAL_VIDEO_RECORDING_TOKEN_SECRET || "default-secret-change-me"; + const expires = Date.now() + expiresInMinutes * 60 * 1000; + + const payload = `${recordingId}:${expires}`; + const hmac = createHmac("sha256", secret).update(payload).digest("hex"); + + return `${payload}:${hmac}`; +} + +export function verifyVideoToken(token: string): { valid: boolean; recordingId?: string } { + try { + const [recordingId, expires, receivedHmac] = token.split(":"); + const secret = process.env.CAL_VIDEO_RECORDING_TOKEN_SECRET || "default-secret-change-me"; + + if (Date.now() > parseInt(expires)) { + return { valid: false }; + } + + // Verify HMAC + const payload = `${recordingId}:${expires}`; + const expectedHmac = createHmac("sha256", secret).update(payload).digest("hex"); + + if (receivedHmac !== expectedHmac) { + return { valid: false }; + } + + return { valid: true, recordingId }; + } catch { + return { valid: false }; + } +} diff --git a/turbo.json b/turbo.json index a85d493246..8978ee74ed 100644 --- a/turbo.json +++ b/turbo.json @@ -483,6 +483,7 @@ "ZOOM_REFRESH_TOKEN", "CALCOM_ADMIN_API_KEY", "NEXT_PUBLIC_SINGLE_ORG_MODE_ENABLED", - "CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY" + "CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY", + "CAL_VIDEO_RECORDING_TOKEN_SECRET" ] }