From 00e563664ef3aa21013a4bc9578119c9d24746f8 Mon Sep 17 00:00:00 2001 From: Michelle Date: Fri, 13 Mar 2026 10:34:39 +0100 Subject: [PATCH] feat(analytics): enhance analytics functionality with token validation and data extraction --- server/config/bbb.js | 4 ++ server/routes/analytics.js | 20 +++++++-- server/routes/rooms.js | 5 ++- src/components/AnalyticsList.jsx | 74 ++++++++++++++++++++------------ src/i18n/de.json | 3 ++ src/i18n/en.json | 3 ++ 6 files changed, 76 insertions(+), 33 deletions(-) diff --git a/server/config/bbb.js b/server/config/bbb.js index 0338e11..0b84c46 100644 --- a/server/config/bbb.js +++ b/server/config/bbb.js @@ -191,4 +191,8 @@ export async function publishRecording(recordID, publish) { return apiCall('publishRecordings', { recordID, publish: publish ? 'true' : 'false' }); } +export function getAnalyticsToken(uid) { + return crypto.createHmac('sha256', BBB_SECRET).update('analytics_' + uid).digest('hex'); +} + export { getRoomPasswords }; diff --git a/server/routes/analytics.js b/server/routes/analytics.js index c217912..4c1ae9a 100644 --- a/server/routes/analytics.js +++ b/server/routes/analytics.js @@ -1,13 +1,24 @@ import { Router } from 'express'; +import crypto from 'crypto'; import { getDb } from '../config/database.js'; import { authenticateToken } from '../middleware/auth.js'; import { log } from '../config/logger.js'; +import { getAnalyticsToken } from '../config/bbb.js'; const router = Router(); -// POST /api/analytics/callback/:uid - BBB Learning Analytics callback (no auth, called by BBB) +// POST /api/analytics/callback/:uid?token=... - BBB Learning Analytics callback (token-secured) router.post('/callback/:uid', async (req, res) => { try { + const { token } = req.query; + const expectedToken = getAnalyticsToken(req.params.uid); + + // Constant-time comparison to prevent timing attacks + if (!token || token.length !== expectedToken.length || + !crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) { + return res.status(403).json({ error: 'Invalid token' }); + } + const db = getDb(); const room = await db.get('SELECT id, uid, learning_analytics FROM rooms WHERE uid = ?', [req.params.uid]); @@ -24,9 +35,10 @@ router.post('/callback/:uid', async (req, res) => { return res.status(400).json({ error: 'Invalid analytics data' }); } - // Extract meeting info from BBB analytics payload - const meetingId = data.intId || data.extId || room.uid; - const meetingName = data.name || room.uid; + // Extract meeting info from BBB learning analytics payload + // Format: { meeting_id, internal_meeting_id, data: { metadata: { meeting_name }, duration, attendees, ... } } + const meetingId = data.internal_meeting_id || data.meeting_id || room.uid; + const meetingName = data.data?.metadata?.meeting_name || data.meeting_id || room.uid; // Upsert: update if same meeting already exists (BBB sends updates during the meeting) const existing = await db.get( diff --git a/server/routes/rooms.js b/server/routes/rooms.js index 9c8662d..859a813 100644 --- a/server/routes/rooms.js +++ b/server/routes/rooms.js @@ -14,6 +14,7 @@ import { endMeeting, getMeetingInfo, isMeetingRunning, + getAnalyticsToken, } from '../config/bbb.js'; import { isFederationEnabled, @@ -484,7 +485,7 @@ router.post('/:uid/start', authenticateToken, async (req, res) => { ? `${baseUrl}/uploads/presentations/${room.presentation_file}` : null; const analyticsCallbackURL = room.learning_analytics - ? `${baseUrl}/api/analytics/callback/${room.uid}` + ? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}` : null; await createMeeting(room, baseUrl, loginURL, presentationUrl, analyticsCallbackURL); const avatarURL = getUserAvatarURL(req, req.user); @@ -632,7 +633,7 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => { const baseUrl = getBaseUrl(req); const loginURL = `${baseUrl}/join/${room.uid}`; const analyticsCallbackURL = room.learning_analytics - ? `${baseUrl}/api/analytics/callback/${room.uid}` + ? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}` : null; await createMeeting(room, baseUrl, loginURL, null, analyticsCallbackURL); } diff --git a/src/components/AnalyticsList.jsx b/src/components/AnalyticsList.jsx index 3b39e95..d49a487 100644 --- a/src/components/AnalyticsList.jsx +++ b/src/components/AnalyticsList.jsx @@ -1,4 +1,4 @@ -import { BarChart3, Trash2, Clock, Users, MessageSquare, Video, Mic, ChevronDown, ChevronUp } from 'lucide-react'; +import { BarChart3, Trash2, Clock, Users, MessageSquare, Video, Mic, ChevronDown, ChevronUp, Hand, BarChart2 } from 'lucide-react'; import { useState } from 'react'; import api from '../services/api'; import { useLanguage } from '../contexts/LanguageContext'; @@ -20,14 +20,15 @@ export default function AnalyticsList({ analytics, onRefresh }) { }); }; - const formatDurationMs = (ms) => { - if (!ms || ms <= 0) return '0m'; - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); + const formatDurationSec = (sec) => { + if (!sec || sec <= 0) return '0m'; + const minutes = Math.floor(sec / 60); const hours = Math.floor(minutes / 60); const mins = minutes % 60; + const secs = sec % 60; if (hours > 0) return `${hours}h ${mins}m`; - return `${mins}m`; + if (minutes > 0) return `${mins}m ${secs}s`; + return `${secs}s`; }; const handleDelete = async (id) => { @@ -48,22 +49,32 @@ export default function AnalyticsList({ analytics, onRefresh }) { setExpanded(prev => ({ ...prev, [id]: !prev[id] })); }; - // Extract user summary from BBB learning analytics data + // Extract user summary from BBB learning analytics callback data + // Payload: { meeting_id, data: { duration, start, finish, attendees: [{ name, moderator, duration, engagement: { chats, talks, raisehand, emojis, poll_votes, talk_time } }] } } const getUserSummary = (data) => { - if (!data?.users) return []; - const usersObj = typeof data.users === 'object' ? data.users : {}; - return Object.values(usersObj).map(u => ({ - name: u.name || '—', - isModerator: u.isModerator || false, - totalTime: u.registeredOn && u.leftOn ? u.leftOn - u.registeredOn : 0, - talkTime: u.talk?.totalTime || 0, - webcamTime: Array.isArray(u.webcams) ? u.webcams.reduce((acc, w) => acc + ((w.stoppedSharingAt || Date.now()) - (w.startedSharingAt || 0)), 0) : 0, - messages: u.totalOfMessages || 0, - emojis: Array.isArray(u.emojis) ? u.emojis.length : 0, - raiseHand: Array.isArray(u.raiseHand) ? u.raiseHand.length : 0, + const attendees = data?.data?.attendees; + if (!Array.isArray(attendees)) return []; + return attendees.map(a => ({ + name: a.name || '—', + isModerator: !!a.moderator, + duration: a.duration || 0, + talkTime: a.engagement?.talk_time || 0, + chats: a.engagement?.chats || 0, + talks: a.engagement?.talks || 0, + raiseHand: a.engagement?.raisehand || 0, + emojis: a.engagement?.emojis || 0, + pollVotes: a.engagement?.poll_votes || 0, })); }; + const getMeetingSummary = (data) => ({ + duration: data?.data?.duration || 0, + start: data?.data?.start || null, + finish: data?.data?.finish || null, + files: data?.data?.files || [], + polls: data?.data?.polls || [], + }); + if (!analytics || analytics.length === 0) { return (
@@ -77,9 +88,10 @@ export default function AnalyticsList({ analytics, onRefresh }) {
{analytics.map(entry => { const users = getUserSummary(entry.data); + const meeting = getMeetingSummary(entry.data); const isExpanded = expanded[entry.id]; const totalParticipants = users.length; - const totalMessages = users.reduce((sum, u) => sum + u.messages, 0); + const totalMessages = users.reduce((sum, u) => sum + u.chats, 0); return (
@@ -91,10 +103,14 @@ export default function AnalyticsList({ analytics, onRefresh }) {
-
+
- {formatDate(entry.createdAt)} + {meeting.start ? formatDate(meeting.start) : formatDate(entry.createdAt)} + + + + {formatDurationSec(meeting.duration)} @@ -134,14 +150,17 @@ export default function AnalyticsList({ analytics, onRefresh }) { {t('analytics.userName')} {t('analytics.role')} + + {t('analytics.duration')} + {t('analytics.talkTime')} - + {t('analytics.messages')} - {t('analytics.messages')} + {t('analytics.raiseHand')} {t('analytics.reactions')} @@ -159,10 +178,11 @@ export default function AnalyticsList({ analytics, onRefresh }) { {u.isModerator ? t('analytics.moderator') : t('analytics.viewer')} - {formatDurationMs(u.talkTime)} - {formatDurationMs(u.webcamTime)} - {u.messages} - {u.emojis + u.raiseHand} + {formatDurationSec(u.duration)} + {formatDurationSec(u.talkTime)} + {u.chats} + {u.raiseHand} + {u.emojis} ))} diff --git a/src/i18n/de.json b/src/i18n/de.json index 057c481..957e944 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -292,6 +292,9 @@ "viewer": "Teilnehmer", "talkTime": "Sprechzeit", "webcamTime": "Webcam-Zeit", + "duration": "Dauer", + "meetingDuration": "Meeting-Dauer", + "raiseHand": "Handheben", "reactions": "Reaktionen" }, "settings": { diff --git a/src/i18n/en.json b/src/i18n/en.json index b47f5fb..a4a0e18 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -292,6 +292,9 @@ "viewer": "Viewer", "talkTime": "Talk time", "webcamTime": "Webcam time", + "duration": "Duration", + "meetingDuration": "Meeting duration", + "raiseHand": "Raise hand", "reactions": "Reactions" }, "settings": {