feat(analytics): enhance analytics functionality with token validation and data extraction
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s

This commit is contained in:
2026-03-13 10:34:39 +01:00
parent 41ad3e037a
commit 00e563664e
6 changed files with 76 additions and 33 deletions

View File

@@ -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 };

View File

@@ -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(

View File

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

View File

@@ -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 (
<div className="text-center py-12">
@@ -77,9 +88,10 @@ export default function AnalyticsList({ analytics, onRefresh }) {
<div className="space-y-3">
{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 (
<div key={entry.id} className="card p-4">
@@ -91,10 +103,14 @@ export default function AnalyticsList({ analytics, onRefresh }) {
</h4>
</div>
<div className="flex items-center gap-4 text-xs text-th-text-s">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-th-text-s">
<span className="flex items-center gap-1">
<Clock size={12} />
{formatDate(entry.createdAt)}
{meeting.start ? formatDate(meeting.start) : formatDate(entry.createdAt)}
</span>
<span className="flex items-center gap-1">
<BarChart2 size={12} />
{formatDurationSec(meeting.duration)}
</span>
<span className="flex items-center gap-1">
<Users size={12} />
@@ -134,14 +150,17 @@ export default function AnalyticsList({ analytics, onRefresh }) {
<tr className="text-left text-xs text-th-text-s border-b border-th-border">
<th className="pb-2 pr-4 font-medium">{t('analytics.userName')}</th>
<th className="pb-2 pr-4 font-medium">{t('analytics.role')}</th>
<th className="pb-2 pr-4 font-medium">
<span className="flex items-center gap-1"><Clock size={11} />{t('analytics.duration')}</span>
</th>
<th className="pb-2 pr-4 font-medium">
<span className="flex items-center gap-1"><Mic size={11} />{t('analytics.talkTime')}</span>
</th>
<th className="pb-2 pr-4 font-medium">
<span className="flex items-center gap-1"><Video size={11} />{t('analytics.webcamTime')}</span>
<span className="flex items-center gap-1"><MessageSquare size={11} />{t('analytics.messages')}</span>
</th>
<th className="pb-2 pr-4 font-medium">
<span className="flex items-center gap-1"><MessageSquare size={11} />{t('analytics.messages')}</span>
<span className="flex items-center gap-1"><Hand size={11} />{t('analytics.raiseHand')}</span>
</th>
<th className="pb-2 font-medium">{t('analytics.reactions')}</th>
</tr>
@@ -159,10 +178,11 @@ export default function AnalyticsList({ analytics, onRefresh }) {
{u.isModerator ? t('analytics.moderator') : t('analytics.viewer')}
</span>
</td>
<td className="py-2 pr-4 text-th-text-s">{formatDurationMs(u.talkTime)}</td>
<td className="py-2 pr-4 text-th-text-s">{formatDurationMs(u.webcamTime)}</td>
<td className="py-2 pr-4 text-th-text-s">{u.messages}</td>
<td className="py-2 text-th-text-s">{u.emojis + u.raiseHand}</td>
<td className="py-2 pr-4 text-th-text-s">{formatDurationSec(u.duration)}</td>
<td className="py-2 pr-4 text-th-text-s">{formatDurationSec(u.talkTime)}</td>
<td className="py-2 pr-4 text-th-text-s">{u.chats}</td>
<td className="py-2 pr-4 text-th-text-s">{u.raiseHand}</td>
<td className="py-2 text-th-text-s">{u.emojis}</td>
</tr>
))}
</tbody>

View File

@@ -292,6 +292,9 @@
"viewer": "Teilnehmer",
"talkTime": "Sprechzeit",
"webcamTime": "Webcam-Zeit",
"duration": "Dauer",
"meetingDuration": "Meeting-Dauer",
"raiseHand": "Handheben",
"reactions": "Reaktionen"
},
"settings": {

View File

@@ -292,6 +292,9 @@
"viewer": "Viewer",
"talkTime": "Talk time",
"webcamTime": "Webcam time",
"duration": "Duration",
"meetingDuration": "Meeting duration",
"raiseHand": "Raise hand",
"reactions": "Reactions"
},
"settings": {