Passed
Push — master ( 8853c4...c4ec0b )
by Yannick
09:15
created

BbbPlugin::get_name()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
use Chamilo\CoreBundle\Entity\AccessUrlRelPlugin;
6
use Chamilo\CoreBundle\Entity\ConferenceMeeting;
7
use Chamilo\CoreBundle\Entity\ConferenceRecording;
8
use Chamilo\CoreBundle\Framework\Container;
9
use Chamilo\CourseBundle\Entity\CCourseSetting;
10
use Chamilo\CoreBundle\Entity\Course;
11
12
/**
13
 * BigBlueButton plugin configuration class.
14
 * Handles plugin options and course settings.
15
 */
16
class BbbPlugin extends Plugin
17
{
18
    const ROOM_OPEN = 0;
19
    const ROOM_CLOSE = 1;
20
    const ROOM_CHECK = 2;
21
22
    public $isCoursePlugin = true;
23
24
    // Default course settings when creating a new course
25
    public $course_settings = [
26
        ['name' => 'big_blue_button_record_and_store', 'type' => 'checkbox'],
27
        ['name' => 'bbb_enable_conference_in_groups', 'type' => 'checkbox'],
28
        ['name' => 'bbb_force_record_generation', 'type' => 'checkbox'],
29
        ['name' => 'big_blue_button_students_start_conference_in_groups', 'type' => 'checkbox'],
30
    ];
31
32
    /**
33
     * BBBPlugin constructor.
34
     * Defines all available plugin settings.
35
     */
36
    protected function __construct()
37
    {
38
        parent::__construct(
39
            '2.9',
40
            'Julio Montoya, Yannick Warnier, Angel Fernando Quiroz Campos, Jose Angel Ruiz',
41
            [
42
                // Core BBB connection
43
                'host'  => 'text',
44
                'salt'  => 'text',
45
46
                // Global features
47
                'enable_global_conference'          => 'boolean',
48
                'enable_global_conference_per_user' => 'boolean',
49
                'enable_conference_in_course_groups'=> 'boolean',
50
                'enable_global_conference_link'     => 'boolean',
51
                'disable_download_conference_link'  => 'boolean',
52
                'hide_conference_link'              => 'boolean',
53
                'disable_course_settings'           => 'boolean',
54
                'max_users_limit'                   => 'text',
55
                'meeting_duration'                  => 'text',
56
57
                // Roles allowed to see/link global conference
58
                'global_conference_allow_roles' => [
59
                    'type'    => 'select',
60
                    'options' => [
61
                        PLATFORM_ADMIN => get_lang('Administrator'),
62
                        COURSEMANAGER  => get_lang('Teacher'),
63
                        STUDENT        => get_lang('Learner'),
64
                        STUDENT_BOSS   => get_lang('Superior (n+1)'),
65
                    ],
66
                    'attributes' => ['multiple' => 'multiple'],
67
                ],
68
69
                // Recordings
70
                'allow_regenerate_recording'           => 'boolean',
71
                'big_blue_button_record_and_store'     => 'checkbox',
72
                'bbb_enable_conference_in_groups'      => 'checkbox',
73
                'bbb_force_record_generation'          => 'checkbox',
74
                'delete_recordings_on_course_delete'   => 'boolean',
75
76
                // Webhooks (for live metrics → dashboard)
77
                'webhooks_enabled' => 'boolean',
78
                'webhooks_scope'   => [
79
                    'type'    => 'select',
80
                    'options' => [
81
                        'per_meeting' => 'Per meeting',
82
                        'global'      => 'Global',
83
                    ],
84
                ],
85
                'webhooks_hash_algo' => [
86
                    'type'    => 'select',
87
                    'options' => [
88
                        'sha256' => 'SHA-256',
89
                        'sha1'   => 'SHA-1',
90
                    ],
91
                ],
92
                // Comma-separated BBB event names to subscribe (optional)
93
                'webhooks_event_filter' => 'text',
94
            ]
95
        );
96
97
        $this->isAdminPlugin = true;
98
    }
99
100
    /**
101
     * Returns a singleton instance of the plugin.
102
     */
103
    public static function create(): self
104
    {
105
        static $result = null;
106
        return $result ??= new self();
107
    }
108
109
    /**
110
     * Validates if a course setting is enabled depending on global plugin configuration.
111
     */
112
    public function validateCourseSetting($variable): bool
113
    {
114
        if ($this->get('disable_course_settings') === 'true') {
115
            return false;
116
        }
117
118
        switch ($variable) {
119
            case 'bbb_enable_conference_in_groups':
120
                return $this->get('enable_conference_in_course_groups') === 'true';
121
            case 'bbb_force_record_generation':
122
                return $this->get('allow_regenerate_recording') === 'true';
123
            default:
124
                return true;
125
        }
126
    }
127
128
    /**
129
     * Returns course-level plugin settings if not disabled globally.
130
     */
131
    public function getCourseSettings(): array
132
    {
133
        if ($this->get('disable_course_settings') === 'true') {
134
            return [];
135
        }
136
137
        return parent::getCourseSettings();
138
    }
139
140
    /**
141
     * Performs automatic updates to all course settings after configuration changes.
142
     */
143
    public function performActionsAfterConfigure(): self
144
    {
145
        if ($this->get('disable_course_settings') === 'true') {
146
            self::updateCourseFieldInAllCourses(
147
                'bbb_enable_conference_in_groups',
148
                $this->get('enable_conference_in_course_groups') === 'true' ? 1 : 0
149
            );
150
            self::updateCourseFieldInAllCourses(
151
                'bbb_force_record_generation',
152
                $this->get('allow_regenerate_recording') === 'true' ? 1 : 0
153
            );
154
            self::updateCourseFieldInAllCourses(
155
                'big_blue_button_record_and_store',
156
                $this->get('big_blue_button_record_and_store') === 'true' ? 1 : 0
157
            );
158
        }
159
160
        return $this;
161
    }
162
163
    /**
164
     * Updates a course setting value across all existing courses.
165
     */
166
    public static function updateCourseFieldInAllCourses(string $variable, string $value): void
167
    {
168
        $em          = Database::getManager();
169
        $courseRepo  = $em->getRepository(Course::class);
170
        $settingRepo = $em->getRepository(CCourseSetting::class);
171
172
        $courses = $courseRepo->createQueryBuilder('c')
173
            ->select('c.id')
174
            ->orderBy('c.id', 'ASC')
175
            ->getQuery()
176
            ->getArrayResult();
177
178
        foreach ($courses as $course) {
179
            $setting = $settingRepo->findOneBy([
180
                'variable' => $variable,
181
                'cId'      => $course['id'],
182
            ]);
183
184
            if ($setting) {
185
                $setting->setValue($value);
186
                $em->persist($setting);
187
            }
188
        }
189
190
        $em->flush();
191
    }
192
193
    /* -----------------------------------------------------------------
194
     * Cleanup hooks: delete BBB recordings when course/session is removed
195
     * ----------------------------------------------------------------- */
196
197
    public function doWhenDeletingCourse($courseId): void
198
    {
199
        if ($this->get('delete_recordings_on_course_delete') !== 'true') {
200
            return;
201
        }
202
        $this->removeBbbRecordingsForCourse((int)$courseId);
203
    }
204
205
    public function doWhenDeletingSession($sessionId): void
206
    {
207
        if ($this->get('delete_recordings_on_course_delete') !== 'true') {
208
            return;
209
        }
210
        $this->removeBbbRecordingsForSession((int)$sessionId);
211
    }
212
213
    private function removeBbbRecordingsForCourse(int $courseId): void
214
    {
215
        $em           = Database::getManager();
216
        $meetingRepo  = $em->getRepository(ConferenceMeeting::class);
217
        $recordingRepo= $em->getRepository(ConferenceRecording::class);
218
219
        $meetings = $meetingRepo->createQueryBuilder('m')
220
            ->where('m.course = :cid')
221
            ->andWhere('m.serviceProvider = :sp')
222
            ->setParameters(['cid' => $courseId, 'sp' => 'bbb'])
223
            ->getQuery()->getResult();
224
225
        foreach ($meetings as $meeting) {
226
            $recs = $recordingRepo->findBy([
227
                'meeting'    => $meeting,
228
                'formatType' => 'bbb',
229
            ]);
230
231
            foreach ($recs as $rec) {
232
                if ($recordId = $this->extractRecordId($rec->getResourceUrl())) {
233
                    $this->deleteRecording($recordId);
234
                }
235
                $em->remove($rec);
236
            }
237
238
            // Optionally remove the meeting entity as well
239
            $em->remove($meeting);
240
        }
241
        $em->flush();
242
    }
243
244
    private function removeBbbRecordingsForSession(int $sessionId): void
245
    {
246
        $em           = Database::getManager();
247
        $meetingRepo  = $em->getRepository(ConferenceMeeting::class);
248
        $recordingRepo= $em->getRepository(ConferenceRecording::class);
249
250
        $meetings = $meetingRepo->findBy([
251
            'session'        => $sessionId,
252
            'serviceProvider'=> 'bbb',
253
        ]);
254
255
        foreach ($meetings as $meeting) {
256
            $recs = $recordingRepo->findBy([
257
                'meeting'    => $meeting,
258
                'formatType' => 'bbb',
259
            ]);
260
261
            foreach ($recs as $rec) {
262
                if ($recordId = $this->extractRecordId($rec->getResourceUrl())) {
263
                    $this->deleteRecording($recordId);
264
                }
265
266
                $em->remove($rec);
267
            }
268
269
            $em->remove($meeting);
270
        }
271
272
        $em->flush();
273
    }
274
275
    // Extracts the recordID from the BBB recording URL
276
    private function extractRecordId(string $url): ?string
277
    {
278
        if (preg_match('/[?&]recordID=([\w-]+)/', $url, $m)) {
279
            return $m[1];
280
        }
281
        if (preg_match('/recordingID[-=](\d+)/', $url, $m)) {
282
            return $m[1];
283
        }
284
        return null;
285
    }
286
287
    // Sends a deleteRecordings API request to BigBlueButton
288
    private function deleteRecording(string $recordId): void
289
    {
290
        $host = rtrim((string)$this->get('host'), '/');
291
        if ($host === '') {
292
            return;
293
        }
294
        if (!preg_match('#/bigbluebutton$#', $host)) {
295
            $host .= '/bigbluebutton';
296
        }
297
298
        $salt     = (string)$this->get('salt');
299
        $query    = 'recordID=' . rawurlencode($recordId);
300
        $checksum = sha1('deleteRecordings' . $query . $salt);
301
        $url      = $host . '/api/deleteRecordings?' . $query . '&checksum=' . $checksum;
302
303
        // Best-effort request (no exception bubbling here)
304
        @file_get_contents($url);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for file_get_contents(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

304
        /** @scrutinizer ignore-unhandled */ @file_get_contents($url);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
305
    }
306
307
    /* --------------------------------
308
     * Install / uninstall lifecycle
309
     * -------------------------------- */
310
311
    public function install(): void
312
    {
313
        $em = Database::getManager();
314
315
        $pluginRepo = Container::getPluginRepository();
316
        $plugin     = $pluginRepo->findOneByTitle($this->get_name());
317
318
        if (!$plugin) {
319
            $plugin = new \Chamilo\CoreBundle\Entity\Plugin();
320
            $plugin->setTitle($this->get_name());
321
        } else {
322
            $plugin = $em->merge($plugin);
323
        }
324
325
        $plugin->setInstalled(true);
326
        $plugin->setInstalledVersion($this->get_version());
327
        $plugin->setSource(\Chamilo\CoreBundle\Entity\Plugin::SOURCE_OFFICIAL);
328
        $em->persist($plugin);
329
330
        $accessUrlRepo         = Container::getAccessUrlRepository();
331
        $accessUrlRelPluginRepo= Container::getAccessUrlRelPluginRepository();
332
333
        foreach ($accessUrlRepo->findAll() as $accessUrl) {
334
            $rel = $accessUrlRelPluginRepo->findOneBy(['plugin' => $plugin, 'url' => $accessUrl]);
335
            if ($rel) {
336
                continue;
337
            }
338
339
            $rel = new AccessUrlRelPlugin();
340
            $rel->setPlugin($plugin);
341
            $rel->setUrl($accessUrl);
342
            $rel->setActive(true);
343
344
            // Build default configuration values
345
            $configuration = [];
346
            foreach ($this->fields as $name => $type) {
347
                if (is_array($type)) {
348
                    $configuration[$name] = ($type['type'] === 'boolean' || $type['type'] === 'checkbox') ? 'false' : '';
349
                } else {
350
                    $configuration[$name] = in_array($type, ['boolean','checkbox'], true) ? 'false' : '';
351
                }
352
            }
353
354
            // Explicit defaults for webhooks
355
            $configuration['webhooks_enabled']      = $configuration['webhooks_enabled']      ?? 'false';
356
            $configuration['webhooks_scope']        = $configuration['webhooks_scope']        ?? 'per_meeting';
357
            $configuration['webhooks_hash_algo']    = $configuration['webhooks_hash_algo']    ?? 'sha256';
358
            $configuration['webhooks_event_filter'] = $configuration['webhooks_event_filter'] ?? '';
359
360
            $rel->setConfiguration($configuration);
361
            $em->persist($rel);
362
        }
363
364
        $em->flush();
365
    }
366
367
    /**
368
     * Uninstalls the plugin:
369
     * - Unlinks from access URLs
370
     * - Marks plugin as not installed
371
     * (Keeps course-level data for safety)
372
     */
373
    public function uninstall(): void
374
    {
375
        $em        = Database::getManager();
376
        $plugin    = Container::getPluginRepository()->findOneByTitle($this->get_name());
377
378
        if (!$plugin) {
379
            return;
380
        }
381
382
        foreach (Container::getAccessUrlRelPluginRepository()->findBy(['plugin' => $plugin]) as $rel) {
383
            $em->remove($rel);
384
        }
385
386
        $plugin->setInstalled(false);
387
        $em->persist($plugin);
388
        $em->flush();
389
    }
390
391
    /* ----------------------------
392
     * UI helpers / permissions
393
     * ---------------------------- */
394
395
    /** Whether current user can see the global conference link. */
396
    public function canCurrentUserSeeGlobalConferenceLink(): bool
397
    {
398
        $allowed = $this->get('global_conference_allow_roles') ?? [];
399
400
        // Stored as array (multi-select) or empty → default to admin only
401
        if (empty($allowed)) {
402
            return api_is_platform_admin();
403
        }
404
405
        foreach ($allowed as $status) {
406
            switch ((int)$status) {
407
                case PLATFORM_ADMIN:
408
                    if (api_is_platform_admin()) { return true; }
409
                    break;
410
                case COURSEMANAGER:
411
                    if (api_is_teacher()) { return true; }
412
                    break;
413
                case STUDENT:
414
                    if (api_is_student()) { return true; }
415
                    break;
416
                case STUDENT_BOSS:
417
                    if (api_is_student_boss()) { return true; }
418
                    break;
419
            }
420
        }
421
        return false;
422
    }
423
424
    /** Setting to decide if the "share link" is shown below the join button. */
425
    public function showShareLink(): bool
426
    {
427
        if (api_get_course_int_id() > 0) {
428
            $hide = (string)$this->get('hide_conference_link');
429
            if ($hide === 'true') {
430
                return false;
431
            }
432
        }
433
        return true;
434
    }
435
436
    public function get_name(): string
437
    {
438
        return 'Bbb';
439
    }
440
441
    /* ----------------------------
442
     * Webhooks helpers & health
443
     * ---------------------------- */
444
445
    /** Toggle: are webhooks enabled for this access URL? */
446
    public function webhooksEnabled(): bool
447
    {
448
        return $this->get('webhooks_enabled') === 'true';
449
    }
450
451
    /** Scope: "per_meeting" (default) or "global". */
452
    public function webhooksScope(): string
453
    {
454
        $v = (string)($this->get('webhooks_scope') ?? 'per_meeting');
455
        return in_array($v, ['per_meeting', 'global'], true) ? $v : 'per_meeting';
456
    }
457
458
    /**
459
     * Hash algorithm used to sign the callback URL (HMAC).
460
     * Note: This is *our* HMAC for webhook.php protection (sha256|sha1),
461
     * while BBB hook API checksums are always sha1(call+query+salt).
462
     */
463
    public function webhooksHashAlgo(): string
464
    {
465
        $v = (string)($this->get('webhooks_hash_algo') ?? 'sha256');
466
        return in_array($v, ['sha256', 'sha1'], true) ? $v : 'sha256';
467
    }
468
469
    /** Optional event filter sent to BBB hooks/create (comma-separated). */
470
    public function webhooksEventFilter(): string
471
    {
472
        return (string)($this->get('webhooks_event_filter') ?? '');
473
    }
474
475
    /**
476
     * Quick health check: verify BBB hooks/list responds SUCCESS.
477
     * Uses sha1(call+query+salt) per BBB spec.
478
     */
479
    public function checkWebhooksHealth(): array
480
    {
481
        if (!$this->webhooksEnabled()) {
482
            return ['enabled' => false, 'ok' => false, 'reason' => 'disabled'];
483
        }
484
        $host = rtrim((string)$this->get('host'), '/');
485
        $salt = (string)$this->get('salt');
486
        if ($host === '' || $salt === '') {
487
            return ['enabled' => true, 'ok' => false, 'reason' => 'missing_config'];
488
        }
489
490
        if (!preg_match('#/bigbluebutton$#', $host)) {
491
            $host .= '/bigbluebutton';
492
        }
493
494
        $call     = 'hooks/list';
495
        $query    = ''; // no params
496
        $checksum = sha1($call . $query . $salt);
497
        $url      = $host . '/api/' . $call . '?checksum=' . $checksum;
498
499
        $xml = @simplexml_load_file($url);
500
        if ($xml && (string)($xml->returncode ?? '') === 'SUCCESS') {
501
            return ['enabled' => true, 'ok' => true];
502
        }
503
        $reason = '';
504
        if ($xml && isset($xml->messageKey)) {
505
            $reason = (string)$xml->messageKey;
506
        }
507
        return ['enabled' => true, 'ok' => false, 'reason' => $reason ?: 'no_response'];
508
    }
509
510
    /**
511
     * Admin banner if hooks are enabled but not installed on server.
512
     */
513
    public function getWebhooksAdminWarningHtml(): ?string
514
    {
515
        $h = $this->checkWebhooksHealth();
516
        if ($h['enabled'] && !$h['ok']) {
517
            $msg = 'Webhooks are not installed on the BBB server — running in reduced mode (basic callbacks only).';
518
            if (!empty($h['reason'])) {
519
                $msg .= ' [' . htmlspecialchars($h['reason'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ']';
520
            }
521
            return '<div class="alert alert-warning" role="alert">' . $msg . '</div>';
522
        }
523
        return null;
524
    }
525
}
526