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>
This commit is contained in:
Udit Takkar
2025-05-23 13:01:33 +01:00
committed by GitHub
co-authored by Syed Ali Shahbaz
parent fc1d6ae402
commit d9df9b3d01
8 changed files with 160 additions and 21 deletions
+4
View File
@@ -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=
@@ -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({
@@ -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");
});
});
+29
View File
@@ -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 });
}
}
@@ -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,
@@ -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,
+35
View File
@@ -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 };
}
}
+2 -1
View File
@@ -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"
]
}