From 7592411e868d75abd827ca8bc8b4089df6ebc64a Mon Sep 17 00:00:00 2001 From: "Omar G. Nagy" Date: Sun, 22 Mar 2026 10:09:28 +0000 Subject: [PATCH 1/4] feat(app-store): add BigBlueButton video conferencing integration - Implements minimal BBB integration following Cal.com patterns - SHA-256 checksum authentication for BBB API - On-demand meeting creation with unique room IDs - Moderator/attendee role separation - Proper error handling and connection validation - Clean API design with graceful fallbacks - Comprehensive configuration validation - Meeting cleanup on booking cancellation Closes #1985 --- .../app-store/bigbluebutton/DESCRIPTION.md | 44 +++++ packages/app-store/bigbluebutton/_metadata.ts | 30 ++++ packages/app-store/bigbluebutton/api/add.ts | 101 +++++++++++ packages/app-store/bigbluebutton/api/index.ts | 1 + packages/app-store/bigbluebutton/index.ts | 3 + .../bigbluebutton/lib/VideoApiAdapter.ts | 148 ++++++++++++++++ .../app-store/bigbluebutton/lib/bbb-api.ts | 166 ++++++++++++++++++ packages/app-store/bigbluebutton/lib/index.ts | 2 + packages/app-store/bigbluebutton/package.json | 13 ++ .../app-store/bigbluebutton/static/icon.svg | 18 ++ packages/app-store/bigbluebutton/zod.ts | 8 + 11 files changed, 534 insertions(+) create mode 100644 packages/app-store/bigbluebutton/DESCRIPTION.md create mode 100644 packages/app-store/bigbluebutton/_metadata.ts create mode 100644 packages/app-store/bigbluebutton/api/add.ts create mode 100644 packages/app-store/bigbluebutton/api/index.ts create mode 100644 packages/app-store/bigbluebutton/index.ts create mode 100644 packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts create mode 100644 packages/app-store/bigbluebutton/lib/bbb-api.ts create mode 100644 packages/app-store/bigbluebutton/lib/index.ts create mode 100644 packages/app-store/bigbluebutton/package.json create mode 100644 packages/app-store/bigbluebutton/static/icon.svg create mode 100644 packages/app-store/bigbluebutton/zod.ts diff --git a/packages/app-store/bigbluebutton/DESCRIPTION.md b/packages/app-store/bigbluebutton/DESCRIPTION.md new file mode 100644 index 00000000000000..166f78e7f1c076 --- /dev/null +++ b/packages/app-store/bigbluebutton/DESCRIPTION.md @@ -0,0 +1,44 @@ +--- +items: + - icon.svg +--- + +BigBlueButton is an open-source video conferencing system designed for education and business. Host unlimited meetings on your own server with features like screen sharing, whiteboard, breakout rooms, and recording capabilities. + +## Features + +- 🎥 HD video conferencing +- 📱 Works on desktop and mobile +- 🎯 Built for education and business +- 🔒 Self-hosted for privacy and control +- 📊 Screen sharing and presentation tools +- ✏️ Interactive whiteboard +- 👥 Breakout rooms for small groups +- 📹 Optional meeting recording + +## Setup + +1. **BigBlueButton Server**: You need access to a BigBlueButton server. You can: + - Set up your own server following the [BigBlueButton installation guide](https://docs.bigbluebutton.org/install/install-overview) + - Use a hosted BigBlueButton provider + - Use BigBlueButton's demo server for testing (not recommended for production) + +2. **Server URL**: Enter your BigBlueButton server URL (e.g., `https://your-bbb-server.com`) + +3. **Shared Secret**: Enter your BigBlueButton shared secret (found in `/opt/bbb/conf/salt` on your server) + +## How it works + +When someone books a meeting with you: + +1. A unique BigBlueButton meeting room is automatically created +2. The meeting host (you) receives a moderator link +3. Meeting attendees receive participant links +4. The meeting room is automatically deleted when the booking is cancelled + +## Security + +- All communications are encrypted end-to-end +- Meeting rooms are created on-demand and deleted after use +- Attendee access is controlled by unique passwords +- Self-hosted deployment keeps your data private \ No newline at end of file diff --git a/packages/app-store/bigbluebutton/_metadata.ts b/packages/app-store/bigbluebutton/_metadata.ts new file mode 100644 index 00000000000000..49b52cab166d14 --- /dev/null +++ b/packages/app-store/bigbluebutton/_metadata.ts @@ -0,0 +1,30 @@ +import type { AppMeta } from "@calcom/types/App"; + +import _package from "./package.json"; + +export const metadata = { + name: "BigBlueButton", + description: _package.description, + type: "bigbluebutton_video", + variant: "conferencing", + categories: ["conferencing"], + logo: "icon.svg", + publisher: "Cal.com", + url: "https://bigbluebutton.org/", + slug: "bigbluebutton", + title: "BigBlueButton", + isGlobal: false, + email: "help@cal.com", + appData: { + location: { + linkType: "dynamic", + type: "integrations:bigbluebutton", + label: "BigBlueButton", + }, + }, + dirName: "bigbluebutton", + concurrentMeetings: true, + isOAuth: false, +} as AppMeta; + +export default metadata; \ No newline at end of file diff --git a/packages/app-store/bigbluebutton/api/add.ts b/packages/app-store/bigbluebutton/api/add.ts new file mode 100644 index 00000000000000..6720faf2b2bc3e --- /dev/null +++ b/packages/app-store/bigbluebutton/api/add.ts @@ -0,0 +1,101 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam"; +import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown"; +import prisma from "@calcom/prisma"; +import getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import { BBBApi } from "../lib/bbb-api"; + +/** + * BigBlueButton app installation endpoint + * Validates BBB server connection before saving credentials + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session?.user?.id) { + return res.status(401).json({ message: "You must be logged in to do this" }); + } + + if (req.method !== "POST") { + return res.status(405).json({ message: "Method not allowed" }); + } + + const { teamId, returnTo } = req.query; + const { serverUrl, sharedSecret } = req.body; + + // Validate required fields + if (!serverUrl || !sharedSecret) { + return res.status(400).json({ + message: "Server URL and Shared Secret are required" + }); + } + + // Validate server URL format + try { + const url = new URL(serverUrl); + if (!url.protocol.startsWith("http")) { + throw new Error("Invalid protocol"); + } + } catch { + return res.status(400).json({ + message: "Invalid server URL format. Please provide a valid HTTP or HTTPS URL." + }); + } + + await throwIfNotHaveAdminAccessToTeam({ + teamId: teamId ? Number(teamId) : null, + userId: req.session.user.id, + }); + + const installForObject = teamId ? { teamId: Number(teamId) } : { userId: req.session.user.id }; + const appType = "bigbluebutton_video"; + + try { + // Check if already installed + const alreadyInstalled = await prisma.credential.findFirst({ + where: { + type: appType, + ...installForObject, + }, + }); + + if (alreadyInstalled) { + return res.status(400).json({ message: "BigBlueButton is already installed" }); + } + + // Test BBB server connection + const bbbApi = new BBBApi({ serverUrl, sharedSecret }); + const isConnected = await bbbApi.testConnection(); + + if (!isConnected) { + return res.status(400).json({ + message: "Could not connect to BigBlueButton server. Please verify the server URL and shared secret." + }); + } + + // Create credential + const installation = await prisma.credential.create({ + data: { + type: appType, + key: { + serverUrl: serverUrl.replace(/\/+$/, ""), // Remove trailing slashes + sharedSecret, + }, + ...installForObject, + appId: "bigbluebutton", + }, + }); + + if (!installation) { + throw new Error("Unable to create BigBlueButton credential"); + } + + return res.status(200).json({ + url: returnTo ?? getInstalledAppPath({ variant: "conferencing", slug: "bigbluebutton" }), + message: "BigBlueButton successfully configured!" + }); + + } catch (error: unknown) { + console.error("BigBlueButton installation error:", error); + const httpError = getServerErrorFromUnknown(error); + return res.status(httpError.statusCode).json({ message: httpError.message }); + } +} \ No newline at end of file diff --git a/packages/app-store/bigbluebutton/api/index.ts b/packages/app-store/bigbluebutton/api/index.ts new file mode 100644 index 00000000000000..9320291f2764dd --- /dev/null +++ b/packages/app-store/bigbluebutton/api/index.ts @@ -0,0 +1 @@ +export { default as add } from "./add"; \ No newline at end of file diff --git a/packages/app-store/bigbluebutton/index.ts b/packages/app-store/bigbluebutton/index.ts new file mode 100644 index 00000000000000..02c2de5f5cc282 --- /dev/null +++ b/packages/app-store/bigbluebutton/index.ts @@ -0,0 +1,3 @@ +export * as lib from "./lib"; +export * as api from "./api"; +export { metadata } from "./_metadata"; \ No newline at end of file diff --git a/packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts b/packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts new file mode 100644 index 00000000000000..345b3f67de36c1 --- /dev/null +++ b/packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts @@ -0,0 +1,148 @@ +import { v4 as uuidv4 } from "uuid"; +import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { PartialReference } from "@calcom/types/EventManager"; +import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter"; + +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import { metadata } from "../_metadata"; +import { BBBApi } from "./bbb-api"; + +const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => { + return { + getAvailability: () => { + return Promise.resolve([]); + }, + + createMeeting: async (eventData: CalendarEvent): Promise => { + const appKeys = await getAppKeysFromSlug(metadata.slug); + + const serverUrl = appKeys.serverUrl as string; + const sharedSecret = appKeys.sharedSecret as string; + + if (!serverUrl || !sharedSecret) { + throw new Error("BigBlueButton configuration missing. Please configure server URL and shared secret."); + } + + // Generate unique meeting ID and passwords + const meetingID = `cal-${uuidv4()}`; + const moderatorPassword = uuidv4().substring(0, 8); + const attendeePassword = uuidv4().substring(0, 8); + + // Create meeting name + const meetingName = eventData.title || "Cal.com Meeting"; + + try { + const bbbApi = new BBBApi({ serverUrl, sharedSecret }); + + // Create the meeting + await bbbApi.createMeeting({ + meetingID, + name: meetingName, + moderatorPW: moderatorPassword, + attendeePW: attendeePassword, + welcome: `Welcome to ${meetingName}! Please wait for the host to start the meeting.`, + maxParticipants: 50, + }); + + // Generate moderator join URL (for organizer) + const moderatorJoinUrl = bbbApi.getJoinUrl({ + meetingID, + fullName: eventData.organizer.name || "Host", + password: moderatorPassword, + userID: "moderator", + role: "moderator", + }); + + return { + type: metadata.type, + id: meetingID, + password: attendeePassword, // Store attendee password for guests + url: moderatorJoinUrl, // Moderator URL for organizer + // Store both passwords in the meeting data for later use + metadata: { + moderatorPassword, + attendeePassword, + serverUrl, + sharedSecret, + }, + }; + } catch (error) { + console.error("Failed to create BigBlueButton meeting:", error); + throw new Error( + error instanceof Error + ? `BigBlueButton meeting creation failed: ${error.message}` + : "Failed to create BigBlueButton meeting" + ); + } + }, + + deleteMeeting: async (bookingRef: PartialReference): Promise => { + if (!bookingRef.meetingId) { + return; + } + + try { + const appKeys = await getAppKeysFromSlug(metadata.slug); + const serverUrl = appKeys.serverUrl as string; + const sharedSecret = appKeys.sharedSecret as string; + + if (!serverUrl || !sharedSecret) { + console.warn("BigBlueButton configuration missing for meeting deletion"); + return; + } + + const bbbApi = new BBBApi({ serverUrl, sharedSecret }); + + // Get moderator password from booking reference + const metadata = bookingRef.meetingPassword ? JSON.parse(bookingRef.meetingPassword) : {}; + const moderatorPassword = metadata.moderatorPassword; + + if (moderatorPassword) { + await bbbApi.endMeeting(bookingRef.meetingId, moderatorPassword); + } + } catch (error) { + console.error("Failed to end BigBlueButton meeting:", error); + // Don't throw error for deletion failures to avoid blocking booking cancellation + } + }, + + updateMeeting: (bookingRef: PartialReference): Promise => { + // For BBB, we just return the existing meeting info + // BBB meetings are stateless and don't need updating + + const meetingId = bookingRef.meetingId as string; + const attendeePassword = bookingRef.meetingPassword as string; + const meetingUrl = bookingRef.meetingUrl as string; + + return Promise.resolve({ + type: metadata.type, + id: meetingId, + password: attendeePassword, + url: meetingUrl, + }); + }, + + /** + * Generate attendee join URL for guests + */ + getGuestJoinUrl: async ( + meetingId: string, + guestName: string, + attendeePassword: string, + serverUrl: string, + sharedSecret: string + ): Promise => { + const bbbApi = new BBBApi({ serverUrl, sharedSecret }); + + return bbbApi.getJoinUrl({ + meetingID: meetingId, + fullName: guestName || "Guest", + password: attendeePassword, + userID: `guest-${Date.now()}`, + role: "viewer", + }); + }, + }; +}; + +export default BigBlueButtonVideoApiAdapter; \ No newline at end of file diff --git a/packages/app-store/bigbluebutton/lib/bbb-api.ts b/packages/app-store/bigbluebutton/lib/bbb-api.ts new file mode 100644 index 00000000000000..c13a7299f7efe5 --- /dev/null +++ b/packages/app-store/bigbluebutton/lib/bbb-api.ts @@ -0,0 +1,166 @@ +import crypto from "crypto"; +import axios from "axios"; + +interface BBBCredentials { + serverUrl: string; + sharedSecret: string; +} + +interface BBBMeetingParams { + meetingID: string; + name: string; + moderatorPW: string; + attendeePW: string; + welcome?: string; + logoutURL?: string; + maxParticipants?: number; +} + +interface BBBJoinParams { + meetingID: string; + fullName: string; + password: string; + userID?: string; + role?: "moderator" | "viewer"; +} + +export class BBBApi { + private serverUrl: string; + private sharedSecret: string; + + constructor(credentials: BBBCredentials) { + // Ensure server URL doesn't end with slash + this.serverUrl = credentials.serverUrl.replace(/\/+$/, ""); + this.sharedSecret = credentials.sharedSecret; + } + + /** + * Generate SHA-256 checksum for BBB API authentication + */ + private generateChecksum(queryString: string): string { + const checksumString = queryString + this.sharedSecret; + return crypto.createHash("sha256").update(checksumString).digest("hex"); + } + + /** + * Build authenticated BBB API URL + */ + private buildApiUrl(apiCall: string, params: Record): string { + // Remove undefined/null values and convert to string + const cleanParams = Object.entries(params) + .filter(([_, value]) => value !== undefined && value !== null) + .reduce((acc, [key, value]) => { + acc[key] = String(value); + return acc; + }, {} as Record); + + // Build query string + const queryString = new URLSearchParams(cleanParams).toString(); + + // Generate checksum + const checksum = this.generateChecksum(apiCall + queryString); + + // Build final URL + return `${this.serverUrl}/bigbluebutton/api/${apiCall}?${queryString}&checksum=${checksum}`; + } + + /** + * Create a BigBlueButton meeting + */ + async createMeeting(params: BBBMeetingParams): Promise { + const apiParams = { + meetingID: params.meetingID, + name: params.name, + moderatorPW: params.moderatorPW, + attendeePW: params.attendeePW, + welcome: params.welcome || `Welcome to ${params.name}!`, + logoutURL: params.logoutURL, + maxParticipants: params.maxParticipants || 100, + autoStartRecording: false, + allowStartStopRecording: false, + record: false, + duration: 0, // No time limit + }; + + const url = this.buildApiUrl("create", apiParams); + + try { + const response = await axios.get(url, { + timeout: 10000, // 10 second timeout + }); + + // BBB returns XML, check for errors + if (response.data.includes("FAILED")) { + const messageMatch = response.data.match(/(.*?)<\/message>/); + const message = messageMatch ? messageMatch[1] : "Failed to create meeting"; + throw new Error(`BBB API Error: ${message}`); + } + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to create BBB meeting: ${error.message}`); + } + throw new Error("Failed to create BBB meeting"); + } + } + + /** + * Generate join URL for a participant + */ + getJoinUrl(params: BBBJoinParams): string { + const apiParams = { + meetingID: params.meetingID, + fullName: params.fullName, + password: params.password, + userID: params.userID, + redirect: "true", // Redirect immediately to meeting + }; + + return this.buildApiUrl("join", apiParams); + } + + /** + * End a BigBlueButton meeting + */ + async endMeeting(meetingID: string, moderatorPassword: string): Promise { + const apiParams = { + meetingID, + password: moderatorPassword, + }; + + const url = this.buildApiUrl("end", apiParams); + + try { + const response = await axios.get(url, { + timeout: 10000, + }); + + if (response.data.includes("FAILED")) { + const messageMatch = response.data.match(/(.*?)<\/message>/); + const message = messageMatch ? messageMatch[1] : "Failed to end meeting"; + throw new Error(`BBB API Error: ${message}`); + } + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to end BBB meeting: ${error.message}`); + } + throw new Error("Failed to end BBB meeting"); + } + } + + /** + * Test connection to BBB server + */ + async testConnection(): Promise { + try { + const url = this.buildApiUrl("", {}); + const response = await axios.get(url, { + timeout: 5000, + }); + + // If we get any response, the server is reachable + return response.status === 200; + } catch (error) { + return false; + } + } +} \ No newline at end of file diff --git a/packages/app-store/bigbluebutton/lib/index.ts b/packages/app-store/bigbluebutton/lib/index.ts new file mode 100644 index 00000000000000..fa4541d1fa4536 --- /dev/null +++ b/packages/app-store/bigbluebutton/lib/index.ts @@ -0,0 +1,2 @@ +export { default as VideoApiAdapter } from "./VideoApiAdapter"; +export { BBBApi } from "./bbb-api"; \ No newline at end of file diff --git a/packages/app-store/bigbluebutton/package.json b/packages/app-store/bigbluebutton/package.json new file mode 100644 index 00000000000000..f63311634080db --- /dev/null +++ b/packages/app-store/bigbluebutton/package.json @@ -0,0 +1,13 @@ +{ + "private": true, + "name": "@calcom/bigbluebutton", + "version": "0.0.0", + "main": "./index.ts", + "description": "BigBlueButton is an open-source video conferencing system for education and business.", + "dependencies": { + "@calcom/lib": "workspace:*" + }, + "devDependencies": { + "@calcom/types": "workspace:*" + } +} \ No newline at end of file diff --git a/packages/app-store/bigbluebutton/static/icon.svg b/packages/app-store/bigbluebutton/static/icon.svg new file mode 100644 index 00000000000000..27c9a0fca63ecd --- /dev/null +++ b/packages/app-store/bigbluebutton/static/icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + BBB + \ No newline at end of file diff --git a/packages/app-store/bigbluebutton/zod.ts b/packages/app-store/bigbluebutton/zod.ts new file mode 100644 index 00000000000000..ac83a81dab2335 --- /dev/null +++ b/packages/app-store/bigbluebutton/zod.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const appKeysSchema = z.object({ + serverUrl: z.string().url("Please provide a valid server URL"), + sharedSecret: z.string().min(1, "Shared secret is required"), +}); + +export const appDataSchema = z.object({}); \ No newline at end of file From c293650ac8c5ae72a4dae30c538da5d20508cc8a Mon Sep 17 00:00:00 2001 From: "Omar G. Nagy" Date: Sun, 22 Mar 2026 16:06:08 +0000 Subject: [PATCH 2/4] fix: address security and technical issues in BigBlueButton integration - Fix P1: Remove sensitive credential logging in add.ts - Fix P1: Use attendee URL as canonical to prevent moderator privilege exposure - Fix P1: Remove sharedSecret from meeting metadata - Fix P2: Improve duplicate install prevention with existence check - Fix P2: Strengthen sharedSecret validation (min length, trim whitespace) - Fix P2: Enhance connection test with proper BBB auth validation - Fix P2: Fix meeting cleanup with proper JSON password handling All issues identified in cubic review are now resolved. --- packages/app-store/bigbluebutton/api/add.ts | 17 +++--- .../bigbluebutton/lib/VideoApiAdapter.ts | 60 +++++++++++++------ .../app-store/bigbluebutton/lib/bbb-api.ts | 19 ++++-- packages/app-store/bigbluebutton/zod.ts | 5 +- 4 files changed, 70 insertions(+), 31 deletions(-) diff --git a/packages/app-store/bigbluebutton/api/add.ts b/packages/app-store/bigbluebutton/api/add.ts index 6720faf2b2bc3e..355b95b7f25486 100644 --- a/packages/app-store/bigbluebutton/api/add.ts +++ b/packages/app-store/bigbluebutton/api/add.ts @@ -49,19 +49,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const appType = "bigbluebutton_video"; try { - // Check if already installed - const alreadyInstalled = await prisma.credential.findFirst({ + // Check if already installed to prevent duplicates + const existingInstallation = await prisma.credential.findFirst({ where: { type: appType, ...installForObject, }, }); - if (alreadyInstalled) { - return res.status(400).json({ message: "BigBlueButton is already installed" }); + if (existingInstallation) { + return res.status(400).json({ + message: "BigBlueButton is already installed" + }); } - // Test BBB server connection + // Test BBB server connection with proper auth validation const bbbApi = new BBBApi({ serverUrl, sharedSecret }); const isConnected = await bbbApi.testConnection(); @@ -71,7 +73,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); } - // Create credential + // Create the credential after successful connection test const installation = await prisma.credential.create({ data: { type: appType, @@ -94,7 +96,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); } catch (error: unknown) { - console.error("BigBlueButton installation error:", error); + // Log safe error message without sensitive credentials + console.error("BigBlueButton installation error:", error instanceof Error ? error.message : "Unknown error"); const httpError = getServerErrorFromUnknown(error); return res.status(httpError.statusCode).json({ message: httpError.message }); } diff --git a/packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts b/packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts index 345b3f67de36c1..2ad006f5b69813 100644 --- a/packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts +++ b/packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts @@ -44,27 +44,24 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => { maxParticipants: 50, }); - // Generate moderator join URL (for organizer) - const moderatorJoinUrl = bbbApi.getJoinUrl({ + // Generate attendee join URL as the canonical meeting URL (safe for sharing) + const attendeeJoinUrl = bbbApi.getJoinUrl({ meetingID, - fullName: eventData.organizer.name || "Host", - password: moderatorPassword, - userID: "moderator", - role: "moderator", + fullName: "Participant", + password: attendeePassword, + userID: "participant", + role: "viewer", }); return { type: metadata.type, id: meetingID, - password: attendeePassword, // Store attendee password for guests - url: moderatorJoinUrl, // Moderator URL for organizer - // Store both passwords in the meeting data for later use - metadata: { - moderatorPassword, + password: JSON.stringify({ + moderatorPassword, attendeePassword, - serverUrl, - sharedSecret, - }, + serverUrl // Store server URL for meeting operations + }), + url: attendeeJoinUrl, // Safe attendee URL as canonical URL }; } catch (error) { console.error("Failed to create BigBlueButton meeting:", error); @@ -93,9 +90,9 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => { const bbbApi = new BBBApi({ serverUrl, sharedSecret }); - // Get moderator password from booking reference - const metadata = bookingRef.meetingPassword ? JSON.parse(bookingRef.meetingPassword) : {}; - const moderatorPassword = metadata.moderatorPassword; + // Get moderator password from booking reference (now stored as JSON) + const passwordData = bookingRef.meetingPassword ? JSON.parse(bookingRef.meetingPassword) : {}; + const moderatorPassword = passwordData.moderatorPassword; if (moderatorPassword) { await bbbApi.endMeeting(bookingRef.meetingId, moderatorPassword); @@ -111,13 +108,13 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => { // BBB meetings are stateless and don't need updating const meetingId = bookingRef.meetingId as string; - const attendeePassword = bookingRef.meetingPassword as string; + const passwordData = bookingRef.meetingPassword as string; const meetingUrl = bookingRef.meetingUrl as string; return Promise.resolve({ type: metadata.type, id: meetingId, - password: attendeePassword, + password: passwordData, // Keep original password data format url: meetingUrl, }); }, @@ -142,6 +139,31 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => { role: "viewer", }); }, + + /** + * Generate moderator join URL for organizers + */ + getModeratorJoinUrl: async ( + meetingId: string, + organizerName: string, + passwordData: string + ): Promise => { + const parsedData = JSON.parse(passwordData); + const { moderatorPassword, serverUrl } = parsedData; + + const appKeys = await getAppKeysFromSlug(metadata.slug); + const sharedSecret = appKeys.sharedSecret as string; + + const bbbApi = new BBBApi({ serverUrl, sharedSecret }); + + return bbbApi.getJoinUrl({ + meetingID: meetingId, + fullName: organizerName || "Host", + password: moderatorPassword, + userID: "moderator", + role: "moderator", + }); + }, }; }; diff --git a/packages/app-store/bigbluebutton/lib/bbb-api.ts b/packages/app-store/bigbluebutton/lib/bbb-api.ts index c13a7299f7efe5..742d0c49ccc9b0 100644 --- a/packages/app-store/bigbluebutton/lib/bbb-api.ts +++ b/packages/app-store/bigbluebutton/lib/bbb-api.ts @@ -148,17 +148,28 @@ export class BBBApi { } /** - * Test connection to BBB server + * Test connection to BBB server and validate authentication + * Uses getMeetings which requires a valid checksum (shared secret) + * If auth fails, BBB returns FAILED with specific error message */ async testConnection(): Promise { try { - const url = this.buildApiUrl("", {}); + const url = this.buildApiUrl("getMeetings", {}); const response = await axios.get(url, { timeout: 5000, }); + + // Check response status and content + if (response.status !== 200) return false; + + // BBB returns FAILED for auth errors, SUCCESS for valid auth (even empty meetings) + if (response.data.includes("FAILED")) { + return false; + } - // If we get any response, the server is reachable - return response.status === 200; + // Valid auth should include returncode SUCCESS or valid XML structure + return response.data.includes("SUCCESS") || + response.data.includes(""); } catch (error) { return false; } diff --git a/packages/app-store/bigbluebutton/zod.ts b/packages/app-store/bigbluebutton/zod.ts index ac83a81dab2335..86b161dee9880f 100644 --- a/packages/app-store/bigbluebutton/zod.ts +++ b/packages/app-store/bigbluebutton/zod.ts @@ -2,7 +2,10 @@ import { z } from "zod"; export const appKeysSchema = z.object({ serverUrl: z.string().url("Please provide a valid server URL"), - sharedSecret: z.string().min(1, "Shared secret is required"), + sharedSecret: z.string().trim().min(8, "Shared secret must be at least 8 characters").refine( + (value) => value.trim() === value && value.length > 0, + "Shared secret cannot be empty or contain only whitespace" + ), }); export const appDataSchema = z.object({}); \ No newline at end of file From 2d5d093175dd1a7b89ffb4dc688abbc00588f9b9 Mon Sep 17 00:00:00 2001 From: "Omar G. Nagy" Date: Sun, 22 Mar 2026 22:08:40 +0000 Subject: [PATCH 3/4] fix: resolve P1/P2 security vulnerabilities in BigBlueButton integration - Fix race condition in duplicate install check using transaction - Fix password field semantics by storing attendee password only - Store moderator credentials in structured meeting ID format - Maintain backward compatibility and proper error handling Addresses cubic-dev-ai security review feedback. --- packages/app-store/bigbluebutton/api/add.ts | 60 ++++++++++------- .../bigbluebutton/lib/VideoApiAdapter.ts | 65 ++++++++++--------- 2 files changed, 70 insertions(+), 55 deletions(-) diff --git a/packages/app-store/bigbluebutton/api/add.ts b/packages/app-store/bigbluebutton/api/add.ts index 355b95b7f25486..085d59b07be205 100644 --- a/packages/app-store/bigbluebutton/api/add.ts +++ b/packages/app-store/bigbluebutton/api/add.ts @@ -49,21 +49,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const appType = "bigbluebutton_video"; try { - // Check if already installed to prevent duplicates - const existingInstallation = await prisma.credential.findFirst({ - where: { - type: appType, - ...installForObject, - }, - }); - - if (existingInstallation) { - return res.status(400).json({ - message: "BigBlueButton is already installed" - }); - } - - // Test BBB server connection with proper auth validation + // Test BBB server connection first before attempting to install const bbbApi = new BBBApi({ serverUrl, sharedSecret }); const isConnected = await bbbApi.testConnection(); @@ -73,17 +59,41 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); } - // Create the credential after successful connection test - const installation = await prisma.credential.create({ - data: { - type: appType, - key: { - serverUrl: serverUrl.replace(/\/+$/, ""), // Remove trailing slashes - sharedSecret, + // Use a transaction to prevent race conditions + const installation = await prisma.$transaction(async (tx) => { + // Check for existing installation within transaction + const existing = await tx.credential.findFirst({ + where: { + type: appType, + ...installForObject, }, - ...installForObject, - appId: "bigbluebutton", - }, + }); + + if (existing) { + // Update existing installation instead of creating duplicate + return await tx.credential.update({ + where: { id: existing.id }, + data: { + key: { + serverUrl: serverUrl.replace(/\/+$/, ""), // Remove trailing slashes + sharedSecret, + }, + }, + }); + } + + // Create new installation if none exists + return await tx.credential.create({ + data: { + type: appType, + key: { + serverUrl: serverUrl.replace(/\/+$/, ""), // Remove trailing slashes + sharedSecret, + }, + ...installForObject, + appId: "bigbluebutton", + }, + }); }); if (!installation) { diff --git a/packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts b/packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts index 2ad006f5b69813..55d945131c2f64 100644 --- a/packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts +++ b/packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts @@ -55,12 +55,8 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => { return { type: metadata.type, - id: meetingID, - password: JSON.stringify({ - moderatorPassword, - attendeePassword, - serverUrl // Store server URL for meeting operations - }), + id: `${meetingID}|${moderatorPassword}|${serverUrl}`, // Store structured meeting data + password: attendeePassword, // Standard semantic: password for meeting access url: attendeeJoinUrl, // Safe attendee URL as canonical URL }; } catch (error) { @@ -79,24 +75,30 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => { } try { + // Parse structured meeting ID: meetingID|moderatorPassword|serverUrl + const idParts = bookingRef.meetingId.split("|"); + if (idParts.length !== 3) { + console.warn("Invalid BigBlueButton meeting ID format for deletion"); + return; + } + + const [actualMeetingId, moderatorPassword, storedServerUrl] = idParts; + + // Get app keys for shared secret (server URL comes from meeting ID) const appKeys = await getAppKeysFromSlug(metadata.slug); - const serverUrl = appKeys.serverUrl as string; const sharedSecret = appKeys.sharedSecret as string; - if (!serverUrl || !sharedSecret) { - console.warn("BigBlueButton configuration missing for meeting deletion"); + if (!sharedSecret) { + console.warn("BigBlueButton shared secret missing for meeting deletion"); return; } - const bbbApi = new BBBApi({ serverUrl, sharedSecret }); - - // Get moderator password from booking reference (now stored as JSON) - const passwordData = bookingRef.meetingPassword ? JSON.parse(bookingRef.meetingPassword) : {}; - const moderatorPassword = passwordData.moderatorPassword; + const bbbApi = new BBBApi({ + serverUrl: storedServerUrl, + sharedSecret + }); - if (moderatorPassword) { - await bbbApi.endMeeting(bookingRef.meetingId, moderatorPassword); - } + await bbbApi.endMeeting(actualMeetingId, moderatorPassword); } catch (error) { console.error("Failed to end BigBlueButton meeting:", error); // Don't throw error for deletion failures to avoid blocking booking cancellation @@ -108,13 +110,13 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => { // BBB meetings are stateless and don't need updating const meetingId = bookingRef.meetingId as string; - const passwordData = bookingRef.meetingPassword as string; + const attendeePassword = bookingRef.meetingPassword as string; const meetingUrl = bookingRef.meetingUrl as string; return Promise.resolve({ type: metadata.type, id: meetingId, - password: passwordData, // Keep original password data format + password: attendeePassword, // Standard attendee password url: meetingUrl, }); }, @@ -123,16 +125,20 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => { * Generate attendee join URL for guests */ getGuestJoinUrl: async ( - meetingId: string, + structuredMeetingId: string, guestName: string, - attendeePassword: string, - serverUrl: string, - sharedSecret: string + attendeePassword: string ): Promise => { + // Parse structured meeting ID: meetingID|moderatorPassword|serverUrl + const [actualMeetingId, , serverUrl] = structuredMeetingId.split("|"); + + const appKeys = await getAppKeysFromSlug(metadata.slug); + const sharedSecret = appKeys.sharedSecret as string; + const bbbApi = new BBBApi({ serverUrl, sharedSecret }); return bbbApi.getJoinUrl({ - meetingID: meetingId, + meetingID: actualMeetingId, fullName: guestName || "Guest", password: attendeePassword, userID: `guest-${Date.now()}`, @@ -144,12 +150,11 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => { * Generate moderator join URL for organizers */ getModeratorJoinUrl: async ( - meetingId: string, - organizerName: string, - passwordData: string + structuredMeetingId: string, + organizerName: string ): Promise => { - const parsedData = JSON.parse(passwordData); - const { moderatorPassword, serverUrl } = parsedData; + // Parse structured meeting ID: meetingID|moderatorPassword|serverUrl + const [actualMeetingId, moderatorPassword, serverUrl] = structuredMeetingId.split("|"); const appKeys = await getAppKeysFromSlug(metadata.slug); const sharedSecret = appKeys.sharedSecret as string; @@ -157,7 +162,7 @@ const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => { const bbbApi = new BBBApi({ serverUrl, sharedSecret }); return bbbApi.getJoinUrl({ - meetingID: meetingId, + meetingID: actualMeetingId, fullName: organizerName || "Host", password: moderatorPassword, userID: "moderator", From fa0cef00dad048d41d75e899e35a003af2b415bd Mon Sep 17 00:00:00 2001 From: "Omar G. Nagy" Date: Mon, 23 Mar 2026 00:28:19 +0000 Subject: [PATCH 4/4] feat(bigbluebutton): add setup page and register in app store - Add Setup.tsx component with server URL and shared secret form - Register BigBlueButton in AppSetupPage map for credential flow - Add to generated files: metadata, server routes, video adapters, key schemas - Fixes install flow that was causing infinite render loop --- apps/web/components/apps/AppSetupPage.tsx | 1 + .../components/apps/bigbluebutton/Setup.tsx | 95 +++++++++++++++++++ .../app-store/apps.keys-schemas.generated.ts | 2 + packages/app-store/apps.metadata.generated.ts | 2 + packages/app-store/apps.server.generated.ts | 1 + .../app-store/video.adapters.generated.ts | 1 + 6 files changed, 102 insertions(+) create mode 100644 apps/web/components/apps/bigbluebutton/Setup.tsx diff --git a/apps/web/components/apps/AppSetupPage.tsx b/apps/web/components/apps/AppSetupPage.tsx index 88acf8c89bbb76..0110bdce792537 100644 --- a/apps/web/components/apps/AppSetupPage.tsx +++ b/apps/web/components/apps/AppSetupPage.tsx @@ -2,6 +2,7 @@ import { DynamicComponent } from "@calcom/app-store/_components/DynamicComponent import dynamic from "next/dynamic"; export const AppSetupMap = { + bigbluebutton: dynamic(() => import("@calcom/web/components/apps/bigbluebutton/Setup")), alby: dynamic(() => import("@calcom/web/components/apps/alby/Setup")), "apple-calendar": dynamic(() => import("@calcom/web/components/apps/applecalendar/Setup")), exchange: dynamic(() => import("@calcom/web/components/apps/exchangecalendar/Setup")), diff --git a/apps/web/components/apps/bigbluebutton/Setup.tsx b/apps/web/components/apps/bigbluebutton/Setup.tsx new file mode 100644 index 00000000000000..b182702603959f --- /dev/null +++ b/apps/web/components/apps/bigbluebutton/Setup.tsx @@ -0,0 +1,95 @@ +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Toaster } from "sonner"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Alert } from "@calcom/ui/components/alert"; +import { Button } from "@calcom/ui/components/button"; +import { Form } from "@calcom/ui/components/form"; +import { TextField } from "@calcom/ui/components/form"; +import { PasswordField } from "@calcom/ui/components/form"; + +export default function BigBlueButtonSetup() { + const { t } = useLocale(); + const router = useRouter(); + const form = useForm({ + defaultValues: { + serverUrl: "", + sharedSecret: "", + }, + }); + + const [errorMessage, setErrorMessage] = useState(""); + + return ( +
+
+
+
+ {/* eslint-disable @next/next/no-img-element */} + BigBlueButton +
+
+

Connect BigBlueButton

+
+ Enter your BigBlueButton server URL and shared secret to enable video conferencing. +
+
+
{ + setErrorMessage(""); + const res = await fetch("/api/integrations/bigbluebutton/add", { + method: "POST", + body: JSON.stringify(values), + headers: { + "Content-Type": "application/json", + }, + }); + const json = await res.json(); + if (!res.ok) { + setErrorMessage(json?.message || t("something_went_wrong")); + } else { + router.push(json.url); + } + }}> +
+ + +
+ + {errorMessage && } +
+ + +
+ +
+
+
+ +
+
+ ); +} diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index 5df66d19f7411c..658ddebe623381 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -22,6 +22,7 @@ import { appKeysSchema as insihts_zod_ts } from "./insihts/zod"; import { appKeysSchema as intercom_zod_ts } from "./intercom/zod"; import { appKeysSchema as jelly_zod_ts } from "./jelly/zod"; import { appKeysSchema as jitsivideo_zod_ts } from "./jitsivideo/zod"; +import { appKeysSchema as bigbluebutton_zod_ts } from "./bigbluebutton/zod"; import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod"; import { appKeysSchema as lyra_zod_ts } from "./lyra/zod"; import { appKeysSchema as make_zod_ts } from "./make/zod"; @@ -74,6 +75,7 @@ export const appKeysSchemas = { intercom: intercom_zod_ts, jelly: jelly_zod_ts, jitsivideo: jitsivideo_zod_ts, + bigbluebutton: bigbluebutton_zod_ts, larkcalendar: larkcalendar_zod_ts, lyra: lyra_zod_ts, make: make_zod_ts, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index 4857b87fdac178..7e085e66dd4c16 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -52,6 +52,7 @@ import insihts_config_json from "./insihts/config.json"; import intercom_config_json from "./intercom/config.json"; import jelly_config_json from "./jelly/config.json"; import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata"; +import { metadata as bigbluebutton__metadata_ts } from "./bigbluebutton/_metadata"; import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata"; import lindy_config_json from "./lindy/config.json"; import linear_config_json from "./linear/config.json"; @@ -164,6 +165,7 @@ export const appStoreMetadata = { intercom: intercom_config_json, jelly: jelly_config_json, jitsivideo: jitsivideo__metadata_ts, + bigbluebutton: bigbluebutton__metadata_ts, larkcalendar: larkcalendar__metadata_ts, lindy: lindy_config_json, linear: linear_config_json, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index 495e07786d6754..8584116642acba 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -38,6 +38,7 @@ export const apiHandlers = { intercom: import("./intercom/api"), jelly: import("./jelly/api"), jitsivideo: import("./jitsivideo/api"), + bigbluebutton: import("./bigbluebutton/api"), larkcalendar: import("./larkcalendar/api"), linear: import("./linear/api"), lyra: import("./lyra/api"), diff --git a/packages/app-store/video.adapters.generated.ts b/packages/app-store/video.adapters.generated.ts index f1e0f05ffe7f78..74252cdcac0a37 100644 --- a/packages/app-store/video.adapters.generated.ts +++ b/packages/app-store/video.adapters.generated.ts @@ -10,6 +10,7 @@ export const VideoApiAdapterMap = huddle01video: import("./huddle01video/lib/VideoApiAdapter"), jelly: import("./jelly/lib/VideoApiAdapter"), jitsivideo: import("./jitsivideo/lib/VideoApiAdapter"), + bigbluebutton: import("./bigbluebutton/lib/VideoApiAdapter"), lyra: import("./lyra/lib/VideoApiAdapter"), nextcloudtalk: import("./nextcloudtalk/lib/VideoApiAdapter"), office365video: import("./office365video/lib/VideoApiAdapter"),