Passed
Push — master ( b3633f...777bb3 )
by Angel Fernando Quiroz
10:18
created

AdminController::testEmail()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 40
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 23
c 0
b 0
f 0
nc 2
nop 5
dl 0
loc 40
rs 9.552
1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CoreBundle\Controller\Admin;
8
9
use Chamilo\CoreBundle\Component\Composer\ScriptHandler;
10
use Chamilo\CoreBundle\Controller\BaseController;
11
use Chamilo\CoreBundle\Entity\Course;
12
use Chamilo\CoreBundle\Entity\ResourceFile;
13
use Chamilo\CoreBundle\Entity\ResourceLink;
14
use Chamilo\CoreBundle\Entity\ResourceNode;
15
use Chamilo\CoreBundle\Entity\ResourceType;
16
use Chamilo\CoreBundle\Form\TestEmailType;
17
use Chamilo\CoreBundle\Framework\Container;
18
use Chamilo\CoreBundle\Helpers\AccessUrlHelper;
19
use Chamilo\CoreBundle\Helpers\CidReqHelper;
20
use Chamilo\CoreBundle\Helpers\MailHelper;
21
use Chamilo\CoreBundle\Helpers\QueryCacheHelper;
22
use Chamilo\CoreBundle\Helpers\TempUploadHelper;
23
use Chamilo\CoreBundle\Helpers\UserHelper;
24
use Chamilo\CoreBundle\Repository\Node\CourseRepository;
25
use Chamilo\CoreBundle\Repository\Node\UserRepository;
26
use Chamilo\CoreBundle\Repository\ResourceFileRepository;
27
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
28
use Chamilo\CoreBundle\Settings\SettingsManager;
29
use Chamilo\CourseBundle\Entity\CDocument;
30
use Doctrine\DBAL\Connection;
31
use Doctrine\ORM\EntityManagerInterface;
32
use MessageManager;
33
use RuntimeException;
34
use Symfony\Component\HttpFoundation\JsonResponse;
35
use Symfony\Component\HttpFoundation\Request;
36
use Symfony\Component\HttpFoundation\Response;
37
use Symfony\Component\Routing\Attribute\Route;
38
use Symfony\Component\Security\Http\Attribute\IsGranted;
39
use Symfony\Contracts\Translation\TranslatorInterface;
40
use Throwable;
41
use UserManager;
42
43
#[Route('/admin')]
44
class AdminController extends BaseController
45
{
46
    private const ITEMS_PER_PAGE = 50;
47
48
    public function __construct(
49
        private readonly ResourceNodeRepository $resourceNodeRepository,
50
        private readonly AccessUrlHelper $accessUrlHelper,
51
        private readonly UserHelper $userHelper,
52
        private readonly CidReqHelper $cidReqHelper
53
    ) {}
54
55
    #[IsGranted('ROLE_ADMIN')]
56
    #[Route('/register-campus', name: 'admin_register_campus', methods: ['POST'])]
57
    public function registerCampus(Request $request, SettingsManager $settingsManager): Response
58
    {
59
        $requestData = $request->toArray();
60
        $doNotListCampus = (bool) $requestData['donotlistcampus'];
61
62
        $settingsManager->setUrl($this->accessUrlHelper->getCurrent());
63
        $settingsManager->updateSetting('platform.registered', 'true');
64
65
        $settingsManager->updateSetting(
66
            'platform.donotlistcampus',
67
            $doNotListCampus ? 'true' : 'false'
68
        );
69
70
        return new Response('', Response::HTTP_NO_CONTENT);
71
    }
72
73
    #[IsGranted('ROLE_ADMIN')]
74
    #[Route('/files_info', name: 'admin_files_info', methods: ['GET'])]
75
    public function listFilesInfo(
76
        Request $request,
77
        ResourceFileRepository $resourceFileRepository,
78
        CourseRepository $courseRepository
79
    ): Response {
80
        $page = $request->query->getInt('page', 1);
81
        $search = $request->query->get('search', '');
82
        $offset = ($page - 1) * self::ITEMS_PER_PAGE;
83
84
        $files = $resourceFileRepository->searchFiles($search, $offset, self::ITEMS_PER_PAGE);
85
        $totalItems = $resourceFileRepository->countFiles($search);
86
        $totalPages = $totalItems > 0 ? (int) ceil($totalItems / self::ITEMS_PER_PAGE) : 1;
87
88
        $fileUrls = [];
89
        $filePaths = [];
90
        $orphanFlags = [];
91
        $linksCount = [];
92
        $coursesByFile = [];
93
94
        /** @var ResourceFile $file */
95
        foreach ($files as $file) {
96
            $resourceNode = $file->getResourceNode();
97
            $count = 0;
98
            $coursesForThisFile = [];
99
100
            if ($resourceNode) {
101
                // Public URL to open/download this file
102
                $fileUrls[$file->getId()] = $this->resourceNodeRepository->getResourceFileUrl($resourceNode);
103
104
                // Count how many ResourceLinks still point to this node and collect courses.
105
                $links = $resourceNode->getResourceLinks();
106
                if ($links) {
107
                    $count = $links->count();
108
109
                    foreach ($links as $link) {
110
                        $course = $link->getCourse();
111
                        if (!$course) {
112
                            continue;
113
                        }
114
115
                        $courseId = $course->getId();
116
117
                        // Root resource node of the course (used to build Documents URL in the template JS)
118
                        $courseResourceNode = $course->getResourceNode();
119
                        $courseResourceNodeId = $courseResourceNode ? $courseResourceNode->getId() : null;
120
121
                        // Avoid duplicates for the same course.
122
                        if (!isset($coursesForThisFile[$courseId])) {
123
                            $coursesForThisFile[$courseId] = [
124
                                'id' => $courseId,
125
                                'code' => $course->getCode(),
126
                                'title' => $course->getTitle(),
127
                                'resourceNodeId' => $courseResourceNodeId,
128
                            ];
129
                        }
130
                    }
131
                }
132
            } else {
133
                $fileUrls[$file->getId()] = null;
134
            }
135
136
            // Physical path on disk (used only for display/copy)
137
            $filePaths[$file->getId()] = '/upload/resource'.$this->resourceNodeRepository->getFilename($file);
138
139
            $linksCount[$file->getId()] = $count;
140
            $orphanFlags[$file->getId()] = 0 === $count;
141
            $coursesByFile[$file->getId()] = array_values($coursesForThisFile);
142
        }
143
144
        // Build course selector options for the "Attach to course" form.
145
        $allCourses = $courseRepository->findBy([], ['title' => 'ASC']);
146
        $courseOptions = [];
147
148
        /** @var Course $course */
149
        foreach ($allCourses as $course) {
150
            $courseResourceNode = $course->getResourceNode();
151
            $courseResourceNodeId = $courseResourceNode ? $courseResourceNode->getId() : null;
152
153
            $courseOptions[] = [
154
                'id' => $course->getId(),
155
                'code' => $course->getCode(),
156
                'title' => $course->getTitle(),
157
                'resourceNodeId' => $courseResourceNodeId,
158
            ];
159
        }
160
161
        return $this->render('@ChamiloCore/Admin/files_info.html.twig', [
162
            'files' => $files,
163
            'fileUrls' => $fileUrls,
164
            'filePaths' => $filePaths,
165
            'totalPages' => $totalPages,
166
            'currentPage' => $page,
167
            'search' => $search,
168
            'orphanFlags' => $orphanFlags,
169
            'linksCount' => $linksCount,
170
            'coursesByFile' => $coursesByFile,
171
            'courseOptions' => $courseOptions,
172
        ]);
173
    }
174
175
    #[IsGranted('ROLE_ADMIN')]
176
    #[Route('/files_info/attach', name: 'admin_files_info_attach', methods: ['POST'])]
177
    public function attachOrphanFileToCourse(
178
        Request $request,
179
        ResourceFileRepository $resourceFileRepository,
180
        CourseRepository $courseRepository,
181
        EntityManagerInterface $em
182
    ): Response {
183
        $token = (string) $request->request->get('_token', '');
184
        if (!$this->isCsrfTokenValid('attach_orphan_file', $token)) {
185
            throw $this->createAccessDeniedException('Invalid CSRF token.');
186
        }
187
188
        $fileId = $request->request->getInt('resource_file_id', 0);
189
        $page = $request->request->getInt('page', 1);
190
        $search = (string) $request->request->get('search', '');
191
192
        if ($fileId <= 0) {
193
            $this->addFlash('error', 'Missing resource file identifier.');
194
195
            return $this->redirectToRoute('admin_files_info', [
196
                'page' => $page,
197
                'search' => $search,
198
            ]);
199
        }
200
201
        // Collect course codes from multi-select.
202
        $courseCodes = [];
203
        $multi = $request->request->all('course_codes');
204
        if (\is_array($multi)) {
205
            foreach ($multi as $code) {
206
                $code = trim((string) $code);
207
                if ('' !== $code) {
208
                    $courseCodes[] = $code;
209
                }
210
            }
211
        }
212
213
        // Fallback to single value if any.
214
        if (0 === \count($courseCodes)) {
215
            $single = $request->request->get('course_code');
216
            $single = null === $single ? '' : trim((string) $single);
217
            if ('' !== $single) {
218
                $courseCodes[] = $single;
219
            }
220
        }
221
222
        // Normalize and remove duplicates.
223
        $courseCodes = array_values(array_unique($courseCodes));
224
225
        if (0 === \count($courseCodes)) {
226
            $this->addFlash('error', 'Please select at least one course.');
227
228
            return $this->redirectToRoute('admin_files_info', [
229
                'page' => $page,
230
                'search' => $search,
231
            ]);
232
        }
233
234
        /** @var ResourceFile|null $resourceFile */
235
        $resourceFile = $resourceFileRepository->find($fileId);
236
        if (!$resourceFile) {
237
            $this->addFlash('error', 'Resource file not found.');
238
239
            return $this->redirectToRoute('admin_files_info', [
240
                'page' => $page,
241
                'search' => $search,
242
            ]);
243
        }
244
245
        // Existing node for this file (can be null in some legacy cases).
246
        $resourceNode = $resourceFile->getResourceNode();
247
248
        // Hidden field in the template: always "1" now.
249
        $createDocuments = (bool) $request->request->get('create_documents', false);
250
251
        // Map existing links by course id to avoid duplicates.
252
        $existingByCourseId = [];
253
        if ($resourceNode) {
254
            $links = $resourceNode->getResourceLinks();
255
            if ($links) {
0 ignored issues
show
introduced by
$links is of type Doctrine\Common\Collections\Collection, thus it always evaluated to true.
Loading history...
256
                foreach ($links as $existingLink) {
257
                    $course = $existingLink->getCourse();
258
                    if ($course) {
259
                        $existingByCourseId[$course->getId()] = true;
260
                    }
261
                }
262
            }
263
        }
264
265
        // "Orphan" means: no links yet or no node at all.
266
        $wasOrphan = (null === $resourceNode) || (0 === \count($existingByCourseId));
267
        $attachedTitles = [];
268
        $skippedTitles = [];
269
270
        // Try to reuse an existing visible document for this node.
271
        $documentRepo = Container::getDocumentRepository();
272
273
        /** @var CDocument|null $sharedDocument */
274
        $sharedDocument = $resourceNode
275
            ? $documentRepo->findOneBy(['resourceNode' => $resourceNode])
276
            : null;
277
278
        foreach ($courseCodes as $code) {
279
            /** @var Course|null $course */
280
            $course = $courseRepository->findOneBy(['code' => $code]);
281
            if (!$course) {
282
                $skippedTitles[] = \sprintf('%s (not found)', $code);
283
284
                continue;
285
            }
286
287
            $courseId = $course->getId();
288
            if (isset($existingByCourseId[$courseId])) {
289
                // Already attached to this course.
290
                $skippedTitles[] = \sprintf('%s (already attached)', (string) $course->getTitle());
291
292
                continue;
293
            }
294
295
            // If there is already a node but it was truly orphan (no links),
296
            // re-parent the node once to the first target course root.
297
            if ($wasOrphan && $resourceNode && method_exists($course, 'getResourceNode')) {
298
                $courseRootNode = $course->getResourceNode();
299
                if ($courseRootNode) {
300
                    $resourceNode->setParent($courseRootNode);
301
                }
302
                $wasOrphan = false;
303
            }
304
305
            // Mark this course as now attached to avoid duplicates in this loop.
306
            $existingByCourseId[$courseId] = true;
307
            $attachedTitles[] = (string) $course->getTitle();
308
309
            if ($createDocuments) {
310
                // Ensure we have a shared document for this file.
311
                if (null === $sharedDocument) {
312
                    // This will create the ResourceNode if needed and the CDocument.
313
                    $sharedDocument = $this->createVisibleDocumentFromResourceFile($resourceFile, $course, $em);
314
315
                    // Refresh local node reference in case it was created inside the helper.
316
                    $resourceNode = $resourceFile->getResourceNode();
317
                }
318
319
                // IMPORTANT: some legacy documents might not have a parent set.
320
                // We must set a parent (owning course) before calling addCourseLink,
321
                // or AbstractResource::addCourseLink() will throw.
322
                if (null === $sharedDocument->getParent()) {
323
                    $sharedDocument->setParent($course);
324
                }
325
326
                // For every course, share the same document via ResourceLinks.
327
                $session = $this->cidReqHelper->getDoctrineSessionEntity();
328
                $group = null;
329
                $sharedDocument->addCourseLink($course, $session, $group);
330
            }
331
        }
332
333
        // Single flush for all changes (links + optional new CDocument).
334
        $em->flush();
335
336
        if (!empty($attachedTitles)) {
337
            $this->addFlash(
338
                'success',
339
                \sprintf(
340
                    'File "%s" has been attached to %d course(s): %s.',
341
                    (string) ($resourceFile->getOriginalName() ?? $resourceFile->getTitle() ?? $resourceFile->getId()),
342
                    \count($attachedTitles),
343
                    implode(', ', $attachedTitles)
344
                )
345
            );
346
        }
347
348
        if (!empty($skippedTitles)) {
349
            $this->addFlash(
350
                'warning',
351
                \sprintf(
352
                    'Some courses were skipped: %s.',
353
                    implode(', ', $skippedTitles)
354
                )
355
            );
356
        }
357
358
        return $this->redirectToRoute('admin_files_info', [
359
            'page' => $page,
360
            'search' => $search,
361
        ]);
362
    }
363
364
    #[IsGranted('ROLE_ADMIN')]
365
    #[Route('/files_info/detach', name: 'admin_files_info_detach', methods: ['POST'])]
366
    public function detachFileFromCourse(
367
        Request $request,
368
        ResourceFileRepository $resourceFileRepository,
369
        EntityManagerInterface $em
370
    ): Response {
371
        $token = (string) $request->request->get('_token', '');
372
        if (!$this->isCsrfTokenValid('detach_file_from_course', $token)) {
373
            throw $this->createAccessDeniedException('Invalid CSRF token.');
374
        }
375
376
        $fileId = $request->request->getInt('resource_file_id', 0);
377
        $courseId = $request->request->getInt('course_id', 0);
378
        $page = $request->request->getInt('page', 1);
379
        $search = (string) $request->request->get('search', '');
380
381
        if ($fileId <= 0 || $courseId <= 0) {
382
            $this->addFlash('error', 'Missing file or course identifier.');
383
384
            return $this->redirectToRoute('admin_files_info', [
385
                'page' => $page,
386
                'search' => $search,
387
            ]);
388
        }
389
390
        /** @var ResourceFile|null $resourceFile */
391
        $resourceFile = $resourceFileRepository->find($fileId);
392
        if (!$resourceFile) {
393
            $this->addFlash('error', 'Resource file not found.');
394
395
            return $this->redirectToRoute('admin_files_info', [
396
                'page' => $page,
397
                'search' => $search,
398
            ]);
399
        }
400
401
        $resourceNode = $resourceFile->getResourceNode();
402
        if (!$resourceNode) {
403
            $this->addFlash('error', 'This resource file has no resource node and cannot be detached.');
404
405
            return $this->redirectToRoute('admin_files_info', [
406
                'page' => $page,
407
                'search' => $search,
408
            ]);
409
        }
410
411
        $links = $resourceNode->getResourceLinks();
412
        $removed = 0;
413
414
        foreach ($links as $link) {
415
            $course = $link->getCourse();
416
            if ($course && $course->getId() === $courseId) {
417
                $em->remove($link);
418
                ++$removed;
419
            }
420
        }
421
422
        if ($removed > 0) {
423
            $em->flush();
424
425
            $this->addFlash(
426
                'success',
427
                \sprintf(
428
                    'File has been detached from %d course link(s).',
429
                    $removed
430
                )
431
            );
432
        } else {
433
            $this->addFlash(
434
                'warning',
435
                'This file is not attached to the selected course.'
436
            );
437
        }
438
439
        return $this->redirectToRoute('admin_files_info', [
440
            'page' => $page,
441
            'search' => $search,
442
        ]);
443
    }
444
445
    #[IsGranted('ROLE_ADMIN')]
446
    #[Route('/files_info/delete', name: 'admin_files_info_delete', methods: ['POST'])]
447
    public function deleteOrphanFile(
448
        Request $request,
449
        ResourceFileRepository $resourceFileRepository,
450
        EntityManagerInterface $em
451
    ): Response {
452
        $token = (string) $request->request->get('_token', '');
453
        if (!$this->isCsrfTokenValid('delete_orphan_file', $token)) {
454
            throw $this->createAccessDeniedException('Invalid CSRF token.');
455
        }
456
457
        $fileId = $request->request->getInt('resource_file_id', 0);
458
        $page = $request->request->getInt('page', 1);
459
        $search = (string) $request->request->get('search', '');
460
461
        if ($fileId <= 0) {
462
            $this->addFlash('error', 'Missing resource file identifier.');
463
464
            return $this->redirectToRoute('admin_files_info', [
465
                'page' => $page,
466
                'search' => $search,
467
            ]);
468
        }
469
470
        $resourceFile = $resourceFileRepository->find($fileId);
471
        if (!$resourceFile) {
472
            $this->addFlash('error', 'Resource file not found.');
473
474
            return $this->redirectToRoute('admin_files_info', [
475
                'page' => $page,
476
                'search' => $search,
477
            ]);
478
        }
479
480
        $resourceNode = $resourceFile->getResourceNode();
481
        $linksCount = $resourceNode ? $resourceNode->getResourceLinks()->count() : 0;
482
        if ($linksCount > 0) {
483
            $this->addFlash('warning', 'This file is still used by at least one course/session and cannot be deleted.');
484
485
            return $this->redirectToRoute('admin_files_info', [
486
                'page' => $page,
487
                'search' => $search,
488
            ]);
489
        }
490
491
        // Compute physical path in var/upload/resource (adapt if you use another directory).
492
        $relativePath = $this->resourceNodeRepository->getFilename($resourceFile);
493
        $storageRoot = $this->getParameter('kernel.project_dir').'/var/upload/resource';
494
        $absolutePath = $storageRoot.$relativePath;
495
496
        if (is_file($absolutePath) && is_writable($absolutePath)) {
497
            @unlink($absolutePath);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). 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

497
            /** @scrutinizer ignore-unhandled */ @unlink($absolutePath);

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...
498
        }
499
500
        // Optionally remove the resource node as well if it is really orphan.
501
        if ($resourceNode) {
502
            $em->remove($resourceNode);
503
        }
504
505
        $em->remove($resourceFile);
506
        $em->flush();
507
508
        $this->addFlash('success', 'Orphan file and its physical content have been deleted definitively.');
509
510
        return $this->redirectToRoute('admin_files_info', [
511
            'page' => $page,
512
            'search' => $search,
513
        ]);
514
    }
515
516
    #[IsGranted('ROLE_ADMIN')]
517
    #[Route('/resources_info', name: 'admin_resources_info', methods: ['GET'])]
518
    public function listResourcesInfo(
519
        Request $request,
520
        ResourceNodeRepository $resourceNodeRepo,
521
        EntityManagerInterface $em
522
    ): Response {
523
        $resourceTypeId = $request->query->getInt('type');
524
        $resourceTypes = $em->getRepository(ResourceType::class)->findAll();
525
526
        $courses = [];
527
        $showUsers = false;
528
        $typeTitle = null;
529
530
        if ($resourceTypeId > 0) {
531
            /** @var ResourceType|null $rt */
532
            $rt = $em->getRepository(ResourceType::class)->find($resourceTypeId);
533
            $typeTitle = $rt?->getTitle();
534
535
            /** Load ResourceLinks for the selected type */
536
            /** @var ResourceLink[] $resourceLinks */
537
            $resourceLinks = $em->getRepository(ResourceLink::class)->createQueryBuilder('rl')
538
                ->join('rl.resourceNode', 'rn')
539
                ->where('rn.resourceType = :type')
540
                ->setParameter('type', $resourceTypeId)
541
                ->getQuery()
542
                ->getResult()
543
            ;
544
545
            /** Aggregate by course/session key */
546
            $seen = [];
547
            $keysMeta = [];
548
            foreach ($resourceLinks as $link) {
549
                $course = $link->getCourse();
550
                if (!$course) {
551
                    continue;
552
                }
553
                $session = $link->getSession();
554
                $node = $link->getResourceNode();
555
556
                $cid = $course->getId();
557
                $sid = $session?->getId() ?? 0;
558
                $key = self::makeKey($cid, $sid);
559
560
                if (!isset($seen[$key])) {
561
                    $seen[$key] = [
562
                        'type' => $sid ? 'session' : 'course',
563
                        'id' => $sid ?: $cid,
564
                        'courseId' => $cid,
565
                        'sessionId' => $sid,
566
                        'title' => $sid ? ($session->getTitle().' - '.$course->getTitle()) : $course->getTitle(),
567
                        'url' => $sid
568
                            ? '/course/'.$cid.'/home?sid='.$sid
569
                            : '/course/'.$cid.'/home',
570
                        'count' => 0,
571
                        'items' => [],
572
                        'users' => [],
573
                        'firstCreatedAt' => $node->getCreatedAt(),
574
                    ];
575
                    $keysMeta[$key] = ['cid' => $cid, 'sid' => $sid];
576
                }
577
578
                $seen[$key]['count']++;
579
                $seen[$key]['items'][] = $node->getTitle();
580
581
                if ($node->getCreatedAt() < $seen[$key]['firstCreatedAt']) {
582
                    $seen[$key]['firstCreatedAt'] = $node->getCreatedAt();
583
                }
584
            }
585
586
            /* Populate users depending on the resource type */
587
            if (!empty($seen)) {
588
                $usersMap = $this->fetchUsersForType($typeTitle, $em, $keysMeta);
589
                foreach ($usersMap as $key => $names) {
590
                    if (isset($seen[$key]) && $names) {
591
                        $seen[$key]['users'] = array_values(array_unique($names));
592
                    }
593
                }
594
                // Show the "Users" column only if there's any user to display
595
                $showUsers = array_reduce($seen, fn ($acc, $row) => $acc || !empty($row['users']), false);
596
            }
597
598
            /** Normalize output. */
599
            $courses = array_values(array_map(static function ($row) {
600
                $row['items'] = array_values(array_unique($row['items']));
601
602
                return $row;
603
            }, $seen));
604
605
            usort($courses, static fn ($a, $b) => strnatcasecmp($a['title'], $b['title']));
606
        }
607
608
        return $this->render('@ChamiloCore/Admin/resources_info.html.twig', [
609
            'resourceTypes' => $resourceTypes,
610
            'selectedType' => $resourceTypeId,
611
            'courses' => $courses,
612
            'showUsers' => $showUsers,
613
            'typeTitle' => $typeTitle,
614
        ]);
615
    }
616
617
    #[IsGranted('ROLE_ADMIN')]
618
    #[Route('/test-cache-all-users', name: 'chamilo_core_user_test_cache_all_users')]
619
    public function testCacheAllUsers(UserRepository $userRepository): JsonResponse
620
    {
621
        // Without cache
622
        $startNoCache = microtime(true);
623
        $usersNoCache = $userRepository->findAllUsers(false);
624
        $timeNoCache = microtime(true) - $startNoCache;
625
626
        // With cache
627
        $startCache = microtime(true);
628
        $resultCached = $userRepository->findAllUsers(true);
629
        $timeCache = microtime(true) - $startCache;
630
631
        // Check if we have a key (we do if cache was used)
632
        $usersCache = $resultCached['data'] ?? $resultCached;
633
634
        $cacheKey = $resultCached['cache_key'] ?? null;
635
636
        return $this->json([
637
            'without_cache' => [
638
                'count' => \count($usersNoCache),
639
                'execution_time' => $timeNoCache,
640
            ],
641
            'with_cache' => [
642
                'count' => \count($usersCache),
643
                'execution_time' => $timeCache,
644
                'cache_key' => $cacheKey,
645
            ],
646
        ]);
647
    }
648
649
    #[IsGranted('ROLE_ADMIN')]
650
    #[Route(path: '/test-cache-all-users/invalidate', name: 'chamilo_core_user_test_cache_all_users_invalidate')]
651
    public function invalidateCacheAllUsers(QueryCacheHelper $queryCacheHelper): JsonResponse
652
    {
653
        $cacheKey = $queryCacheHelper->getCacheKey('findAllUsers', []);
654
        $queryCacheHelper->invalidate('findAllUsers');
655
656
        return $this->json([
657
            'message' => 'Cache for users invalidated!',
658
            'invalidated_cache_key' => $cacheKey,
659
        ]);
660
    }
661
662
    #[IsGranted('ROLE_ADMIN')]
663
    #[Route('/cleanup-temp-uploads', name: 'admin_cleanup_temp_uploads', methods: ['GET'])]
664
    public function showCleanupTempUploads(
665
        TempUploadHelper $tempUploadHelper,
666
    ): Response {
667
        $stats = $tempUploadHelper->stats(); // ['files' => int, 'bytes' => int]
668
669
        return $this->render('@ChamiloCore/Admin/cleanup_temp_uploads.html.twig', [
670
            'tempDir' => $tempUploadHelper->getTempDir(),
671
            'stats' => $stats,
672
            'defaultOlderThan' => 0, // 0 = delete all
673
        ]);
674
    }
675
676
    #[IsGranted('ROLE_ADMIN')]
677
    #[Route('/cleanup-temp-uploads', name: 'admin_cleanup_temp_uploads_run', methods: ['POST'])]
678
    public function runCleanupTempUploads(
679
        Request $request,
680
        TempUploadHelper $tempUploadHelper,
681
    ): Response {
682
        // CSRF
683
        $token = (string) $request->request->get('_token', '');
684
        if (!$this->isCsrfTokenValid('cleanup_temp_uploads', $token)) {
685
            throw $this->createAccessDeniedException('Invalid CSRF token.');
686
        }
687
688
        // Read inputs
689
        $olderThan = (int) $request->request->get('older_than', 0);
690
        $dryRun = (bool) $request->request->get('dry_run', false);
691
692
        // Purge temp uploads/cache (configurable dir via helper parameter)
693
        $purge = $tempUploadHelper->purge(olderThanMinutes: $olderThan, dryRun: $dryRun);
694
695
        if ($dryRun) {
696
            $this->addFlash('success', \sprintf(
697
                'DRY RUN: %d files (%.2f MB) would be removed from %s.',
698
                $purge['files'],
699
                $purge['bytes'] / 1048576,
700
                $tempUploadHelper->getTempDir()
701
            ));
702
        } else {
703
            $this->addFlash('success', \sprintf(
704
                'Temporary uploads/cache cleaned: %d files removed (%.2f MB) in %s.',
705
                $purge['files'],
706
                $purge['bytes'] / 1048576,
707
                $tempUploadHelper->getTempDir()
708
            ));
709
        }
710
711
        // Remove legacy build main.js and hashed variants
712
        $publicBuild = $this->getParameter('kernel.project_dir').'/public/build';
713
        if (is_dir($publicBuild) && is_readable($publicBuild)) {
714
            @unlink($publicBuild.'/main.js');
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). 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

714
            /** @scrutinizer ignore-unhandled */ @unlink($publicBuild.'/main.js');

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...
715
            $files = @scandir($publicBuild) ?: [];
716
            foreach ($files as $f) {
717
                if (preg_match('/^main\..*\.js$/', $f)) {
718
                    @unlink($publicBuild.'/'.$f);
719
                }
720
            }
721
        }
722
723
        // Rebuild styles/assets like original archive_cleanup.php
724
        try {
725
            ScriptHandler::dumpCssFiles();
726
            $this->addFlash('success', 'The styles and assets in the web/ folder have been refreshed.');
727
        } catch (Throwable $e) {
728
            $this->addFlash('error', 'The styles and assets could not be refreshed. Ensure public/ is writable.');
729
            error_log($e->getMessage());
730
        }
731
732
        return $this->redirectToRoute('admin_cleanup_temp_uploads', [], Response::HTTP_SEE_OTHER);
733
    }
734
735
    #[IsGranted('ROLE_ADMIN')]
736
    #[Route('/email_tester', name: 'admin_email_tester')]
737
    public function testEmail(
738
        Request $request,
739
        MailHelper $mailHelper,
740
        TranslatorInterface $translator,
741
        SettingsManager $settingsManager,
742
        UserHelper $userHelper,
743
    ): Response {
744
        $errorsInfo = MessageManager::failedSentMailErrors();
745
746
        $form = $this->createForm(TestEmailType::class);
747
        $form->handleRequest($request);
748
749
        if ($form->isSubmitted() && $form->isValid()) {
750
            $data = $form->getData();
751
752
            $mailHelper->send(
753
                $translator->trans('User testing of e-mail configuration'),
754
                $data['destination'],
755
                $data['subject'],
756
                $data['content'],
757
                UserManager::formatUserFullName($userHelper->getCurrent()),
758
                $settingsManager->getSetting('mail.mailer_from_email')
759
            );
760
761
            $this->addFlash(
762
                'success',
763
                $translator->trans('E-mail sent. This procedure works in all aspects similarly to the normal e-mail sending of Chamilo, but allows for more flexibility in terms of destination e-mail and message body.')
764
            );
765
766
            return $this->redirectToRoute('admin_email_tester');
767
        }
768
769
        return $this->render(
770
            '@ChamiloCore/Admin/email_tester.html.twig',
771
            [
772
                'mailer_dsn' => $settingsManager->getSetting('mail.mailer_dsn'),
773
                'form' => $form,
774
                'errors' => $errorsInfo,
775
            ]
776
        );
777
    }
778
779
    /**
780
     * Create a visible CDocument in a course from an existing ResourceFile.
781
     */
782
    private function createVisibleDocumentFromResourceFile(
783
        ResourceFile $resourceFile,
784
        Course $course,
785
        EntityManagerInterface $em
786
    ): CDocument {
787
        $userEntity = $this->userHelper->getCurrent();
788
        if (null === $userEntity) {
789
            throw new RuntimeException('Current user is required to create or reuse a document.');
790
        }
791
792
        // Current node (may be null for truly orphan files).
793
        $resourceNode = $resourceFile->getResourceNode();
794
795
        if (null === $resourceNode) {
796
            $courseRootNode = $course->getResourceNode();
797
            if (null === $courseRootNode) {
798
                throw new RuntimeException('Course root node is required to attach a resource node.');
799
            }
800
801
            // Create the node that will be shared by all courses.
802
            $resourceNode = new ResourceNode();
803
            $resourceNode
804
                ->setCreator($userEntity)
805
                ->setTitle(
806
                    $resourceFile->getOriginalName()
807
                    ?? $resourceFile->getTitle()
808
                    ?? (string) $resourceFile->getId()
809
                )
810
                ->setParent($courseRootNode)
811
                ->setResourceType(
812
                    $this->resourceNodeRepository->getResourceTypeForClass(CDocument::class)
813
                )
814
            ;
815
816
            // Link file <-> node so getFirstResourceFile() returns this file.
817
            $resourceNode->addResourceFile($resourceFile);
818
            $resourceFile->setResourceNode($resourceNode);
819
820
            $em->persist($resourceNode);
821
            $em->persist($resourceFile);
822
        }
823
824
        $documentRepo = Container::getDocumentRepository();
825
826
        /** @var CDocument|null $document */
827
        $document = $documentRepo->findOneBy([
828
            'resourceNode' => $resourceNode,
829
        ]);
830
831
        if (null === $document) {
832
            $title = $resourceFile->getOriginalName()
833
                ?? $resourceFile->getTitle()
834
                ?? (string) $resourceFile->getId();
835
836
            $document = (new CDocument())
837
                ->setFiletype('file')
838
                ->setTitle($title)
839
                ->setComment(null)
840
                ->setReadonly(false)
841
                ->setTemplate(false)
842
                ->setCreator($userEntity)
843
                // First course becomes the logical owner. The node is shared.
844
                ->setParent($course)
845
                ->setResourceNode($resourceNode)
846
            ;
847
848
            $em->persist($document);
849
        }
850
851
        // IMPORTANT: Always ensure the file is linked to the document's node,
852
        // even if it already had a resource node.
853
        $documentNode = $document->getResourceNode();
854
        if (null !== $documentNode && $resourceFile->getResourceNode() !== $documentNode) {
855
            // Move the file to the shared document node used by the document.
856
            $documentNode->addResourceFile($resourceFile);
857
            $resourceFile->setResourceNode($documentNode);
858
            $em->persist($resourceFile);
859
        }
860
861
        // Do NOT create course links or flush here: this is handled by the caller.
862
        return $document;
863
    }
864
865
    /**
866
     * Returns a map key => [user names...] depending on the selected resource type.
867
     *
868
     * @param array<string,array{cid:int,sid:int}> $keysMeta
869
     *
870
     * @return array<string,string[]>
871
     */
872
    private function fetchUsersForType(?string $typeTitle, EntityManagerInterface $em, array $keysMeta): array
873
    {
874
        $type = \is_string($typeTitle) ? strtolower($typeTitle) : '';
875
876
        return match ($type) {
877
            'dropbox' => $this->fetchDropboxRecipients($em, $keysMeta),
878
            // 'student_publications' => $this->fetchStudentPublicationsUsers($em, $keysMeta), // TODO
879
            default => $this->fetchUsersFromResourceLinks($em, $keysMeta),
880
        };
881
    }
882
883
    /**
884
     * Default behavior: list users tied to ResourceLink.user (user-scoped visibility).
885
     *
886
     * @param array<string,array{cid:int,sid:int}> $keysMeta
887
     *
888
     * @return array<string,string[]>
889
     */
890
    private function fetchUsersFromResourceLinks(EntityManagerInterface $em, array $keysMeta): array
891
    {
892
        if (!$keysMeta) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $keysMeta of type array<string,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...
893
            return [];
894
        }
895
896
        // Load resource links having a user and group them by (cid,sid)
897
        $q = $em->createQuery(
898
            'SELECT rl, c, s, u
899
           FROM Chamilo\CoreBundle\Entity\ResourceLink rl
900
           LEFT JOIN rl.course c
901
           LEFT JOIN rl.session s
902
           LEFT JOIN rl.user u
903
          WHERE rl.user IS NOT NULL'
904
        );
905
906
        /** @var ResourceLink[] $links */
907
        $links = $q->getResult();
908
909
        $out = [];
910
        foreach ($links as $rl) {
911
            $cid = $rl->getCourse()?->getId();
912
            if (!$cid) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $cid of type integer|null is loosely compared to false; this is ambiguous if the integer can be 0. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
913
                continue;
914
            }
915
            $sid = $rl->getSession()?->getId() ?? 0;
916
            $key = self::makeKey($cid, $sid);
917
            if (!isset($keysMeta[$key])) {
918
                continue; // ignore links not present in the current table
919
            }
920
921
            $name = $rl->getUser()?->getFullName();
922
            if ($name) {
923
                $out[$key][] = $name;
924
            }
925
        }
926
        // Dedupe
927
        foreach ($out as $k => $arr) {
928
            $out[$k] = array_values(array_unique(array_filter($arr)));
929
        }
930
931
        return $out;
932
    }
933
934
    /**
935
     * Dropbox-specific: list real recipients from c_dropbox_person (joined with c_dropbox_file and user).
936
     *
937
     * @param array<string,array{cid:int,sid:int}> $keysMeta
938
     *
939
     * @return array<string,string[]>
940
     */
941
    private function fetchDropboxRecipients(EntityManagerInterface $em, array $keysMeta): array
942
    {
943
        if (!$keysMeta) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $keysMeta of type array<string,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...
944
            return [];
945
        }
946
947
        $cids = array_values(array_unique(array_map(static fn ($m) => (int) $m['cid'], $keysMeta)));
948
        if (!$cids) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $cids 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...
949
            return [];
950
        }
951
952
        $conn = $em->getConnection();
953
        $sql = "SELECT
954
                    p.c_id                       AS cid,
955
                    f.session_id                 AS sid,
956
                    CONCAT(u.firstname, ' ', u.lastname) AS uname
957
                FROM c_dropbox_person p
958
                INNER JOIN c_dropbox_file f
959
                        ON f.iid = p.file_id
960
                       AND f.c_id = p.c_id
961
                INNER JOIN `user` u
962
                        ON u.id = p.user_id
963
                WHERE p.c_id IN (:cids)
964
        ";
965
966
        $rows = $conn->executeQuery($sql, ['cids' => $cids], ['cids' => Connection::PARAM_INT_ARRAY])->fetchAllAssociative();
967
968
        $out = [];
969
        foreach ($rows as $r) {
970
            $cid = (int) ($r['cid'] ?? 0);
971
            $sid = (int) ($r['sid'] ?? 0);
972
            $key = self::makeKey($cid, $sid);
973
            if (!isset($keysMeta[$key])) {
974
                continue; // ignore entries not displayed in the table
975
            }
976
            $uname = trim((string) ($r['uname'] ?? ''));
977
            if ('' !== $uname) {
978
                $out[$key][] = $uname;
979
            }
980
        }
981
        // Dedupe
982
        foreach ($out as $k => $arr) {
983
            $out[$k] = array_values(array_unique(array_filter($arr)));
984
        }
985
986
        return $out;
987
    }
988
989
    /**
990
     * Helper to build the aggregation key for course/session rows.
991
     */
992
    private static function makeKey(int $cid, int $sid): string
993
    {
994
        return $sid > 0 ? ('s'.$sid.'-'.$cid) : ('c'.$cid);
995
    }
996
}
997