entity_mesh-1.1.1/src/EntityRender.php
src/EntityRender.php
<?php
namespace Drupal\entity_mesh;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Component\Utility\DeprecationHelper;
use Drupal\Core\Access\AccessManager;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Database\StatementInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\TranslatableInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Session\AccountSwitcherInterface;
use Drupal\entity_mesh\Language\LanguageNegotiatorSwitcher;
/**
* Service description.
*
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
class EntityRender extends Entity {
/**
* The Renderer manager.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* The AccountSwitcher manager.
*
* @var \Drupal\Core\Session\AccountSwitcherInterface
*/
protected $accountSwitcher;
/**
* Switch to the language of rendered entities.
*
* @var \Drupal\entity_mesh\Language\LanguageNegotiatorSwitcher
*/
protected LanguageNegotiatorSwitcher $languageNegotiatorSwitcher;
/**
* Module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected ModuleHandlerInterface $moduleHandler;
/**
* Access manager.
*
* @var \Drupal\Core\Access\AccessManager
*/
protected AccessManager $accessManager;
/**
* Theme switcher.
*
* @var \Drupal\entity_mesh\ThemeSwitcherInterface
*/
protected ThemeSwitcherInterface $themeSwitcher;
/**
* Cache for entity DOM to avoid redundant rendering.
*
* This cache prevents the same entity from being rendered multiple times
* during a single request. This is particularly useful when
* countEntityLinks() is called before setTargetsInSourceFromEntityRender(),
* as both methods need the same DOM. The cache is cleared after
* createSourceFromEntity() to free memory.
*
* @var array
*/
protected array $domCache = [];
/**
* Constructs a Menu object.
*
* @param \Drupal\entity_mesh\RepositoryInterface $entity_mesh_repository
* Entity Mesh repository.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity repository.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer manager.
* @param \Drupal\Core\Session\AccountSwitcherInterface $account_switcher
* The AccountSwitcher manager.
* @param \Drupal\entity_mesh\Language\LanguageNegotiatorSwitcher $language_negotiator_switcher
* The language negotiator switcher service.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* Module handler.
* @param \Drupal\Core\Access\AccessManager $access_manager
* Access manager.
* @param \Drupal\entity_mesh\ThemeSwitcher $theme_switcher
* The theme switcher service.
* @param \Drupal\entity_mesh\TrackerManagerInterface $tracker_manager
* The tracker manager.
*/
public function __construct(
RepositoryInterface $entity_mesh_repository,
EntityTypeManagerInterface $entity_type_manager,
LanguageManagerInterface $language_manager,
ConfigFactoryInterface $config_factory,
RendererInterface $renderer,
AccountSwitcherInterface $account_switcher,
LanguageNegotiatorSwitcher $language_negotiator_switcher,
ModuleHandlerInterface $module_handler,
AccessManager $access_manager,
ThemeSwitcher $theme_switcher,
TrackerManagerInterface $tracker_manager,
) {
parent::__construct($entity_mesh_repository, $entity_type_manager, $language_manager, $config_factory, $tracker_manager);
$this->renderer = $renderer;
$this->accountSwitcher = $account_switcher;
$this->type = 'entity_render';
$this->languageNegotiatorSwitcher = $language_negotiator_switcher;
$this->moduleHandler = $module_handler;
$this->accessManager = $access_manager;
$this->themeSwitcher = $theme_switcher;
}
/**
* {@inheritDoc}
*/
public function createSourceFromEntity(EntityInterface $entity): ?SourceInterface {
$source = $this->createBasicSourceFromEntity($entity);
if (!$source instanceof SourceInterface) {
return NULL;
}
$this->setTargetsInSourceFromEntityRender($entity, $source);
// Clear the DOM cache after processing the entity to free memory.
$this->clearDomCache();
return $source;
}
/**
* Clear the DOM cache.
*
* This should be called after processing entities to free memory,
* especially important in batch operations.
*/
public function clearDomCache(): void {
$this->domCache = [];
}
/**
* Set the targets in the source from the links in the entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param \Drupal\entity_mesh\SourceInterface $source
* The source.
*/
protected function setTargetsInSourceFromEntityRender(EntityInterface $entity, SourceInterface $source) {
$langcode = $entity->language()->getId();
try {
$dom = $this->getFullEntityDom($entity, $langcode);
}
catch (\Exception $e) {
$this->entityMeshRepository->getLogger()->error('Error getting the entity DOM (entity=:entity, id=:id) = :message', [
':entity' => $entity->getEntityTypeId(),
':id' => $entity->id(),
':message' => $e->getMessage(),
]);
return;
}
// Get and save target links.
$target_links = $this->getTargetLinks($dom);
if ($this->config->get('entity_mesh.settings')->get('debug')) {
$this->entityMeshRepository->getLogger()->debug('Links to process in this node (entity=:entity, id=:id, lang=:lang): :link_numbers', [
':entity' => $entity->getEntityTypeId(),
':id' => $entity->id(),
':lang' => $langcode,
':link_numbers' => count($target_links),
]);
}
foreach ($target_links as $target_link) {
$this->setTargetFromHref($target_link, $source);
}
// Get and save iframes.
$iframe_links = $this->getIframeLinks($dom);
foreach ($iframe_links as $iframe_link) {
$this->setTargetFromIframe($iframe_link, $source);
}
}
/**
* Count total links in an entity without processing them.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to count links from.
*
* @return int
* The total number of links (hrefs + iframes).
*/
public function countEntityLinks(EntityInterface $entity): int {
$langcode = $entity->language()->getId();
try {
$dom = $this->getFullEntityDom($entity, $langcode);
}
catch (\Exception $e) {
// If we can't get the DOM, assume no links.
return 0;
}
// Count target links and iframe links.
$target_links = $this->getTargetLinks($dom);
$iframe_links = $this->getIframeLinks($dom);
// Get current entity's URL to exclude self-references.
$entity_url = '';
if ($entity->hasLinkTemplate('canonical')) {
try {
$entity_url = $entity->toUrl('canonical')->toString();
$target_links = array_filter($target_links, function ($link) use ($entity_url) {
// Exclude self-references.
return $link !== $entity_url;
});
}
catch (\Exception $e) {
// If we can't get the URL, continue without filtering.
}
}
return count($target_links) + count($iframe_links);
}
/**
* Get target links.
*/
protected function getTargetLinks($dom) {
$links = $dom->getElementsByTagName('a');
$target_links = [];
foreach ($links as $link) {
$href = trim(strip_tags($link->getAttribute('href')));
if (!empty($href) && $href[0] != "#") {
// @todo integrate nodeValue, note same link can appear several times
// with different or same anchors
// 'target_anchor' => strip_tags($link->nodeValue),
$target_links[] = $href;
}
}
// Remove duplicate hrefs.
return array_unique($target_links);
}
/**
* Get the iframes links.
*/
protected function getIframeLinks($dom) {
$iframes = $dom->getElementsByTagName('iframe');
$iframe_links = [];
foreach ($iframes as $iframe) {
$src = trim(strip_tags($iframe->getAttribute('src')));
if (!empty($src) && $src[0] != "#") {
$iframe_links[] = $src;
}
}
// Remove duplicate hrefs.
return array_unique($iframe_links);
}
/**
* Get the full entity DOM.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to render.
* @param string $langcode
* The language code.
*
* @return \DOMDocument
* The DOM document.
*
* @SuppressWarnings(PHPMD.ErrorControlOperator)
*/
protected function getFullEntityDom(EntityInterface $entity, string $langcode) {
// Generate cache key based on entity type, ID, and language.
$cache_key = $entity->getEntityTypeId() . ':' . $entity->id() . ':' . $langcode;
// Check if DOM is already cached.
if (isset($this->domCache[$cache_key])) {
return $this->domCache[$cache_key];
}
// Switch to the default theme in case the admin theme is enabled.
$previous_theme = $this->themeSwitcher->switchToDefault();
$this->accountSwitcher->switchTo($this->entityMeshRepository->getMeshAccount());
// Find LINKs from rendered entity page output.
// Switch to the anonymous user:
// @todo allow to configure default user by settings form.
// Switch system to the entity language so the entity is fully rendered
// in the specific language.
$this->languageNegotiatorSwitcher->switchLanguage($entity->language());
// Load all modules before rendering.
if (!$this->moduleHandler->isLoaded()) {
$this->moduleHandler->loadAll();
}
try {
// Render entity HTML output:
$view_mode = 'full';
$view_builder = $this->entityTypeManager->getViewBuilder($entity->getEntityTypeId());
$pre_render = $view_builder->view($entity, $view_mode, $langcode);
$render_output = DeprecationHelper::backwardsCompatibleCall(
currentVersion: \Drupal::VERSION,
deprecatedVersion: '10.3',
currentCallable: fn() => $this->renderer->renderInIsolation($pre_render),
deprecatedCallable: fn() => $this->renderer->renderPlain($pre_render),
);
// Switches back to the current language:
$this->languageNegotiatorSwitcher->switchBack();
// Switch back to the current user:
$this->accountSwitcher->switchBack();
// Restore the original theme.
$this->themeSwitcher->switchBack($previous_theme);
// Parse the HTML.
$dom = new \DOMDocument();
// The @ is used to suppress any parsing errors that will be thrown
// if the $html string isn't valid XHTML.
// Fix UTF-8 encoding issues with diacritical marks
// by explicitly setting encoding.
@$dom->loadHTML('<?xml encoding="UTF-8">' . $render_output, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
// Cache the DOM before returning.
$this->domCache[$cache_key] = $dom;
return $dom;
}
catch (\Exception $e) {
// Ensure we switch everything back in case of an error.
$this->languageNegotiatorSwitcher->switchBack();
$this->accountSwitcher->switchBack();
$this->themeSwitcher->switchBack($previous_theme);
throw $e;
}
}
/**
* Set the target from iframe.
*
* @param string $href
* Href.
* @param SourceInterface $source
* Source.
*/
protected function setTargetFromIframe(string $href, SourceInterface $source) {
/** @var \Drupal\entity_mesh\TargetInterface $target */
$target = $this->entityMeshRepository->instanceEmptyTarget();
$target->processHrefAndSetComponents($href);
$target->setCategory('iframe');
$source->addTarget($target);
}
/**
* Set the target from the href.
*
* @param string $href
* Href.
* @param SourceInterface $source
* Source.
*/
protected function setTargetFromHref(string $href, SourceInterface $source) {
/** @var \Drupal\entity_mesh\TargetInterface $target */
$target = $this->entityMeshRepository->instanceEmptyTarget();
$target->processHrefAndSetComponents($href);
$target->setCategory('link');
if ($target->getLinkType() == 'internal' && $this->ifProcessInternalTarget()) {
$this->processInternalHref($target);
// Check that the target is not the source to avoid circular references.
if ($source->getSourceEntityType() == $target->getEntityType()
&& $source->getSourceEntityId() == $target->getEntityId()) {
return;
}
}
if ($this->shouldRegisterTarget($target)) {
$source->addTarget($target);
}
}
/**
* Process internal href.
*
* @param \Drupal\entity_mesh\TargetInterface $target
* The target.
*/
protected function processInternalHref(TargetInterface $target) {
$path = (string) $target->getPath();
if ($path === '') {
return;
}
$alias = $this->entityMeshRepository->getPathWithoutLangPrefix($path);
$target->setEntityLangcode($this->entityMeshRepository->getLangcodeFromPath($path));
$found_data = $this->setDataIfRedirection($alias, $target);
// Get the info from alias.
// This method is quicker than using the router service,
// so firstly apply this system.
if (!$found_data) {
$found_data = $this->setDataTargetFromAliasIfExists($alias, $target);
}
// If this method not found the data, use the router service.
if (!$found_data) {
$this->setDataTargetFromRoute($target);
}
// If the target is set as link broken maybe is because is a file url.
if ($target->getSubcategory() === 'broken-link') {
$this->setDataTargetIfFileUrl($target);
}
$this->setBundleInTarget($target);
if (!($this->accessCheckTarget($target))) {
$target->setSubcategory('access-denied-link');
}
}
/**
* Set the bundle in the target.
*
* @param \Drupal\entity_mesh\TargetInterface $target
* The target.
*/
protected function setBundleInTarget(TargetInterface $target) {
if ($target->getEntityType() === NULL || $target->getEntityId() === NULL) {
return;
}
try {
$storage = $this->entityTypeManager->getStorage($target->getEntityType());
}
catch (PluginNotFoundException $e) {
return;
}
$entity = $storage->load($target->getEntityId());
if (!$entity instanceof EntityInterface || empty($entity->bundle())) {
return;
}
$target->setEntityBundle($entity->bundle());
}
/**
* Get the data if it is a redirection.
*
* @param string $alias
* The alias.
* @param TargetInterface $target
* The target.
*
* @return bool
* If is a redirection.
*/
protected function setDataIfRedirection(string &$alias, TargetInterface $target): bool {
$langcode = $target->getEntityLangcode();
// Check if is a redirected link.
$uri = $this->entityMeshRepository->ifRedirectionForPath($alias, $langcode);
if ($uri === NULL) {
return FALSE;
}
$type = '';
$target->setSubcategory('redirected-link');
// Check if it starts with "internal:".
if (str_starts_with($uri, "internal:")) {
$uri = substr($uri, strlen("internal:"));
$type = 'internal';
}
elseif (str_starts_with($uri, "entity:")) {
$uri = substr($uri, strlen("entity:"));
$type = 'entity';
}
elseif (str_starts_with($uri, "http")) {
$target->setLinkType('external');
$target->setPath('External redirection: ' . $uri);
return TRUE;
}
$uri = $this->entityMeshRepository->getPathWithoutLangPrefix($uri);
if ($type === '') {
$alias = $uri;
return FALSE;
}
// The internal can be an alias.
if ($type === 'internal' && $possible_alias = $this->getPathFromAliasTables($uri, $langcode)) {
$uri = $possible_alias->path;
}
return $this->processInternalPaths($uri, $target);
}
/**
* Set data about the target from alias if exists.
*
* @param string $alias
* Alias.
* @param TargetInterface $target
* Target.
*
* @return bool
* If this method found the data.
*/
protected function setDataTargetFromAliasIfExists(string $alias, TargetInterface $target): bool {
// Get the info from alias.
$langcode = $target->getEntityLangcode();
if ($this->moduleHandler->moduleExists('path_alias')) {
$record = $this->getPathFromAliasTables($alias, $langcode);
if ($record) {
return $this->processInternalPaths($record->path, $target);
}
}
return FALSE;
}
/**
* Process internal paths.
*
* @param string $path
* The path.
* @param TargetInterface $target
* The target.
*
* @return bool
* If the path is processed.
*/
protected function processInternalPaths(string $path, TargetInterface $target) {
$path = explode('/', ltrim($path, '/'));
if (count($path) < 2) {
return FALSE;
}
if ($path[0] === 'taxonomy') {
$path[0] = 'taxonomy_term';
}
if (isset($path[2]) && is_numeric($path[2])) {
$target->setEntityType($path[0]);
$target->setEntityId((string) $path[2]);
return TRUE;
}
$target->setEntityType($path[0]);
$target->setEntityId($path[1] ?? '');
return TRUE;
}
/**
* Set data about the target from route.
*
* @param TargetInterface $target
* The target.
*/
protected function setDataTargetFromRoute($target) {
if (empty($target->getPath())) {
return;
}
// Get the path from target.
$path = $target->getPath();
try {
// @phpstan-ignore-next-line
$route_match = \Drupal::service('router.no_access_checks')->match($path);
}
catch (\Exception $e) {
$target->setSubcategory('broken-link');
return;
}
if (empty($route_match['_route'])) {
$target->setSubcategory('broken-link');
return;
}
// Check if the route match belongs to a entity route.
$entity = $this->checkAndGetEntityFromEntityRoute($route_match);
if ($entity instanceof EntityInterface) {
$target->setEntityType($entity->getEntityTypeId());
$target->setEntityId((string) $entity->id());
return;
}
// Check if the route match belongs to a view route.
if (isset($route_match['view_id']) && isset($route_match['display_id'])) {
$target->setEntityType('view');
$target->setEntityId($route_match['view_id'] . '.' . $route_match['display_id']);
}
}
/**
* Set data target if file url.
*
* @param TargetInterface $target
* The target.
*/
protected function setDataTargetIfFileUrl($target) {
// Check if the path is a file.
$path = $this->entityMeshRepository->getPathFromFileUrl($target->getPath() ?? '');
if (!$path) {
return;
}
// Try get the url from the path.
$file = $this->entityMeshRepository->getFileFromUrl($path);
if (empty($file)) {
return;
}
$target->setSubcategory($target->getCategory() ?? '');
// Try get the media that referred to the file.
$media = $this->entityMeshRepository->getMediaFileByEntityFile($file);
// If we do not have a media, set information of the entity file.
if (!$media) {
$target->setEntityType($file->getEntityTypeId());
$target->setEntityId((string) $file->id());
return;
}
$target->setEntityType($media->getEntityTypeId());
$target->setEntityBundle($media->bundle());
$target->setEntityId((string) $media->id());
}
/**
* Get the path form alias.
*
* @param string $alias
* The alias.
* @param string $langcode
* The language code.
*
* @return mixed|null
* Return the path.
*/
protected function getPathFromAliasTables(string $alias, string $langcode) {
$query = $this->entityMeshRepository->getDatabaseService()->select('path_alias', 'pa');
$query->fields('pa', ['path']);
$or = $query->orConditionGroup()
->condition('alias', $alias)
->condition('path', $alias);
$query->condition($or);
if (!empty($langcode)) {
$query->condition('langcode', $langcode);
}
$result = $query->execute();
if (!$result instanceof StatementInterface) {
return NULL;
}
return $result->fetchObject();
}
/**
* {@inheritDoc}
*/
public function deleteItem(string $entity_type, string $entity_id, string $type = '') {
parent::deleteItem('entity_render', $entity_type, $entity_id);
}
/**
* Access check uri.
*
* @param \Drupal\entity_mesh\Target $target
* Uri to test.
*
* @return bool
* Access allowed or not.
*/
public function accessCheckTarget(Target $target) {
// Only check internal targets.
if ($target->getLinkType() !== 'internal'
|| empty($target->getEntityType())
|| empty($target->getEntityId())) {
return TRUE;
}
if ($target->getEntityType() === 'view') {
$route_view = 'view.' . $target->getEntityId();
// Check access.
return $this->accessManager->checkNamedRoute($route_view, [], $this->entityMeshRepository->getMeshAccount());
}
try {
$storage = $this->entityTypeManager->getStorage($target->getEntityType());
}
catch (PluginNotFoundException) {
return TRUE;
}
$entity = $storage->load($target->getEntityId());
if (!$entity) {
// Deny access if the entity does not exist.
return FALSE;
}
// Special handling for webform entities.
if ($target->getEntityType() === 'webform') {
return $this->checkWebformAccess($entity);
}
// If the target does not have translation, check default translation.
if (empty($target->getEntityLangcode())
|| !($entity instanceof TranslatableInterface)
|| !($entity->isTranslatable())) {
return $this->entityMeshRepository->checkViewAccessEntity($entity);
}
// If does not have translation, also denied access.
if (!($entity->hasTranslation($target->getEntityLangcode()))) {
return FALSE;
}
$translation = $entity->getTranslation($target->getEntityLangcode());
return $this->entityMeshRepository->checkViewAccessEntity($translation);
}
/**
* Check access for webform entities.
*
* Webforms use a specific access control system based on access rules.
* This method checks if the mesh account can create submissions for the
* webform, which is the equivalent to viewing/accessing the webform for
* anonymous users.
*
* @param \Drupal\Core\Entity\EntityInterface $webform
* The webform entity.
*
* @return bool
* TRUE if access is granted, FALSE otherwise.
*/
protected function checkWebformAccess(EntityInterface $webform) {
$mesh_account = $this->entityMeshRepository->getMeshAccount();
// Check if webform module is available and entity is a webform.
if (!$this->moduleHandler->moduleExists('webform')) {
return FALSE;
}
// Get the webform access rules manager service.
try {
/** @var \Drupal\webform\WebformAccessRulesManagerInterface $access_rules_manager */
$access_rules_manager = \Drupal::service('webform.access_rules_manager');
}
catch (\Exception $e) {
// If the service is not available, fall back to standard access check.
return $webform->access('view', $mesh_account);
}
// Check if the mesh account can create submissions (which means
// they can access/view the webform).
return $access_rules_manager->checkWebformAccess('create', $mesh_account, $webform);
}
}
