Passed
Push — master ( 245794...a4f279 )
by
unknown
17:45 queued 08:28
created

CourseChatUtils::getConnectedUserIdSet()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 32
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 18
nc 6
nop 0
dl 0
loc 32
rs 9.6666
c 0
b 0
f 0
1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
use Chamilo\CoreBundle\Entity\CourseRelUser;
6
use Chamilo\CoreBundle\Entity\ResourceFile;
7
use Chamilo\CoreBundle\Entity\ResourceLink;
8
use Chamilo\CoreBundle\Entity\ResourceNode;
9
use Chamilo\CoreBundle\Entity\Session;
10
use Chamilo\CoreBundle\Entity\SessionRelCourseRelUser;
11
use Chamilo\CoreBundle\Entity\User;
12
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
13
use Chamilo\CoreBundle\Repository\ResourceRepository;
14
use Chamilo\CourseBundle\Entity\CChatConnected;
15
use Chamilo\CourseBundle\Entity\CChatConversation;
16
use Chamilo\CourseBundle\Entity\CDocument;
17
use Chamilo\CourseBundle\Repository\CDocumentRepository;
18
use Doctrine\Common\Collections\Criteria;
19
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
20
use Doctrine\DBAL\LockMode;
21
use Michelf\MarkdownExtra;
22
use Symfony\Component\HttpFoundation\File\UploadedFile;
23
24
/**
25
 * Course chat utils.
26
 */
27
class CourseChatUtils
28
{
29
    private $groupId;
30
    private $courseId;
31
    private $sessionId;
32
    private $userId;
33
34
    /** @var ResourceNode */
35
    private $resourceNode;
36
37
    /** @var ResourceRepository */
38
    private $repository;
39
40
    /** Debug flag */
41
    private $debug = false;
42
43
    private bool $restrictToCoachSetting = false;
44
    private bool $savePrivateConversationsInDocuments = false;
45
46
    public function __construct($courseId, $userId, $sessionId, $groupId, ResourceNode $resourceNode, ResourceRepository $repository)
47
    {
48
        $this->courseId     = (int) $courseId;
49
        $this->userId       = (int) $userId;
50
        $this->sessionId    = (int) $sessionId;
51
        $this->groupId      = (int) $groupId;
52
        $this->resourceNode = $resourceNode;
53
        $this->repository   = $repository;
54
55
        $this->restrictToCoachSetting = ('true' === api_get_setting('chat.course_chat_restrict_to_coach'));
56
        $this->savePrivateConversationsInDocuments = ('true' === api_get_setting('chat.save_private_conversations_in_documents'));
57
58
        $this->dbg('construct', [
59
            'courseId'     => $courseId,
60
            'userId'       => $userId,
61
            'sessionId'    => $sessionId,
62
            'groupId'      => $groupId,
63
            'parentNodeId' => $resourceNode->getId() ?? null,
64
            'repo'         => get_class($repository),
65
        ]);
66
    }
67
68
    private function shouldMirrorToDocuments(int $friendId): bool
69
    {
70
        // Private 1:1 conversations should NOT be mirrored by default.
71
        if ($friendId > 0) {
72
            return $this->savePrivateConversationsInDocuments;
73
        }
74
75
        // General / session / group chat: keep current behavior (mirrored).
76
        return true;
77
    }
78
79
    /** Simple debug helper */
80
    private function dbg(string $msg, array $ctx = []): void
81
    {
82
        if (!$this->debug) { return; }
83
        $line = '[CourseChat] '.$msg;
84
        if ($ctx) { $line .= ' | '.json_encode($ctx, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); }
0 ignored issues
show
Bug Best Practice introduced by
The expression $ctx 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...
85
        error_log($line);
86
    }
87
88
    /** Build a slug out of the file title (matches our nodes like: messages-...-log-html) */
89
    private function makeSlug(string $fileTitle): string
90
    {
91
        $slug = strtolower($fileTitle);
92
        $slug = strtr($slug, ['.' => '-']);
93
        $slug = preg_replace('~[^a-z0-9\-\_]+~', '-', $slug);
94
        $slug = preg_replace('~-+~', '-', $slug);
95
        return trim($slug, '-');
96
    }
97
98
    /** Build a base name by day and scope (all / session / group / 1:1) */
99
    private function buildBasename(int $friendId = 0): string
100
    {
101
        $dateNow  = date('Y-m-d');
102
        $basename = 'messages-'.$dateNow;
103
104
        if ($this->groupId && !$friendId) {
105
            $basename .= '_gid-'.$this->groupId;
106
        } elseif ($this->sessionId && !$friendId) {
107
            $basename .= '_sid-'.$this->sessionId;
108
        } elseif ($friendId) {
109
            // stable order for 1:1 (smallest id first)
110
            $basename .= ($this->userId < $friendId)
111
                ? '_uid-'.$this->userId.'-'.$friendId
112
                : '_uid-'.$friendId.'-'.$this->userId;
113
        }
114
        return $basename;
115
    }
116
117
    /** Returns [fileTitle, slug] */
118
    private function buildNames(int $friendId = 0): array
119
    {
120
        $fileTitle = $this->buildBasename($friendId).'-log.html';
121
        $slug      = $this->makeSlug($fileTitle);
122
        return [$fileTitle, $slug];
123
    }
124
125
    /** Create node + conversation + empty file (used only from saveMessage under a lock) */
126
    private function createNodeWithResource(string $fileTitle, string $slug, ResourceNode $parentNode): ResourceNode
0 ignored issues
show
Unused Code introduced by
The method createNodeWithResource() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
127
    {
128
        $em = Database::getManager();
129
130
        $this->dbg('node.create.start', ['slug' => $slug, 'title' => $fileTitle, 'parent' => $parentNode->getId()]);
131
132
        // temporary empty file
133
        $h = tmpfile();
134
        fwrite($h, '');
135
        $meta     = stream_get_meta_data($h);
136
        $uploaded = new UploadedFile($meta['uri'], $fileTitle, 'text/html', null, true);
137
138
        // conversation
139
        $conversation = new CChatConversation();
140
        if (method_exists($conversation, 'setTitle')) {
141
            $conversation->setTitle($fileTitle);
142
        } else {
143
            $conversation->setResourceName($fileTitle);
144
        }
145
        $conversation->setParentResourceNode($parentNode->getId());
146
147
        // node
148
        $node = new ResourceNode();
149
        $node->setTitle($fileTitle);
150
        $node->setSlug($slug);
151
        $node->setResourceType($this->repository->getResourceType());
152
        $node->setCreator(api_get_user_entity(api_get_user_id()));
153
        $node->setParent($parentNode);
154
155
        if (method_exists($conversation, 'setResourceNode')) {
156
            $conversation->setResourceNode($node);
157
        }
158
159
        $em->persist($node);
160
        $em->persist($conversation);
161
162
        // attach file
163
        $this->repository->addFile($conversation, $uploaded);
164
165
        // publish
166
        $course  = api_get_course_entity($this->courseId);
167
        $session = api_get_session_entity($this->sessionId);
168
        $group   = api_get_group_entity();
169
        $conversation->setParent($course);
170
        $conversation->addCourseLink($course, $session, $group);
171
172
        $em->flush();
173
174
        $this->dbg('node.create.ok', ['nodeId' => $node->getId()]);
175
176
        return $node;
177
    }
178
179
    /** Sanitize and convert message to safe HTML */
180
    public static function prepareMessage($message): string
181
    {
182
        if (empty($message)) {
183
            return '';
184
        }
185
186
        $message = trim($message);
187
        $message = nl2br($message);
188
        $message = Security::remove_XSS($message);
189
190
        // url -> anchor
191
        $message = preg_replace(
192
            '@((https?://)?([-\w]+\.[-\w\.]+)+\w(:\d+)?(/([-\w/_\.]*(\?\S+)?)?)*)@',
193
            '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
194
            $message
195
        );
196
        // add http:// when missing
197
        $message = preg_replace(
198
            '/<a\s[^>]*href\s*=\s*"((?!https?:\/\/)[^"]*)"[^>]*>/i',
199
            '<a href="http://$1" target="_blank" rel="noopener noreferrer">',
200
            $message
201
        );
202
203
        $message = MarkdownExtra::defaultTransform($message);
204
205
        return $message;
206
    }
207
208
    private function mirrorDailyCopyToDocuments(string $fileTitle, string $html): void
209
    {
210
        try {
211
            $em = Database::getManager();
212
            /** @var CDocumentRepository $docRepo */
213
            $docRepo = $em->getRepository(CDocument::class);
214
215
            $course  = api_get_course_entity($this->courseId);
216
            $session = api_get_session_entity($this->sessionId) ?: null;
217
218
            $top = $docRepo->ensureChatSystemFolder($course, $session);
219
220
            /** @var ResourceNodeRepository $nodeRepo */
221
            $nodeRepo = $em->getRepository(ResourceNode::class);
222
223
            $em->beginTransaction();
224
            try {
225
                try {
226
                    $em->getConnection()->executeStatement(
227
                        'SELECT id FROM resource_node WHERE id = ? FOR UPDATE',
228
                        [$top->getId()]
229
                    );
230
                } catch (\Throwable $e) {
231
                    error_log('[CourseChat] mirror FOR UPDATE skipped: '.$e->getMessage());
232
                }
233
234
                $fileNode = $docRepo->findChildDocumentFileByTitle($top, $fileTitle);
235
236
                if ($fileNode) {
237
                    /** @var ResourceFile|null $rf */
238
                    $rf = $em->getRepository(ResourceFile::class)->findOneBy(
239
                        ['resourceNode' => $fileNode, 'originalName' => $fileTitle],
240
                        ['id' => 'DESC']
241
                    );
242
                    if (!$rf) {
243
                        $rf = $em->getRepository(ResourceFile::class)->findOneBy(
244
                            ['resourceNode' => $fileNode],
245
                            ['id' => 'DESC']
246
                        );
247
                    }
248
249
                    if ($rf) {
250
                        $fs = $nodeRepo->getFileSystem();
251
                        $fname = $nodeRepo->getFilename($rf);
252
                        if ($fs->fileExists($fname)) { $fs->delete($fname); }
253
                        $fs->write($fname, $html);
254
                        if (method_exists($rf, 'setSize')) { $rf->setSize(strlen($html)); $em->persist($rf); }
255
                        $em->flush();
256
                    } else {
257
                        $h = tmpfile(); fwrite($h, $html);
258
                        $meta = stream_get_meta_data($h);
259
                        $uploaded = new UploadedFile($meta['uri'], $fileTitle, 'text/html', null, true);
260
261
                        $docRepo->createFileInFolder(
262
                            $course, $top, $uploaded, 'Daily chat copy',
263
                            ResourceLink::VISIBILITY_PUBLISHED, $session
264
                        );
265
                        $em->flush();
266
                    }
267
268
                } else {
269
                    $h = tmpfile(); fwrite($h, $html);
270
                    $meta = stream_get_meta_data($h);
271
                    $uploaded = new UploadedFile($meta['uri'], $fileTitle, 'text/html', null, true);
272
273
                    $docRepo->createFileInFolder(
274
                        $course, $top, $uploaded, 'Daily chat copy',
275
                        ResourceLink::VISIBILITY_PUBLISHED, $session
276
                    );
277
                    $em->flush();
278
                }
279
280
                $em->commit();
281
            } catch (UniqueConstraintViolationException $e) {
282
                $em->rollback();
283
                $fileNode = $docRepo->findChildDocumentFileByTitle($top, $fileTitle);
284
                if ($fileNode) {
285
                    /** @var ResourceFile|null $rf */
286
                    $rf = $em->getRepository(ResourceFile::class)->findOneBy(
287
                        ['resourceNode' => $fileNode, 'originalName' => $fileTitle],
288
                        ['id' => 'DESC']
289
                    ) ?: $em->getRepository(ResourceFile::class)->findOneBy(
290
                        ['resourceNode' => $fileNode],
291
                        ['id' => 'DESC']
292
                    );
293
294
                    if ($rf) {
295
                        $fs = $nodeRepo->getFileSystem();
296
                        $fname = $nodeRepo->getFilename($rf);
297
                        if ($fs->fileExists($fname)) { $fs->delete($fname); }
298
                        $fs->write($fname, $html);
299
                        if (method_exists($rf, 'setSize')) { $rf->setSize(strlen($html)); $em->persist($rf); }
300
                        $em->flush();
301
                        return;
302
                    }
303
                }
304
                throw $e;
305
            } catch (\Throwable $e) {
306
                $em->rollback();
307
                throw $e;
308
            }
309
310
        } catch (\Throwable $e) {
311
            $this->dbg('mirrorDailyCopy.error', ['err' => $e->getMessage()]);
312
        }
313
    }
314
315
    /**
316
     * Return the latest *chat* node for today (by createdAt DESC, id DESC).
317
     * It filters by the chat resourceType to avoid collisions with Document nodes.
318
     */
319
    private function findExistingNode(string $fileTitle, string $slug, ResourceNode $parentNode): ?ResourceNode
320
    {
321
        $em = \Database::getManager();
322
        $rt = $this->repository->getResourceType();
323
324
        $qb = $em->createQueryBuilder();
325
        $qb->select('n')
326
            ->from(ResourceNode::class, 'n')
327
            ->where('n.parent = :parent')
328
            ->andWhere('n.resourceType = :rt')
329
            ->andWhere('(n.title = :title OR n.slug = :slug)')
330
            ->setParameter('parent', $parentNode)
331
            ->setParameter('rt', $rt)
332
            ->setParameter('title', $fileTitle)
333
            ->setParameter('slug',  $slug)
334
            ->orderBy('n.createdAt', 'DESC')
335
            ->addOrderBy('n.id', 'DESC')
336
            ->setMaxResults(1);
337
338
        /** @var ResourceNode|null $node */
339
        return $qb->getQuery()->getOneOrNullResult();
340
    }
341
342
    /**
343
     * Append a message to the last daily file (chronological order).
344
     */
345
    public function saveMessage($message, $friendId = 0)
346
    {
347
        $this->dbg('saveMessage.in', ['friendId' => (int)$friendId, 'rawLen' => strlen((string)$message)]);
348
        if (!is_string($message) || trim($message) === '') { return false; }
349
350
        [$fileTitle, $slug] = $this->buildNames((int)$friendId);
351
352
        $em = \Database::getManager();
353
        /** @var ResourceNodeRepository $nodeRepo */
354
        $nodeRepo = $em->getRepository(ResourceNode::class);
355
356
        // Parent = chat root node (CChatConversation) provided by controller
357
        $parent = $nodeRepo->find($this->resourceNode->getId());
358
        if (!$parent) { $this->dbg('saveMessage.error.noParent'); return false; }
359
360
        // Best-effort file lock by day
361
        $lockPath = sys_get_temp_dir().'/ch_chat_lock_'.$parent->getId().'_'.$slug.'.lock';
362
        $lockH = @fopen($lockPath, 'c'); if ($lockH) { @flock($lockH, LOCK_EX); }
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for flock(). 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

362
        $lockH = @fopen($lockPath, 'c'); if ($lockH) { /** @scrutinizer ignore-unhandled */ @flock($lockH, LOCK_EX); }

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...
introduced by
$lockH is of type false|resource, thus it always evaluated to false.
Loading history...
363
364
        try {
365
            $em->beginTransaction();
366
            try {
367
                $em->lock($parent, LockMode::PESSIMISTIC_WRITE);
368
369
                $node = $this->findExistingNode($fileTitle, $slug, $parent);
370
371
                if (!$node) {
372
                    // Create conversation + node (same as before)
373
                    $conversation = new CChatConversation();
374
                    (method_exists($conversation, 'setTitle')
375
                        ? $conversation->setTitle($fileTitle)
376
                        : $conversation->setResourceName($fileTitle));
377
                    $conversation->setParentResourceNode($parent->getId());
378
379
                    $node = new ResourceNode();
380
                    $node->setTitle($fileTitle);
381
                    $node->setSlug($slug);
382
                    $node->setResourceType($this->repository->getResourceType());
383
                    $node->setCreator(api_get_user_entity(api_get_user_id()));
384
                    $node->setParent($parent);
385
386
                    if (method_exists($conversation, 'setResourceNode')) {
387
                        $conversation->setResourceNode($node);
388
                    }
389
390
                    $em->persist($node);
391
                    $em->persist($conversation);
392
393
                    $course  = api_get_course_entity($this->courseId);
394
                    $session = api_get_session_entity($this->sessionId);
395
                    $group   = api_get_group_entity();
396
                    $conversation->setParent($course);
397
                    $conversation->addCourseLink($course, $session, $group);
398
399
                    $em->flush();
400
                }
401
402
                $em->commit();
403
            } catch (UniqueConstraintViolationException $e) {
404
                $em->rollback();
405
                $node = $this->findExistingNode($fileTitle, $slug, $parent);
406
                if (!$node) { throw $e; }
407
            } catch (\Throwable $e) {
408
                $em->rollback();
409
                throw $e;
410
            }
411
412
            // Ensure conversation still exists (as you already did)
413
            $conversation = $em->getRepository(CChatConversation::class)
414
                ->findOneBy(['resourceNode' => $node]);
415
            if (!$conversation) {
416
                $em->beginTransaction();
417
                try {
418
                    $em->lock($parent, LockMode::PESSIMISTIC_WRITE);
419
420
                    $conversation = new CChatConversation();
421
                    (method_exists($conversation, 'setTitle')
422
                        ? $conversation->setTitle($fileTitle)
423
                        : $conversation->setResourceName($fileTitle));
424
                    $conversation->setParentResourceNode($parent->getId());
425
                    if (method_exists($conversation, 'setResourceNode')) {
426
                        $conversation->setResourceNode($node);
427
                    }
428
                    $em->persist($conversation);
429
430
                    $course  = api_get_course_entity($this->courseId);
431
                    $session = api_get_session_entity($this->sessionId);
432
                    $group   = api_get_group_entity();
433
                    $conversation->setParent($course);
434
                    $conversation->addCourseLink($course, $session, $group);
435
436
                    $em->flush();
437
                    $em->commit();
438
                } catch (UniqueConstraintViolationException $e) {
439
                    $em->rollback();
440
                    $conversation = $em->getRepository(CChatConversation::class)
441
                        ->findOneBy(['resourceNode' => $node]);
442
                    if (!$conversation) { throw $e; }
443
                } catch (\Throwable $e) {
444
                    $em->rollback();
445
                    throw $e;
446
                }
447
            }
448
449
            // Bubble HTML (unchanged)
450
            $user      = api_get_user_entity($this->userId);
451
            $isMaster  = api_is_course_admin();
452
            $timeNow   = date('d/m/y H:i:s');
453
            $userPhoto = \UserManager::getUserPicture($this->userId);
454
            $htmlMsg   = self::prepareMessage($message);
455
456
            $bubble = $isMaster
457
                ? '<div class="message-teacher"><div class="content-message"><div class="chat-message-block-name">'
458
                .\UserManager::formatUserFullName($user).'</div><div class="chat-message-block-content">'
459
                .$htmlMsg.'</div><div class="message-date">'.$timeNow
460
                .'</div></div><div class="icon-message"></div><img class="chat-image" src="'.$userPhoto.'"></div>'
461
                : '<div class="message-student"><img class="chat-image" src="'.$userPhoto.'"><div class="icon-message"></div>'
462
                .'<div class="content-message"><div class="chat-message-block-name">'.\UserManager::formatUserFullName($user)
463
                .'</div><div class="chat-message-block-content">'.$htmlMsg.'</div><div class="message-date">'
464
                .$timeNow.'</div></div></div>';
465
466
            // Locate ResourceFile (same logic as before)
467
            $rf = $em->createQueryBuilder()
468
                ->select('rf')
469
                ->from(ResourceFile::class, 'rf')
470
                ->where('rf.resourceNode = :node AND rf.originalName = :name')
471
                ->setParameter('node', $node)
472
                ->setParameter('name', $fileTitle)
473
                ->orderBy('rf.id', 'DESC')
474
                ->setMaxResults(1)
475
                ->getQuery()
476
                ->getOneOrNullResult();
477
478
            if (!$rf) {
479
                $rf = $em->createQueryBuilder()
480
                    ->select('rf')
481
                    ->from(ResourceFile::class, 'rf')
482
                    ->where('rf.resourceNode = :node')
483
                    ->setParameter('node', $node)
484
                    ->orderBy('rf.id', 'DESC')
485
                    ->setMaxResults(1)
486
                    ->getQuery()
487
                    ->getOneOrNullResult();
488
            }
489
490
            $existing = '';
491
            if ($rf) {
492
                try { $existing = $nodeRepo->getResourceNodeFileContent($node, $rf) ?? ''; }
493
                catch (\Throwable $e) { $existing = ''; }
494
            }
495
            $newContent = $existing.$bubble;
496
497
            if ($rf) {
498
                $fs = $nodeRepo->getFileSystem();
499
                $fname = $nodeRepo->getFilename($rf);
500
                if ($fs->fileExists($fname)) { $fs->delete($fname); }
501
                $fs->write($fname, $newContent);
502
                if (method_exists($rf, 'setSize')) { $rf->setSize(strlen($newContent)); $em->persist($rf); }
503
                $em->flush();
504
            } else {
505
                if (method_exists($this->repository, 'addFileFromString')) {
506
                    $this->repository->addFileFromString($conversation, $fileTitle, 'text/html', $newContent, true);
507
                } else {
508
                    $h = tmpfile(); fwrite($h, $newContent);
509
                    $meta = stream_get_meta_data($h);
510
                    $uploaded = new UploadedFile(
511
                        $meta['uri'], $fileTitle, 'text/html', null, true
512
                    );
513
                    $this->repository->addFile($conversation, $uploaded);
514
                }
515
                $em->flush();
516
            }
517
518
            // Mirror in Documents only when allowed by admin setting
519
            if ($this->shouldMirrorToDocuments((int) $friendId)) {
520
                $this->mirrorDailyCopyToDocuments($fileTitle, $newContent);
521
            }
522
523
            $this->dbg('saveMessage.append.ok', ['nodeId' => $node->getId(), 'bytes' => strlen($newContent)]);
524
            return true;
525
526
        } catch (\Throwable $e) {
527
            $this->dbg('saveMessage.error', ['err' => $e->getMessage()]);
528
            return false;
529
        } finally {
530
            if ($lockH) { @flock($lockH, LOCK_UN); @fclose($lockH); }
0 ignored issues
show
introduced by
$lockH is of type false|resource, thus it always evaluated to false.
Loading history...
Security Best Practice introduced by
It seems like you do not handle an error condition for fclose(). 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

530
            if ($lockH) { @flock($lockH, LOCK_UN); /** @scrutinizer ignore-unhandled */ @fclose($lockH); }

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...
531
        }
532
    }
533
534
    private function getConnectedUserIdSet(): array
535
    {
536
        $date = new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC'));
537
        $date->modify('-5 seconds');
538
539
        $extraCondition = $this->groupId
540
            ? 'AND ccc.toGroupId = '.$this->groupId
541
            : 'AND ccc.sessionId = '.$this->sessionId;
542
543
        $rows = Database::getManager()
544
            ->createQuery("
545
            SELECT ccc.userId AS uid
546
            FROM ChamiloCourseBundle:CChatConnected ccc
547
            WHERE ccc.lastConnection > :date
548
              AND ccc.cId = :course
549
              $extraCondition
550
        ")
551
            ->setParameters([
552
                'date' => $date,
553
                'course' => $this->courseId,
554
            ])
555
            ->getArrayResult();
556
557
        $set = [];
558
        foreach ($rows as $r) {
559
            $id = (int) ($r['uid'] ?? 0);
560
            if ($id > 0) {
561
                $set[$id] = true;
562
            }
563
        }
564
565
        return $set;
566
    }
567
568
    /**
569
     * Read the last daily file HTML (optionally reset it).
570
     */
571
    public function readMessages($reset = false, $friendId = 0)
572
    {
573
        [$fileTitle, $slug] = $this->buildNames((int)$friendId);
574
575
        $this->dbg('readMessages.in', [
576
            'friendId' => (int)$friendId,
577
            'reset'    => (bool)$reset,
578
            'file'     => $fileTitle,
579
            'slug'     => $slug,
580
        ]);
581
582
        $em = \Database::getManager();
583
        /** @var ResourceNodeRepository $nodeRepo */
584
        $nodeRepo = $em->getRepository(ResourceNode::class);
585
586
        $parent = $nodeRepo->find($this->resourceNode->getId());
587
        if (!$parent) { $this->dbg('readMessages.error.noParent'); return ''; }
588
589
        // read-only: do not create
590
        $node = $this->findExistingNode($fileTitle, $slug, $parent);
591
        if (!$node) { $this->dbg('readMessages.notfound'); return ''; }
592
593
        // locate the same ResourceFile by originalName (latest id desc)
594
        $rfRepo = $em->getRepository(ResourceFile::class);
595
        /** @var ResourceFile|null $rf */
596
        $rf = $rfRepo->findOneBy(
597
            ['resourceNode' => $node, 'originalName' => $fileTitle],
598
            ['id' => 'DESC']
599
        );
600
601
        // optional reset
602
        if ($reset) {
603
            $target = $rf ?: ($node->getResourceFiles()->first() ?: null);
604
            if ($target) {
605
                $fs       = $nodeRepo->getFileSystem();
606
                $fileName = $nodeRepo->getFilename($target);
607
                if ($fs->fileExists($fileName)) {
608
                    $fs->delete($fileName);
609
                    $fs->write($fileName, '');
610
                }
611
                if (method_exists($target, 'setSize')) { $target->setSize(0); $em->persist($target); }
612
                $em->flush();
613
                $this->dbg('readMessages.reset.ok', ['nodeId' => $node->getId(), 'rfId' => $target->getId()]);
614
            }
615
        }
616
617
        try {
618
            // primary: exact RF by originalName
619
            if ($rf) {
620
                $html = $nodeRepo->getResourceNodeFileContent($node, $rf);
621
                $this->dbg('readMessages.out.byOriginalName', [
622
                    'nodeId' => $node->getId(),
623
                    'rfId'   => $rf->getId(),
624
                    'bytes'  => strlen($html ?? ''),
625
                ]);
626
                return $html ?? '';
627
            }
628
629
            // fallback: first attached file (covers legacy hashed names)
630
            $html = $nodeRepo->getResourceNodeFileContent($node);
631
            $this->dbg('readMessages.out.fallbackFirst', [
632
                'nodeId' => $node->getId(),
633
                'bytes'  => strlen($html ?? ''),
634
            ]);
635
            return $html ?? '';
636
637
        } catch (\Throwable $e) {
638
            $this->dbg('readMessages.read.error', ['err' => $e->getMessage()]);
639
            return '';
640
        }
641
    }
642
643
    /** Force a user to exit all course chat connections */
644
    public static function exitChat($userId)
645
    {
646
        $listCourse = CourseManager::get_courses_list_by_user_id($userId);
647
        foreach ($listCourse as $course) {
648
            Database::getManager()
649
                ->createQuery('
650
                    DELETE FROM ChamiloCourseBundle:CChatConnected ccc
651
                    WHERE ccc.cId = :course AND ccc.userId = :user
652
                ')
653
                ->execute([
654
                    'course' => intval($course['real_id']),
655
                    'user'   => intval($userId),
656
                ]);
657
        }
658
    }
659
660
    /** Remove inactive connections (simple heartbeat) */
661
    public function disconnectInactiveUsers(): void
662
    {
663
        $em = Database::getManager();
664
        $extraCondition = $this->groupId
665
            ? "AND ccc.toGroupId = {$this->groupId}"
666
            : "AND ccc.sessionId = {$this->sessionId}";
667
668
        $connectedUsers = $em
669
            ->createQuery("
670
                SELECT ccc FROM ChamiloCourseBundle:CChatConnected ccc
671
                WHERE ccc.cId = :course $extraCondition
672
            ")
673
            ->setParameter('course', $this->courseId)
674
            ->getResult();
675
676
        $now  = new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC'));
677
        $nowTs = $now->getTimestamp();
678
679
        /** @var CChatConnected $connection */
680
        foreach ($connectedUsers as $connection) {
681
            $lastTs = $connection->getLastConnection()->getTimestamp();
682
            if (0 !== strcmp($now->format('Y-m-d'), $connection->getLastConnection()->format('Y-m-d'))) {
683
                continue;
684
            }
685
            if (($nowTs - $lastTs) <= 5) {
686
                continue;
687
            }
688
689
            $em->createQuery('
690
                DELETE FROM ChamiloCourseBundle:CChatConnected ccc
691
                WHERE ccc.cId = :course
692
                  AND ccc.userId = :user
693
                  AND ccc.sessionId = :sid
694
                  AND ccc.toGroupId = :gid
695
            ')->execute([
696
                'course' => $this->courseId,
697
                'user'   => $connection->getUserId(),
698
                'sid'    => $this->sessionId,
699
                'gid'    => $this->groupId,
700
            ]);
701
        }
702
    }
703
704
    /** Keep (or create) the "connected" record for current user */
705
    public function keepUserAsConnected(): void
706
    {
707
        $em = Database::getManager();
708
        $extraCondition = $this->groupId
709
            ? 'AND ccc.toGroupId = '.$this->groupId
710
            : 'AND ccc.sessionId = '.$this->sessionId;
711
712
        $currentTime = new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC'));
713
714
        /** @var CChatConnected|null $connection */
715
        $connection = $em
716
            ->createQuery("
717
                SELECT ccc FROM ChamiloCourseBundle:CChatConnected ccc
718
                WHERE ccc.userId = :user AND ccc.cId = :course $extraCondition
719
            ")
720
            ->setParameters([
721
                'user'   => $this->userId,
722
                'course' => $this->courseId,
723
            ])
724
            ->getOneOrNullResult();
725
726
        if ($connection) {
727
            $connection->setLastConnection($currentTime);
728
            $em->persist($connection);
729
            $em->flush();
730
            return;
731
        }
732
733
        $connection = new CChatConnected();
734
        $connection
735
            ->setCId($this->courseId)
736
            ->setUserId($this->userId)
737
            ->setLastConnection($currentTime)
738
            ->setSessionId($this->sessionId)
739
            ->setToGroupId($this->groupId);
740
741
        $em->persist($connection);
742
        $em->flush();
743
    }
744
745
    /** Legacy helper (kept for BC) */
746
    public function getFileName($absolute = false, $friendId = 0): string
747
    {
748
        $base = $this->buildBasename((int)$friendId).'.log.html';
749
        if (!$absolute) { return $base; }
750
751
        $document_path = '/document';
752
        $chatPath = $document_path.'/chat_files/';
753
754
        if ($this->groupId) {
755
            $group_info = GroupManager::get_group_properties($this->groupId);
756
            $chatPath = $document_path.$group_info['directory'].'/chat_files/';
757
        }
758
759
        return $chatPath.$base;
760
    }
761
762
    /** Count users online (simple 5s heartbeat window) */
763
    public function countUsersOnline(): int
764
    {
765
        $date = new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC'));
766
        $date->modify('-5 seconds');
767
768
        $extraCondition = $this->groupId
769
            ? 'AND ccc.toGroupId = '.$this->groupId
770
            : 'AND ccc.sessionId = '.$this->sessionId;
771
772
        $number = Database::getManager()
773
            ->createQuery("
774
                SELECT COUNT(ccc.userId) FROM ChamiloCourseBundle:CChatConnected ccc
775
                WHERE ccc.lastConnection > :date AND ccc.cId = :course $extraCondition
776
            ")
777
            ->setParameters([
778
                'date'   => $date,
779
                'course' => $this->courseId,
780
            ])
781
            ->getSingleScalarResult();
782
783
        return (int) $number;
784
    }
785
786
    /** Return basic info for connected/eligible users */
787
    public function listUsersOnline(): array
788
    {
789
        $subscriptions = $this->getUsersSubscriptions();
790
        $usersInfo = [];
791
792
        $connectedSet = $this->getConnectedUserIdSet();
793
        if ($this->groupId) {
794
            /** @var User $groupUser */
795
            foreach ($subscriptions as $groupUser) {
796
                $usersInfo[] = $this->formatUser($groupUser, $groupUser->getStatus(), $connectedSet);
797
            }
798
        } else {
799
            /** @var CourseRelUser|SessionRelCourseRelUser $subscription */
800
            foreach ($subscriptions as $subscription) {
801
                $user = $subscription->getUser();
802
                $usersInfo[] = $this->formatUser(
803
                    $user,
804
                    $this->sessionId ? $user->getStatus() : $subscription->getStatus(),
805
                    $connectedSet
806
                );
807
            }
808
        }
809
810
        return $usersInfo;
811
    }
812
813
    /** Normalize user card info */
814
    private function formatUser(User $user, $status, array $connectedSet): array
815
    {
816
        return [
817
            'id'            => $user->getId(),
818
            'firstname'     => $user->getFirstname(),
819
            'lastname'      => $user->getLastname(),
820
            'status'        => $status,
821
            'image_url'     => UserManager::getUserPicture($user->getId()),
822
            'profile_url'   => api_get_path(WEB_CODE_PATH).'social/profile.php?u='.$user->getId(),
823
            'complete_name' => UserManager::formatUserFullName($user),
824
            'username'      => $user->getUsername(),
825
            'email'         => $user->getEmail(),
826
            'isConnected'   => isset($connectedSet[$user->getId()]),
827
        ];
828
    }
829
830
    /** Fetch subscriptions (course / session / group) */
831
    private function getUsersSubscriptions()
832
    {
833
        $em = Database::getManager();
834
835
        if ($this->groupId) {
836
            $students = $em
837
                ->createQuery(
838
                    'SELECT u FROM ChamiloCoreBundle:User u
839
                     INNER JOIN ChamiloCourseBundle:CGroupRelUser gru
840
                        WITH u.id = gru.userId AND gru.cId = :course
841
                     WHERE u.id != :user AND gru.groupId = :group
842
                       AND u.active = true'
843
                )
844
                ->setParameters(['course' => $this->courseId, 'user' => $this->userId, 'group' => $this->groupId])
845
                ->getResult();
846
847
            $tutors = $em
848
                ->createQuery(
849
                    'SELECT u FROM ChamiloCoreBundle:User u
850
                     INNER JOIN ChamiloCourseBundle:CGroupRelTutor grt
851
                        WITH u.id = grt.userId AND grt.cId = :course
852
                     WHERE u.id != :user AND grt.groupId = :group
853
                       AND u.active = true'
854
                )
855
                ->setParameters(['course' => $this->courseId, 'user' => $this->userId, 'group' => $this->groupId])
856
                ->getResult();
857
858
            return array_merge($tutors, $students);
859
        }
860
861
        $course = api_get_course_entity($this->courseId);
862
863
        if ($this->sessionId) {
864
            $session   = api_get_session_entity($this->sessionId);
865
            $criteria  = Criteria::create()->where(Criteria::expr()->eq('course', $course));
866
            $userCoach = api_is_course_session_coach($this->userId, $course->getId(), $session->getId());
867
868
            if ('true' === api_get_setting('chat.course_chat_restrict_to_coach')) {
869
                if ($userCoach) {
870
                    $criteria->andWhere(Criteria::expr()->eq('status', Session::STUDENT));
871
                } else {
872
                    $criteria->andWhere(Criteria::expr()->eq('status', Session::COURSE_COACH));
873
                }
874
            }
875
876
            $criteria->orderBy(['status' => Criteria::DESC]);
877
878
            return $session
879
                ->getUserCourseSubscriptions()
0 ignored issues
show
Bug introduced by
The method getUserCourseSubscriptions() does not exist on Chamilo\CoreBundle\Entity\Session. ( Ignorable by Annotation )

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

879
                ->/** @scrutinizer ignore-call */ getUserCourseSubscriptions()

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
880
                ->matching($criteria)
881
                ->filter(function (SessionRelCourseRelUser $scru) {
882
                    return $scru->getUser()->isActive();
883
                });
884
        }
885
886
        return $course
887
            ->getUsers()
888
            ->filter(function (CourseRelUser $cru) {
889
                return $cru->getUser()->isActive();
890
            });
891
    }
892
893
    /** Quick online check for one user */
894
    private function userIsConnected($userId): int
0 ignored issues
show
Unused Code introduced by
The method userIsConnected() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
895
    {
896
        $date = new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC'));
897
        $date->modify('-5 seconds');
898
899
        $extraCondition = $this->groupId
900
            ? 'AND ccc.toGroupId = '.$this->groupId
901
            : 'AND ccc.sessionId = '.$this->sessionId;
902
903
        $number = Database::getManager()
904
            ->createQuery("
905
                SELECT COUNT(ccc.userId) FROM ChamiloCourseBundle:CChatConnected ccc
906
                WHERE ccc.lastConnection > :date AND ccc.cId = :course AND ccc.userId = :user $extraCondition
907
            ")
908
            ->setParameters([
909
                'date'   => $date,
910
                'course' => $this->courseId,
911
                'user'   => $userId,
912
            ])
913
            ->getSingleScalarResult();
914
915
        return (int) $number;
916
    }
917
}
918