feat(analytics): enhance analytics functionality with token validation and data extraction
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -292,6 +292,9 @@
|
||||
"viewer": "Teilnehmer",
|
||||
"talkTime": "Sprechzeit",
|
||||
"webcamTime": "Webcam-Zeit",
|
||||
"duration": "Dauer",
|
||||
"meetingDuration": "Meeting-Dauer",
|
||||
"raiseHand": "Handheben",
|
||||
"reactions": "Reaktionen"
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -292,6 +292,9 @@
|
||||
"viewer": "Viewer",
|
||||
"talkTime": "Talk time",
|
||||
"webcamTime": "Webcam time",
|
||||
"duration": "Duration",
|
||||
"meetingDuration": "Meeting duration",
|
||||
"raiseHand": "Raise hand",
|
||||
"reactions": "Reactions"
|
||||
},
|
||||
"settings": {
|
||||
|
||||
Reference in New Issue
Block a user