src/Lms/UserInterface/Rest/Controller/MaterialController.php line 105

Open in your IDE?
  1. <?php
  2. namespace CodersLab\Lms\UserInterface\Rest\Controller;
  3. use CodersLab\Lms\Modules\Courses\Application\Query\NavigationQuery;
  4. use CodersLab\Lms\Modules\IdentityAccess\Application\Command\UpdateLastSeenMaterial;
  5. use CodersLab\Lms\Modules\IdentityAccess\Application\IContentAccessContext;
  6. use CodersLab\Lms\Modules\IdentityAccess\Application\Query\Model\Progress\MaterialProgress;
  7. use CodersLab\Lms\Modules\IdentityAccess\Application\Query\ProgressQuery;
  8. use CodersLab\Lms\Modules\Learning\Application\Query\ResultQuery;
  9. use CodersLab\Lms\Modules\Materials\Application\Command\ReportMaterialIssue;
  10. use CodersLab\Lms\Modules\Materials\Application\Query\CourseQuery;
  11. use CodersLab\Lms\Modules\Materials\Application\Query\MaterialQuery;
  12. use CodersLab\Lms\Modules\Materials\Application\Query\MaterialsSearchQuery;
  13. use CodersLab\Lms\Modules\Materials\Application\Query\Model\MaterialReview as ModelMaterialReview;
  14. use CodersLab\Lms\Modules\Materials\Application\Service\Material\MaterialFactory;
  15. use CodersLab\Lms\Modules\Materials\Application\Service\Material\MaterialInstanceSlug\IMaterialOnInstanceFactory;
  16. use CodersLab\Lms\Modules\Materials\Application\Service\Material\MaterialInstanceSlug\Slug;
  17. use CodersLab\Lms\Modules\Materials\Application\Service\SearchEngine\FilterDecorator;
  18. use CodersLab\Lms\Modules\Materials\Domain\Pill\Access;
  19. use CodersLab\Lms\SharedKernel\Application\IUserRepository;
  20. use CodersLab\Lms\SharedKernel\Application\Response\LmsApiResponse;
  21. use CodersLab\Lms\SharedKernel\Application\Response\MaterialRedirectMetadata;
  22. use CodersLab\Lms\SharedKernel\Application\Response\Metadata;
  23. use CodersLab\Lms\SharedKernel\Application\Response\NavigationMetadata;
  24. use CodersLab\Lms\SharedKernel\Application\SecurityContext;
  25. use CodersLab\Lms\SharedKernel\Common\DTO\MaterialIssue;
  26. use CodersLab\Lms\SharedKernel\Common\Exception\AccessDeniedHttpException;
  27. use CodersLab\Lms\SharedKernel\Common\Exception\NotFoundException;
  28. use CodersLab\Lms\SharedKernel\Domain\Bus\CommandBus;
  29. use CodersLab\Lms\SharedKernel\Domain\Courses\MemberType;
  30. use CodersLab\Lms\SharedKernel\Domain\Identity\Uuid;
  31. use Exception;
  32. use Nelmio\ApiDocBundle\Annotation\Model;
  33. use OpenApi\Annotations as OA;
  34. use Symfony\Component\HttpFoundation\BinaryFileResponse;
  35. use Symfony\Component\HttpFoundation\Request;
  36. use Symfony\Component\HttpFoundation\Response;
  37. use Symfony\Component\HttpFoundation\ResponseHeaderBag;
  38. use Symfony\Component\Routing\Annotation\Route;
  39. use Symfony\Contracts\Cache\ItemInterface;
  40. use Symfony\Contracts\Cache\TagAwareCacheInterface;
  41. /**
  42.  * @OA\Tag(name="Material")
  43.  */
  44. final class MaterialController
  45. {
  46.     public const REQUEST_CACHE_PDF_TAG 'pdf.request_cache';
  47.     public function __construct(
  48.         private SecurityContext $security,
  49.         private IContentAccessContext $accessContext,
  50.         private MaterialQuery $materialQuery,
  51.         private MaterialFactory $materialFactory,
  52.         private CommandBus $commandBus,
  53.         private NavigationQuery $navigationQuery,
  54.         private IUserRepository $userRepository,
  55.         private ResultQuery $resultQuery,
  56.         private MaterialsSearchQuery $materialsSearchQuery,
  57.         private CourseQuery $courseQuery,
  58.         private ProgressQuery $progressQuery,
  59.         private IMaterialOnInstanceFactory $materialOnInstanceFactory,
  60.         private TagAwareCacheInterface $cache
  61.     ) {
  62.     }
  63.     /**
  64.      * @Route(
  65.      *     "/material/{instanceId}/{chapterId}/{materialId}",
  66.      *     name="material_details_id",
  67.      *     methods={"GET"},
  68.      *     requirements={"chapterId"="[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}"}
  69.      * )
  70.      * @OA\Parameter(name="materialId", in="path", @OA\Schema(type="string"), required=true)
  71.      * @OA\Parameter(name="chapterId", in="path", @OA\Schema(type="string"), required=true)
  72.      * @OA\Response(
  73.      *     response=200,
  74.      *     description="Single material",
  75.      *     @OA\Schema(ref=@Model(type="CodersLab\Lms\Modules\Materials\Application\Query\Model\Material"))
  76.      * )
  77.      */
  78.     public function getMaterialByChapterId(string $materialIdstring $instanceId): LmsApiResponse
  79.     {
  80.         $slug $this->materialOnInstanceFactory->guessSlug(new Uuid($materialId), $instanceId);
  81.         return new LmsApiResponse(null, new MaterialRedirectMetadata($slug), Response::HTTP_SEE_OTHER);
  82.     }
  83.     /**
  84.      * @Route(
  85.      *     "/material/{instanceId}/{slug}/{materialId}",
  86.      *     name="material_details_slug",
  87.      *     methods={"GET"},
  88.      *     requirements={"slug"="[0-9]+\-[0-9]+"}
  89.      * )
  90.      * @OA\Parameter(name="materialId", in="path", @OA\Schema(type="string"), required=true)
  91.      * @OA\Parameter(name="chapterId", in="path", @OA\Schema(type="string"), required=true)
  92.      * @OA\Response(
  93.      *     response=200,
  94.      *     description="Single material",
  95.      *     @OA\Schema(ref=@Model(type="CodersLab\Lms\Modules\Materials\Application\Query\Model\Material"))
  96.      * )
  97.      */
  98.     public function getMaterialBySlug(string $slugstring $materialIdstring $instanceId): LmsApiResponse
  99.     {
  100.         $user $this->security->getLoggedUser();
  101.         $materialAccess $this->accessContext->hasMaterialAccess($user->id(), $materialId$user->getAccess());
  102.         $directAccess $this->accessContext->hasDirectMaterialAccess($user->id(), $materialId);
  103.         if (($materialAccess || $directAccess) === false) {
  104.             throw new AccessDeniedHttpException();
  105.         }
  106.         $materialOnInstance $this->materialOnInstanceFactory->getForMaterial(Slug::createFromString($slug), $materialId$instanceId);
  107.         $material $this->materialFactory->create($materialId, (string)$materialOnInstance->getChapterId(), (string)$instanceId);
  108.         $review $this->materialQuery->findUserReview($materialId$user->id());
  109.         if ($review instanceof ModelMaterialReview) {
  110.             $material->setUserReview($review);
  111.         }
  112.         $metadata null;
  113.         if (!$material->getAccess()->isLecturer()) {
  114.             if ($material->getType()->isQuiz()) {
  115.                 $result $this->resultQuery->onMaterial(
  116.                     (string)$user->id(),
  117.                     $instanceId,
  118.                     $materialId
  119.                 );
  120.                 $material->setResult($result);
  121.             }
  122.             $metadata NavigationMetadata::create(
  123.                 $this->navigationQuery->getNavigationForMaterial(
  124.                     $material->getUuid(),
  125.                     $materialOnInstance->getChapterId(),
  126.                     $user->id()
  127.                 )
  128.             );
  129.         }
  130.         $progress $this->progressQuery->forMaterial($instanceId$materialId, (string)$user->id());
  131.         if ($progress instanceof MaterialProgress) {
  132.             $material->setProgress($progress);
  133.         }
  134.         $this->commandBus->dispatch(new UpdateLastSeenMaterial(
  135.             new Uuid($materialId),
  136.             (string)$instanceId,
  137.             (string)$user->id()
  138.         ));
  139.         return new LmsApiResponse($material$metadata);
  140.     }
  141.     /**
  142.      * @Route("/direct-access/material/{chapterId}/{materialId}", name="direct_access_material_details",
  143.      *     methods={"GET"})
  144.      * @OA\Parameter(name="id", in="path", @OA\Schema(type="string"), required=true)
  145.      * @OA\Parameter(name="courseId", in="path", @OA\Schema(type="string"), required=true)
  146.      * @OA\Response(
  147.      *     response=200,
  148.      *     description="Single material",
  149.      *     @OA\Schema(ref=@Model(type="CodersLab\Lms\Modules\Materials\Application\Query\Model\Material"))
  150.      * )
  151.      */
  152.     public function getMaterialDirectAccess(string $materialIdstring $chapterId): LmsApiResponse
  153.     {
  154.         $user $this->security->getLoggedUser();
  155.         $directAccess $this->accessContext->hasDirectMaterialAccess($user->id(), $materialId);
  156.         if ($directAccess === false) {
  157.             throw new AccessDeniedHttpException();
  158.         }
  159.         $material $this->materialFactory->createForDirectAccess($materialId$chapterId);
  160.         if ($material->getAccess()->isLecturer()) {
  161.             $metadata null;
  162.         } else {
  163.             $metadata NavigationMetadata::create(
  164.                 $this->navigationQuery->getNavigationForMaterial(
  165.                     $material->getUuid(),
  166.                     new Uuid($chapterId)
  167.                 )
  168.             );
  169.         }
  170.         return new LmsApiResponse($material$metadata);
  171.     }
  172.     /**
  173.      * @Route (
  174.      *     "/direct-access/material/{chapterId}/{materialId}/read",
  175.      *     name="direct_material_base64_read",
  176.      *     methods={"GET"},
  177.      *     requirements={"slug"="[0-9]+\-[0-9]+"}
  178.      *     )
  179.      * @OA\Parameter(name="chapterId", in="query", @OA\Schema(type="string"))
  180.      * @OA\Parameter(name="materialId", in="query", @OA\Schema(type="string"))
  181.      * @OA\Response(response=200, description="Single material base64 content")
  182.      *
  183.      * @return LmsApiResponse
  184.      * @throws \CodersLab\Lms\SharedKernel\Common\Exception\NotFoundException
  185.      */
  186.     public function readCourseMaterialDirectAccess(string $materialIdstring $chapterId): LmsApiResponse
  187.     {
  188.         $user $this->security->getLoggedUser();
  189.         $materialAccess $this->accessContext->hasMaterialAccess($user->id(), $materialId$user->getAccess());
  190.         $directAccess $this->accessContext->hasDirectMaterialAccess($user->id(), $materialId);
  191.         if (($materialAccess || $directAccess) === false) {
  192.             throw new AccessDeniedHttpException();
  193.         }
  194.         $this->materialQuery->getById($materialId); //check is material exists
  195.         $content $this->materialQuery->getMaterialContent($materialId$chapterId);
  196.         if ($content->downloadPath === '') {
  197.             throw new NotFoundException();
  198.         }
  199.         //        $cacheKey = 'pdf.content_cache.'.$materialId;
  200.         //        $responseBody = $this->cache->get($cacheKey, function (ItemInterface $item) use ($content) {
  201.         //            $item->tag(self::REQUEST_CACHE_PDF_TAG);
  202.         //            $encodedFileContent = file_get_contents($content->downloadPath);
  203.         //            return base64_encode($encodedFileContent);
  204.         //        });
  205.         $encodedFileContent file_get_contents($content->downloadPath);
  206.         $responseBody base64_encode($encodedFileContent);
  207.         $response = new LmsApiResponse($responseBody);
  208.         $response->setPublic();
  209.         $response->setMaxAge(3600);
  210.         return $response;
  211.     }
  212.     /**
  213.      * @Route (
  214.      *     "/material/{instanceId}/{chapterId}/{materialId}/download",
  215.      *     name="material_download_id",
  216.      *     methods={"GET"},
  217.      *     requirements={"chapterId"="[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12}"}
  218.      * )
  219.      * @OA\Parameter(name="id", in="path", @OA\Schema(type="string"))
  220.      * @OA\Parameter(name="instanceId", in="query", @OA\Schema(type="string"))
  221.      * @OA\Parameter(name="chapterId", in="query", @OA\Schema(type="string"))
  222.      * @OA\Parameter(name="materialId", in="query", @OA\Schema(type="string"))
  223.      * @OA\Response(response=200, description="Single material html content")
  224.      */
  225.     public function downloadCourseMaterialById(): Response
  226.     {
  227.         return new Response(nullResponse::HTTP_MOVED_PERMANENTLY);
  228.     }
  229.     /**
  230.      * @Route (
  231.      *     "/material/{instanceId}/{slug}/{materialId}/read",
  232.      *     name="material_base64_read",
  233.      *     methods={"GET"},
  234.      *     requirements={"slug"="[0-9]+\-[0-9]+"}
  235.      *     )
  236.      *
  237.      * @OA\Parameter(name="id", in="path", @OA\Schema(type="string"))
  238.      * @OA\Parameter(name="instanceId", in="query", @OA\Schema(type="string"))
  239.      * @OA\Parameter(name="chapterId", in="query", @OA\Schema(type="string"))
  240.      * @OA\Parameter(name="materialId", in="query", @OA\Schema(type="string"))
  241.      * @OA\Response(response=200, description="Single material base64 content")
  242.      *
  243.      * @return LmsApiResponse
  244.      * @throws \CodersLab\Lms\SharedKernel\Common\Exception\NotFoundException
  245.      */
  246.     public function readCourseMaterialBySlug(string $materialIdstring $slugstring $instanceId): LmsApiResponse
  247.     {
  248.         $user $this->security->getLoggedUser();
  249.         $materialAccess $this->accessContext->hasMaterialAccess($user->id(), $materialId$user->getAccess());
  250.         $directAccess $this->accessContext->hasDirectMaterialAccess($user->id(), $materialId);
  251.         if (($materialAccess || $directAccess) === false) {
  252.             throw new AccessDeniedHttpException();
  253.         }
  254.         $this->materialQuery->getById($materialId); //check is material exists
  255.         $materialOnInstance $this->materialOnInstanceFactory->getForMaterial(Slug::createFromString($slug), $materialId$instanceId);
  256.         $content $this->materialQuery->getMaterialContent($materialId, (string)$materialOnInstance->getChapterId(), $instanceId);
  257.         if ($content->downloadPath === '') {
  258.             throw new NotFoundException();
  259.         }
  260.         $cacheKey 'pdf.content_cache.'.$materialId;
  261.         $responseBody $this->cache->get($cacheKey, function (ItemInterface $item) use ($content) {
  262.             $item->tag(self::REQUEST_CACHE_PDF_TAG);
  263.             $encodedFileContent file_get_contents($content->downloadPath);
  264.             return base64_encode($encodedFileContent);
  265.         });
  266.         $response = new LmsApiResponse($responseBody);
  267.         $response->setPublic();
  268.         $response->setMaxAge(3600);
  269.         return $response;
  270.     }
  271.     /**
  272.      * @Route (
  273.      *     "/material/{instanceId}/{slug}/{materialId}/download",
  274.      *     name="material_download_slug",
  275.      *     methods={"GET"},
  276.      *     requirements={"slug"="[0-9]+\-[0-9]+"}
  277.      *     )
  278.      *
  279.      * @OA\Parameter(name="id", in="path", @OA\Schema(type="string"))
  280.      * @OA\Parameter(name="instanceId", in="query", @OA\Schema(type="string"))
  281.      * @OA\Parameter(name="chapterId", in="query", @OA\Schema(type="string"))
  282.      * @OA\Parameter(name="materialId", in="query", @OA\Schema(type="string"))
  283.      * @OA\Response(response=200, description="Single material html content")
  284.      *
  285.      * @return BinaryFileResponse
  286.      * @throws \CodersLab\Lms\SharedKernel\Common\Exception\NotFoundException
  287.      */
  288.     public function downloadCourseMaterialBySlug(string $materialIdstring $slugstring $instanceId): BinaryFileResponse
  289.     {
  290.         $user $this->security->getLoggedUser();
  291.         $material $this->materialQuery->getById($materialId);
  292.         $materialOnInstance $this->materialOnInstanceFactory->getForMaterial(Slug::createFromString($slug), $materialId$instanceId);
  293.         $content $this->materialQuery->getMaterialContent($materialId, (string)$materialOnInstance->getChapterId(), $instanceId);
  294.         if ($content->downloadPath === '') {
  295.             throw new NotFoundException();
  296.         }
  297.         $materialAccess $this->accessContext->hasMaterialAccess($user->id(), $material->getId(), $user->getAccess());
  298.         $directAccess $this->accessContext->hasDirectMaterialAccess($user->id(), $material->getId());
  299.         if (($materialAccess || $directAccess) === false) {
  300.             throw new AccessDeniedHttpException();
  301.         }
  302.         $response = new BinaryFileResponse($content->downloadPath);
  303.         $response->headers->set('Content-Type'$this->getContentTypeHeader($content->downloadPath));
  304.         $response->setContentDisposition(
  305.             ResponseHeaderBag::DISPOSITION_ATTACHMENT,
  306.             'attachment'
  307.         );
  308.         return $response;
  309.     }
  310.     /**
  311.      * @Route("/direct-access/material/{chapterId}/{materialId}/download", name="material_download_direct_access",
  312.      *     methods={"GET"})
  313.      *
  314.      * @OA\Parameter(name="id", in="path", @OA\Schema(type="string"))
  315.      * @OA\Parameter(name="chapterId", in="path", @OA\Schema(type="string"))
  316.      * @OA\Parameter(name="materialId", in="path", @OA\Schema(type="string"))
  317.      * @OA\Response(response=200, description="Single material html content")
  318.      */
  319.     public function downloadCourseMaterialDirectAccess(string $materialIdstring $chapterId): BinaryFileResponse
  320.     {
  321.         $user $this->security->getLoggedUser();
  322.         $material $this->materialQuery->getById($materialId);
  323.         $content $this->materialQuery->getMaterialContent($materialId$chapterId);
  324.         if ($content->downloadPath === '') {
  325.             throw new NotFoundException();
  326.         }
  327.         $directAccess $this->accessContext->hasDirectMaterialAccess($user->id(), $material->getId());
  328.         if ($directAccess === false) {
  329.             throw new AccessDeniedHttpException();
  330.         }
  331.         $response = new BinaryFileResponse($content->downloadPath);
  332.         $response->headers->set('Content-Type'$this->getContentTypeHeader($content->downloadPath));
  333.         $response->setContentDisposition(
  334.             ResponseHeaderBag::DISPOSITION_ATTACHMENT,
  335.             'attachment'
  336.         );
  337.         return $response;
  338.     }
  339.     /**
  340.      * @Route ("/material/issue", name="material_issue", methods={"POST"})
  341.      *
  342.      * @OA\RequestBody(
  343.      *  @OA\Parameter(name="materialId", @OA\Schema(type="string")),
  344.      *  @OA\Parameter(name="instanceId", @OA\Schema(type="string")),
  345.      *  @OA\Parameter(name="message", @OA\Schema(type="string")),
  346.      *  @OA\Parameter(name="selected", @OA\Schema(type="string"))
  347.      * )
  348.      * @OA\Response(response=200, description="Single material html content")
  349.      */
  350.     public function issue(Request $request): Response
  351.     {
  352.         $materialId = new Uuid($request->request->get('materialId'));
  353.         $materialCourseInfo $this->materialQuery->getMaterialCourseInfo($materialId$request->request->get('instanceId'));
  354.         $this->commandBus->dispatch(new ReportMaterialIssue(new MaterialIssue(
  355.             $materialId,
  356.             $materialCourseInfo->getMaterialName(),
  357.             $materialCourseInfo->getModuleNr(),
  358.             $materialCourseInfo->getChapterNr(),
  359.             $materialCourseInfo->getSignature(),
  360.             $materialCourseInfo->getMaterialsMode(),
  361.             $request->request->get('selected'),
  362.             $request->request->get('message'),
  363.             $this->userRepository->getById((string)$this->security->getLoggedUserId())->getEmail(),
  364.             $materialCourseInfo->getTechnology(),
  365.         )));
  366.         return new Response();
  367.     }
  368.     /**
  369.      * @Route ("/material/search", name="materials_search", methods={"GET"})
  370.      *
  371.      * @OA\Parameter(name="q", required=true, in="query", @OA\Schema(type="string"))
  372.      * @OA\Parameter(name="type", in="query", @OA\Schema(type="array", @OA\Items(@OA\Schema(type="string"))))
  373.      * @OA\Parameter(name="page", in="query", @OA\Schema(type="integer"))
  374.      * @OA\Parameter(name="perPage", in="query", @OA\Schema(type="integer"))
  375.      * @OA\Parameter(name="instanceId", required=true, in="query", @OA\Schema(type="integer"))
  376.      * @OA\Response(
  377.      *     response=200,
  378.      *     description="Materials search result",
  379.      *     @OA\Schema(type="array",
  380.      *          @OA\Items(
  381.      *              @OA\Schema(type="object"),
  382.      *              @OA\Property(property="all", @OA\Schema(type="integer")),
  383.      *              @OA\Property(
  384.      *                  property="materials",
  385.      *                  @OA\Schema(type="array",
  386.      *                      @OA\Items(ref=@Model(type="CodersLab\Lms\Modules\Materials\Application\Service\SearchEngine\Model\Material"))
  387.      *                  )
  388.      *             )
  389.      *          )
  390.      *     )
  391.      * )
  392.      */
  393.     public function search(Request $request): LmsApiResponse
  394.     {
  395.         $user $this->security->getLoggedUser();
  396.         $roles $this->accessContext->getUserCourseInstanceRoles(
  397.             $user->id(),
  398.             $request->query->get('instanceId')
  399.         );
  400.         $isStudent false;
  401.         foreach ($roles as $role) {
  402.             if ($role->equals(MemberType::STUDENT())) {
  403.                 $isStudent true;
  404.             }
  405.         }
  406.         /** @var array<string> $type */
  407.         $type $request->query->get('type') ?? [];
  408.         $filter = new FilterDecorator(
  409.             $request->query->get('q'),
  410.             $type,
  411.             $request->query->get('instanceId'),
  412.             $this->courseQuery->findCourseIdForInstance($request->query->get('instanceId')),
  413.             $isStudent && count($roles) === Access::STUDENT Access::LECTURER,
  414.             $this->accessContext->getModulesAccess(
  415.                 (string)$this->security->getLoggedUserId(),
  416.                 $request->query->get('instanceId'),
  417.             ),
  418.             $request->query->get('page') !== null ? (int)$request->query->get('page') : null,
  419.             $request->query->get('perPage') !== null ? (int)$request->query->get('perPage') : null,
  420.         );
  421.         $data $this->materialsSearchQuery->search($filter);
  422.         /** @var array<\CodersLab\Lms\Modules\Materials\Application\Service\SearchEngine\Model\Material> $materials */
  423.         $materials = [];
  424.         /** @var \CodersLab\Lms\Modules\Materials\Application\Service\SearchEngine\Model\Material $material */
  425.         foreach ($data['materials'] as $material) {
  426.             $materials[$material->getId()] = $material;
  427.             //            if ($material->getScore() >= Elasticsearch::BOOST) {
  428.             //                $perfectMatches++;
  429.             //            }
  430.         }
  431.         if (count($materials) > 0) {
  432.             $materialsProgress $this->progressQuery->forMaterials(
  433.                 $request->query->get('instanceId'),
  434.                 array_keys($materials),
  435.                 (string)$user->id()
  436.             );
  437.             foreach ($materialsProgress as $materialProgress) {
  438.                 $materials[(string)$materialProgress->getMaterialId()]->setStatus(
  439.                     $materialProgress->getStatus(),
  440.                     $materialProgress->isVisited()
  441.                 );
  442.             }
  443.         }
  444.         //HL-2353 disable material search logging
  445.         //        if ($request->query->get('q') !== null) {
  446.         //            $this->commandBus->dispatch(new LogMaterialSearch(
  447.         //                new DateTime(),
  448.         //                (string)$user->id(),
  449.         //                $user->getEmail(),
  450.         //                $request->query->get('q'),
  451.         //                $request->query->get('instanceId'),
  452.         //                $perfectMatches
  453.         //            ));
  454.         //        }
  455.         return new LmsApiResponse($data, new Metadata($filter->getFilter()->page(), 0$filter->getFilter()->limit(), 0));
  456.     }
  457.     private function getContentTypeHeader(string $filePath): string
  458.     {
  459.         $ext pathinfo($filePathPATHINFO_EXTENSION);
  460.         if (!in_array($ext, ['zip''pdf'], true)) {
  461.             throw new Exception('Attachment extension not supported.');
  462.         }
  463.         return 'application/' $ext;
  464.     }
  465. }