import crypto from 'crypto'; import xml2js from 'xml2js'; import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js'; const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/'; const BBB_SECRET = process.env.BBB_SECRET || ''; if (!BBB_SECRET) { log.bbb.warn('WARNING: BBB_SECRET is not set. BBB API calls will use an empty secret.'); } // HTML-escape for safe embedding in BBB welcome messages function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function getChecksum(apiCall, params) { const queryString = new URLSearchParams(params).toString(); const raw = apiCall + queryString + BBB_SECRET; return crypto.createHash('sha256').update(raw).digest('hex'); } function buildUrl(apiCall, params = {}) { const checksum = getChecksum(apiCall, params); const queryString = new URLSearchParams({ ...params, checksum }).toString(); return `${BBB_URL}${apiCall}?${queryString}`; } async function apiCall(apiCallName, params = {}, xmlBody = null) { const url = buildUrl(apiCallName, params); const method = xmlBody ? 'POST' : 'GET'; const start = Date.now(); try { const fetchOptions = xmlBody ? { method: 'POST', headers: { 'Content-Type': 'application/xml' }, body: xmlBody } : {}; const response = await fetch(url, fetchOptions); const duration = Date.now() - start; const xml = await response.text(); const result = await xml2js.parseStringPromise(xml, { explicitArray: false, trim: true, }); const returncode = result?.response?.returncode || '-'; const paramStr = sanitizeBBBParams(params); // Greenlight-style: method action → status returncode (duration) params log.bbb.info( `${fmtMethod(method)} ${apiCallName} → ${fmtStatus(response.status)} ${fmtReturncode(returncode)} (${fmtDuration(duration)}) ${paramStr}` ); return result.response; } catch (error) { const duration = Date.now() - start; log.bbb.error( `${fmtMethod(method)} ${apiCallName} ✗ FAILED (${fmtDuration(duration)}) ${error.message}` ); throw error; } } // Generate deterministic passwords from room UID function getRoomPasswords(uid) { const modPw = crypto.createHash('sha256').update(uid + '_mod_' + BBB_SECRET).digest('hex').substring(0, 16); const attPw = crypto.createHash('sha256').update(uid + '_att_' + BBB_SECRET).digest('hex').substring(0, 16); return { moderatorPW: modPw, attendeePW: attPw }; } export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null) { const { moderatorPW, attendeePW } = getRoomPasswords(room.uid); // Build welcome message with guest invite link // HTML-escape user-controlled content to prevent stored XSS via BBB let welcome = room.welcome_message ? escapeHtml(room.welcome_message) : t('defaultWelcome'); if (logoutURL) { const guestLink = `${logoutURL}/join/${room.uid}`; welcome += `

To invite other participants, share this link:
${escapeHtml(guestLink)}`; // Access code is intentionally NOT shown in the welcome message to prevent // leaking it to all meeting participants. } const params = { meetingID: room.uid, name: room.name.length >= 2 ? room.name : room.name.padEnd(2, ' '), attendeePW, moderatorPW, welcome, record: room.record_meeting ? 'true' : 'false', autoStartRecording: 'false', allowStartStopRecording: 'true', muteOnStart: room.mute_on_join ? 'true' : 'false', 'meta_bbb-origin': 'Redlight', 'meta_bbb-origin-server-name': 'Redlight', }; if (logoutURL) { params.logoutURL = logoutURL; } if (loginURL) { params.loginURL = loginURL; } if (room.max_participants > 0) { params.maxParticipants = room.max_participants.toString(); } if (room.access_code) { params.lockSettingsLockOnJoin = 'true'; } // Build optional presentation XML body - escape URL to prevent XML injection let xmlBody = null; if (presentationUrl) { const safeUrl = presentationUrl .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); xmlBody = ``; } return apiCall('create', params, xmlBody); } export async function joinMeeting(uid, name, isModerator = false, avatarURL = null) { const { moderatorPW, attendeePW } = getRoomPasswords(uid); const params = { meetingID: uid, fullName: name, password: isModerator ? moderatorPW : attendeePW, redirect: 'true', }; if (avatarURL) { params.avatarURL = avatarURL; } return buildUrl('join', params); } export async function endMeeting(uid) { const { moderatorPW } = getRoomPasswords(uid); return apiCall('end', { meetingID: uid, password: moderatorPW }); } export async function getMeetingInfo(uid) { return apiCall('getMeetingInfo', { meetingID: uid }); } export async function isMeetingRunning(uid) { const result = await apiCall('isMeetingRunning', { meetingID: uid }); return result.running === 'true'; } export async function getMeetings() { return apiCall('getMeetings', {}); } export async function getRecordings(meetingID) { const params = meetingID ? { meetingID } : {}; const result = await apiCall('getRecordings', params); if (result.returncode !== 'SUCCESS' || !result.recordings) { return []; } const recordings = result.recordings.recording; if (!recordings) return []; return Array.isArray(recordings) ? recordings : [recordings]; } export async function getRecordingByRecordId(recordID) { const result = await apiCall('getRecordings', { recordID }); if (result.returncode !== 'SUCCESS' || !result.recordings) { return null; } const recordings = result.recordings.recording; if (!recordings) return null; const arr = Array.isArray(recordings) ? recordings : [recordings]; return arr[0] || null; } export async function deleteRecording(recordID) { return apiCall('deleteRecordings', { recordID }); } export async function publishRecording(recordID, publish) { return apiCall('publishRecordings', { recordID, publish: publish ? 'true' : 'false' }); } export { getRoomPasswords };