diff --git a/modules/xray_audit_insight/src/OEmbed/ResourceFetcherDecorator.php b/modules/xray_audit_insight/src/OEmbed/ResourceFetcherDecorator.php new file mode 100644 index 0000000000000000000000000000000000000000..d6ea11fe1364609a306a7754824152cdfe195e50 --- /dev/null +++ b/modules/xray_audit_insight/src/OEmbed/ResourceFetcherDecorator.php @@ -0,0 +1,46 @@ +<?php + +namespace Drupal\xray_audit_insight\OEmbed; + +use Drupal\media\OEmbed\ResourceFetcherInterface; +use Drupal\media\OEmbed\ResourceException; +use Drupal\xray_audit_insight\XrayAuditInsightReport; + +/** + * Decorates the original ResourceFetcher service to add logging capabilities. + */ +class ResourceFetcherDecorator implements ResourceFetcherInterface { + + /** + * Constructs a new ResourceFetcherDecorator. + * + * @param \Drupal\media\OEmbed\ResourceFetcherInterface $resourceFetcher + * The original resource fetcher service. + * @param \Drupal\xray_audit_insight\XrayAuditInsightReport $insightReport + * The XrayAuditInsightReport service for logging insights. + */ + public function __construct( + protected ResourceFetcherInterface $resourceFetcher, + protected XrayAuditInsightReport $insightReport, + ) {} + + /** + * {@inheritdoc} + */ + public function fetchResource($url) { + try { + return $this->resourceFetcher->fetchResource($url); + } + catch (ResourceException $e) { + // Log the exception to the xray_audit_insight table. + $this->insightReport->addInsightData( + 'external_resource', + $e->getMessage(), + $url + ); + + throw $e; + } + } + +} diff --git a/modules/xray_audit_insight/src/Plugin/insights/XrayAuditExternalResourcesInsight.php b/modules/xray_audit_insight/src/Plugin/insights/XrayAuditExternalResourcesInsight.php new file mode 100644 index 0000000000000000000000000000000000000000..23ebd00d832358bebf59405c6a1a30157f93951d --- /dev/null +++ b/modules/xray_audit_insight/src/Plugin/insights/XrayAuditExternalResourcesInsight.php @@ -0,0 +1,85 @@ +<?php + +namespace Drupal\xray_audit_insight\Plugin\insights; + +use Drupal\Core\Url; +use Drupal\Core\Link; +use Drupal\xray_audit_insight\Plugin\XrayAuditInsightPluginBase; +use Drupal\xray_audit\Plugin\XrayAuditTaskPluginInterface; + +/** + * Plugin implementation for external resources needing review. + * + * @XrayAuditInsightPlugin ( + * id = "external_resources_review", + * label = @Translation("External resources needing review"), + * description = @Translation("Identified external resources that should be reviewed"), + * sort = 7 + * ) + */ +class XrayAuditExternalResourcesInsight extends XrayAuditInsightPluginBase { + + /** + * {@inheritdoc} + */ + protected $taskPluginId = 'external_resources'; + + /** + * {@inheritdoc} + */ + protected $operation = 'external_resources'; + + /** + * {@inheritdoc} + */ + public function getInsightsForDrupalReport(): array { + $insights = $this->getInsights(); + $needs_review = $insights['external_resources_review']; + if ($needs_review) { + $url = Url::fromUserInput($this->getPathReport($this->taskPluginId, $this->operation)); + $reports_link = Link::fromTextAndUrl($this->t('task report'), $url); + $value = $this->t('External resources should be reviewed. Please check the @report_link for details', [ + '@report_link' => $reports_link->toString(), + ]); + $severity = REQUIREMENT_WARNING; + } + else { + $value = $this->t("No external resources need review."); + $severity = NULL; + } + + return [ + 'external_resources_review' => $this->buildInsightForDrupalReport( + $this->label(), + $value, + '', + $severity + ), + ]; + } + + /** + * {@inheritdoc} + */ + public function getInsights(): array { + return ['external_resources_review' => $this->existResourcesNeedingReview()]; + } + + /** + * Check if there are external resources needing review. + * + * @return bool + * True if there are resources needing review. + */ + protected function existResourcesNeedingReview(): bool { + $task_plugin = $this->getInstancedPlugin($this->taskPluginId, $this->operation); + $result = $task_plugin instanceof XrayAuditTaskPluginInterface ? $task_plugin->getDataOperationResult($this->operation) : []; + foreach ($result as $resource) { + if (!empty($resource->needs_review)) { + return TRUE; + } + } + return FALSE; + } + +} diff --git a/modules/xray_audit_insight/src/XrayAuditInsightReport.php b/modules/xray_audit_insight/src/XrayAuditInsightReport.php new file mode 100644 index 0000000000000000000000000000000000000000..e0cabe9095a194f52893d1bb13f542c0de8b27a1 --- /dev/null +++ b/modules/xray_audit_insight/src/XrayAuditInsightReport.php @@ -0,0 +1,103 @@ +<?php + +namespace Drupal\xray_audit_insight; + +use Drupal\Core\Database\Connection; + +/** + * Service for managing the xray_audit_insight table. + */ +class XrayAuditInsightReport { + + /** + * The database connection. + * + * @var \Drupal\Core\Database\Connection + */ + protected $database; + + /** + * Constructs a new XrayAuditInsightReport object. + * + * @param \Drupal\Core\Database\Connection $database + * The database connection. + */ + public function __construct(Connection $database) { + $this->database = $database; + } + + /** + * Adds a new insight with text data to the xray_audit_insight table. + * + * Only adds the record if no record with the same type and data exists. + * + * @param string $type + * The type of the insight. + * @param string $message + * The message associated with the insight. + * @param string $url + * The URL to be stored. + * + * @return bool + * TRUE if a new record was added, FALSE if a duplicate was found. + */ + public function addInsightData(string $type, string $message, string $url) { + $data = $url; + + // Check if a record with the same type and data already exists. + $exists = $this->database->select('xray_audit_insight', 'xai') + ->fields('xai', ['id']) + ->condition('type', $type) + ->condition('data', $data) + ->condition('message', $message) + ->range(0, 1) + ->execute() + ->fetchField(); + + // Only insert if no matching record exists. + if (!$exists) { + $this->database->insert('xray_audit_insight') + ->fields([ + 'type' => $type, + 'data' => $data, + 'message' => $message, + ]) + ->execute(); + return TRUE; + } + + return FALSE; + } + + /** + * Gets insights for a specific external resource URL. + * + * @param string $normalized_url + * The normalized URL to check. + * + * @return array + * Array of matching insights. + */ + public function getExternalResourceInsights(string $normalized_url) { + $query = $this->database->select('xray_audit_insight', 'xai') + ->fields('xai') + ->condition('type', 'external_resource') + ->condition('data', $normalized_url); + return $query->execute()->fetchAll(); + } + + /** + * Cleans up orphaned external resource insights that don't match any media. + * + * @param array $normalized_urls + * Array of active normalized URLs. + */ + public function cleanupOrphanedExternalResourceInsights(array $normalized_urls) { + + $this->database->delete('xray_audit_insight') + ->condition('data', $normalized_urls, 'NOT IN') + ->execute(); + + } + +} diff --git a/modules/xray_audit_insight/xray_audit_insight.install b/modules/xray_audit_insight/xray_audit_insight.install new file mode 100644 index 0000000000000000000000000000000000000000..89204dffc9ffbcdb73570024444a55f464c5cfc4 --- /dev/null +++ b/modules/xray_audit_insight/xray_audit_insight.install @@ -0,0 +1,58 @@ +<?php + +/** + * @file + * Install, update and uninstall functions for the xray_audit_insight module. + */ + +/** + * Implements hook_schema(). + */ +function xray_audit_insight_schema() { + $schema['xray_audit_insight'] = [ + 'description' => 'Stores xray audit insight data', + 'fields' => [ + 'id' => [ + 'type' => 'serial', + 'not null' => TRUE, + 'description' => 'Primary Key: Unique insight ID.', + ], + 'type' => [ + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'description' => 'Insight type.', + ], + 'message' => [ + 'type' => 'text', + 'size' => 'big', + 'not null' => FALSE, + 'description' => 'Text data containing insight information.', + ], + 'data' => [ + 'type' => 'text', + 'size' => 'big', + 'not null' => FALSE, + 'description' => 'Text data containing insight information.', + ], + ], + 'primary key' => ['id'], + 'indexes' => [ + 'type' => ['type'], + ], + ]; + + return $schema; +} + +/** + * Create xray_audit_insight table. + */ +function xray_audit_insight_update_9001() { + $schema = \Drupal::database()->schema(); + $table_name = 'xray_audit_insight'; + + if (!$schema->tableExists($table_name)) { + $schema->createTable($table_name, xray_audit_insight_schema()[$table_name]); + } +} diff --git a/modules/xray_audit_insight/xray_audit_insight.services.yml b/modules/xray_audit_insight/xray_audit_insight.services.yml index 0a6dcb5574ab9387a909ef7aaf2c26ffdfb796b2..fec69cccb01cc65b27dfbb32cfb3d6c6b1c504dc 100644 --- a/modules/xray_audit_insight/xray_audit_insight.services.yml +++ b/modules/xray_audit_insight/xray_audit_insight.services.yml @@ -2,3 +2,14 @@ services: plugin_manager.xray_audit_insight: class: Drupal\xray_audit_insight\Plugin\XrayAuditInsightPluginManager parent: default_plugin_manager + + xray_audit_insight.report: + class: Drupal\xray_audit_insight\XrayAuditInsightReport + arguments: ['@database'] + + xray_audit_insight.resource_fetcher: + class: Drupal\xray_audit_insight\OEmbed\ResourceFetcherDecorator + arguments: [ '@xray_audit_insight.resource_fetcher.inner', '@xray_audit_insight.report'] + decorates: media.oembed.resource_fetcher + decoration_priority: 10 + public: false diff --git a/src/Plugin/xray_audit/tasks/ContentDisplay/XrayAuditExternalResourcesPlugin.php b/src/Plugin/xray_audit/tasks/ContentDisplay/XrayAuditExternalResourcesPlugin.php new file mode 100644 index 0000000000000000000000000000000000000000..adaad2e4827c92127a4483dac0c0ea02cdd79cdf --- /dev/null +++ b/src/Plugin/xray_audit/tasks/ContentDisplay/XrayAuditExternalResourcesPlugin.php @@ -0,0 +1,314 @@ +<?php + +namespace Drupal\xray_audit\Plugin\xray_audit\tasks\ContentDisplay; + +use Drupal\Component\Utility\Html; +use Drupal\xray_audit\Plugin\XrayAuditTaskPluginBase; +use Drupal\xray_audit\Utils\XrayAuditTableFilter; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Plugin implementation of external resources. + * + * @XrayAuditTaskPlugin ( + * id = "external_resources", + * label = @Translation("External Resources"), + * description = @Translation("External resources referenced in content."), + * group = "content_display", + * sort = 2, + * operations = { + * "external_resources" = { + * "label" = "External Resources", + * "description" = "Lists external resources referenced in content" + * } + * } + * ) + */ +class XrayAuditExternalResourcesPlugin extends XrayAuditTaskPluginBase { + + /** + * The entity type manager service. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * The entity field manager service. + * + * @var \Drupal\Core\Entity\EntityFieldManagerInterface + */ + protected $entityFieldManager; + + /** + * The module handler service. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected $moduleHandler; + + /** + * The XrayAuditInsightReport service. + * + * @var \Drupal\xray_audit_insight\XrayAuditInsightReport|null + */ + protected $insightReport; + + /** + * The URL resolver service. + * + * @var \Drupal\media\OEmbed\UrlResolverInterface + */ + protected $urlResolver; + + /** + * The link generator service. + * + * @var \Drupal\Core\Utility\LinkGeneratorInterface + */ + protected $linkGenerator; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + $instance = new static( + $configuration, + $plugin_id, + $plugin_definition + ); + $instance->entityTypeManager = $container->get('entity_type.manager'); + $instance->entityFieldManager = $container->get('entity_field.manager'); + $instance->moduleHandler = $container->get('module_handler'); + $instance->linkGenerator = $container->get('link_generator'); + + if ($instance->moduleHandler->moduleExists('media')) { + $instance->urlResolver = $container->get('media.oembed.url_resolver'); + } + // Check if xray_audit_insight module is enabled. + if ($instance->moduleHandler->moduleExists('xray_audit_insight')) { + $instance->insightReport = $container->get('xray_audit_insight.report'); + + } + + return $instance; + } + + /** + * {@inheritdoc} + */ + public function getDataOperationResult(string $operation = '') { + if (!$this->moduleHandler->moduleExists('media')) { + return []; + } + + $oembed_fields = $this->getOembedFields(); + $results = $this->processMediaEntities($oembed_fields); + $this->cleanupInsights($results); + + return $results; + } + + /** + * Gets all OEmbed fields from media bundles. + */ + private function getOembedFields(): array { + $oembed_fields = []; + $bundles = $this->entityTypeManager->getStorage('media_type')->loadMultiple(); + $display_storage = $this->entityTypeManager->getStorage('entity_view_display'); + + foreach ($bundles as $bundle_id => $bundle) { + $displays = $display_storage->loadByProperties([ + 'targetEntityType' => 'media', + 'bundle' => $bundle_id, + ]); + + foreach ($displays as $display) { + foreach ($display->getComponents() as $field_name => $component) { + if (isset($component['type']) && str_contains($component['type'], 'oembed')) { + $oembed_fields[$bundle_id][$field_name] = $field_name; + } + } + } + } + + return $oembed_fields; + } + + /** + * Process media entities and collect results. + */ + private function processMediaEntities(array $oembed_fields): array { + $results = []; + $media_storage = $this->entityTypeManager->getStorage('media'); + + foreach ($oembed_fields as $bundle_id => $field_names) { + $media_ids = $media_storage->getQuery() + ->condition('bundle', $bundle_id) + ->accessCheck(FALSE) + ->execute(); + + if (empty($media_ids)) { + continue; + } + + $media_entities = $media_storage->loadMultiple($media_ids); + foreach ($media_entities as $media_id => $media) { + $this->processMediaEntity($media, $media_id, $bundle_id, $field_names, $results); + } + } + + return $results; + } + + /** + * Process a single media entity. + */ + private function processMediaEntity($media, $media_id, $bundle_id, array $field_names, array &$results): void { + foreach ($field_names as $field_name) { + if (!$media->hasField($field_name) || $media->get($field_name)->isEmpty()) { + continue; + } + + foreach ($media->get($field_name) as $field_item) { + $value = $field_item->getValue(); + if (!isset($value['value'])) { + continue; + } + + $result = $this->createBaseResult($media_id, $bundle_id, $value['value'], $media); + $this->addInsightData($result, $value['value']); + $results[] = (object) $result; + } + } + } + + /** + * Creates base result array for a media item. + */ + private function createBaseResult($media_id, $bundle_id, $resource_url, $media): array { + return [ + 'entity_id' => $media_id, + 'entity_type' => 'media', + 'bundle' => $bundle_id, + 'resource' => $resource_url, + 'edit_url' => $media->toUrl('edit-form'), + 'needs_review' => FALSE, + 'insight_message' => '', + ]; + } + + /** + * Adds insight data to the result. + */ + private function addInsightData(array &$result, string $resource_url): void { + if (!$this->insightReport) { + return; + } + + try { + $normalized_url = $this->urlResolver->getResourceUrl($resource_url); + $insights = $this->insightReport->getExternalResourceInsights($normalized_url); + + if (!empty($insights)) { + $result['needs_review'] = TRUE; + $result['insight_message'] = end($insights)->message; + } + } + catch (\Exception $e) { + $this->insightReport->addInsightData('external_resource', $e->getMessage(), $resource_url); + $result['needs_review'] = TRUE; + $result['insight_message'] = $e->getMessage(); + } + } + + /** + * Cleanup orphaned insights. + */ + private function cleanupInsights(array $results): void { + if (!$this->insightReport) { + return; + } + + $media_urls = array_reduce($results, function ($urls, $result) { + $urls[] = $result->resource; + try { + $urls[] = $this->urlResolver->getResourceUrl($result->resource); + } + catch (\Exception) { + // Ignore URL resolution errors. + } + return $urls; + }, []); + + $this->insightReport->cleanupOrphanedExternalResourceInsights($media_urls); + } + + /** + * {@inheritdoc} + */ + public function buildDataRenderArray(array $data, string $operation = '') { + $build = []; + + $rows = []; + + $headers = [ + $this->t('Entity ID'), + $this->t('Entity Type'), + $this->t('Bundle'), + $this->t('Resource URL'), + $this->t('Edit'), + ]; + + foreach ($data as $row) { + $highlight = FALSE; + $row_data = [ + $row->entity_id ?? '', + $row->entity_type ?? '', + $row->bundle ?? '', + $row->resource ?? '', + $this->linkGenerator->generate($this->t('Edit'), $row->edit_url) ?? '', + ]; + + // Add insight columns if xray_audit_insight module is enabled. + if ($this->moduleHandler->moduleExists('xray_audit_insight')) { + $row_data[] = isset($row->needs_review) && $row->needs_review ? $this->t('Yes') : $this->t('No'); + $row_data[] = $row->insight_message ?? ''; + $highlight = isset($row->needs_review) && $row->needs_review; + } + + $rows[] = ['data' => $row_data, 'class' => $highlight ? ['xray-audit--highlighted'] : []]; + } + + // Add insight headers if xray_audit_insight module is enabled. + if ($this->moduleHandler->moduleExists('xray_audit_insight')) { + $headers[] = $this->t('Needs Review'); + $headers[] = $this->t('Message'); + } + $unique_id = Html::getUniqueId('xray-audit-external-resources'); + $build['table'] = [ + '#theme' => 'table', + '#header' => $headers, + '#sticky' => TRUE, + '#weight' => 10, + '#rows' => $rows, + '#empty' => $this->t('No external resources found'), + '#attributes' => [ + 'class' => ['xray-audit__table'], + 'id' => $unique_id, + ], + '#attached' => [ + 'library' => [ + 'xray_audit/xray_audit', + ], + ], + ]; + + $columns_indexes = [0, 1, 2, 3, 4, 5, 6]; + $build['filter'] = XrayAuditTableFilter::generateRenderableFilterInput($unique_id, $columns_indexes, NULL, $headers); + $build['filter']['#weight'] = 6; + + return $build; + } + +}