Passed
Pull Request — master (#6739)
by
unknown
08:30
created

BbbPlugin::extractRecordId()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 5
nc 3
nop 1
dl 0
loc 13
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
                'host' => 'text',
43
                'salt' => 'text',
44
                'enable_global_conference' => 'boolean',
45
                'enable_global_conference_per_user' => 'boolean',
46
                'enable_conference_in_course_groups' => 'boolean',
47
                'enable_global_conference_link' => 'boolean',
48
                'disable_download_conference_link' => 'boolean',
49
                'max_users_limit' => 'text',
50
                'global_conference_allow_roles' => [
51
                    'type' => 'select',
52
                    'options' => [
53
                        PLATFORM_ADMIN => get_lang('Administrator'),
54
                        COURSEMANAGER => get_lang('Teacher'),
55
                        STUDENT => get_lang('Learner'),
56
                        STUDENT_BOSS => get_lang('Superior (n+1)'),
57
                    ],
58
                    'attributes' => ['multiple' => 'multiple'],
59
                ],
60
                'allow_regenerate_recording' => 'boolean',
61
                'big_blue_button_record_and_store' => 'checkbox',
62
                'bbb_enable_conference_in_groups' => 'checkbox',
63
                'bbb_force_record_generation' => 'checkbox',
64
                'disable_course_settings' => 'boolean',
65
                'meeting_duration' => 'text',
66
                'delete_recordings_on_course_delete' => 'boolean',
67
                'hide_conference_link' => 'boolean',
68
                'webhooks_enabled' => 'boolean',
69
                'webhooks_scope' => [
70
                    'type' => 'select',
71
                    'options' => [
72
                        'per_meeting' => 'Per meeting',
73
                        'global'      => 'Global',
74
                    ],
75
                ],
76
                'webhooks_hash_algo' => [
77
                    'type' => 'select',
78
                    'options' => [
79
                        'sha256' => 'SHA-256',
80
                        'sha1'   => 'SHA-1',
81
                    ],
82
                ],
83
                'webhooks_event_filter' => 'text',
84
            ]
85
        );
86
87
        $this->isAdminPlugin = true;
88
    }
89
90
    /**
91
     * Returns a singleton instance of the plugin.
92
     */
93
    public static function create(): self
94
    {
95
        static $result = null;
96
        return $result ??= new self();
97
    }
98
99
    /**
100
     * Validates if a course setting is enabled depending on global plugin configuration.
101
     */
102
    public function validateCourseSetting($variable): bool
103
    {
104
        if ($this->get('disable_course_settings') === 'true') {
105
            return false;
106
        }
107
108
        switch ($variable) {
109
            case 'bbb_enable_conference_in_groups':
110
                return $this->get('enable_conference_in_course_groups') === 'true';
111
            case 'bbb_force_record_generation':
112
                return $this->get('allow_regenerate_recording') === 'true';
113
            default:
114
                return true;
115
        }
116
    }
117
118
    /**
119
     * Returns course-level plugin settings if not disabled globally.
120
     */
121
    public function getCourseSettings(): array
122
    {
123
        if ($this->get('disable_course_settings') === 'true') {
124
            return [];
125
        }
126
127
        return parent::getCourseSettings();
128
    }
129
130
    /**
131
     * Performs automatic updates to all course settings after configuration changes.
132
     */
133
    public function performActionsAfterConfigure(): self
134
    {
135
        if ($this->get('disable_course_settings') === 'true') {
136
            self::updateCourseFieldInAllCourses(
137
                'bbb_enable_conference_in_groups',
138
                $this->get('enable_conference_in_course_groups') === 'true' ? 1 : 0
139
            );
140
            self::updateCourseFieldInAllCourses(
141
                'bbb_force_record_generation',
142
                $this->get('allow_regenerate_recording') === 'true' ? 1 : 0
143
            );
144
            self::updateCourseFieldInAllCourses(
145
                'big_blue_button_record_and_store',
146
                $this->get('big_blue_button_record_and_store') === 'true' ? 1 : 0
147
            );
148
        }
149
150
        return $this;
151
    }
152
153
    /**
154
     * Updates a course setting value across all existing courses.
155
     */
156
    public static function updateCourseFieldInAllCourses(string $variable, string $value): void
157
    {
158
        $entityManager = Database::getManager();
159
        $courseRepo = $entityManager->getRepository(Course::class);
160
        $settingRepo = $entityManager->getRepository(CCourseSetting::class);
161
162
        $courses = $courseRepo->createQueryBuilder('c')
163
            ->select('c.id')
164
            ->orderBy('c.id', 'ASC')
165
            ->getQuery()
166
            ->getArrayResult();
167
168
        foreach ($courses as $course) {
169
            $setting = $settingRepo->findOneBy([
170
                'variable' => $variable,
171
                'cId' => $course['id'],
172
            ]);
173
174
            if ($setting) {
175
                $setting->setValue($value);
176
                $entityManager->persist($setting);
177
            }
178
        }
179
180
        $entityManager->flush();
181
    }
182
183
    // Hook called when a course is deleted
184
    public function doWhenDeletingCourse($courseId): void
185
    {
186
        // Check if the setting is enabled
187
        if ($this->get('delete_recordings_on_course_delete') !== 'true') {
188
            return;
189
        }
190
191
        $this->removeBbbRecordingsForCourse($courseId);
192
    }
193
194
    // Hook called when a session is deleted
195
    public function doWhenDeletingSession($sessionId): void
196
    {
197
        // Check if the setting is enabled
198
        if ($this->get('delete_recordings_on_course_delete') !== 'true') {
199
            return;
200
        }
201
202
        $this->removeBbbRecordingsForSession($sessionId);
203
    }
204
205
    // Remove BBB recordings linked to a specific course
206
    private function removeBbbRecordingsForCourse(int $courseId): void
207
    {
208
        $em = Database::getManager();
209
        $meetingRepo = $em->getRepository(ConferenceMeeting::class);
210
        $recordingRepo = $em->getRepository(ConferenceRecording::class);
211
212
        // Get all BBB meetings for this course
213
        $meetings = $meetingRepo->createQueryBuilder('m')
214
            ->where('m.course = :cid')
215
            ->andWhere('m.serviceProvider = :sp')
216
            ->setParameters(['cid' => $courseId, 'sp' => 'bbb'])
217
            ->getQuery()
218
            ->getResult();
219
220
        foreach ($meetings as $meeting) {
221
            // Get all recordings of this meeting
222
            $recordings = $recordingRepo->findBy([
223
                'meeting' => $meeting,
224
                'formatType' => 'bbb',
225
            ]);
226
227
            foreach ($recordings as $rec) {
228
                // Try to extract the record ID from the URL
229
                if ($recordId = $this->extractRecordId($rec->getResourceUrl())) {
230
                    $this->deleteRecording($recordId); // Call BBB API to delete
231
                }
232
233
                $em->remove($rec); // Remove local record
234
            }
235
236
            $em->remove($meeting); // Optionally remove the meeting entity
237
        }
238
239
        $em->flush(); // Save all removals
240
    }
241
242
    // Remove BBB recordings linked to a specific session
243
    private function removeBbbRecordingsForSession(int $sessionId): void
244
    {
245
        $em = Database::getManager();
246
        $meetingRepo = $em->getRepository(ConferenceMeeting::class);
247
        $recordingRepo = $em->getRepository(ConferenceRecording::class);
248
249
        // Get all BBB meetings for this session
250
        $meetings = $meetingRepo->findBy([
251
            'session' => $sessionId,
252
            'serviceProvider' => 'bbb',
253
        ]);
254
255
        foreach ($meetings as $meeting) {
256
            $recordings = $recordingRepo->findBy([
257
                'meeting' => $meeting,
258
                'formatType' => 'bbb',
259
            ]);
260
261
            foreach ($recordings 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
        // Match parameter ?recordID=xxx
279
        if (preg_match('/[?&]recordID=([\w-]+)/', $url, $matches)) {
280
            return $matches[1];
281
        }
282
283
        // Optional: match paths like .../recordingID-123456
284
        if (preg_match('/recordingID[-=](\d+)/', $url, $matches)) {
285
            return $matches[1];
286
        }
287
288
        return null;
289
    }
290
291
    // Sends a deleteRecordings API request to BigBlueButton
292
    private function deleteRecording(string $recordId): void
293
    {
294
        $host = rtrim($this->get('host'), '/');
295
        $salt = $this->get('salt');
296
297
        $query = "recordID={$recordId}";
298
        $checksum = sha1('deleteRecordings' . $query . $salt);
299
        $url = "{$host}/bigbluebutton/api/deleteRecordings?{$query}&checksum={$checksum}";
300
301
        // Send the request (silently)
302
        @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

302
        /** @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...
303
    }
304
305
    /**
306
     * Installs the plugin
307
     */
308
    public function install(): void
309
    {
310
        $em = Database::getManager();
311
312
        $pluginRepo = Container::getPluginRepository();
313
        $plugin = $pluginRepo->findOneByTitle($this->get_name());
314
315
        if (!$plugin) {
316
            $plugin = new \Chamilo\CoreBundle\Entity\Plugin();
317
            $plugin->setTitle($this->get_name());
318
        } else {
319
            $plugin = $em->merge($plugin);
320
        }
321
322
        $plugin->setInstalled(true);
323
        $plugin->setInstalledVersion($this->get_version());
324
        $plugin->setSource(\Chamilo\CoreBundle\Entity\Plugin::SOURCE_OFFICIAL);
325
        $em->persist($plugin);
326
327
        $accessUrlRepo = Container::getAccessUrlRepository();
328
        $accessUrlRelPluginRepo = Container::getAccessUrlRelPluginRepository();
329
330
        $accessUrls = $accessUrlRepo->findAll();
331
332
        foreach ($accessUrls as $accessUrl) {
333
            $rel = $accessUrlRelPluginRepo->findOneBy([
334
                'plugin' => $plugin,
335
                'url' => $accessUrl,
336
            ]);
337
338
            if (!$rel) {
339
                $rel = new AccessUrlRelPlugin();
340
                $rel->setPlugin($plugin);
341
                $rel->setUrl($accessUrl);
342
                $rel->setActive(true);
343
344
                $configuration = [];
345
                foreach ($this->fields as $name => $type) {
346
                    if (is_array($type)) {
347
                        $configuration[$name] = $type['type'] === 'boolean' ? 'false' : '';
348
                    } else {
349
                        $configuration[$name] = in_array($type, ['boolean','checkbox'], true) ? 'false' : '';
350
                    }
351
                }
352
353
                // Explicit defaults for webhooks
354
                $configuration['webhooks_enabled']      = $configuration['webhooks_enabled']      ?? 'false';
355
                $configuration['webhooks_scope']        = $configuration['webhooks_scope']        ?? 'per_meeting';
356
                $configuration['webhooks_hash_algo']    = $configuration['webhooks_hash_algo']    ?? 'sha256';
357
                $configuration['webhooks_event_filter'] = $configuration['webhooks_event_filter'] ?? '';
358
359
                $rel->setConfiguration($configuration);
360
361
                $em->persist($rel);
362
            }
363
        }
364
365
        $em->flush();
366
    }
367
368
    /**
369
     * Uninstalls the plugin.
370
     * - Removes AccessUrl relations
371
     * - Marks the plugin as not installed
372
     * - Keeps course-level settings/data intact (safer for future re-installs)
373
     */
374
    public function uninstall(): void
375
    {
376
        $em = Database::getManager();
377
378
        $pluginRepo = Container::getPluginRepository();
379
        $relRepo = Container::getAccessUrlRelPluginRepository();
380
381
        $plugin = $pluginRepo->findOneByTitle($this->get_name());
382
383
        if (!$plugin) {
384
            return;
385
        }
386
387
        $rels = $relRepo->findBy(['plugin' => $plugin]);
388
        foreach ($rels as $rel) {
389
            $em->remove($rel);
390
        }
391
392
        $plugin->setInstalled(false);
393
394
        $em->persist($plugin);
395
        $em->flush();
396
    }
397
398
    public function canCurrentUserSeeGlobalConferenceLink(): bool
399
    {
400
        $allowedStatuses = $this->get('global_conference_allow_roles') ?? [];
401
402
        if (empty($allowedStatuses)) {
403
            return api_is_platform_admin();
404
        }
405
406
        foreach ($allowedStatuses as $status) {
407
            switch ((int) $status) {
408
                case PLATFORM_ADMIN:
409
                    if (api_is_platform_admin()) {
410
                        return true;
411
                    }
412
                    break;
413
                case COURSEMANAGER:
414
                    if (api_is_teacher()) {
415
                        return true;
416
                    }
417
                    break;
418
                case STUDENT:
419
                    if (api_is_student()) {
420
                        return true;
421
                    }
422
                    break;
423
                case STUDENT_BOSS:
424
                    if (api_is_student_boss()) {
425
                        return true;
426
                    }
427
                    break;
428
            }
429
        }
430
431
        return false;
432
    }
433
434
    public function webhooksEnabled(): bool
435
    {
436
        return $this->get('webhooks_enabled') === 'true';
437
    }
438
    public function webhooksScope(): string
439
    {
440
        $v = (string) ($this->get('webhooks_scope') ?? 'per_meeting');
441
        return in_array($v, ['per_meeting','global'], true) ? $v : 'per_meeting';
442
    }
443
    public function webhooksHashAlgo(): string
444
    {
445
        $v = (string) ($this->get('webhooks_hash_algo') ?? 'sha256');
446
        return in_array($v, ['sha256','sha1'], true) ? $v : 'sha256';
447
    }
448
    public function webhooksEventFilter(): string
449
    {
450
        return (string) ($this->get('webhooks_event_filter') ?? '');
451
    }
452
453
    public function checkWebhooksHealth(): array
454
    {
455
        if (!$this->webhooksEnabled()) {
456
            return ['enabled' => false, 'ok' => false, 'reason' => 'disabled'];
457
        }
458
        $host = rtrim((string) $this->get('host'), '/');
459
        $salt = (string) $this->get('salt');
460
        if ($host === '' || $salt === '') {
461
            return ['enabled' => true, 'ok' => false, 'reason' => 'missing_config'];
462
        }
463
464
        if (!preg_match('#/bigbluebutton$#', $host)) {
465
            $host .= '/bigbluebutton';
466
        }
467
468
        $call = 'hooks/list';
469
        $query = ''; // no params
470
        $checksum = sha1($call.$query.$salt);
471
        $url = $host.'/api/'.$call.'?checksum='.$checksum;
472
473
        $xml = @simplexml_load_file($url);
474
        if ($xml && (string)($xml->returncode ?? '') === 'SUCCESS') {
475
            return ['enabled' => true, 'ok' => true];
476
        }
477
        $reason = '';
478
        if ($xml && isset($xml->messageKey)) $reason = (string)$xml->messageKey;
479
        return ['enabled' => true, 'ok' => false, 'reason' => $reason ?: 'no_response'];
480
    }
481
482
    public function getWebhooksAdminWarningHtml(): ?string
483
    {
484
        $h = $this->checkWebhooksHealth();
485
        if ($h['enabled'] && !$h['ok']) {
486
            $msg = 'Webhooks are not installed on the BBB server — running in reduced mode (basic callbacks only).';
487
            if (!empty($h['reason'])) {
488
                $msg .= ' [' . htmlspecialchars($h['reason'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ']';
489
            }
490
            return '<div class="alert alert-warning" role="alert">'.$msg.'</div>';
491
        }
492
        return null;
493
    }
494
495
    /**
496
     * Make sure the current user can see the "share" link under the join
497
     * button in the course tool page.
498
     * The method is called "show" while the setting is "hide" because we favor
499
     * minimal disruption in the introduction of new settings (so by default,
500
     * the link is shown to respect older versions' behavior).
501
     * @return bool
502
     */
503
    public function showShareLink(): bool
504
    {
505
        if (api_get_course_int_id() > 0) {
506
            // If not in a course context, we always share the link
507
            // (hiding is only for within courses)
508
            $hideLink = $this->get('hide_conference_link');
509
            if (!empty($hideLink)) {
510
                if ('true' === $hideLink) {
511
                    return false;
512
                }
513
            }
514
        }
515
        return true;
516
    }
517
518
    public function get_name(): string
519
    {
520
        return 'Bbb';
521
    }
522
}
523