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:
co-authored by
Syed Ali Shahbaz
parent
fc1d6ae402
commit
d9df9b3d01
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user