Passed
Push — master ( 4e1a3d...f9a448 )
by
unknown
18:52 queued 09:08
created

ResourceController::changeVisibility()   B

Complexity

Conditions 11
Paths 84

Size

Total Lines 66
Code Lines 39

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 11
eloc 39
nc 84
nop 4
dl 0
loc 66
rs 7.3166
c 0
b 0
f 0

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;
8
9
use Chamilo\CoreBundle\Entity\Course;
10
use Chamilo\CoreBundle\Entity\ResourceFile;
11
use Chamilo\CoreBundle\Entity\ResourceLink;
12
use Chamilo\CoreBundle\Entity\ResourceNode;
13
use Chamilo\CoreBundle\Entity\Session;
14
use Chamilo\CoreBundle\Entity\User;
15
use Chamilo\CoreBundle\Helpers\ResourceFileHelper;
16
use Chamilo\CoreBundle\Helpers\UserHelper;
17
use Chamilo\CoreBundle\Repository\ResourceFileRepository;
18
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
19
use Chamilo\CoreBundle\Repository\ResourceWithLinkInterface;
20
use Chamilo\CoreBundle\Repository\TrackEDownloadsRepository;
21
use Chamilo\CoreBundle\Security\Authorization\Voter\ResourceNodeVoter;
22
use Chamilo\CoreBundle\Tool\ToolChain;
23
use Chamilo\CoreBundle\Traits\ControllerTrait;
24
use Chamilo\CoreBundle\Traits\CourseControllerTrait;
25
use Chamilo\CoreBundle\Traits\GradebookControllerTrait;
26
use Chamilo\CoreBundle\Traits\ResourceControllerTrait;
27
use Chamilo\CourseBundle\Controller\CourseControllerInterface;
28
use Chamilo\CourseBundle\Entity\CTool;
29
use Chamilo\CourseBundle\Repository\CLinkRepository;
30
use Chamilo\CourseBundle\Repository\CShortcutRepository;
31
use Chamilo\CourseBundle\Repository\CToolRepository;
32
use Doctrine\Common\Collections\ArrayCollection;
33
use Doctrine\Common\Collections\Criteria;
34
use Doctrine\ORM\EntityManagerInterface;
35
use Symfony\Bundle\SecurityBundle\Security;
36
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
37
use Symfony\Component\HttpFoundation\JsonResponse;
38
use Symfony\Component\HttpFoundation\RedirectResponse;
39
use Symfony\Component\HttpFoundation\Request;
40
use Symfony\Component\HttpFoundation\Response;
41
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
42
use Symfony\Component\HttpFoundation\StreamedResponse;
43
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
44
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
45
use Symfony\Component\Routing\Attribute\Route;
46
use Symfony\Component\Routing\RouterInterface;
47
use Symfony\Component\Serializer\SerializerInterface;
48
use ZipStream\Option\Archive;
49
use ZipStream\ZipStream;
50
51
use const PHP_EOL;
52
53
/**
54
 * @author Julio Montoya <[email protected]>.
55
 */
56
#[Route('/r')]
57
class ResourceController extends AbstractResourceController implements CourseControllerInterface
58
{
59
    use ControllerTrait;
60
    use CourseControllerTrait;
61
    use GradebookControllerTrait;
62
    use ResourceControllerTrait;
63
64
    public function __construct(
65
        private readonly UserHelper $userHelper,
66
        private readonly ResourceNodeRepository $resourceNodeRepository,
67
        private readonly ResourceFileRepository $resourceFileRepository
68
    ) {}
69
70
    #[Route(path: '/{tool}/{type}/{id}/disk_space', methods: ['GET', 'POST'], name: 'chamilo_core_resource_disk_space')]
71
    public function diskSpace(Request $request): Response
72
    {
73
        $nodeId = $request->get('id');
74
        $repository = $this->getRepositoryFromRequest($request);
75
76
        /** @var ResourceNode $resourceNode */
77
        $resourceNode = $repository->getResourceNodeRepository()->find($nodeId);
78
79
        $this->denyAccessUnlessGranted(
80
            ResourceNodeVoter::VIEW,
81
            $resourceNode,
82
            $this->trans('Unauthorised access to resource')
83
        );
84
85
        $course = $this->getCourse();
86
        $totalSize = 0;
87
        if (null !== $course) {
88
            $totalSize = $course->getDiskQuota();
89
        }
90
91
        $size = $repository->getResourceNodeRepository()->getSize(
92
            $resourceNode,
93
            $repository->getResourceType(),
94
            $course
95
        );
96
97
        $labels[] = $course->getTitle();
0 ignored issues
show
Comprehensibility Best Practice introduced by
$labels was never initialized. Although not strictly required by PHP, it is generally a good practice to add $labels = array(); before regardless.
Loading history...
98
        $data[] = $size;
0 ignored issues
show
Comprehensibility Best Practice introduced by
$data was never initialized. Although not strictly required by PHP, it is generally a good practice to add $data = array(); before regardless.
Loading history...
99
        $sessions = $course->getSessions();
100
101
        foreach ($sessions as $sessionRelCourse) {
102
            $session = $sessionRelCourse->getSession();
103
104
            $labels[] = $course->getTitle().' - '.$session->getTitle();
105
            $size = $repository->getResourceNodeRepository()->getSize(
106
                $resourceNode,
107
                $repository->getResourceType(),
108
                $course,
109
                $session
110
            );
111
            $data[] = $size;
112
        }
113
114
        /*$groups = $course->getGroups();
115
        foreach ($groups as $group) {
116
            $labels[] = $course->getTitle().' - '.$group->getTitle();
117
            $size = $repository->getResourceNodeRepository()->getSize(
118
                $resourceNode,
119
                $repository->getResourceType(),
120
                $course,
121
                null,
122
                $group
123
            );
124
            $data[] = $size;
125
        }*/
126
127
        $used = array_sum($data);
128
        $labels[] = $this->trans('Free space on disk');
129
        $data[] = $totalSize - $used;
130
131
        return $this->render(
132
            '@ChamiloCore/Resource/disk_space.html.twig',
133
            [
134
                'resourceNode' => $resourceNode,
135
                'labels' => $labels,
136
                'data' => $data,
137
            ]
138
        );
139
    }
140
141
    /**
142
     * View file of a resource node.
143
     */
144
    #[Route('/{tool}/{type}/{id}/view', name: 'chamilo_core_resource_view', methods: ['GET'])]
145
    public function view(
146
        Request $request,
147
        TrackEDownloadsRepository $trackEDownloadsRepository,
148
        ResourceFileHelper $resourceFileHelper,
149
    ): Response {
150
        $id = $request->get('id');
151
        $resourceFileId = $request->get('resourceFileId');
152
        $filter = (string) $request->get('filter');
153
        $resourceNode = $this->getResourceNodeRepository()->findOneBy(['uuid' => $id]);
154
155
        if (null === $resourceNode) {
156
            throw new FileNotFoundException($this->trans('Resource not found'));
157
        }
158
159
        $resourceFile = null;
160
        if ($resourceFileId) {
161
            $resourceFile = $this->resourceFileRepository->find($resourceFileId);
162
        }
163
164
        $resourceFile ??= $resourceFileHelper->resolveResourceFileByAccessUrl($resourceNode);
165
166
        if (!$resourceFile) {
167
            throw new FileNotFoundException($this->trans('Resource file not found for the given resource node'));
168
        }
169
170
        $user = $this->userHelper->getCurrent();
171
        $firstResourceLink = $resourceNode->getResourceLinks()->first();
172
        if ($firstResourceLink && $user) {
173
            $url = $resourceFile->getOriginalName();
174
            $trackEDownloadsRepository->saveDownload($user, $firstResourceLink, $url);
175
        }
176
177
        $cid = (int) $request->query->get('cid');
178
        $sid = (int) $request->query->get('sid');
179
        $allUserInfo = null;
180
        if ($cid && $user) {
181
            $allUserInfo = $this->getAllInfoToCertificate(
182
                $user->getId(),
183
                $cid,
184
                $sid,
185
                false
186
            );
187
        }
188
189
        return $this->processFile($request, $resourceNode, $resourceFile, 'show', $filter, $allUserInfo);
190
    }
191
192
    /**
193
     * Redirect resource to link.
194
     *
195
     * @return RedirectResponse|void
196
     */
197
    #[Route('/{tool}/{type}/{id}/link', name: 'chamilo_core_resource_link', methods: ['GET'])]
198
    public function link(Request $request, RouterInterface $router, CLinkRepository $cLinkRepository): RedirectResponse
199
    {
200
        $tool = $request->get('tool');
201
        $type = $request->get('type');
202
        $id = $request->get('id');
203
        $resourceNode = $this->getResourceNodeRepository()->find($id);
204
205
        if (null === $resourceNode) {
206
            throw new FileNotFoundException('Resource not found');
207
        }
208
209
        if ('course_tool' === $tool && 'links' === $type) {
210
            $cLink = $cLinkRepository->findOneBy(['resourceNode' => $resourceNode]);
211
            if ($cLink) {
212
                $url = $cLink->getUrl();
213
214
                return $this->redirect($url);
215
            }
216
217
            throw new FileNotFoundException('CLink not found for the given resource node');
218
        } else {
219
            $repo = $this->getRepositoryFromRequest($request);
220
            if ($repo instanceof ResourceWithLinkInterface) {
221
                $resource = $repo->getResourceFromResourceNode($resourceNode->getId());
222
                $url = $repo->getLink($resource, $router, $this->getCourseUrlQueryToArray());
223
224
                return $this->redirect($url);
225
            }
226
227
            $this->abort('No redirect');
0 ignored issues
show
Bug Best Practice introduced by
In this branch, the function will implicitly return null which is incompatible with the type-hinted return Symfony\Component\HttpFoundation\RedirectResponse. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
228
        }
229
    }
230
231
    /**
232
     * Download file of a resource node.
233
     */
234
    #[Route('/{tool}/{type}/{id}/download', name: 'chamilo_core_resource_download', methods: ['GET'])]
235
    public function download(
236
        Request $request,
237
        TrackEDownloadsRepository $trackEDownloadsRepository,
238
        ResourceFileHelper $resourceFileHelper,
239
        ResourceNodeRepository $resourceNodeRepository,
240
    ): Response {
241
        $id = $request->get('id');
242
        $resourceNode = $this->getResourceNodeRepository()->findOneBy(['uuid' => $id]);
243
244
        if (null === $resourceNode) {
245
            throw new FileNotFoundException($this->trans('Resource not found'));
246
        }
247
248
        $repo = $this->getRepositoryFromRequest($request);
249
250
        $this->denyAccessUnlessGranted(
251
            ResourceNodeVoter::VIEW,
252
            $resourceNode,
253
            $this->trans('Unauthorised access to resource')
254
        );
255
256
        $resourceFile = $resourceFileHelper->resolveResourceFileByAccessUrl($resourceNode);
257
258
        // If resource node has a file just download it. Don't download the children.
259
        if ($resourceFile) {
260
            $user = $this->userHelper->getCurrent();
261
            $firstResourceLink = $resourceNode->getResourceLinks()->first();
262
263
            if ($firstResourceLink && $user) {
264
                $url = $resourceFile->getOriginalName();
265
                $trackEDownloadsRepository->saveDownload($user, $firstResourceLink, $url);
266
            }
267
268
            // Redirect to download single file.
269
            return $this->processFile($request, $resourceNode, $resourceFile, 'download', '', null);
270
        }
271
272
        $zipName = $resourceNode->getSlug().'.zip';
273
        $resourceNodeRepo = $repo->getResourceNodeRepository();
274
        $type = $repo->getResourceType();
275
276
        $criteria = Criteria::create()
277
            ->where(Criteria::expr()->neq('resourceFiles', null)) // must have a file
278
            ->andWhere(Criteria::expr()->eq('resourceType', $type)) // only download same type
279
        ;
280
281
        $qb = $resourceNodeRepo->getChildrenQueryBuilder($resourceNode);
282
        $qbAlias = $qb->getRootAliases()[0];
283
284
        $qb
285
            ->leftJoin(\sprintf('%s.resourceFiles', $qbAlias), 'resourceFiles') // must have a file
286
            ->addCriteria($criteria)
287
        ;
288
289
        /** @var ArrayCollection|ResourceNode[] $children */
290
        $children = $qb->getQuery()->getResult();
291
        $count = \count($children);
292
        if (0 === $count) {
293
            $params = $this->getResourceParams($request);
294
            $params['id'] = $id;
295
296
            $this->addFlash('warning', $this->trans('No files'));
297
298
            return $this->redirectToRoute('chamilo_core_resource_list', $params);
299
        }
300
301
        $response = new StreamedResponse(
302
            function () use ($zipName, $children, $resourceFileHelper, $resourceNodeRepository): void {
303
                // Define suitable options for ZipStream Archive.
304
                $options = new Archive();
305
                $options->setContentType('application/octet-stream');
306
                // initialise zipstream with output zip filename and options.
307
                $zip = new ZipStream($zipName, $options);
308
309
                /** @var ResourceNode $node */
310
                foreach ($children as $node) {
311
                    $resourceFile = $resourceFileHelper->resolveResourceFileByAccessUrl($node);
312
313
                    if ($resourceFile) {
314
                        $stream = $resourceNodeRepository->getResourceNodeFileStream($node, $resourceFile);
315
                        $fileName = $resourceFile->getOriginalName();
316
                        $zip->addFileFromStream($fileName, $stream);
317
                    }
318
                }
319
                $zip->finish();
320
            }
321
        );
322
323
        // Convert the file name to ASCII using iconv
324
        $zipName = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $zipName);
325
326
        $disposition = $response->headers->makeDisposition(
327
            ResponseHeaderBag::DISPOSITION_ATTACHMENT,
328
            $zipName // Transliterator::transliterate($zipName)
329
        );
330
        $response->headers->set('Content-Disposition', $disposition);
331
        $response->headers->set('Content-Type', 'application/octet-stream');
332
333
        return $response;
334
    }
335
336
    #[Route('/{tool}/{type}/{id}/change_visibility', name: 'chamilo_core_resource_change_visibility', methods: ['POST'])]
337
    public function changeVisibility(
338
        Request $request,
339
        EntityManagerInterface $entityManager,
340
        SerializerInterface $serializer,
341
        Security $security,
342
    ): Response {
343
        /** @var User $user */
344
        $user = $security->getUser();
345
        $isAdmin = ($user->isSuperAdmin() || $user->isAdmin());
346
        $isCourseTeacher = ($user->hasRole('ROLE_CURRENT_COURSE_TEACHER') || $user->hasRole('ROLE_CURRENT_COURSE_SESSION_TEACHER'));
347
348
        if (!($isCourseTeacher || $isAdmin)) {
349
            throw new AccessDeniedHttpException();
350
        }
351
352
        $session = null;
353
        if ($this->getSession()) {
354
            $sessionId = $this->getSession()->getId();
355
            $session = $entityManager->getRepository(Session::class)->find($sessionId);
356
        }
357
        $courseId = $this->getCourse()->getId();
358
        $course = $entityManager->getRepository(Course::class)->find($courseId);
359
        $id = $request->attributes->getInt('id');
360
        $resourceNode = $this->getResourceNodeRepository()->findOneBy(['id' => $id]);
361
362
        if (null === $resourceNode) {
363
            throw new NotFoundHttpException($this->trans('Resource not found'));
364
        }
365
366
        $link = null;
367
        foreach ($resourceNode->getResourceLinks() as $resourceLink) {
368
            if ($resourceLink->getSession() === $session) {
369
                $link = $resourceLink;
370
371
                break;
372
            }
373
        }
374
375
        if (null === $link) {
376
            $link = new ResourceLink();
377
            $link->setResourceNode($resourceNode)
378
                ->setSession($session)
379
                ->setCourse($course)
380
                ->setVisibility(ResourceLink::VISIBILITY_DRAFT)
381
            ;
382
            $entityManager->persist($link);
383
        } else {
384
            if (ResourceLink::VISIBILITY_PUBLISHED === $link->getVisibility()) {
385
                $link->setVisibility(ResourceLink::VISIBILITY_DRAFT);
386
            } else {
387
                $link->setVisibility(ResourceLink::VISIBILITY_PUBLISHED);
388
            }
389
        }
390
391
        $entityManager->flush();
392
393
        $json = $serializer->serialize(
394
            $link,
395
            'json',
396
            [
397
                'groups' => ['ctool:read'],
398
            ]
399
        );
400
401
        return JsonResponse::fromJsonString($json);
402
    }
403
404
    #[Route(
405
        '/{tool}/{type}/change_visibility/{visibility}',
406
        name: 'chamilo_core_resource_change_visibility_all',
407
        methods: ['POST']
408
    )]
409
    public function changeVisibilityAll(
410
        Request $request,
411
        CToolRepository $toolRepository,
412
        CShortcutRepository $shortcutRepository,
413
        ToolChain $toolChain,
414
        EntityManagerInterface $entityManager,
415
        Security $security
416
    ): Response {
417
        /** @var User $user */
418
        $user = $security->getUser();
419
        $isAdmin = ($user->isSuperAdmin() || $user->isAdmin());
420
        $isCourseTeacher = ($user->hasRole('ROLE_CURRENT_COURSE_TEACHER') || $user->hasRole('ROLE_CURRENT_COURSE_SESSION_TEACHER'));
421
422
        if (!($isCourseTeacher || $isAdmin)) {
423
            throw new AccessDeniedHttpException();
424
        }
425
426
        $visibility = $request->attributes->get('visibility');
427
428
        $session = null;
429
        if ($this->getSession()) {
430
            $sessionId = $this->getSession()->getId();
431
            $session = $entityManager->getRepository(Session::class)->find($sessionId);
432
        }
433
        $courseId = $this->getCourse()->getId();
434
        $course = $entityManager->getRepository(Course::class)->find($courseId);
435
436
        $result = $toolRepository->getResourcesByCourse($course, $session)
437
            ->addSelect('tool')
438
            ->innerJoin('resource.tool', 'tool')
439
            ->getQuery()
440
            ->getResult()
441
        ;
442
443
        $skipTools = ['course_tool',
444
            // 'chat',
445
            // 'notebook',
446
            // 'wiki'
447
        ];
448
449
        /** @var CTool $item */
450
        foreach ($result as $item) {
451
            if (\in_array($item->getTitle(), $skipTools, true)) {
452
                continue;
453
            }
454
            $toolModel = $toolChain->getToolFromName($item->getTool()->getTitle());
455
456
            if (!\in_array($toolModel->getCategory(), ['authoring', 'interaction'], true)) {
0 ignored issues
show
Bug introduced by
The method getCategory() does not exist on Chamilo\CoreBundle\Tool\AbstractTool. It seems like you code against a sub-type of said class. However, the method does not exist in Chamilo\CoreBundle\Tool\AbstractCourseTool. Are you sure you never get one of those? ( Ignorable by Annotation )

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

456
            if (!\in_array($toolModel->/** @scrutinizer ignore-call */ getCategory(), ['authoring', 'interaction'], true)) {
Loading history...
457
                continue;
458
            }
459
460
            $resourceNode = $item->getResourceNode();
461
462
            /** @var ResourceLink $link */
463
            $link = null;
464
            foreach ($resourceNode->getResourceLinks() as $resourceLink) {
465
                if ($resourceLink->getSession() === $session) {
466
                    $link = $resourceLink;
467
468
                    break;
469
                }
470
            }
471
472
            if (null === $link) {
473
                $link = new ResourceLink();
474
                $link->setResourceNode($resourceNode)
475
                    ->setSession($session)
476
                    ->setCourse($course)
477
                    ->setVisibility(ResourceLink::VISIBILITY_DRAFT)
478
                ;
479
                $entityManager->persist($link);
480
            }
481
482
            if ('show' === $visibility) {
483
                $link->setVisibility(ResourceLink::VISIBILITY_PUBLISHED);
484
            } elseif ('hide' === $visibility) {
485
                $link->setVisibility(ResourceLink::VISIBILITY_DRAFT);
486
            }
487
        }
488
489
        $entityManager->flush();
490
491
        return new Response(null, Response::HTTP_NO_CONTENT);
492
    }
493
494
    #[Route('/resource_files/{resourceNodeId}/variants', name: 'chamilo_core_resource_files_variants', methods: ['GET'])]
495
    public function getVariants(string $resourceNodeId, EntityManagerInterface $em): JsonResponse
496
    {
497
        $variants = $em->getRepository(ResourceFile::class)->createQueryBuilder('rf')
498
            ->join('rf.resourceNode', 'rn')
499
            ->leftJoin('rn.creator', 'creator')
500
            ->where('rf.resourceNode = :resourceNodeId')
501
            ->andWhere('rf.accessUrl IS NOT NULL')
502
            ->setParameter('resourceNodeId', $resourceNodeId)
503
            ->getQuery()
504
            ->getResult()
505
        ;
506
507
        $data = [];
508
509
        /** @var ResourceFile $variant */
510
        foreach ($variants as $variant) {
511
            $data[] = [
512
                'id' => $variant->getId(),
513
                'title' => $variant->getOriginalName(),
514
                'mimeType' => $variant->getMimeType(),
515
                'size' => $variant->getSize(),
516
                'updatedAt' => $variant->getUpdatedAt()->format('Y-m-d H:i:s'),
517
                'url' => $variant->getAccessUrl() ? $variant->getAccessUrl()->getUrl() : null,
518
                'path' => $this->resourceNodeRepository->getResourceFileUrl($variant->getResourceNode(), [], null, $variant),
519
                'creator' => $variant->getResourceNode()->getCreator() ? $variant->getResourceNode()->getCreator()->getFullName() : 'Unknown',
520
            ];
521
        }
522
523
        return $this->json($data);
524
    }
525
526
    #[Route('/resource_files/{id}/delete_variant', methods: ['DELETE'], name: 'chamilo_core_resource_files_delete_variant')]
527
    public function deleteVariant(int $id, EntityManagerInterface $em): JsonResponse
528
    {
529
        $variant = $em->getRepository(ResourceFile::class)->find($id);
530
        if (!$variant) {
531
            return $this->json(['error' => 'Variant not found'], Response::HTTP_NOT_FOUND);
532
        }
533
534
        $em->remove($variant);
535
        $em->flush();
536
537
        return $this->json(['success' => true]);
538
    }
539
540
    private function processFile(Request $request, ResourceNode $resourceNode, ResourceFile $resourceFile, string $mode = 'show', string $filter = '', ?array $allUserInfo = null): mixed
541
    {
542
        $this->denyAccessUnlessGranted(
543
            ResourceNodeVoter::VIEW,
544
            $resourceNode,
545
            $this->trans('Unauthorised view access to resource')
546
        );
547
548
        $fileName = $resourceFile->getOriginalName();
549
        $fileSize = $resourceFile->getSize();
550
        $mimeType = $resourceFile->getMimeType() ?: '';
551
        [$start, $end, $length] = $this->getRange($request, $fileSize);
552
        $resourceNodeRepo = $this->getResourceNodeRepository();
553
554
        // Convert the file name to ASCII using iconv
555
        $fileName = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $fileName);
556
557
        // MIME normalization for HTML
558
        $looksLikeHtmlByExt = (bool) preg_match('/\.x?html?$/i', (string) $fileName);
559
        if ('' === $mimeType || false === stripos($mimeType, 'html')) {
560
            if ($looksLikeHtmlByExt) {
561
                $mimeType = 'text/html; charset=UTF-8';
562
            }
563
        }
564
565
        switch ($mode) {
566
            case 'download':
567
                $forceDownload = true;
568
569
                break;
570
571
            case 'show':
572
            default:
573
                $forceDownload = false;
574
                // If it's an image then send it to Glide.
575
                if (str_contains($mimeType, 'image')) {
576
                    $glide = $this->getGlide();
577
                    $server = $glide->getServer();
578
                    $params = $request->query->all();
579
580
                    // The filter overwrites the params from GET.
581
                    if (!empty($filter)) {
582
                        $params = $glide->getFilters()[$filter] ?? [];
583
                    }
584
585
                    // The image was cropped manually by the user, so we force to render this version,
586
                    // no matter other crop parameters.
587
                    $crop = $resourceFile->getCrop();
588
                    if (!empty($crop)) {
589
                        $params['crop'] = $crop;
590
                    }
591
592
                    $filePath = $resourceNodeRepo->getFilename($resourceFile);
593
594
                    $response = $server->getImageResponse($filePath, $params);
595
596
                    $disposition = $response->headers->makeDisposition(
597
                        ResponseHeaderBag::DISPOSITION_INLINE,
598
                        $fileName
599
                    );
600
                    $response->headers->set('Content-Disposition', $disposition);
601
602
                    return $response;
603
                }
604
605
                // Modify the HTML content before displaying it.
606
                if (str_contains($mimeType, 'html')) {
607
                    $content = $resourceNodeRepo->getResourceNodeFileContent($resourceNode, $resourceFile);
608
609
                    if (null !== $allUserInfo) {
610
                        $tagsToReplace = $allUserInfo[0];
611
                        $replacementValues = $allUserInfo[1];
612
                        $content = str_replace($tagsToReplace, $replacementValues, $content);
613
                    }
614
615
                    $content = $this->injectGlossaryJs($request, $content, $resourceNode);
616
617
                    $response = new Response();
618
                    $disposition = $response->headers->makeDisposition(
619
                        ResponseHeaderBag::DISPOSITION_INLINE,
620
                        $fileName
621
                    );
622
                    $response->headers->set('Content-Disposition', $disposition);
623
                    $response->headers->set('Content-Type', 'text/html; charset=UTF-8');
624
625
                    // Existing translate_html logic
626
                    if ('true' === $this->getSettingsManager()->getSetting('editor.translate_html')) {
627
                        $user = $this->userHelper->getCurrent();
628
                        if (null !== $user) {
629
                            // Overwrite user_json, otherwise it will be loaded by the TwigListener.php
630
                            $userJson = json_encode(['locale' => $user->getLocale()]);
631
                            $js = $this->renderView(
632
                                '@ChamiloCore/Layout/document.html.twig',
633
                                ['breadcrumb' => '', 'user_json' => $userJson]
634
                            );
635
                            // Insert inside the head tag.
636
                            $content = str_replace('</head>', $js.'</head>', $content);
637
                        }
638
                    }
639
                    $response->setContent($content);
640
641
                    return $response;
642
                }
643
644
                break;
645
        }
646
647
        $response = new StreamedResponse(
648
            function () use ($resourceNodeRepo, $resourceFile, $start, $length): void {
649
                $stream = $resourceNodeRepo->getResourceNodeFileStream(
650
                    $resourceFile->getResourceNode(),
651
                    $resourceFile
652
                );
653
654
                $this->echoBuffer($stream, $start, $length);
655
            }
656
        );
657
658
        $disposition = $response->headers->makeDisposition(
659
            $forceDownload ? ResponseHeaderBag::DISPOSITION_ATTACHMENT : ResponseHeaderBag::DISPOSITION_INLINE,
660
            $fileName
661
        );
662
663
        $response->headers->set('Content-Disposition', $disposition);
664
        $response->headers->set('Content-Type', $mimeType ?: 'application/octet-stream');
665
        $response->headers->set('Content-Length', (string) $length);
666
        $response->headers->set('Accept-Ranges', 'bytes');
667
        $response->headers->set('Content-Range', "bytes $start-$end/$fileSize");
668
        $response->setStatusCode(
669
            $start > 0 || $end < $fileSize - 1 ? Response::HTTP_PARTIAL_CONTENT : Response::HTTP_OK
670
        );
671
672
        return $response;
673
    }
674
675
    private function injectGlossaryJs(
676
        Request $request,
677
        string $content,
678
        ?ResourceNode $resourceNode = null
679
    ): string {
680
        // First normalize broken HTML coming from templates/editors
681
        $content = $this->normalizeGeneratedHtml($content);
682
683
        $tool = (string) $request->attributes->get('tool');
684
        if ('document' !== $tool) {
685
            return $content;
686
        }
687
688
        // Global kill switch (applies to all tools/contexts)
689
        $settingsManager = $this->getSettingsManager();
690
        $modeRaw = (string) $settingsManager->getSetting('glossary.show_glossary_in_extra_tools', true);
691
        $mode = strtolower(trim($modeRaw));
692
693
        if (in_array($mode, ['', 'none', 'false', '0'], true)) {
694
            return $content;
695
        }
696
697
        // Detect when this document is being displayed from a Learning Path context
698
        $origin = strtolower(trim((string) $request->query->get('origin', '')));
699
        $isLpContext =
700
            $request->query->has('lp_id')
701
            || $request->query->has('learnpath_id')
702
            || $request->query->has('learnpath_item_id')
703
            || ('learnpath' === $origin);
704
705
        // Only inject for LP when the mode allows it
706
        if ($isLpContext) {
707
            if (!in_array($mode, ['true', 'lp', 'exercise_and_lp'], true)) {
708
                return $content;
709
            }
710
        } else {
711
            // Do not inject in the standalone Documents tool (prevents unwanted highlights outside LP/exercises)
712
            return $content;
713
        }
714
715
        $course = $this->getCourse();
716
        $session = $this->getSession();
717
718
        $resourceNodeParentId = null;
719
        if ($resourceNode && $resourceNode->getParent()) {
720
            $resourceNodeParentId = $resourceNode->getParent()->getId();
721
        }
722
723
        $jsConfig = $this->renderView(
724
            '@ChamiloCore/Glossary/glossary_auto.html.twig',
725
            [
726
                'course' => $course,
727
                'session' => $session,
728
                'resourceNodeParentId' => $resourceNodeParentId,
729
            ]
730
        );
731
732
        if (false !== stripos($content, '</body>')) {
733
            return str_ireplace('</body>', $jsConfig.'</body>', $content);
0 ignored issues
show
Bug Best Practice introduced by
The expression return str_ireplace('</b... . '</body>', $content) could return the type array which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
734
        }
735
736
        return $content.$jsConfig;
737
    }
738
739
    /**
740
     * Normalize generated HTML documents coming from templates/editors.
741
     *
742
     * This method tries to fix the pattern:
743
     * <head>...</head><!DOCTYPE html><html>...<head>...</head><body>...</body></html>
744
     *
745
     * It will:
746
     * - Extract the first <head>...</head> block (if it appears before <!DOCTYPE).
747
     * - Keep the proper <!DOCTYPE html><html>... document.
748
     * - Inject the inner content of the first head into the main <head> of the document.
749
     */
750
    private function normalizeGeneratedHtml(string $content): string
751
    {
752
        $upper = strtoupper($content);
753
754
        $firstHeadStart = stripos($upper, '<HEAD');
755
        $firstHeadEnd = stripos($upper, '</HEAD>');
756
        $doctypePos = stripos($upper, '<!DOCTYPE');
757
        $htmlPos = stripos($upper, '<HTML');
758
759
        // If we do not have the pattern <head>...</head> before <!DOCTYPE html>, do nothing.
760
        if (false === $firstHeadStart || false === $firstHeadEnd || false === $doctypePos) {
761
            return $content;
762
        }
763
764
        if (!($firstHeadStart <= $firstHeadEnd && $firstHeadEnd < $doctypePos)) {
765
            // The first <head> is not clearly before the <!DOCTYPE>, keep content as-is.
766
            return $content;
767
        }
768
769
        // Extract the first <head>...</head> block (including tags).
770
        $headBlockLength = $firstHeadEnd + \strlen('</head>') - $firstHeadStart;
771
        $headBlock = substr($content, $firstHeadStart, $headBlockLength);
772
773
        // Remove that first <head> block from the beginning part.
774
        // Everything from <!DOCTYPE ...> will be treated as the "real" document.
775
        $baseDoc = substr($content, $doctypePos);
776
777
        // Extract only the inner content of the first head (we do not want nested <head> tags).
778
        $innerHead = preg_replace('~^.*?<head[^>]*>|</head>.*$~is', '', $headBlock);
779
        if (null === $innerHead) {
780
            // preg_replace error or something weird, bail out and keep original content.
781
            return $content;
782
        }
783
        $innerHead = trim($innerHead);
784
785
        // If there is nothing interesting inside the first head, just return the base document.
786
        if ('' === $innerHead) {
787
            return $baseDoc;
788
        }
789
790
        // Now inject innerHead into the main <head> of the base document.
791
        $upperBase = strtoupper($baseDoc);
792
        $secondHeadStart = stripos($upperBase, '<HEAD');
793
        if (false === $secondHeadStart) {
794
            // No <head> in the base document, return base doc unchanged.
795
            return $baseDoc;
796
        }
797
798
        $secondHeadTagEnd = strpos($baseDoc, '>', $secondHeadStart);
799
        if (false === $secondHeadTagEnd) {
800
            // Malformed head tag, do not touch.
801
            return $baseDoc;
802
        }
803
804
        $insertionPos = $secondHeadTagEnd + 1;
805
806
        return substr($baseDoc, 0, $insertionPos)
807
            .PHP_EOL.$innerHead.PHP_EOL
808
            .substr($baseDoc, $insertionPos);
809
    }
810
}
811