Passed
Pull Request — master (#6810)
by
unknown
10:30
created

read_raw_payload()   B

Complexity

Conditions 8
Paths 5

Size

Total Lines 19
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 8
eloc 12
c 1
b 0
f 1
nc 5
nop 0
dl 0
loc 19
rs 8.4444
1
<?php
2
/* For license terms, see /license.txt */
3
4
/**
5
 * BBB Webhook endpoint for Chamilo (Bbb plugin)
6
 *
7
 * Responsibilities:
8
 *  - Validate HMAC query signature (our own signature added when registering the hook)
9
 *  - Parse payload (JSON preferred; XML and form as fallback)
10
 *  - Map events to per-participant metrics in ConferenceActivity.metrics (JSON)
11
 *  - Ensure there is an OPEN ConferenceActivity row for (meeting,user)
12
 */
13
14
use Chamilo\CoreBundle\Entity\ConferenceActivity;
15
use Chamilo\CoreBundle\Entity\ConferenceMeeting;
16
use Chamilo\CoreBundle\Entity\User;
17
use Chamilo\CoreBundle\Repository\ConferenceActivityRepository;
18
use Chamilo\CoreBundle\Repository\ConferenceMeetingRepository;
19
use Chamilo\CoreBundle\Repository\Node\UserRepository;
20
21
require_once dirname(__DIR__, 3).'/public/main/inc/global.inc.php';
22
23
// --------- Debug toggle (set from plugin/config if you want) ----------
24
$DEBUG = true; // TODO: set to false in production, or read from $plugin->get('debug_webhooks') === 'true'
25
26
// Small helper
27
function dbg($msg){ global $DEBUG; if ($DEBUG) { error_log('[BBB webhook] '.$msg); } }
28
29
// --------- Safe JSON response ----------
30
function http_json($code, $data) {
31
    http_response_code($code);
32
    header('Content-Type: application/json; charset=utf-8');
33
    echo json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
34
    exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
35
}
36
37
// --------- Payload readers ----------
38
function read_raw_payload() {
39
    $raw = file_get_contents('php://input');
40
    if ($raw === '' || $raw === false) { return [null, null, 0]; }
41
    // JSON
42
    $js = json_decode($raw, true);
43
    if (json_last_error() === JSON_ERROR_NONE && is_array($js)) {
44
        return ['json', $js, strlen($raw)];
45
    }
46
    // XML
47
    $xml = @simplexml_load_string($raw);
48
    if ($xml) {
49
        return ['xml', $xml, strlen($raw)];
50
    }
51
    // form-encoded
52
    parse_str($raw, $arr);
53
    if (is_array($arr) && $arr) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $arr of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
54
        return ['form', $arr, strlen($raw)];
55
    }
56
    return [null, null, strlen($raw)];
57
}
58
59
// --------- Metrics helpers ----------
60
function metrics_get(array $m, string $path, $default=null) {
61
    $p = explode('.', $path);
62
    foreach ($p as $k) {
63
        if (!is_array($m) || !array_key_exists($k, $m)) return $default;
64
        $m = $m[$k];
65
    }
66
    return $m;
67
}
68
function metrics_set(array &$m, string $path, $value) {
69
    $p = explode('.', $path);
70
    $cur =& $m;
71
    foreach ($p as $k) {
72
        if (!isset($cur[$k]) || !is_array($cur[$k])) $cur[$k] = [];
73
        $cur =& $cur[$k];
74
    }
75
    $cur = $value;
76
}
77
function metrics_inc(array &$m, string $path, int $delta=1) {
78
    $v = (int) metrics_get($m, $path, 0);
79
    metrics_set($m, $path, $v + $delta);
80
}
81
function metrics_add_seconds(array &$m, string $tempStartPath, string $totalPath, int $stopTs) {
82
    $startTs = (int) metrics_get($m, $tempStartPath, 0);
83
    if ($startTs > 0 && $stopTs >= $startTs) {
84
        $acc = (int) metrics_get($m, $totalPath, 0);
85
        metrics_set($m, $totalPath, $acc + ($stopTs - $startTs));
86
        metrics_set($m, $tempStartPath, 0);
87
    }
88
}
89
90
try {
91
    // ---------- 1) Validate HMAC we add at webhook registration ----------
92
    $au  = isset($_GET['au'])  ? (int) $_GET['au']  : 0;
93
    $mid = isset($_GET['mid']) ? (string) $_GET['mid'] : '';
94
    $ts  = isset($_GET['ts'])  ? (int) $_GET['ts'] : 0;   // optional but recommended to avoid replay
95
    $sig = isset($_GET['sig']) ? (string) $_GET['sig'] : '';
96
97
    $plugin   = BbbPlugin::create();
98
    $hashAlgo = $plugin->webhooksHashAlgo(); // 'sha256' | 'sha1'
99
    $salt     = (string) $plugin->get('salt');
100
101
    if (!$salt || !$hashAlgo) {
102
        dbg('plugin not configured (missing salt/hashAlgo)');
103
        http_json(500, ['ok'=>false,'error'=>'plugin_not_configured']);
104
    }
105
106
    if (!$au || !$sig) {
107
        dbg('missing signature fields');
108
        http_json(400, ['ok'=>false,'error'=>'missing_signature_fields']);
109
    }
110
111
    // Optional anti-replay: allow 15 minutes skew
112
    if ($ts && abs(time() - $ts) > 900) {
113
        dbg('expired signature (timestamp out of window)');
114
        http_json(403, ['ok'=>false,'error'=>'expired_signature']);
115
    }
116
117
    // IMPORTANT: this must match how you generated it in Bbb::buildWebhookCallbackUrl()
118
    // If there you used au|mid|ts then keep au|mid|ts here; if you used au|mid, keep that.
119
    $payloadForHmac = $au.'|'.$mid;  // sin timestamp
120
    $expected = hash_hmac($hashAlgo, $payloadForHmac, $salt);
121
    if (!hash_equals($expected, $sig)) {
122
        error_log('[BBB webhook] bad signature: payload='.$payloadForHmac);
123
        http_response_code(403);
124
        echo json_encode(['ok'=>false,'error'=>'bad_signature']);
125
        exit;
126
    }
127
128
    // ---------- 2) Parse incoming payload ----------
129
    list($fmt, $payloadObj, $rawLen) = read_raw_payload();
130
    dbg('request ok; body_format=' . ($fmt ?: 'none') . ' body_size=' . $rawLen . 'B');
131
132
    if (!$fmt) {
133
        // Some BBB pings might not have body
134
        http_json(200, ['ok'=>true,'note'=>'no_payload']);
135
    }
136
137
    $ev = [
138
        'event'        => null,
139
        'meetingID'    => null,
140
        'internalID'   => null,
141
        'userID'       => null,
142
        'username'     => null,
143
        'emoji'        => null,
144
        'timestamp'    => time(),
145
    ];
146
147
    if ($fmt === 'json') {
148
        $ev['event']      = $payloadObj['event']           ?? ($payloadObj['header']['name'] ?? null);
149
        $ev['meetingID']  = $payloadObj['meetingID']       ?? ($payloadObj['payload']['meeting']['externalMeetingID'] ?? null);
150
        $ev['internalID'] = $payloadObj['internalMeetingID'] ?? ($payloadObj['payload']['meeting']['internalMeetingID'] ?? null);
151
        $ev['userID']     = $payloadObj['userID']          ?? ($payloadObj['payload']['user']['externalUserID'] ?? null);
152
        $ev['username']   = $payloadObj['username']        ?? ($payloadObj['payload']['user']['name'] ?? null);
153
        $ev['emoji']      = $payloadObj['emoji']           ?? ($payloadObj['payload']['emoji'] ?? null);
154
        $ev['timestamp']  = (int)($payloadObj['timestamp'] ?? time());
155
    } elseif ($fmt === 'xml') {
156
        $ev['event']      = (string)($payloadObj->event ?? $payloadObj->header->name ?? '');
157
        $ev['meetingID']  = (string)($payloadObj->meetingID ?? $payloadObj->payload->meeting->externalMeetingID ?? '');
158
        $ev['internalID'] = (string)($payloadObj->internalMeetingID ?? $payloadObj->payload->meeting->internalMeetingID ?? '');
159
        $ev['userID']     = (string)($payloadObj->userID ?? $payloadObj->payload->user->externalUserID ?? '');
160
        $ev['username']   = (string)($payloadObj->username ?? $payloadObj->payload->user->name ?? '');
161
        $ev['emoji']      = (string)($payloadObj->emoji ?? $payloadObj->payload->emoji ?? '');
162
        $ev['timestamp']  = (int)($payloadObj->timestamp ?? time());
163
    } else { // form
164
        $arr = $payloadObj;
165
        $ev['event']      = $arr['event']      ?? ($arr['name'] ?? null);
166
        $ev['meetingID']  = $arr['meetingID']  ?? ($arr['externalMeetingID'] ?? null);
167
        $ev['internalID'] = $arr['internalMeetingID'] ?? null;
168
        $ev['userID']     = $arr['userID']     ?? ($arr['externalUserID'] ?? null);
169
        $ev['username']   = $arr['username']   ?? null;
170
        $ev['emoji']      = $arr['emoji']      ?? null;
171
        $ev['timestamp']  = (int)($arr['timestamp'] ?? time());
172
    }
173
174
    // If hook was registered per meeting, enforce the meetingID from query
175
    if ($mid !== '') { $ev['meetingID'] = $mid; }
176
177
    dbg('event='.($ev['event'] ?? 'null').' meetingID='.($ev['meetingID'] ?? 'null').' userID='.($ev['userID'] ?? 'null'));
178
179
    // ---------- 3) Resolve meeting and user ----------
180
    $em = Database::getManager();
181
    /** @var ConferenceMeetingRepository $mRepo */
182
    $mRepo = $em->getRepository(ConferenceMeeting::class);
183
    /** @var ConferenceActivityRepository $aRepo */
184
    $aRepo = $em->getRepository(ConferenceActivity::class);
185
    /** @var UserRepository $uRepo */
186
    $uRepo = $em->getRepository(User::class);
187
188
    // Meeting by external remoteId first, then internalMeetingId
189
    $meeting = null;
190
    if (!empty($ev['meetingID'])) {
191
        $meeting = $mRepo->findOneBy(['remoteId' => (string)$ev['meetingID']]);
192
    }
193
    if (!$meeting && !empty($ev['internalID'])) {
194
        $meeting = $mRepo->findOneBy(['internalMeetingId' => (string)$ev['internalID']]);
195
    }
196
    if (!$meeting) {
197
        dbg('meeting not found');
198
        http_json(200, ['ok'=>true,'note'=>'meeting_not_found']);
199
    }
200
201
    // Resolve user: prefer numeric externalUserID; fallback to username
202
    $user = null;
203
    if (!empty($ev['userID']) && ctype_digit((string)$ev['userID'])) {
204
        $user = $uRepo->find((int)$ev['userID']);
205
    }
206
    if (!$user && !empty($ev['username'])) {
207
        $user = $uRepo->findOneBy(['username' => (string)$ev['username']]);
208
    }
209
    if (!$user) {
210
        dbg('user not found');
211
        http_json(200, ['ok'=>true,'note'=>'user_not_found']);
212
    }
213
214
    // ---------- 4) Find or create OPEN ConferenceActivity ----------
215
    $open = $aRepo->createQueryBuilder('a')
216
        ->where('a.meeting = :m')
217
        ->andWhere('a.participant = :u')
218
        ->andWhere('a.close = :open')
219
        ->setParameter('m', $meeting)
220
        ->setParameter('u', $user)
221
        ->setParameter('open', BbbPlugin::ROOM_OPEN)
222
        ->orderBy('a.id','DESC')
223
        ->getQuery()->getOneOrNullResult();
224
225
    if (!$open) {
226
        $open = new ConferenceActivity();
227
        $open->setMeeting($meeting);
228
        $open->setParticipant($user);
229
        $open->setInAt(new \DateTime('now', new \DateTimeZone('UTC')));
230
        $open->setOutAt(new \DateTime('now', new \DateTimeZone('UTC')));
231
        $open->setClose(BbbPlugin::ROOM_OPEN);
232
        $em->persist($open);
233
        $em->flush();
234
    }
235
236
    // ---------- 5) Load/update metrics ----------
237
    $metrics = $open->getMetrics();
238
    if (!is_array($metrics)) {
239
        $metrics = [
240
            'totals' => ['talk_seconds'=>0, 'camera_seconds'=>0],
241
            'counts' => ['messages'=>0, 'reactions'=>0, 'hands'=>0, 'reactions_breakdown'=>[]],
242
            'temp'   => ['talk_started_at'=>0, 'camera_started_at'=>0],
243
        ];
244
    }
245
246
    $eName = strtolower((string)($ev['event'] ?? ''));
247
    $tsEvt = (int)($ev['timestamp'] ?? time());
248
    $changed = false;
249
250
    switch ($eName) {
251
        // Chat
252
        case 'publicchatmessageposted':
253
        case 'chat_message_posted':
254
        case 'message_posted':
255
            metrics_inc($metrics, 'counts.messages', 1);
256
            $changed = true;
257
            break;
258
259
        // Voice start/stop
260
        case 'uservoiceactivated':
261
        case 'user_talking_started':
262
        case 'audio_talk_started':
263
            metrics_set($metrics, 'temp.talk_started_at', $tsEvt);
264
            $changed = true;
265
            break;
266
267
        case 'uservoicedeactivated':
268
        case 'user_talking_stopped':
269
        case 'audio_talk_stopped':
270
            metrics_add_seconds($metrics, 'temp.talk_started_at', 'totals.talk_seconds', $tsEvt);
271
            $changed = true;
272
            break;
273
274
        // Camera start/stop
275
        case 'webcamsharestarted':
276
        case 'camera_share_started':
277
            metrics_set($metrics, 'temp.camera_started_at', $tsEvt);
278
            $changed = true;
279
            break;
280
281
        case 'webcamsharestopped':
282
        case 'camera_share_stopped':
283
            metrics_add_seconds($metrics, 'temp.camera_started_at', 'totals.camera_seconds', $tsEvt);
284
            $changed = true;
285
            break;
286
287
        // Reactions
288
        case 'useremojichanged':
289
        case 'user_reaction_changed':
290
        case 'reaction':
291
            $emoji = (string)($ev['emoji'] ?? '');
292
            if ($emoji !== '') {
293
                metrics_inc($metrics, 'counts.reactions', 1);
294
                $rb = metrics_get($metrics, 'counts.reactions_breakdown', []);
295
                $rb[$emoji] = (int)($rb[$emoji] ?? 0) + 1;
296
                metrics_set($metrics, 'counts.reactions_breakdown', $rb);
297
                $changed = true;
298
            }
299
            break;
300
301
        // Hand raise
302
        case 'userraisedhand':
303
        case 'user_hand_raised':
304
            metrics_inc($metrics, 'counts.hands', 1);
305
            $changed = true;
306
            break;
307
308
        // Participant left
309
        case 'participantleft':
310
        case 'user_left':
311
            metrics_add_seconds($metrics, 'temp.talk_started_at',   'totals.talk_seconds',   $tsEvt);
312
            metrics_add_seconds($metrics, 'temp.camera_started_at', 'totals.camera_seconds', $tsEvt);
313
            $outAt = (new \DateTime('@'.$tsEvt))->setTimezone(new \DateTimeZone('UTC'));
314
            $open->setOutAt($outAt);
315
            $open->setClose(BbbPlugin::ROOM_CLOSE);
316
            $changed = true;
317
            break;
318
319
        // Participant joined: ensure row exists (already done)
320
        case 'participantjoined':
321
        case 'user_joined':
322
            $changed = true;
323
            break;
324
325
        default:
326
            dbg('unknown event: '.$eName);
327
            break;
328
    }
329
330
    if ($changed) {
331
        $open->setMetrics($metrics);
332
        $em->persist($open);
333
        $em->flush();
334
    }
335
336
    http_json(200, [
337
        'ok'         => true,
338
        'event'      => $eName,
339
        'meeting_id' => $meeting->getId(),
340
        'user_id'    => $user->getId(),
341
    ]);
342
343
} catch (\Throwable $e) {
344
    // Never leak stack traces to caller, but log them if DEBUG
345
    dbg('unhandled exception: '.$e->getMessage());
346
    http_json(500, ['ok'=>false,'error'=>'internal_error']);
347
}
348