chamilo /
chamilo-lms
| 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
|
|||
| 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
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 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 |
In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.