Passed
Push — master ( 9cd2a6...a3e4f3 )
by
unknown
17:17 queued 08:22
created

AdminController::fetchDropboxRecipients()   B

Complexity

Conditions 7
Paths 10

Size

Total Lines 46
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

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

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

689
            /** @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...
690
            $files = @scandir($publicBuild) ?: [];
691
            foreach ($files as $f) {
692
                if (preg_match('/^main\..*\.js$/', $f)) {
693
                    @unlink($publicBuild.'/'.$f);
694
                }
695
            }
696
        }
697
698
        // Rebuild styles/assets like original archive_cleanup.php
699
        try {
700
            ScriptHandler::dumpCssFiles();
701
            $this->addFlash('success', 'The styles and assets in the web/ folder have been refreshed.');
702
        } catch (Throwable $e) {
703
            $this->addFlash('error', 'The styles and assets could not be refreshed. Ensure public/ is writable.');
704
            error_log($e->getMessage());
705
        }
706
707
        return $this->redirectToRoute('admin_cleanup_temp_uploads', [], Response::HTTP_SEE_OTHER);
708
    }
709
710
    /**
711
     * Create a visible CDocument in a course from an existing ResourceFile.
712
     */
713
    private function createVisibleDocumentFromResourceFile(
714
        ResourceFile $resourceFile,
715
        Course $course,
716
        EntityManagerInterface $em
717
    ): void {
718
        $userEntity = $this->userHelper->getCurrent();
719
        if (null === $userEntity) {
720
            return;
721
        }
722
723
        $session = $this->cidReqHelper->getDoctrineSessionEntity();
724
        $group = null;
725
726
        $documentRepo = Container::getDocumentRepository();
727
728
        $parentResource = $course;
729
        $parentNode = $parentResource->getResourceNode();
730
731
        $title = $resourceFile->getOriginalName()
732
            ?? $resourceFile->getTitle()
733
            ?? (string) $resourceFile->getId();
734
735
        $existingDocument = $documentRepo->findCourseResourceByTitle(
736
            $title,
737
            $parentNode,
738
            $course,
739
            $session,
740
            $group
741
        );
742
743
        if (null !== $existingDocument) {
744
            // Document already exists for this title in this course context.
745
            return;
746
        }
747
748
        $document = (new CDocument())
749
            ->setFiletype('file')
750
            ->setTitle($title)
751
            ->setComment(null)
752
            ->setReadonly(false)
753
            ->setCreator($userEntity)
754
            ->setParent($parentResource)
755
            ->addCourseLink($course, $session, $group)
756
        ;
757
758
        $em->persist($document);
759
        $em->flush();
760
761
        // Physical file path in var/upload/resource (hashed filename)
762
        $relativePath = $this->resourceNodeRepository->getFilename($resourceFile);
763
        $storageRoot = $this->getParameter('kernel.project_dir').'/var/upload/resource';
764
        $absolutePath = $storageRoot.$relativePath;
765
766
        if (!is_file($absolutePath)) {
767
            // If the physical file is missing, we cannot create the document content.
768
            return;
769
        }
770
771
        // This will copy the file into the course documents structure,
772
        // using $title as the base name (Document repository handles dedup and hashing).
773
        $documentRepo->addFileFromPath($document, $title, $absolutePath);
774
    }
775
776
    /**
777
     * Returns a map key => [user names...] depending on the selected resource type.
778
     *
779
     * @param array<string,array{cid:int,sid:int}> $keysMeta
780
     *
781
     * @return array<string,string[]>
782
     */
783
    private function fetchUsersForType(?string $typeTitle, EntityManagerInterface $em, array $keysMeta): array
784
    {
785
        $type = \is_string($typeTitle) ? strtolower($typeTitle) : '';
786
787
        return match ($type) {
788
            'dropbox' => $this->fetchDropboxRecipients($em, $keysMeta),
789
            // 'student_publications' => $this->fetchStudentPublicationsUsers($em, $keysMeta), // TODO
790
            default => $this->fetchUsersFromResourceLinks($em, $keysMeta),
791
        };
792
    }
793
794
    /**
795
     * Default behavior: list users tied to ResourceLink.user (user-scoped visibility).
796
     *
797
     * @param array<string,array{cid:int,sid:int}> $keysMeta
798
     *
799
     * @return array<string,string[]>
800
     */
801
    private function fetchUsersFromResourceLinks(EntityManagerInterface $em, array $keysMeta): array
802
    {
803
        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...
804
            return [];
805
        }
806
807
        // Load resource links having a user and group them by (cid,sid)
808
        $q = $em->createQuery(
809
            'SELECT rl, c, s, u
810
           FROM Chamilo\CoreBundle\Entity\ResourceLink rl
811
           LEFT JOIN rl.course c
812
           LEFT JOIN rl.session s
813
           LEFT JOIN rl.user u
814
          WHERE rl.user IS NOT NULL'
815
        );
816
817
        /** @var ResourceLink[] $links */
818
        $links = $q->getResult();
819
820
        $out = [];
821
        foreach ($links as $rl) {
822
            $cid = $rl->getCourse()?->getId();
823
            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...
824
                continue;
825
            }
826
            $sid = $rl->getSession()?->getId() ?? 0;
827
            $key = self::makeKey($cid, $sid);
828
            if (!isset($keysMeta[$key])) {
829
                continue; // ignore links not present in the current table
830
            }
831
832
            $name = $rl->getUser()?->getFullName();
833
            if ($name) {
834
                $out[$key][] = $name;
835
            }
836
        }
837
        // Dedupe
838
        foreach ($out as $k => $arr) {
839
            $out[$k] = array_values(array_unique(array_filter($arr)));
840
        }
841
842
        return $out;
843
    }
844
845
    /**
846
     * Dropbox-specific: list real recipients from c_dropbox_person (joined with c_dropbox_file and user).
847
     *
848
     * @param array<string,array{cid:int,sid:int}> $keysMeta
849
     *
850
     * @return array<string,string[]>
851
     */
852
    private function fetchDropboxRecipients(EntityManagerInterface $em, array $keysMeta): array
853
    {
854
        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...
855
            return [];
856
        }
857
858
        $cids = array_values(array_unique(array_map(static fn ($m) => (int) $m['cid'], $keysMeta)));
859
        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...
860
            return [];
861
        }
862
863
        $conn = $em->getConnection();
864
        $sql = "SELECT
865
                    p.c_id                       AS cid,
866
                    f.session_id                 AS sid,
867
                    CONCAT(u.firstname, ' ', u.lastname) AS uname
868
                FROM c_dropbox_person p
869
                INNER JOIN c_dropbox_file f
870
                        ON f.iid = p.file_id
871
                       AND f.c_id = p.c_id
872
                INNER JOIN `user` u
873
                        ON u.id = p.user_id
874
                WHERE p.c_id IN (:cids)
875
        ";
876
877
        $rows = $conn->executeQuery($sql, ['cids' => $cids], ['cids' => Connection::PARAM_INT_ARRAY])->fetchAllAssociative();
878
879
        $out = [];
880
        foreach ($rows as $r) {
881
            $cid = (int) ($r['cid'] ?? 0);
882
            $sid = (int) ($r['sid'] ?? 0);
883
            $key = self::makeKey($cid, $sid);
884
            if (!isset($keysMeta[$key])) {
885
                continue; // ignore entries not displayed in the table
886
            }
887
            $uname = trim((string) ($r['uname'] ?? ''));
888
            if ('' !== $uname) {
889
                $out[$key][] = $uname;
890
            }
891
        }
892
        // Dedupe
893
        foreach ($out as $k => $arr) {
894
            $out[$k] = array_values(array_unique(array_filter($arr)));
895
        }
896
897
        return $out;
898
    }
899
900
    /**
901
     * Helper to build the aggregation key for course/session rows.
902
     */
903
    private static function makeKey(int $cid, int $sid): string
904
    {
905
        return $sid > 0 ? ('s'.$sid.'-'.$cid) : ('c'.$cid);
906
    }
907
}
908