Passed
Push — master ( b85dcb...675fac )
by
unknown
10:14
created

AdminController::attachOrphanFileToCourse()   C

Complexity

Conditions 11
Paths 16

Size

Total Lines 109
Code Lines 62

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 11
eloc 62
c 0
b 0
f 0
nc 16
nop 4
dl 0
loc 109
rs 6.6824

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\Helpers\AccessUrlHelper;
16
use Chamilo\CoreBundle\Helpers\QueryCacheHelper;
17
use Chamilo\CoreBundle\Helpers\TempUploadHelper;
18
use Chamilo\CoreBundle\Repository\Node\CourseRepository;
19
use Chamilo\CoreBundle\Repository\Node\UserRepository;
20
use Chamilo\CoreBundle\Repository\ResourceFileRepository;
21
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
22
use Chamilo\CoreBundle\Settings\SettingsManager;
23
use Doctrine\DBAL\Connection;
24
use Doctrine\ORM\EntityManagerInterface;
25
use Symfony\Component\HttpFoundation\JsonResponse;
26
use Symfony\Component\HttpFoundation\Request;
27
use Symfony\Component\HttpFoundation\Response;
28
use Symfony\Component\Routing\Annotation\Route;
29
use Symfony\Component\Security\Http\Attribute\IsGranted;
30
use Throwable;
31
32
#[Route('/admin')]
33
class AdminController extends BaseController
34
{
35
    private const ITEMS_PER_PAGE = 50;
36
37
    public function __construct(
38
        private readonly ResourceNodeRepository $resourceNodeRepository,
39
        private readonly AccessUrlHelper $accessUrlHelper
40
    ) {}
41
42
    #[IsGranted('ROLE_ADMIN')]
43
    #[Route('/register-campus', name: 'admin_register_campus', methods: ['POST'])]
44
    public function registerCampus(Request $request, SettingsManager $settingsManager): Response
45
    {
46
        $requestData = $request->toArray();
47
        $doNotListCampus = (bool) $requestData['donotlistcampus'];
48
49
        $settingsManager->setUrl($this->accessUrlHelper->getCurrent());
50
        $settingsManager->updateSetting('platform.registered', 'true');
51
52
        $settingsManager->updateSetting(
53
            'platform.donotlistcampus',
54
            $doNotListCampus ? 'true' : 'false'
55
        );
56
57
        return new Response('', Response::HTTP_NO_CONTENT);
58
    }
59
60
    #[IsGranted('ROLE_ADMIN')]
61
    #[Route('/files_info', name: 'admin_files_info', methods: ['GET'])]
62
    public function listFilesInfo(Request $request, ResourceFileRepository $resourceFileRepository): Response
63
    {
64
        $page = $request->query->getInt('page', 1);
65
        $search = $request->query->get('search', '');
66
        $offset = ($page - 1) * self::ITEMS_PER_PAGE;
67
68
        $files = $resourceFileRepository->searchFiles($search, $offset, self::ITEMS_PER_PAGE);
69
        $totalItems = $resourceFileRepository->countFiles($search);
70
        $totalPages = $totalItems > 0 ? (int) ceil($totalItems / self::ITEMS_PER_PAGE) : 1;
71
72
        $fileUrls = [];
73
        $filePaths = [];
74
        $orphanFlags = [];
75
        $linksCount = [];
76
77
        foreach ($files as $file) {
78
            $resourceNode = $file->getResourceNode();
79
            $count = 0;
80
81
            if ($resourceNode) {
82
                $fileUrls[$file->getId()] = $this->resourceNodeRepository->getResourceFileUrl($resourceNode);
83
84
                // Count how many ResourceLinks still point to this node
85
                $links = $resourceNode->getResourceLinks();
86
                $count = $links ? $links->count() : 0;
87
            } else {
88
                $fileUrls[$file->getId()] = null;
89
            }
90
91
            $filePaths[$file->getId()] = '/upload/resource'.$this->resourceNodeRepository->getFilename($file);
92
93
            $linksCount[$file->getId()] = $count;
94
            $orphanFlags[$file->getId()] = 0 === $count;
95
        }
96
97
        return $this->render('@ChamiloCore/Admin/files_info.html.twig', [
98
            'files' => $files,
99
            'fileUrls' => $fileUrls,
100
            'filePaths' => $filePaths,
101
            'totalPages' => $totalPages,
102
            'currentPage' => $page,
103
            'search' => $search,
104
            'orphanFlags' => $orphanFlags,
105
            'linksCount' => $linksCount,
106
        ]);
107
    }
108
109
    #[IsGranted('ROLE_ADMIN')]
110
    #[Route('/files_info/attach', name: 'admin_files_info_attach', methods: ['POST'])]
111
    public function attachOrphanFileToCourse(
112
        Request $request,
113
        ResourceFileRepository $resourceFileRepository,
114
        CourseRepository $courseRepository,
115
        EntityManagerInterface $em
116
    ): Response {
117
        $token = (string) $request->request->get('_token', '');
118
        if (!$this->isCsrfTokenValid('attach_orphan_file', $token)) {
119
            throw $this->createAccessDeniedException('Invalid CSRF token.');
120
        }
121
122
        $fileId = $request->request->getInt('resource_file_id', 0);
123
        $courseCode = trim((string) $request->request->get('course_code', ''));
124
125
        $page = $request->request->getInt('page', 1);
126
        $search = (string) $request->request->get('search', '');
127
128
        if ($fileId <= 0) {
129
            $this->addFlash('error', 'Missing resource file identifier.');
130
131
            return $this->redirectToRoute('admin_files_info', [
132
                'page' => $page,
133
                'search' => $search,
134
            ]);
135
        }
136
137
        if ('' === $courseCode) {
138
            $this->addFlash('error', 'Please provide a course code.');
139
140
            return $this->redirectToRoute('admin_files_info', [
141
                'page' => $page,
142
                'search' => $search,
143
            ]);
144
        }
145
146
        /** @var ResourceFile|null $resourceFile */
147
        $resourceFile = $resourceFileRepository->find($fileId);
148
        if (!$resourceFile) {
149
            $this->addFlash('error', 'Resource file not found.');
150
151
            return $this->redirectToRoute('admin_files_info', [
152
                'page' => $page,
153
                'search' => $search,
154
            ]);
155
        }
156
157
        $resourceNode = $resourceFile->getResourceNode();
158
        $linksCount = $resourceNode ? $resourceNode->getResourceLinks()->count() : 0;
159
        if ($linksCount > 0) {
160
            // Safety check: this file is not orphan anymore.
161
            $this->addFlash('warning', 'This file is no longer orphan and cannot be attached.');
162
163
            return $this->redirectToRoute('admin_files_info', [
164
                'page' => $page,
165
                'search' => $search,
166
            ]);
167
        }
168
169
        /** @var Course|null $course */
170
        $course = $courseRepository->findOneBy(['code' => $courseCode]);
171
        if (!$course) {
172
            $this->addFlash('error', sprintf('Course with code "%s" was not found.', $courseCode));
173
174
            return $this->redirectToRoute('admin_files_info', [
175
                'page' => $page,
176
                'search' => $search,
177
            ]);
178
        }
179
180
        if (!$resourceNode) {
181
            $this->addFlash('error', 'This resource file has no resource node and cannot be attached.');
182
183
            return $this->redirectToRoute('admin_files_info', [
184
                'page' => $page,
185
                'search' => $search,
186
            ]);
187
        }
188
189
        // re-parent the ResourceNode to the course documents root
190
        if (method_exists($course, 'getResourceNode')) {
191
            $courseRootNode = $course->getResourceNode();
192
193
            if ($courseRootNode) {
194
                $resourceNode->setParent($courseRootNode);
195
            }
196
        }
197
198
        // Create a new ResourceLink so that the file appears in the course context
199
        $link = new ResourceLink();
200
        $link->setResourceNode($resourceNode);
201
        $link->setCourse($course);
202
        $link->setSession(null);
203
        $em->persist($link);
204
        $em->flush();
205
206
        $this->addFlash(
207
            'success',
208
            sprintf(
209
                'File "%s" has been attached to course "%s" (hidden in the documents root).',
210
                (string) ($resourceFile->getOriginalName() ?? $resourceFile->getTitle() ?? $resourceFile->getId()),
211
                (string) $course->getTitle()
212
            )
213
        );
214
215
        return $this->redirectToRoute('admin_files_info', [
216
            'page' => $page,
217
            'search' => $search,
218
        ]);
219
    }
220
221
    #[IsGranted('ROLE_ADMIN')]
222
    #[Route('/files_info/delete', name: 'admin_files_info_delete', methods: ['POST'])]
223
    public function deleteOrphanFile(
224
        Request $request,
225
        ResourceFileRepository $resourceFileRepository,
226
        EntityManagerInterface $em
227
    ): Response {
228
        $token = (string) $request->request->get('_token', '');
229
        if (!$this->isCsrfTokenValid('delete_orphan_file', $token)) {
230
            throw $this->createAccessDeniedException('Invalid CSRF token.');
231
        }
232
233
        $fileId = $request->request->getInt('resource_file_id', 0);
234
        $page = $request->request->getInt('page', 1);
235
        $search = (string) $request->request->get('search', '');
236
237
        if ($fileId <= 0) {
238
            $this->addFlash('error', 'Missing resource file identifier.');
239
240
            return $this->redirectToRoute('admin_files_info', [
241
                'page' => $page,
242
                'search' => $search,
243
            ]);
244
        }
245
246
        $resourceFile = $resourceFileRepository->find($fileId);
247
        if (!$resourceFile) {
248
            $this->addFlash('error', 'Resource file not found.');
249
250
            return $this->redirectToRoute('admin_files_info', [
251
                'page' => $page,
252
                'search' => $search,
253
            ]);
254
        }
255
256
        $resourceNode = $resourceFile->getResourceNode();
257
        $linksCount = $resourceNode ? $resourceNode->getResourceLinks()->count() : 0;
258
        if ($linksCount > 0) {
259
            $this->addFlash('warning', 'This file is still used by at least one course/session and cannot be deleted.');
260
261
            return $this->redirectToRoute('admin_files_info', [
262
                'page' => $page,
263
                'search' => $search,
264
            ]);
265
        }
266
267
        // Compute physical path in var/upload/resource (adapt if you use another directory).
268
        $relativePath = $this->resourceNodeRepository->getFilename($resourceFile);
269
        $storageRoot = $this->getParameter('kernel.project_dir').'/var/upload/resource';
270
        $absolutePath = $storageRoot.$relativePath;
271
272
        if (is_file($absolutePath) && is_writable($absolutePath)) {
273
            @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

273
            /** @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...
274
        }
275
276
        // Optionally remove the resource node as well if it is really orphan.
277
        if ($resourceNode) {
278
            $em->remove($resourceNode);
279
        }
280
281
        $em->remove($resourceFile);
282
        $em->flush();
283
284
        $this->addFlash('success', 'Orphan file and its physical content have been deleted definitively.');
285
286
        return $this->redirectToRoute('admin_files_info', [
287
            'page' => $page,
288
            'search' => $search,
289
        ]);
290
    }
291
292
    #[IsGranted('ROLE_ADMIN')]
293
    #[Route('/resources_info', name: 'admin_resources_info', methods: ['GET'])]
294
    public function listResourcesInfo(
295
        Request $request,
296
        ResourceNodeRepository $resourceNodeRepo,
297
        EntityManagerInterface $em
298
    ): Response {
299
        $resourceTypeId = $request->query->getInt('type');
300
        $resourceTypes = $em->getRepository(ResourceType::class)->findAll();
301
302
        $courses = [];
303
        $showUsers = false;
304
        $typeTitle = null;
305
306
        if ($resourceTypeId > 0) {
307
            /** @var ResourceType|null $rt */
308
            $rt = $em->getRepository(ResourceType::class)->find($resourceTypeId);
309
            $typeTitle = $rt?->getTitle();
310
311
            /** Load ResourceLinks for the selected type */
312
            /** @var ResourceLink[] $resourceLinks */
313
            $resourceLinks = $em->getRepository(ResourceLink::class)->createQueryBuilder('rl')
314
                ->join('rl.resourceNode', 'rn')
315
                ->where('rn.resourceType = :type')
316
                ->setParameter('type', $resourceTypeId)
317
                ->getQuery()
318
                ->getResult()
319
            ;
320
321
            /** Aggregate by course/session key */
322
            $seen = [];
323
            $keysMeta = [];
324
            foreach ($resourceLinks as $link) {
325
                $course = $link->getCourse();
326
                if (!$course) {
327
                    continue;
328
                }
329
                $session = $link->getSession();
330
                $node = $link->getResourceNode();
331
332
                $cid = $course->getId();
333
                $sid = $session?->getId() ?? 0;
334
                $key = self::makeKey($cid, $sid);
335
336
                if (!isset($seen[$key])) {
337
                    $seen[$key] = [
338
                        'type' => $sid ? 'session' : 'course',
339
                        'id' => $sid ?: $cid,
340
                        'courseId' => $cid,
341
                        'sessionId' => $sid,
342
                        'title' => $sid ? ($session->getTitle().' - '.$course->getTitle()) : $course->getTitle(),
343
                        'url' => $sid
344
                            ? '/course/'.$cid.'/home?sid='.$sid
345
                            : '/course/'.$cid.'/home',
346
                        'count' => 0,
347
                        'items' => [],
348
                        'users' => [],
349
                        'firstCreatedAt' => $node->getCreatedAt(),
350
                    ];
351
                    $keysMeta[$key] = ['cid' => $cid, 'sid' => $sid];
352
                }
353
354
                $seen[$key]['count']++;
355
                $seen[$key]['items'][] = $node->getTitle();
356
357
                if ($node->getCreatedAt() < $seen[$key]['firstCreatedAt']) {
358
                    $seen[$key]['firstCreatedAt'] = $node->getCreatedAt();
359
                }
360
            }
361
362
            /* Populate users depending on the resource type */
363
            if (!empty($seen)) {
364
                $usersMap = $this->fetchUsersForType($typeTitle, $em, $keysMeta);
365
                foreach ($usersMap as $key => $names) {
366
                    if (isset($seen[$key]) && $names) {
367
                        $seen[$key]['users'] = array_values(array_unique($names));
368
                    }
369
                }
370
                // Show the "Users" column only if there's any user to display
371
                $showUsers = array_reduce($seen, fn ($acc, $row) => $acc || !empty($row['users']), false);
372
            }
373
374
            /** Normalize output */
375
            $courses = array_values(array_map(function ($row) {
376
                $row['items'] = array_values(array_unique($row['items']));
377
378
                return $row;
379
            }, $seen));
380
381
            usort($courses, fn ($a, $b) => strnatcasecmp($a['title'], $b['title']));
382
        }
383
384
        return $this->render('@ChamiloCore/Admin/resources_info.html.twig', [
385
            'resourceTypes' => $resourceTypes,
386
            'selectedType' => $resourceTypeId,
387
            'courses' => $courses,
388
            'showUsers' => $showUsers,
389
            'typeTitle' => $typeTitle,
390
        ]);
391
    }
392
393
    #[IsGranted('ROLE_ADMIN')]
394
    #[Route('/test-cache-all-users', name: 'chamilo_core_user_test_cache_all_users')]
395
    public function testCacheAllUsers(UserRepository $userRepository): JsonResponse
396
    {
397
        // Without cache
398
        $startNoCache = microtime(true);
399
        $usersNoCache = $userRepository->findAllUsers(false);
400
        $timeNoCache = microtime(true) - $startNoCache;
401
402
        // With cache
403
        $startCache = microtime(true);
404
        $resultCached = $userRepository->findAllUsers(true);
405
        $timeCache = microtime(true) - $startCache;
406
407
        // Check if we have a key (we do if cache was used)
408
        $usersCache = $resultCached['data'] ?? $resultCached;
409
410
        $cacheKey = $resultCached['cache_key'] ?? null;
411
412
        return $this->json([
413
            'without_cache' => [
414
                'count' => \count($usersNoCache),
415
                'execution_time' => $timeNoCache,
416
            ],
417
            'with_cache' => [
418
                'count' => \count($usersCache),
419
                'execution_time' => $timeCache,
420
                'cache_key' => $cacheKey,
421
            ],
422
        ]);
423
    }
424
425
    #[IsGranted('ROLE_ADMIN')]
426
    #[Route(path: '/test-cache-all-users/invalidate', name: 'chamilo_core_user_test_cache_all_users_invalidate')]
427
    public function invalidateCacheAllUsers(QueryCacheHelper $queryCacheHelper): JsonResponse
428
    {
429
        $cacheKey = $queryCacheHelper->getCacheKey('findAllUsers', []);
430
        $queryCacheHelper->invalidate('findAllUsers');
431
432
        return $this->json([
433
            'message' => 'Cache for users invalidated!',
434
            'invalidated_cache_key' => $cacheKey,
435
        ]);
436
    }
437
438
    #[IsGranted('ROLE_ADMIN')]
439
    #[Route('/cleanup-temp-uploads', name: 'admin_cleanup_temp_uploads', methods: ['GET'])]
440
    public function showCleanupTempUploads(
441
        TempUploadHelper $tempUploadHelper,
442
    ): Response {
443
        $stats = $tempUploadHelper->stats(); // ['files' => int, 'bytes' => int]
444
445
        return $this->render('@ChamiloCore/Admin/cleanup_temp_uploads.html.twig', [
446
            'tempDir' => $tempUploadHelper->getTempDir(),
447
            'stats' => $stats,
448
            'defaultOlderThan' => 0, // 0 = delete all
449
        ]);
450
    }
451
452
    #[IsGranted('ROLE_ADMIN')]
453
    #[Route('/cleanup-temp-uploads', name: 'admin_cleanup_temp_uploads_run', methods: ['POST'])]
454
    public function runCleanupTempUploads(
455
        Request $request,
456
        TempUploadHelper $tempUploadHelper,
457
    ): Response {
458
        // CSRF
459
        $token = (string) $request->request->get('_token', '');
460
        if (!$this->isCsrfTokenValid('cleanup_temp_uploads', $token)) {
461
            throw $this->createAccessDeniedException('Invalid CSRF token.');
462
        }
463
464
        // Read inputs
465
        $olderThan = (int) $request->request->get('older_than', 0);
466
        $dryRun = (bool) $request->request->get('dry_run', false);
467
468
        // Purge temp uploads/cache (configurable dir via helper parameter)
469
        $purge = $tempUploadHelper->purge(olderThanMinutes: $olderThan, dryRun: $dryRun);
470
471
        if ($dryRun) {
472
            $this->addFlash('success', \sprintf(
473
                'DRY RUN: %d files (%.2f MB) would be removed from %s.',
474
                $purge['files'],
475
                $purge['bytes'] / 1048576,
476
                $tempUploadHelper->getTempDir()
477
            ));
478
        } else {
479
            $this->addFlash('success', \sprintf(
480
                'Temporary uploads/cache cleaned: %d files removed (%.2f MB) in %s.',
481
                $purge['files'],
482
                $purge['bytes'] / 1048576,
483
                $tempUploadHelper->getTempDir()
484
            ));
485
        }
486
487
        // Remove legacy build main.js and hashed variants (best effort)
488
        $publicBuild = $this->getParameter('kernel.project_dir').'/public/build';
489
        if (is_dir($publicBuild) && is_readable($publicBuild)) {
490
            @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

490
            /** @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...
491
            $files = @scandir($publicBuild) ?: [];
492
            foreach ($files as $f) {
493
                if (preg_match('/^main\..*\.js$/', $f)) {
494
                    @unlink($publicBuild.'/'.$f);
495
                }
496
            }
497
        }
498
499
        // Rebuild styles/assets like original archive_cleanup.php
500
        try {
501
            ScriptHandler::dumpCssFiles();
502
            $this->addFlash('success', 'The styles and assets in the web/ folder have been refreshed.');
503
        } catch (Throwable $e) {
504
            $this->addFlash('error', 'The styles and assets could not be refreshed. Ensure public/ is writable.');
505
            error_log($e->getMessage());
506
        }
507
508
        return $this->redirectToRoute('admin_cleanup_temp_uploads', [], Response::HTTP_SEE_OTHER);
509
    }
510
511
    /**
512
     * Returns a map key => [user names...] depending on the selected resource type.
513
     *
514
     * @param array<string,array{cid:int,sid:int}> $keysMeta
515
     *
516
     * @return array<string,string[]>
517
     */
518
    private function fetchUsersForType(?string $typeTitle, EntityManagerInterface $em, array $keysMeta): array
519
    {
520
        $type = \is_string($typeTitle) ? strtolower($typeTitle) : '';
521
522
        return match ($type) {
523
            'dropbox' => $this->fetchDropboxRecipients($em, $keysMeta),
524
            // 'student_publications' => $this->fetchStudentPublicationsUsers($em, $keysMeta), // TODO
525
            default => $this->fetchUsersFromResourceLinks($em, $keysMeta),
526
        };
527
    }
528
529
    /**
530
     * Default behavior: list users tied to ResourceLink.user (user-scoped visibility).
531
     *
532
     * @param array<string,array{cid:int,sid:int}> $keysMeta
533
     *
534
     * @return array<string,string[]>
535
     */
536
    private function fetchUsersFromResourceLinks(EntityManagerInterface $em, array $keysMeta): array
537
    {
538
        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...
539
            return [];
540
        }
541
542
        // Load resource links having a user and group them by (cid,sid)
543
        $q = $em->createQuery(
544
            'SELECT rl, c, s, u
545
           FROM Chamilo\CoreBundle\Entity\ResourceLink rl
546
           LEFT JOIN rl.course c
547
           LEFT JOIN rl.session s
548
           LEFT JOIN rl.user u
549
          WHERE rl.user IS NOT NULL'
550
        );
551
552
        /** @var ResourceLink[] $links */
553
        $links = $q->getResult();
554
555
        $out = [];
556
        foreach ($links as $rl) {
557
            $cid = $rl->getCourse()?->getId();
558
            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...
559
                continue;
560
            }
561
            $sid = $rl->getSession()?->getId() ?? 0;
562
            $key = self::makeKey($cid, $sid);
563
            if (!isset($keysMeta[$key])) {
564
                continue; // ignore links not present in the current table
565
            }
566
567
            $name = $rl->getUser()?->getFullName();
568
            if ($name) {
569
                $out[$key][] = $name;
570
            }
571
        }
572
        // Dedupe
573
        foreach ($out as $k => $arr) {
574
            $out[$k] = array_values(array_unique(array_filter($arr)));
575
        }
576
577
        return $out;
578
    }
579
580
    /**
581
     * Dropbox-specific: list real recipients from c_dropbox_person (joined with c_dropbox_file and user).
582
     *
583
     * @param array<string,array{cid:int,sid:int}> $keysMeta
584
     *
585
     * @return array<string,string[]>
586
     */
587
    private function fetchDropboxRecipients(EntityManagerInterface $em, array $keysMeta): array
588
    {
589
        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...
590
            return [];
591
        }
592
593
        $cids = array_values(array_unique(array_map(fn ($m) => (int) $m['cid'], $keysMeta)));
594
        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...
595
            return [];
596
        }
597
598
        $conn = $em->getConnection();
599
        $sql = "SELECT
600
                    p.c_id                       AS cid,
601
                    f.session_id                 AS sid,
602
                    CONCAT(u.firstname, ' ', u.lastname) AS uname
603
                FROM c_dropbox_person p
604
                INNER JOIN c_dropbox_file f
605
                        ON f.iid = p.file_id
606
                       AND f.c_id = p.c_id
607
                INNER JOIN `user` u
608
                        ON u.id = p.user_id
609
                WHERE p.c_id IN (:cids)
610
        ";
611
612
        $rows = $conn->executeQuery($sql, ['cids' => $cids], ['cids' => Connection::PARAM_INT_ARRAY])->fetchAllAssociative();
613
614
        $out = [];
615
        foreach ($rows as $r) {
616
            $cid = (int) ($r['cid'] ?? 0);
617
            $sid = (int) ($r['sid'] ?? 0);
618
            $key = self::makeKey($cid, $sid);
619
            if (!isset($keysMeta[$key])) {
620
                continue; // ignore entries not displayed in the table
621
            }
622
            $uname = trim((string) ($r['uname'] ?? ''));
623
            if ('' !== $uname) {
624
                $out[$key][] = $uname;
625
            }
626
        }
627
        // Dedupe
628
        foreach ($out as $k => $arr) {
629
            $out[$k] = array_values(array_unique(array_filter($arr)));
630
        }
631
632
        return $out;
633
    }
634
635
    /**
636
     * Helper to build the aggregation key for course/session rows.
637
     */
638
    private static function makeKey(int $cid, int $sid): string
639
    {
640
        return $sid > 0 ? ('s'.$sid.'-'.$cid) : ('c'.$cid);
641
    }
642
}
643