diff --git a/src/CoreBundle/Helpers/PageHelper.php b/src/CoreBundle/Helpers/PageHelper.php index ba8a1c19c09..8ec841081da 100644 --- a/src/CoreBundle/Helpers/PageHelper.php +++ b/src/CoreBundle/Helpers/PageHelper.php @@ -12,16 +12,34 @@ use Chamilo\CoreBundle\Entity\User; use Chamilo\CoreBundle\Repository\PageCategoryRepository; use Chamilo\CoreBundle\Repository\PageRepository; +use Chamilo\CoreBundle\Repository\SysAnnouncementRepository; +use Symfony\Component\Security\Core\User\UserInterface; class PageHelper { protected PageRepository $pageRepository; protected PageCategoryRepository $pageCategoryRepository; - public function __construct(PageRepository $pageRepository, PageCategoryRepository $pageCategoryRepository) - { + /** + * Repository used to read system announcements (platform news). + */ + protected SysAnnouncementRepository $sysAnnouncementRepository; + + /** + * Helper used to retrieve the current AccessUrl. + */ + protected AccessUrlHelper $accessUrlHelper; + + public function __construct( + PageRepository $pageRepository, + PageCategoryRepository $pageCategoryRepository, + SysAnnouncementRepository $sysAnnouncementRepository, + AccessUrlHelper $accessUrlHelper + ) { $this->pageRepository = $pageRepository; $this->pageCategoryRepository = $pageCategoryRepository; + $this->sysAnnouncementRepository = $sysAnnouncementRepository; + $this->accessUrlHelper = $accessUrlHelper; } public function createDefaultPages(User $user, AccessUrl $url, string $locale): bool @@ -99,8 +117,7 @@ public function createDefaultPages(User $user, AccessUrl $url, string $locale): $this->pageCategoryRepository->update($footerPrivateCategory); - // Categories for extra content in admin blocks - + // Categories for extra content in admin blocks. foreach (self::getCategoriesForAdminBlocks() as $nameBlock) { $usersAdminBlock = (new PageCategory()) ->setTitle($nameBlock) @@ -142,4 +159,68 @@ public static function getCategoriesForAdminBlocks(): array 'block-admin-chamilo', ]; } + + /** + * Checks if a document file URL is effectively exposed through a visible system announcement. + * + * This centralizes the logic used by different parts of the platform (e.g. voters, controllers) + * to decide if a file coming from personal files can be considered "public" because it is + * embedded inside a system announcement that is visible to the current user. + * + * @param string $pathInfo Full request path (e.g. /r/document/files/{uuid}/view) + * @param string|null $identifier File identifier extracted from the URL (usually a UUID) + * @param UserInterface|null $user Current user, or null to behave as anonymous + * @param string $locale Current locale used to fetch announcements + */ + public function isFilePathExposedByVisibleAnnouncement( + string $pathInfo, + ?string $identifier, + ?UserInterface $user, + string $locale + ): bool { + // Only relax security for the document file viewer route. + if ('' === $pathInfo || !str_contains($pathInfo, '/r/document/files/')) { + return false; + } + + // Normalize user: if no authenticated user is provided, behave as anonymous. + if (null === $user) { + $anon = new User(); + $anon->setRoles(['ROLE_ANONYMOUS']); + $user = $anon; + } + + $accessUrl = $this->accessUrlHelper->getCurrent(); + + // Fetch announcements that are visible for the given user, URL and locale. + $announcements = $this->sysAnnouncementRepository->getAnnouncements( + $user, + $accessUrl, + $locale + ); + + foreach ($announcements as $item) { + $content = ''; + + if (\is_array($item)) { + $content = (string) ($item['content'] ?? ''); + } elseif (\is_object($item) && method_exists($item, 'getContent')) { + $content = (string) $item->getContent(); + } + + if ('' === $content) { + continue; + } + + // Check if the announcement HTML contains the viewer path or the identifier. + if ( + str_contains($content, $pathInfo) + || ($identifier && str_contains($content, $identifier)) + ) { + return true; + } + } + + return false; + } } diff --git a/src/CoreBundle/Security/Authorization/Voter/ResourceNodeVoter.php b/src/CoreBundle/Security/Authorization/Voter/ResourceNodeVoter.php index 805161ac78f..35b28788118 100644 --- a/src/CoreBundle/Security/Authorization/Voter/ResourceNodeVoter.php +++ b/src/CoreBundle/Security/Authorization/Voter/ResourceNodeVoter.php @@ -11,6 +11,7 @@ use Chamilo\CoreBundle\Entity\ResourceNode; use Chamilo\CoreBundle\Entity\ResourceRight; use Chamilo\CoreBundle\Entity\Session; +use Chamilo\CoreBundle\Helpers\PageHelper; use Chamilo\CoreBundle\Settings\SettingsManager; use Chamilo\CourseBundle\Entity\CDocument; use Chamilo\CourseBundle\Entity\CGroup; @@ -51,7 +52,8 @@ public function __construct( private Security $security, private RequestStack $requestStack, private SettingsManager $settingsManager, - private EntityManagerInterface $entityManager + private EntityManagerInterface $entityManager, + private PageHelper $pageHelper, ) {} public static function getReaderMask(): int @@ -117,6 +119,11 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ return true; } + // Special case: allow file assets that are embedded inside a visible system announcement. + if (self::VIEW === $attribute && $this->isAnnouncementFileVisibleForCurrentRequest($resourceNode, $token)) { + return true; + } + // @todo switch ($attribute) { case self::VIEW: @@ -516,6 +523,47 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ return false; } + /** + * Checks if the current request is viewing a document file that is embedded + * inside a visible system announcement, delegating the heavy logic to PageHelper. + */ + private function isAnnouncementFileVisibleForCurrentRequest(ResourceNode $resourceNode, TokenInterface $token): bool + { + $type = $resourceNode->getResourceType()?->getTitle(); + if ('files' !== $type) { + return false; + } + + $request = $this->requestStack->getCurrentRequest(); + if (null === $request) { + return false; + } + + $pathInfo = (string) $request->getPathInfo(); + if ('' === $pathInfo) { + return false; + } + + // Extract file identifier from /r/document/files/{identifier}/view. + $segments = explode('/', trim($pathInfo, '/')); + $identifier = null; + if (\count($segments) >= 4) { + // ... /r/document/files/{identifier}/view + $identifier = $segments[\count($segments) - 2] ?? null; + } + + $userFromToken = $token->getUser(); + $user = $userFromToken instanceof UserInterface ? $userFromToken : null; + $locale = $request->getLocale(); + + return $this->pageHelper->isFilePathExposedByVisibleAnnouncement( + $pathInfo, + \is_string($identifier) ? $identifier : null, + $user, + $locale + ); + } + private function isBlogResource(ResourceNode $node): bool { $type = $node->getResourceType()?->getTitle();