Passed
Push — master ( b3392d...18dba7 )
by
unknown
17:42 queued 08:30
created

CourseChatUtils   F

Complexity

Total Complexity 101

Size/Duplication

Total Lines 832
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 513
c 0
b 0
f 0
dl 0
loc 832
rs 2
wmc 101

20 Methods

Rating   Name   Duplication   Size   Complexity  
A findExistingNode() 0 21 1
A prepareMessage() 0 26 2
B getUsersSubscriptions() 0 59 5
F saveMessage() 0 184 27
A exitChat() 0 12 2
A countUsersOnline() 0 21 2
A __construct() 0 16 1
A formatUser() 0 13 1
A disconnectInactiveUsers() 0 37 5
A userIsConnected() 0 22 2
A createNodeWithResource() 0 51 3
A listUsersOnline() 0 22 5
A buildNames() 0 5 1
A getFileName() 0 14 3
A dbg() 0 6 3
B readMessages() 0 69 11
F mirrorDailyCopyToDocuments() 0 104 16
A keepUserAsConnected() 0 38 3
A makeSlug() 0 7 1
B buildBasename() 0 16 7

How to fix   Complexity   

Complex Class

Complex classes like CourseChatUtils often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use CourseChatUtils, and based on these observations, apply Extract Interface, too.

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
    public function __construct($courseId, $userId, $sessionId, $groupId, ResourceNode $resourceNode, ResourceRepository $repository)
44
    {
45
        $this->courseId     = (int) $courseId;
46
        $this->userId       = (int) $userId;
47
        $this->sessionId    = (int) $sessionId;
48
        $this->groupId      = (int) $groupId;
49
        $this->resourceNode = $resourceNode;
50
        $this->repository   = $repository;
51
52
        $this->dbg('construct', [
53
            'courseId'     => $courseId,
54
            'userId'       => $userId,
55
            'sessionId'    => $sessionId,
56
            'groupId'      => $groupId,
57
            'parentNodeId' => $resourceNode->getId() ?? null,
58
            'repo'         => get_class($repository),
59
        ]);
60
    }
61
62
    /** Simple debug helper */
63
    private function dbg(string $msg, array $ctx = []): void
64
    {
65
        if (!$this->debug) { return; }
66
        $line = '[CourseChat] '.$msg;
67
        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...
68
        error_log($line);
69
    }
70
71
    /** Build a slug out of the file title (matches our nodes like: messages-...-log-html) */
72
    private function makeSlug(string $fileTitle): string
73
    {
74
        $slug = strtolower($fileTitle);
75
        $slug = strtr($slug, ['.' => '-']);
76
        $slug = preg_replace('~[^a-z0-9\-\_]+~', '-', $slug);
77
        $slug = preg_replace('~-+~', '-', $slug);
78
        return trim($slug, '-');
79
    }
80
81
    /** Build a base name by day and scope (all / session / group / 1:1) */
82
    private function buildBasename(int $friendId = 0): string
83
    {
84
        $dateNow  = date('Y-m-d');
85
        $basename = 'messages-'.$dateNow;
86
87
        if ($this->groupId && !$friendId) {
88
            $basename .= '_gid-'.$this->groupId;
89
        } elseif ($this->sessionId && !$friendId) {
90
            $basename .= '_sid-'.$this->sessionId;
91
        } elseif ($friendId) {
92
            // stable order for 1:1 (smallest id first)
93
            $basename .= ($this->userId < $friendId)
94
                ? '_uid-'.$this->userId.'-'.$friendId
95
                : '_uid-'.$friendId.'-'.$this->userId;
96
        }
97
        return $basename;
98
    }
99
100
    /** Returns [fileTitle, slug] */
101
    private function buildNames(int $friendId = 0): array
102
    {
103
        $fileTitle = $this->buildBasename($friendId).'-log.html';
104
        $slug      = $this->makeSlug($fileTitle);
105
        return [$fileTitle, $slug];
106
    }
107
108
    /** Create node + conversation + empty file (used only from saveMessage under a lock) */
109
    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...
110
    {
111
        $em = Database::getManager();
112
113
        $this->dbg('node.create.start', ['slug' => $slug, 'title' => $fileTitle, 'parent' => $parentNode->getId()]);
114
115
        // temporary empty file
116
        $h = tmpfile();
117
        fwrite($h, '');
118
        $meta     = stream_get_meta_data($h);
119
        $uploaded = new UploadedFile($meta['uri'], $fileTitle, 'text/html', null, true);
120
121
        // conversation
122
        $conversation = new CChatConversation();
123
        if (method_exists($conversation, 'setTitle')) {
124
            $conversation->setTitle($fileTitle);
125
        } else {
126
            $conversation->setResourceName($fileTitle);
127
        }
128
        $conversation->setParentResourceNode($parentNode->getId());
129
130
        // node
131
        $node = new ResourceNode();
132
        $node->setTitle($fileTitle);
133
        $node->setSlug($slug);
134
        $node->setResourceType($this->repository->getResourceType());
135
        $node->setCreator(api_get_user_entity(api_get_user_id()));
136
        $node->setParent($parentNode);
137
138
        if (method_exists($conversation, 'setResourceNode')) {
139
            $conversation->setResourceNode($node);
140
        }
141
142
        $em->persist($node);
143
        $em->persist($conversation);
144
145
        // attach file
146
        $this->repository->addFile($conversation, $uploaded);
147
148
        // publish
149
        $course  = api_get_course_entity($this->courseId);
150
        $session = api_get_session_entity($this->sessionId);
151
        $group   = api_get_group_entity();
152
        $conversation->setParent($course);
153
        $conversation->addCourseLink($course, $session, $group);
154
155
        $em->flush();
156
157
        $this->dbg('node.create.ok', ['nodeId' => $node->getId()]);
158
159
        return $node;
160
    }
161
162
    /** Sanitize and convert message to safe HTML */
163
    public static function prepareMessage($message): string
164
    {
165
        if (empty($message)) {
166
            return '';
167
        }
168
169
        $message = trim($message);
170
        $message = nl2br($message);
171
        $message = Security::remove_XSS($message);
172
173
        // url -> anchor
174
        $message = preg_replace(
175
            '@((https?://)?([-\w]+\.[-\w\.]+)+\w(:\d+)?(/([-\w/_\.]*(\?\S+)?)?)*)@',
176
            '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
177
            $message
178
        );
179
        // add http:// when missing
180
        $message = preg_replace(
181
            '/<a\s[^>]*href\s*=\s*"((?!https?:\/\/)[^"]*)"[^>]*>/i',
182
            '<a href="http://$1" target="_blank" rel="noopener noreferrer">',
183
            $message
184
        );
185
186
        $message = MarkdownExtra::defaultTransform($message);
187
188
        return $message;
189
    }
190
191
    private function mirrorDailyCopyToDocuments(string $fileTitle, string $html): void
192
    {
193
        try {
194
            $em = Database::getManager();
195
            /** @var CDocumentRepository $docRepo */
196
            $docRepo = $em->getRepository(CDocument::class);
197
198
            $course  = api_get_course_entity($this->courseId);
199
            $session = api_get_session_entity($this->sessionId) ?: null;
200
201
            $top = $docRepo->ensureChatSystemFolder($course, $session);
202
203
            /** @var ResourceNodeRepository $nodeRepo */
204
            $nodeRepo = $em->getRepository(ResourceNode::class);
205
206
            $em->beginTransaction();
207
            try {
208
                try {
209
                    $em->getConnection()->executeStatement(
210
                        'SELECT id FROM resource_node WHERE id = ? FOR UPDATE',
211
                        [$top->getId()]
212
                    );
213
                } catch (\Throwable $e) {
214
                    error_log('[CourseChat] mirror FOR UPDATE skipped: '.$e->getMessage());
215
                }
216
217
                $fileNode = $docRepo->findChildDocumentFileByTitle($top, $fileTitle);
218
219
                if ($fileNode) {
220
                    /** @var ResourceFile|null $rf */
221
                    $rf = $em->getRepository(ResourceFile::class)->findOneBy(
222
                        ['resourceNode' => $fileNode, 'originalName' => $fileTitle],
223
                        ['id' => 'DESC']
224
                    );
225
                    if (!$rf) {
226
                        $rf = $em->getRepository(ResourceFile::class)->findOneBy(
227
                            ['resourceNode' => $fileNode],
228
                            ['id' => 'DESC']
229
                        );
230
                    }
231
232
                    if ($rf) {
233
                        $fs = $nodeRepo->getFileSystem();
234
                        $fname = $nodeRepo->getFilename($rf);
235
                        if ($fs->fileExists($fname)) { $fs->delete($fname); }
236
                        $fs->write($fname, $html);
237
                        if (method_exists($rf, 'setSize')) { $rf->setSize(strlen($html)); $em->persist($rf); }
238
                        $em->flush();
239
                    } else {
240
                        $h = tmpfile(); fwrite($h, $html);
241
                        $meta = stream_get_meta_data($h);
242
                        $uploaded = new UploadedFile($meta['uri'], $fileTitle, 'text/html', null, true);
243
244
                        $docRepo->createFileInFolder(
245
                            $course, $top, $uploaded, 'Daily chat copy',
246
                            ResourceLink::VISIBILITY_PUBLISHED, $session
247
                        );
248
                        $em->flush();
249
                    }
250
251
                } else {
252
                    $h = tmpfile(); fwrite($h, $html);
253
                    $meta = stream_get_meta_data($h);
254
                    $uploaded = new UploadedFile($meta['uri'], $fileTitle, 'text/html', null, true);
255
256
                    $docRepo->createFileInFolder(
257
                        $course, $top, $uploaded, 'Daily chat copy',
258
                        ResourceLink::VISIBILITY_PUBLISHED, $session
259
                    );
260
                    $em->flush();
261
                }
262
263
                $em->commit();
264
            } catch (UniqueConstraintViolationException $e) {
265
                $em->rollback();
266
                $fileNode = $docRepo->findChildDocumentFileByTitle($top, $fileTitle);
267
                if ($fileNode) {
268
                    /** @var ResourceFile|null $rf */
269
                    $rf = $em->getRepository(ResourceFile::class)->findOneBy(
270
                        ['resourceNode' => $fileNode, 'originalName' => $fileTitle],
271
                        ['id' => 'DESC']
272
                    ) ?: $em->getRepository(ResourceFile::class)->findOneBy(
273
                        ['resourceNode' => $fileNode],
274
                        ['id' => 'DESC']
275
                    );
276
277
                    if ($rf) {
278
                        $fs = $nodeRepo->getFileSystem();
279
                        $fname = $nodeRepo->getFilename($rf);
280
                        if ($fs->fileExists($fname)) { $fs->delete($fname); }
281
                        $fs->write($fname, $html);
282
                        if (method_exists($rf, 'setSize')) { $rf->setSize(strlen($html)); $em->persist($rf); }
283
                        $em->flush();
284
                        return;
285
                    }
286
                }
287
                throw $e;
288
            } catch (\Throwable $e) {
289
                $em->rollback();
290
                throw $e;
291
            }
292
293
        } catch (\Throwable $e) {
294
            $this->dbg('mirrorDailyCopy.error', ['err' => $e->getMessage()]);
295
        }
296
    }
297
298
    /**
299
     * Return the latest *chat* node for today (by createdAt DESC, id DESC).
300
     * It filters by the chat resourceType to avoid collisions with Document nodes.
301
     */
302
    private function findExistingNode(string $fileTitle, string $slug, ResourceNode $parentNode): ?ResourceNode
303
    {
304
        $em = \Database::getManager();
305
        $rt = $this->repository->getResourceType();
306
307
        $qb = $em->createQueryBuilder();
308
        $qb->select('n')
309
            ->from(ResourceNode::class, 'n')
310
            ->where('n.parent = :parent')
311
            ->andWhere('n.resourceType = :rt')
312
            ->andWhere('(n.title = :title OR n.slug = :slug)')
313
            ->setParameter('parent', $parentNode)
314
            ->setParameter('rt', $rt)
315
            ->setParameter('title', $fileTitle)
316
            ->setParameter('slug',  $slug)
317
            ->orderBy('n.createdAt', 'DESC')
318
            ->addOrderBy('n.id', 'DESC')
319
            ->setMaxResults(1);
320
321
        /** @var ResourceNode|null $node */
322
        return $qb->getQuery()->getOneOrNullResult();
323
    }
324
325
    /**
326
     * Append a message to the last daily file (chronological order).
327
     */
328
    public function saveMessage($message, $friendId = 0)
329
    {
330
        $this->dbg('saveMessage.in', ['friendId' => (int)$friendId, 'rawLen' => strlen((string)$message)]);
331
        if (!is_string($message) || trim($message) === '') { return false; }
332
333
        [$fileTitle, $slug] = $this->buildNames((int)$friendId);
334
335
        $em = \Database::getManager();
336
        /** @var ResourceNodeRepository $nodeRepo */
337
        $nodeRepo = $em->getRepository(ResourceNode::class);
338
339
        // Parent = chat root node (CChatConversation) provided by controller
340
        $parent = $nodeRepo->find($this->resourceNode->getId());
341
        if (!$parent) { $this->dbg('saveMessage.error.noParent'); return false; }
342
343
        // Best-effort file lock by day
344
        $lockPath = sys_get_temp_dir().'/ch_chat_lock_'.$parent->getId().'_'.$slug.'.lock';
345
        $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

345
        $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...
346
347
        try {
348
            $em->beginTransaction();
349
            try {
350
                $em->lock($parent, LockMode::PESSIMISTIC_WRITE);
351
352
                $node = $this->findExistingNode($fileTitle, $slug, $parent);
353
354
                if (!$node) {
355
                    // Create conversation + node (same as before)
356
                    $conversation = new CChatConversation();
357
                    (method_exists($conversation, 'setTitle')
358
                        ? $conversation->setTitle($fileTitle)
359
                        : $conversation->setResourceName($fileTitle));
360
                    $conversation->setParentResourceNode($parent->getId());
361
362
                    $node = new ResourceNode();
363
                    $node->setTitle($fileTitle);
364
                    $node->setSlug($slug);
365
                    $node->setResourceType($this->repository->getResourceType());
366
                    $node->setCreator(api_get_user_entity(api_get_user_id()));
367
                    $node->setParent($parent);
368
369
                    if (method_exists($conversation, 'setResourceNode')) {
370
                        $conversation->setResourceNode($node);
371
                    }
372
373
                    $em->persist($node);
374
                    $em->persist($conversation);
375
376
                    $course  = api_get_course_entity($this->courseId);
377
                    $session = api_get_session_entity($this->sessionId);
378
                    $group   = api_get_group_entity();
379
                    $conversation->setParent($course);
380
                    $conversation->addCourseLink($course, $session, $group);
381
382
                    $em->flush();
383
                }
384
385
                $em->commit();
386
            } catch (UniqueConstraintViolationException $e) {
387
                $em->rollback();
388
                $node = $this->findExistingNode($fileTitle, $slug, $parent);
389
                if (!$node) { throw $e; }
390
            } catch (\Throwable $e) {
391
                $em->rollback();
392
                throw $e;
393
            }
394
395
            // Ensure conversation still exists (as you already did)
396
            $conversation = $em->getRepository(CChatConversation::class)
397
                ->findOneBy(['resourceNode' => $node]);
398
            if (!$conversation) {
399
                $em->beginTransaction();
400
                try {
401
                    $em->lock($parent, LockMode::PESSIMISTIC_WRITE);
402
403
                    $conversation = new CChatConversation();
404
                    (method_exists($conversation, 'setTitle')
405
                        ? $conversation->setTitle($fileTitle)
406
                        : $conversation->setResourceName($fileTitle));
407
                    $conversation->setParentResourceNode($parent->getId());
408
                    if (method_exists($conversation, 'setResourceNode')) {
409
                        $conversation->setResourceNode($node);
410
                    }
411
                    $em->persist($conversation);
412
413
                    $course  = api_get_course_entity($this->courseId);
414
                    $session = api_get_session_entity($this->sessionId);
415
                    $group   = api_get_group_entity();
416
                    $conversation->setParent($course);
417
                    $conversation->addCourseLink($course, $session, $group);
418
419
                    $em->flush();
420
                    $em->commit();
421
                } catch (UniqueConstraintViolationException $e) {
422
                    $em->rollback();
423
                    $conversation = $em->getRepository(CChatConversation::class)
424
                        ->findOneBy(['resourceNode' => $node]);
425
                    if (!$conversation) { throw $e; }
426
                } catch (\Throwable $e) {
427
                    $em->rollback();
428
                    throw $e;
429
                }
430
            }
431
432
            // Bubble HTML (unchanged)
433
            $user      = api_get_user_entity($this->userId);
434
            $isMaster  = api_is_course_admin();
435
            $timeNow   = date('d/m/y H:i:s');
436
            $userPhoto = \UserManager::getUserPicture($this->userId);
437
            $htmlMsg   = self::prepareMessage($message);
438
439
            $bubble = $isMaster
440
                ? '<div class="message-teacher"><div class="content-message"><div class="chat-message-block-name">'
441
                .\UserManager::formatUserFullName($user).'</div><div class="chat-message-block-content">'
442
                .$htmlMsg.'</div><div class="message-date">'.$timeNow
443
                .'</div></div><div class="icon-message"></div><img class="chat-image" src="'.$userPhoto.'"></div>'
444
                : '<div class="message-student"><img class="chat-image" src="'.$userPhoto.'"><div class="icon-message"></div>'
445
                .'<div class="content-message"><div class="chat-message-block-name">'.\UserManager::formatUserFullName($user)
446
                .'</div><div class="chat-message-block-content">'.$htmlMsg.'</div><div class="message-date">'
447
                .$timeNow.'</div></div></div>';
448
449
            // Locate ResourceFile (same logic as before)
450
            $rf = $em->createQueryBuilder()
451
                ->select('rf')
452
                ->from(ResourceFile::class, 'rf')
453
                ->where('rf.resourceNode = :node AND rf.originalName = :name')
454
                ->setParameter('node', $node)
455
                ->setParameter('name', $fileTitle)
456
                ->orderBy('rf.id', 'DESC')
457
                ->setMaxResults(1)
458
                ->getQuery()
459
                ->getOneOrNullResult();
460
461
            if (!$rf) {
462
                $rf = $em->createQueryBuilder()
463
                    ->select('rf')
464
                    ->from(ResourceFile::class, 'rf')
465
                    ->where('rf.resourceNode = :node')
466
                    ->setParameter('node', $node)
467
                    ->orderBy('rf.id', 'DESC')
468
                    ->setMaxResults(1)
469
                    ->getQuery()
470
                    ->getOneOrNullResult();
471
            }
472
473
            $existing = '';
474
            if ($rf) {
475
                try { $existing = $nodeRepo->getResourceNodeFileContent($node, $rf) ?? ''; }
476
                catch (\Throwable $e) { $existing = ''; }
477
            }
478
            $newContent = $existing.$bubble;
479
480
            if ($rf) {
481
                $fs = $nodeRepo->getFileSystem();
482
                $fname = $nodeRepo->getFilename($rf);
483
                if ($fs->fileExists($fname)) { $fs->delete($fname); }
484
                $fs->write($fname, $newContent);
485
                if (method_exists($rf, 'setSize')) { $rf->setSize(strlen($newContent)); $em->persist($rf); }
486
                $em->flush();
487
            } else {
488
                if (method_exists($this->repository, 'addFileFromString')) {
489
                    $this->repository->addFileFromString($conversation, $fileTitle, 'text/html', $newContent, true);
490
                } else {
491
                    $h = tmpfile(); fwrite($h, $newContent);
492
                    $meta = stream_get_meta_data($h);
493
                    $uploaded = new UploadedFile(
494
                        $meta['uri'], $fileTitle, 'text/html', null, true
495
                    );
496
                    $this->repository->addFile($conversation, $uploaded);
497
                }
498
                $em->flush();
499
            }
500
501
            // Mirror in Documents (unchanged call; see method below updated with lock)
502
            $this->mirrorDailyCopyToDocuments($fileTitle, $newContent);
503
504
            $this->dbg('saveMessage.append.ok', ['nodeId' => $node->getId(), 'bytes' => strlen($newContent)]);
505
            return true;
506
507
        } catch (\Throwable $e) {
508
            $this->dbg('saveMessage.error', ['err' => $e->getMessage()]);
509
            return false;
510
        } finally {
511
            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

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

822
                ->/** @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...
823
                ->matching($criteria)
824
                ->filter(function (SessionRelCourseRelUser $scru) {
825
                    return $scru->getUser()->isActive();
826
                });
827
        }
828
829
        return $course
830
            ->getUsers()
831
            ->filter(function (CourseRelUser $cru) {
832
                return $cru->getUser()->isActive();
833
            });
834
    }
835
836
    /** Quick online check for one user */
837
    private function userIsConnected($userId): int
838
    {
839
        $date = new \DateTime(api_get_utc_datetime(), new \DateTimeZone('UTC'));
840
        $date->modify('-5 seconds');
841
842
        $extraCondition = $this->groupId
843
            ? 'AND ccc.toGroupId = '.$this->groupId
844
            : 'AND ccc.sessionId = '.$this->sessionId;
845
846
        $number = Database::getManager()
847
            ->createQuery("
848
                SELECT COUNT(ccc.userId) FROM ChamiloCourseBundle:CChatConnected ccc
849
                WHERE ccc.lastConnection > :date AND ccc.cId = :course AND ccc.userId = :user $extraCondition
850
            ")
851
            ->setParameters([
852
                'date'   => $date,
853
                'course' => $this->courseId,
854
                'user'   => $userId,
855
            ])
856
            ->getSingleScalarResult();
857
858
        return (int) $number;
859
    }
860
}
861