Passed
Push — master ( 76cb70...440cff )
by Angel Fernando Quiroz
10:57
created

ResourceController::view()   B

Complexity

Conditions 8
Paths 11

Size

Total Lines 46
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 28
c 0
b 0
f 0
nc 11
nop 3
dl 0
loc 46
rs 8.4444
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\AccessUrlHelper;
16
use Chamilo\CoreBundle\Helpers\ResourceFileHelper;
17
use Chamilo\CoreBundle\Helpers\UserHelper;
18
use Chamilo\CoreBundle\Repository\ResourceFileRepository;
19
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
20
use Chamilo\CoreBundle\Repository\ResourceWithLinkInterface;
21
use Chamilo\CoreBundle\Repository\TrackEDownloadsRepository;
22
use Chamilo\CoreBundle\Security\Authorization\Voter\ResourceNodeVoter;
23
use Chamilo\CoreBundle\Settings\SettingsManager;
24
use Chamilo\CoreBundle\Tool\ToolChain;
25
use Chamilo\CoreBundle\Traits\ControllerTrait;
26
use Chamilo\CoreBundle\Traits\CourseControllerTrait;
27
use Chamilo\CoreBundle\Traits\GradebookControllerTrait;
28
use Chamilo\CoreBundle\Traits\ResourceControllerTrait;
29
use Chamilo\CourseBundle\Controller\CourseControllerInterface;
30
use Chamilo\CourseBundle\Entity\CTool;
31
use Chamilo\CourseBundle\Repository\CLinkRepository;
32
use Chamilo\CourseBundle\Repository\CShortcutRepository;
33
use Chamilo\CourseBundle\Repository\CToolRepository;
34
use Doctrine\Common\Collections\ArrayCollection;
35
use Doctrine\Common\Collections\Criteria;
36
use Doctrine\ORM\EntityManagerInterface;
37
use Symfony\Bundle\SecurityBundle\Security;
38
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
39
use Symfony\Component\HttpFoundation\JsonResponse;
40
use Symfony\Component\HttpFoundation\RedirectResponse;
41
use Symfony\Component\HttpFoundation\Request;
42
use Symfony\Component\HttpFoundation\Response;
43
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
44
use Symfony\Component\HttpFoundation\StreamedResponse;
45
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
46
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
47
use Symfony\Component\Routing\Attribute\Route;
48
use Symfony\Component\Routing\RouterInterface;
49
use Symfony\Component\Serializer\SerializerInterface;
50
use ZipStream\Option\Archive;
51
use ZipStream\ZipStream;
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, 'show', $filter, $allUserInfo, $resourceFile);
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
    ): Response {
240
        $id = $request->get('id');
241
        $resourceNode = $this->getResourceNodeRepository()->findOneBy(['uuid' => $id]);
242
243
        if (null === $resourceNode) {
244
            throw new FileNotFoundException($this->trans('Resource not found'));
245
        }
246
247
        $repo = $this->getRepositoryFromRequest($request);
248
249
        $this->denyAccessUnlessGranted(
250
            ResourceNodeVoter::VIEW,
251
            $resourceNode,
252
            $this->trans('Unauthorised access to resource')
253
        );
254
255
        $resourceFile = $resourceFileHelper->resolveResourceFileByAccessUrl($resourceNode);
256
257
        // If resource node has a file just download it. Don't download the children.
258
        if ($resourceFile) {
259
            $user = $this->userHelper->getCurrent();
260
            $firstResourceLink = $resourceNode->getResourceLinks()->first();
261
262
            if ($firstResourceLink && $user) {
263
                $url = $resourceFile->getOriginalName();
264
                $trackEDownloadsRepository->saveDownload($user, $firstResourceLink, $url);
265
            }
266
267
            // Redirect to download single file.
268
            return $this->processFile($request, $resourceNode, 'download', '', null, $resourceFile);
269
        }
270
271
        $zipName = $resourceNode->getSlug().'.zip';
272
        $resourceNodeRepo = $repo->getResourceNodeRepository();
273
        $type = $repo->getResourceType();
274
275
        $criteria = Criteria::create()
276
            ->where(Criteria::expr()->neq('resourceFiles', null)) // must have a file
277
            ->andWhere(Criteria::expr()->eq('resourceType', $type)) // only download same type
278
        ;
279
280
        $qb = $resourceNodeRepo->getChildrenQueryBuilder($resourceNode);
281
        $qbAlias = $qb->getRootAliases()[0];
282
283
        $qb
284
            ->leftJoin(\sprintf('%s.resourceFiles', $qbAlias), 'resourceFiles') // must have a file
285
            ->addCriteria($criteria)
286
        ;
287
288
        /** @var ArrayCollection|ResourceNode[] $children */
289
        $children = $qb->getQuery()->getResult();
290
        $count = \count($children);
291
        if (0 === $count) {
292
            $params = $this->getResourceParams($request);
293
            $params['id'] = $id;
294
295
            $this->addFlash('warning', $this->trans('No files'));
296
297
            return $this->redirectToRoute('chamilo_core_resource_list', $params);
298
        }
299
300
        $response = new StreamedResponse(
301
            function () use ($zipName, $children, $repo): void {
302
                // Define suitable options for ZipStream Archive.
303
                $options = new Archive();
304
                $options->setContentType('application/octet-stream');
305
                // initialise zipstream with output zip filename and options.
306
                $zip = new ZipStream($zipName, $options);
307
308
                /** @var ResourceNode $node */
309
                foreach ($children as $node) {
310
                    $resourceFiles = $node->getResourceFiles();
311
                    $resourceFile = $resourceFiles->filter(fn ($file) => null === $file->getAccessUrl())->first();
312
313
                    if ($resourceFile) {
314
                        $stream = $repo->getResourceNodeFileStream($node);
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, string $mode = 'show', string $filter = '', ?array $allUserInfo = null, ?ResourceFile $resourceFile = null): mixed
541
    {
542
        $this->denyAccessUnlessGranted(
543
            ResourceNodeVoter::VIEW,
544
            $resourceNode,
545
            $this->trans('Unauthorised view access to resource')
546
        );
547
548
        $resourceFile ??= $resourceNode->getResourceFiles()->first();
549
550
        if (!$resourceFile) {
551
            throw $this->createNotFoundException($this->trans('File not found for resource'));
552
        }
553
554
        $fileName = $resourceFile->getOriginalName();
555
        $fileSize = $resourceFile->getSize();
556
        $mimeType = $resourceFile->getMimeType() ?: '';
557
        [$start, $end, $length] = $this->getRange($request, $fileSize);
558
        $resourceNodeRepo = $this->getResourceNodeRepository();
559
560
        // Convert the file name to ASCII using iconv
561
        $fileName = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $fileName);
562
563
        // MIME normalization for HTML
564
        $looksLikeHtmlByExt = (bool) preg_match('/\.x?html?$/i', (string) $fileName);
565
        if ('' === $mimeType || false === stripos($mimeType, 'html')) {
566
            if ($looksLikeHtmlByExt) {
567
                $mimeType = 'text/html; charset=UTF-8';
568
            }
569
        }
570
571
        switch ($mode) {
572
            case 'download':
573
                $forceDownload = true;
574
575
                break;
576
577
            case 'show':
578
            default:
579
                $forceDownload = false;
580
                // If it's an image then send it to Glide.
581
                if (str_contains($mimeType, 'image')) {
582
                    $glide = $this->getGlide();
583
                    $server = $glide->getServer();
584
                    $params = $request->query->all();
585
586
                    // The filter overwrites the params from GET.
587
                    if (!empty($filter)) {
588
                        $params = $glide->getFilters()[$filter] ?? [];
589
                    }
590
591
                    // The image was cropped manually by the user, so we force to render this version,
592
                    // no matter other crop parameters.
593
                    $crop = $resourceFile->getCrop();
594
                    if (!empty($crop)) {
595
                        $params['crop'] = $crop;
596
                    }
597
598
                    $filePath = $resourceNodeRepo->getFilename($resourceFile);
599
600
                    $response = $server->getImageResponse($filePath, $params);
601
602
                    $disposition = $response->headers->makeDisposition(
603
                        ResponseHeaderBag::DISPOSITION_INLINE,
604
                        $fileName
605
                    );
606
                    $response->headers->set('Content-Disposition', $disposition);
607
608
                    return $response;
609
                }
610
611
                // Modify the HTML content before displaying it.
612
                if (str_contains($mimeType, 'html')) {
613
                    $content = $resourceNodeRepo->getResourceNodeFileContent($resourceNode, $resourceFile);
614
615
                    if (null !== $allUserInfo) {
616
                        $tagsToReplace = $allUserInfo[0];
617
                        $replacementValues = $allUserInfo[1];
618
                        $content = str_replace($tagsToReplace, $replacementValues, $content);
619
                    }
620
621
                    $content = $this->injectGlossaryJs($request, $content, $resourceNode);
622
623
                    $response = new Response();
624
                    $disposition = $response->headers->makeDisposition(
625
                        ResponseHeaderBag::DISPOSITION_INLINE,
626
                        $fileName
627
                    );
628
                    $response->headers->set('Content-Disposition', $disposition);
629
                    $response->headers->set('Content-Type', 'text/html; charset=UTF-8');
630
631
                    // Existing translate_html logic
632
                    if ('true' === $this->getSettingsManager()->getSetting('editor.translate_html')) {
633
                        $user = $this->userHelper->getCurrent();
634
                        if (null !== $user) {
635
                            // Overwrite user_json, otherwise it will be loaded by the TwigListener.php
636
                            $userJson = json_encode(['locale' => $user->getLocale()]);
637
                            $js = $this->renderView(
638
                                '@ChamiloCore/Layout/document.html.twig',
639
                                ['breadcrumb' => '', 'user_json' => $userJson]
640
                            );
641
                            // Insert inside the head tag.
642
                            $content = str_replace('</head>', $js.'</head>', $content);
643
                        }
644
                    }
645
                    $response->setContent($content);
646
647
                    return $response;
648
                }
649
650
                break;
651
        }
652
653
        $response = new StreamedResponse(
654
            function () use ($resourceNodeRepo, $resourceFile, $start, $length): void {
655
                $stream = $resourceNodeRepo->getResourceNodeFileStream(
656
                    $resourceFile->getResourceNode(),
657
                    $resourceFile
658
                );
659
660
                $this->echoBuffer($stream, $start, $length);
661
            }
662
        );
663
664
        $this->setHeadersToStreamedResponse(
665
            $response,
666
            $forceDownload,
667
            $fileName,
668
            $mimeType ?: 'application/octet-stream',
669
            $length,
670
            $start,
671
            $end,
672
            $fileSize
673
        );
674
675
        return $response;
676
    }
677
678
    private function injectGlossaryJs(
679
        Request $request,
680
        string $content,
681
        ?ResourceNode $resourceNode = null
682
    ): string {
683
        // First normalize broken HTML coming from templates/editors
684
        $content = $this->normalizeGeneratedHtml($content);
685
686
        $tool = (string) $request->attributes->get('tool');
687
688
        if ('document' !== $tool) {
689
            return $content;
690
        }
691
692
        $course = $this->getCourse();
693
        $session = $this->getSession();
694
695
        $resourceNodeParentId = null;
696
        if ($resourceNode && $resourceNode->getParent()) {
697
            $resourceNodeParentId = $resourceNode->getParent()->getId();
698
        }
699
700
        $jsConfig = $this->renderView(
701
            '@ChamiloCore/Glossary/glossary_auto.html.twig',
702
            [
703
                'course' => $course,
704
                'session' => $session,
705
                'resourceNodeParentId' => $resourceNodeParentId,
706
            ]
707
        );
708
709
        if (false !== stripos($content, '</body>')) {
710
            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...
711
        }
712
713
        return $content.$jsConfig;
714
    }
715
716
717
    /**
718
     * Normalize generated HTML documents coming from templates/editors.
719
     *
720
     * This method tries to fix the pattern:
721
     * <head>...</head><!DOCTYPE html><html>...<head>...</head><body>...</body></html>
722
     *
723
     * It will:
724
     * - Extract the first <head>...</head> block (if it appears before <!DOCTYPE).
725
     * - Keep the proper <!DOCTYPE html><html>... document.
726
     * - Inject the inner content of the first head into the main <head> of the document.
727
     */
728
    private function normalizeGeneratedHtml(string $content): string
729
    {
730
        $upper = strtoupper($content);
731
732
        $firstHeadStart = stripos($upper, '<HEAD');
733
        $firstHeadEnd   = stripos($upper, '</HEAD>');
734
        $doctypePos     = stripos($upper, '<!DOCTYPE');
735
        $htmlPos        = stripos($upper, '<HTML');
736
737
        // If we do not have the pattern <head>...</head> before <!DOCTYPE html>, do nothing.
738
        if (false === $firstHeadStart || false === $firstHeadEnd || false === $doctypePos) {
739
            return $content;
740
        }
741
742
        if (!($firstHeadStart <= $firstHeadEnd && $firstHeadEnd < $doctypePos)) {
743
            // The first <head> is not clearly before the <!DOCTYPE>, keep content as-is.
744
            return $content;
745
        }
746
747
        // Extract the first <head>...</head> block (including tags).
748
        $headBlockLength = $firstHeadEnd + strlen('</head>') - $firstHeadStart;
749
        $headBlock       = substr($content, $firstHeadStart, $headBlockLength);
750
751
        // Remove that first <head> block from the beginning part.
752
        // Everything from <!DOCTYPE ...> will be treated as the "real" document.
753
        $baseDoc = substr($content, $doctypePos);
754
755
        // Extract only the inner content of the first head (we do not want nested <head> tags).
756
        $innerHead = preg_replace('~^.*?<head[^>]*>|</head>.*$~is', '', $headBlock);
757
        if (null === $innerHead) {
758
            // preg_replace error or something weird, bail out and keep original content.
759
            return $content;
760
        }
761
        $innerHead = trim($innerHead);
762
763
        // If there is nothing interesting inside the first head, just return the base document.
764
        if ('' === $innerHead) {
765
            return $baseDoc;
766
        }
767
768
        // Now inject innerHead into the main <head> of the base document.
769
        $upperBase = strtoupper($baseDoc);
770
        $secondHeadStart = stripos($upperBase, '<HEAD');
771
        if (false === $secondHeadStart) {
772
            // No <head> in the base document, return base doc unchanged.
773
            return $baseDoc;
774
        }
775
776
        $secondHeadTagEnd = strpos($baseDoc, '>', $secondHeadStart);
777
        if (false === $secondHeadTagEnd) {
778
            // Malformed head tag, do not touch.
779
            return $baseDoc;
780
        }
781
782
        $insertionPos = $secondHeadTagEnd + 1;
783
784
        $normalized =
785
            substr($baseDoc, 0, $insertionPos)
786
            .PHP_EOL.$innerHead.PHP_EOL
787
            .substr($baseDoc, $insertionPos);
788
789
        return $normalized;
790
    }
791
}
792