Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/app-store/apps.keys-schemas.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
**/
import { appKeysSchema as alby_zod_ts } from "./alby/zod";
import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod";
import { appKeysSchema as bigbluebutton_zod_ts } from "./bigbluebutton/zod";
import { appKeysSchema as btcpayserver_zod_ts } from "./btcpayserver/zod";
import { appKeysSchema as closecom_zod_ts } from "./closecom/zod";
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
Expand Down Expand Up @@ -55,6 +56,7 @@ import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appKeysSchemas = {
alby: alby_zod_ts,
basecamp3: basecamp3_zod_ts,
bigbluebutton: bigbluebutton_zod_ts,
btcpayserver: btcpayserver_zod_ts,
closecom: closecom_zod_ts,
dailyvideo: dailyvideo_zod_ts,
Expand Down
2 changes: 2 additions & 0 deletions packages/app-store/apps.metadata.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import attio_config_json from "./attio/config.json";
import autocheckin_config_json from "./autocheckin/config.json";
import baa_for_hipaa_config_json from "./baa-for-hipaa/config.json";
import basecamp3_config_json from "./basecamp3/config.json";
import { metadata as bigbluebutton__metadata_ts } from "./bigbluebutton/_metadata";
import bolna_config_json from "./bolna/config.json";
import btcpayserver_config_json from "./btcpayserver/config.json";
import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
Expand Down Expand Up @@ -121,6 +122,7 @@ export const appStoreMetadata = {
autocheckin: autocheckin_config_json,
"baa-for-hipaa": baa_for_hipaa_config_json,
basecamp3: basecamp3_config_json,
bigbluebutton: bigbluebutton__metadata_ts,
bolna: bolna_config_json,
btcpayserver: btcpayserver_config_json,
caldavcalendar: caldavcalendar__metadata_ts,
Expand Down
2 changes: 2 additions & 0 deletions packages/app-store/apps.schemas.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
**/
import { appDataSchema as alby_zod_ts } from "./alby/zod";
import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod";
import { appDataSchema as bigbluebutton_zod_ts } from "./bigbluebutton/zod";
import { appDataSchema as btcpayserver_zod_ts } from "./btcpayserver/zod";
import { appDataSchema as closecom_zod_ts } from "./closecom/zod";
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
Expand Down Expand Up @@ -55,6 +56,7 @@ import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appDataSchemas = {
alby: alby_zod_ts,
basecamp3: basecamp3_zod_ts,
bigbluebutton: bigbluebutton_zod_ts,
btcpayserver: btcpayserver_zod_ts,
closecom: closecom_zod_ts,
dailyvideo: dailyvideo_zod_ts,
Expand Down
1 change: 1 addition & 0 deletions packages/app-store/apps.server.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const apiHandlers = {
applecalendar: import("./applecalendar/api"),
attio: import("./attio/api"),
basecamp3: import("./basecamp3/api"),
bigbluebutton: import("./bigbluebutton/api"),
btcpayserver: import("./btcpayserver/api"),
caldavcalendar: import("./caldavcalendar/api"),
campfire: import("./campfire/api"),
Expand Down
3 changes: 3 additions & 0 deletions packages/app-store/bigbluebutton/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
BigBlueButton is an open-source web conferencing system built for online learning and webinars.

This integration lets Cal.com create a BigBlueButton meeting URL for bookings.
29 changes: 29 additions & 0 deletions packages/app-store/bigbluebutton/_metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { AppMeta } from "@calcom/types/App";

export const metadata = {
name: "BigBlueButton",
description: "Open-source video conferencing for online learning and webinars.",
installed: true,
type: "bigbluebutton_video",
variant: "conferencing",
categories: ["conferencing"],
logo: "icon.svg",
publisher: "Cal.diy",
url: "https://bigbluebutton.org/",
slug: "bigbluebutton",
title: "BigBlueButton",
isGlobal: false,
email: "help@cal.com",
appData: {
location: {
linkType: "dynamic",
type: "integrations:bigbluebutton",
label: "BigBlueButton",
},
Comment on lines +17 to +22
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Localize the location label.

appData.location.label is a user-facing string, so hardcoding "BigBlueButton" here skips the app’s i18n flow. Please add it to packages/i18n/locales/en/common.json and reference the translated value instead.

As per coding guidelines, "**/*.{ts,tsx,jsx}: Add translations to packages/i18n/locales/en/common.json for all UI strings".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/app-store/bigbluebutton/_metadata.ts` around lines 17 - 22, Update
the user-facing label in packages/app-store/bigbluebutton/_metadata.ts to use a
translation key instead of the hardcoded string: replace appData.location.label
= "BigBlueButton" with a lookup of the i18n key (e.g.,
t('apps.bigbluebutton.label')) and add that key/value to
packages/i18n/locales/en/common.json (for example "apps": { "bigbluebutton": {
"label": "BigBlueButton" }}) so the string flows through the app i18n system.

},
dirName: "bigbluebutton",
concurrentMeetings: true,
isOAuth: false,
} as AppMeta;

export default metadata;
109 changes: 109 additions & 0 deletions packages/app-store/bigbluebutton/api/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown";
import prisma from "@calcom/prisma";
import type { NextApiRequest, NextApiResponse } from "next";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";

const getSingleQueryValue = (value: string | string[] | undefined) =>
Array.isArray(value) ? value[0] : value;

const getTeamId = (value: string | string[] | undefined) => {
const rawTeamId = getSingleQueryValue(value);
if (!rawTeamId) {
return null;
}

if (!/^\d+$/.test(rawTeamId)) {
throw new ErrorWithCode(ErrorCode.BadRequest, "Invalid teamId");
}

return Number(rawTeamId);
};

const getSafeReturnTo = (value: string | string[] | undefined) => {
const returnTo = getSingleQueryValue(value);
if (
!returnTo ||
!returnTo.startsWith("/") ||
returnTo.startsWith("//") ||
/^[a-z][a-z\d+.-]*:/i.test(returnTo)
) {
return getInstalledAppPath({
variant: "conferencing",
slug: "bigbluebutton",
});
}

return returnTo;
};

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" });
}

const { teamId, returnTo } = req.query;

try {
const teamIdNumber = getTeamId(teamId);

await throwIfNotHaveAdminAccessToTeam({
teamId: teamIdNumber,
userId: req.session.user.id,
});

const installForObject = teamIdNumber
? { teamId: teamIdNumber }
: { userId: req.session.user.id };
const appType = "bigbluebutton_video";

const alreadyInstalled = await prisma.credential.findFirst({
where: {
type: appType,
...installForObject,
},
select: {
id: true,
},
});

if (alreadyInstalled) {
throw new ErrorWithCode(ErrorCode.BookingConflict, "Already installed");
}

const installation = await prisma.credential.create({
data: {
type: appType,
key: {},
...installForObject,
appId: "bigbluebutton",
},
select: {
id: true,
},
});

if (!installation) {
throw new ErrorWithCode(
ErrorCode.InternalServerError,
"Unable to create user credential for bigbluebutton",
);
}
} catch (error: unknown) {
const httpError = getServerErrorFromUnknown(error);
return res
.status(httpError.statusCode)
.json({ message: httpError.message });
}

return res.status(200).json({
url: getSafeReturnTo(returnTo),
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
1 change: 1 addition & 0 deletions packages/app-store/bigbluebutton/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as add } from "./add";
3 changes: 3 additions & 0 deletions packages/app-store/bigbluebutton/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { metadata } from "./_metadata";
export * as api from "./api";
export * as lib from "./lib";
156 changes: 156 additions & 0 deletions packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { createHash, randomUUID } from "node:crypto";
import type { CalendarEvent, EventBusyDate } 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";

type BigBlueButtonKeys = {
bigBlueButtonServerUrl?: string;
bigBlueButtonSharedSecret?: string;
};

const normalizeApiUrl = (serverUrl: string) => {
const url = serverUrl.trim().replace(/\/+$/, "");
return url.endsWith("/bigbluebutton/api") ? url : `${url}/bigbluebutton/api`;
};

const checksum = (method: string, query: string, sharedSecret: string) =>
createHash("sha1").update(`${method}${query}${sharedSecret}`).digest("hex");

const deriveMeetingPassword = (
role: "attendee" | "moderator",
meetingID: string,
sharedSecret: string,
) =>
createHash("sha256")
.update(`${role}:${meetingID}:${sharedSecret}`)
.digest("hex")
.slice(0, 24);

const buildApiUrl = (
apiUrl: string,
method: string,
params: URLSearchParams,
sharedSecret: string,
) => {
const query = params.toString();
const signedQuery = new URLSearchParams(params);
signedQuery.set("checksum", checksum(method, query, sharedSecret));
return `${apiUrl}/${method}?${signedQuery.toString()}`;
};

const assertSuccessResponse = async (response: Response) => {
const body = await response.text();

if (!response.ok || !body.includes("<returncode>SUCCESS</returncode>")) {
throw new Error(`BigBlueButton API request failed: ${body}`);
}
};

const getBigBlueButtonConfig = async () => {
const appKeys = (await getAppKeysFromSlug(
metadata.slug,
)) as BigBlueButtonKeys;
Comment on lines +56 to +59
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Read BigBlueButton keys from the installed credential, not from the app slug.

This adapter is created per credential, but getBigBlueButtonConfig() ignores that input and resolves keys by metadata.slug instead. For a non-global app, that collapses every install onto one shared config and can create/end meetings against the wrong BBB server or secret.

Also applies to: 71-77

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/app-store/bigbluebutton/lib/VideoApiAdapter.ts` around lines 54 -
57, getBigBlueButtonConfig currently fetches keys by metadata.slug which ignores
the credential this adapter instance was created for; change it to read keys
from the installed credential instead. Replace the
getAppKeysFromSlug(metadata.slug) call in getBigBlueButtonConfig with the call
that fetches keys for the adapter's installed credential (use the credential
identifier available on the adapter instance, e.g., installedCredential.id or
credential.id) and keep the result typed as BigBlueButtonKeys; make the same
replacement for the second occurrence mentioned (lines 71-77) so each adapter
uses its own credential's keys rather than the shared slug-based config.

const serverUrl = appKeys.bigBlueButtonServerUrl?.trim();
const sharedSecret = appKeys.bigBlueButtonSharedSecret?.trim();

if (!serverUrl || !sharedSecret) {
throw new Error("BigBlueButton server URL and shared secret are required");
}

return {
apiUrl: normalizeApiUrl(serverUrl),
sharedSecret,
};
};

const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => {
return {
getAvailability: (): Promise<EventBusyDate[]> => {
return Promise.resolve([]);
},
createMeeting: async (eventData: CalendarEvent): Promise<VideoCallData> => {
const { apiUrl, sharedSecret } = await getBigBlueButtonConfig();
const meetingID = eventData.uid || randomUUID();
const attendeePassword = deriveMeetingPassword(
"attendee",
meetingID,
sharedSecret,
);
const moderatorPassword = deriveMeetingPassword(
"moderator",
meetingID,
sharedSecret,
);

const createParams = new URLSearchParams({
name: eventData.title,
meetingID,
attendeePW: attendeePassword,
moderatorPW: moderatorPassword,
record: "false",
});

const createUrl = buildApiUrl(
apiUrl,
"create",
createParams,
sharedSecret,
);
await assertSuccessResponse(await fetch(createUrl));

const joinParams = new URLSearchParams({
fullName: "Guest",
meetingID,
password: attendeePassword,
redirect: "true",
});

return {
type: metadata.type,
id: meetingID,
password: moderatorPassword,
url: buildApiUrl(apiUrl, "join", joinParams, sharedSecret),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
},
deleteMeeting: async (uid: string): Promise<void> => {
const { apiUrl, sharedSecret } = await getBigBlueButtonConfig();
const moderatorPassword = deriveMeetingPassword(
"moderator",
uid,
sharedSecret,
);

const endParams = new URLSearchParams({
meetingID: uid,
password: moderatorPassword,
});

await assertSuccessResponse(
await fetch(buildApiUrl(apiUrl, "end", endParams, sharedSecret)),
);
},
updateMeeting: (bookingRef: PartialReference): Promise<VideoCallData> => {
const { meetingId, meetingPassword, meetingUrl } = bookingRef;

if (!meetingId || !meetingPassword || !meetingUrl) {
throw new Error(
"BigBlueButton booking reference is missing meeting data",
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return Promise.resolve({
type: metadata.type,
id: meetingId,
password: meetingPassword,
url: meetingUrl,
});
},
};
};

export default BigBlueButtonVideoApiAdapter;
1 change: 1 addition & 0 deletions packages/app-store/bigbluebutton/lib/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as VideoApiAdapter } from "./VideoApiAdapter";
13 changes: 13 additions & 0 deletions packages/app-store/bigbluebutton/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"private": true,
"name": "@calcom/bigbluebutton",
"version": "0.0.0",
"main": "./index.ts",
"description": "BigBlueButton is an open-source web conferencing system built for online learning and webinars.",
"dependencies": {
"@calcom/lib": "workspace:*"
},
"devDependencies": {
"@calcom/types": "workspace:*"
}
}
6 changes: 6 additions & 0 deletions packages/app-store/bigbluebutton/static/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions packages/app-store/bigbluebutton/zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { z } from "zod";

export const appKeysSchema = z.object({
bigBlueButtonServerUrl: z.string().url().optional(),
bigBlueButtonSharedSecret: z.string().min(1).optional(),
});

export const appDataSchema = z.object({});
Loading
Loading