diff --git a/README.md b/README.md index 06eef40..aab5cab 100644 --- a/README.md +++ b/README.md @@ -19,25 +19,50 @@ See [the module's docs for more info](modules/migrate_embargoes_to_embargo/READM Configuration options can be set at `admin/config/content/embargo`, including a contact email and notification message. -Embargoes can be managed at `admin/content/embargo`. +Embargoes can be managed at `admin/content/embargo`. To add an IP range for use on embargoes, navigate to `admin/content/embargo/range` and click 'Add IP range'. Ranges created via this method can then be used as IP address whitelists when creating -embargoes. This [CIDR to IPv4 Conversion utility](https://www.ipaddressguide.com/cidr) +embargoes. This [CIDR to IPv4 Conversion utility](https://www.ipaddressguide.com/cidr) can be helpful in creating valid CIDR IP ranges. +### `search_api` processor(s) + +We have multiple `search_api` processors which attempt to constrain search +results based on the effects of embargoes on the entities represented by search +results, including: + +- `embargo_processor` ("Embargo access (deprecated)") + - Adds additional properties to the indexed rows, requiring additional index + maintenance on mutation of the entities under consideration, but should + theoretically work with any `search_api` backend +- `embargo_join_process` ("Embargo access, join-wise") + - Requires Solr/Solarium-compatible index, and indexing of embargo entities in + the same index as the node/media/files to be search, tracking necessary info + and performing + [Solr joins](https://solr.apache.org/guide/solr/latest/query-guide/join-query-parser.html) + to constrain results + +Typically, only one should be used in any particular index. + ## Usage ### Applying an embargo -An embargo can be applied to an existing node by clicking the -"Embargoes" tab on a node, or navigating to +An embargo can be applied to an existing node by clicking the +"Embargoes" tab on a node, or navigating to `embargoes/node/{node_id}`. From here, an embargo can be applied if it doesn't already exist, and existing embargoes can be modified or removed. -## Known Issues -Embargoed items may show up in search results. To work around this at a cost to performance you can enable access checking in your search views. +## Known Issues/FAQ + +- Embargoed items show up in search results + - Enable one of our `search_api` processors to handle applying embargo restrictions. +- "Embargo access, join-wise" does not show up as an available processor + - Ensure embargo entities are being indexed in the given index. + - Ensure that eligible node/media/files entities are being indexed in the + given index. ## Troubleshooting/Issues diff --git a/embargo.module b/embargo.module index ff0a01e..7dd6a09 100644 --- a/embargo.module +++ b/embargo.module @@ -5,18 +5,18 @@ * Hook implementations. */ +use Drupal\Core\Access\AccessResultInterface; use Drupal\Core\Database\Query\AlterableInterface; -use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Session\AccountInterface; -use Drupal\embargo\EmbargoInterface; use Drupal\embargo\EmbargoStorage; -use Drupal\islandora_hierarchical_access\LUTGeneratorInterface; +use Drupal\media\MediaInterface; +use Drupal\node\NodeInterface; /** * Implements hook_entity_type_alter(). */ -function embargo_entity_type_alter(array &$entity_types) { +function embargo_entity_type_alter(array &$entity_types) : void { $applicable_entity_types = EmbargoStorage::applicableEntityTypes(); foreach ($applicable_entity_types as $entity_type_id) { $entity_type = &$entity_types[$entity_type_id]; @@ -27,7 +27,7 @@ function embargo_entity_type_alter(array &$entity_types) { /** * Implements hook_entity_access(). */ -function embargo_entity_access(EntityInterface $entity, $operation, AccountInterface $account) { +function embargo_entity_access(EntityInterface $entity, $operation, AccountInterface $account) : AccessResultInterface { /** @var \Drupal\embargo\Access\EmbargoAccessCheckInterface $service */ $service = \Drupal::service('access_check.embargo'); return $service->access($entity, $account); @@ -36,7 +36,7 @@ function embargo_entity_access(EntityInterface $entity, $operation, AccountInter /** * Implements hook_file_download(). */ -function embargo_file_download($uri) { +function embargo_file_download($uri) : null|array|int { $files = \Drupal::entityTypeManager() ->getStorage('file') ->loadByProperties(['uri' => $uri]); @@ -47,12 +47,14 @@ function embargo_file_download($uri) { return -1; } } + + return NULL; } /** * Implements hook_query_TAG_alter() for `node_access` tagged queries. */ -function embargo_query_node_access_alter(AlterableInterface $query) { +function embargo_query_node_access_alter(AlterableInterface $query) : void { /** @var \Drupal\embargo\Access\QueryTagger $tagger */ $tagger = \Drupal::service('embargo.query_tagger'); $tagger->tagNode($query); @@ -61,7 +63,7 @@ function embargo_query_node_access_alter(AlterableInterface $query) { /** * Implements hook_theme(). */ -function embargo_theme($existing, $type, $theme, $path) { +function embargo_theme($existing, $type, $theme, $path) : array { return [ 'embargo_ip_access_exemption' => [ 'template' => 'embargo-ip-access-exemption', @@ -87,72 +89,68 @@ function embargo_theme($existing, $type, $theme, $path) { * Implements hook_ENTITY_TYPE_insert() for embargo entities. */ function embargo_embargo_insert(EntityInterface $entity) : void { - _embargo_search_api_track($entity); + /** @var \Drupal\embargo\SearchApiTracker $tracker */ + $tracker = \Drupal::service('embargo.search_api_tracker_helper'); + $tracker->track($entity); } /** * Implements hook_ENTITY_TYPE_update() for embargo entities. */ function embargo_embargo_update(EntityInterface $entity) : void { - _embargo_search_api_track($entity); + /** @var \Drupal\embargo\SearchApiTracker $tracker */ + $tracker = \Drupal::service('embargo.search_api_tracker_helper'); + $tracker->track($entity); } /** * Implements hook_ENTITY_TYPE_delete() for embargo entities. */ function embargo_embargo_delete(EntityInterface $entity) : void { - _embargo_search_api_track($entity); + /** @var \Drupal\embargo\SearchApiTracker $tracker */ + $tracker = \Drupal::service('embargo.search_api_tracker_helper'); + $tracker->track($entity); } /** - * Helper; deal with updating indexes of related items. - * - * @param \Drupal\Core\Entity\EntityInterface $entity - * The embargo instance. + * Implements hook_ENTITY_TYPE_delete() for node entities. */ -function _embargo_search_api_track(EntityInterface $entity) : void { - assert($entity instanceof EmbargoInterface); - if (!\Drupal::moduleHandler()->moduleExists('search_api')) { - return; - } +function embargo_node_delete(EntityInterface $entity) : void { + assert($entity instanceof NodeInterface); + /** @var \Drupal\embargo\SearchApiTracker $tracker */ + $tracker = \Drupal::service('embargo.search_api_tracker_helper'); + $tracker->propagateChildren($entity); +} - // On updates, deal with the original value, in addition to the new. - if (isset($entity->original)) { - _embargo_search_api_track($entity->original); - } +/** + * Implements hook_ENTITY_TYPE_insert() for media entities. + */ +function embargo_media_insert(EntityInterface $entity) : void { + assert($entity instanceof MediaInterface); - if (!($node = $entity->getEmbargoedNode())) { - // No embargoed node? - return; - } + /** @var \Drupal\embargo\SearchApiTracker $tracker */ + $tracker = \Drupal::service('embargo.search_api_tracker_helper'); + $tracker->mediaWriteReaction($entity); +} - /** @var \Drupal\search_api\Plugin\search_api\datasource\ContentEntityTrackingManager $tracking_manager */ - $tracking_manager = \Drupal::getContainer()->get('search_api.entity_datasource.tracking_manager'); - /** @var \Drupal\search_api\Utility\TrackingHelperInterface $tracking_helper */ - $tracking_helper = \Drupal::getContainer()->get('search_api.tracking_helper'); - - $track = function (ContentEntityInterface $entity) use ($tracking_manager, $tracking_helper) { - $tracking_manager->trackEntityChange($entity); - $tracking_helper->trackReferencedEntityUpdate($entity); - }; - - $track($node); - - $results = \Drupal::database()->select(LUTGeneratorInterface::TABLE_NAME, 'lut') - ->fields('lut', ['mid', 'fid']) - ->condition('nid', $node->id()) - ->execute(); - $media_ids = array_unique($results->fetchCol(/* 0 */)); - $file_ids = array_unique($results->fetchCol(1)); - - $entity_type_manager = \Drupal::entityTypeManager(); - /** @var \Drupal\media\MediaInterface $media */ - foreach ($entity_type_manager->getStorage('media')->loadMultiple($media_ids) as $media) { - $track($media); - } - /** @var \Drupal\file\FileInterface $file */ - foreach ($entity_type_manager->getStorage('file')->loadMultiple($file_ids) as $file) { - $track($file); - } +/** + * Implements hook_ENTITY_TYPE_update() for media entities. + */ +function embargo_media_update(EntityInterface $entity) : void { + assert($entity instanceof MediaInterface); + + /** @var \Drupal\embargo\SearchApiTracker $tracker */ + $tracker = \Drupal::service('embargo.search_api_tracker_helper'); + $tracker->mediaWriteReaction($entity); +} + +/** + * Implements hook_ENTITY_TYPE_delete() for media entities. + */ +function embargo_media_delete(EntityInterface $entity) : void { + assert($entity instanceof MediaInterface); + /** @var \Drupal\embargo\SearchApiTracker $tracker */ + $tracker = \Drupal::service('embargo.search_api_tracker_helper'); + $tracker->mediaDeleteReaction($entity); } diff --git a/embargo.services.yml b/embargo.services.yml index b316143..c6ef73b 100644 --- a/embargo.services.yml +++ b/embargo.services.yml @@ -18,11 +18,12 @@ services: - '@entity_type.manager' - '@datetime.time' - '@date.formatter' + - '@event_dispatcher' embargo.route_subscriber: class: Drupal\embargo\Routing\EmbargoRouteSubscriber arguments: ['@entity_type.manager'] tags: - - { name: event_subscriber } + - { name: 'event_subscriber' } embargo.ip_range_redirect: class: '\Drupal\embargo\EventSubscriber\IpRangeRedirect' arguments: @@ -38,3 +39,26 @@ services: - '@service_container' tags: - { name: 'event_subscriber' } + embargo.search_api_tracker_helper: + class: Drupal\embargo\SearchApiTracker + factory: [null, 'create'] + arguments: + - '@service_container' + embargo.search_api_solr_join_processor_event_subscriber: + class: Drupal\embargo\EventSubscriber\EmbargoJoinProcessorEventSubscriber + factory: [null, 'create'] + arguments: + - '@service_container' + tags: + - { name: 'event_subscriber' } + cache_context.ip.embargo_range: + class: Drupal\embargo\Cache\Context\IpRangeCacheContext + arguments: + - '@request_stack' + - '@entity_type.manager' + tags: + - { name: 'cache.context' } + embargo.tagging_event_subscriber: + class: Drupal\embargo\EventSubscriber\TaggingEventSubscriber + tags: + - { name: 'event_subscriber' } diff --git a/src/Access/QueryTagger.php b/src/Access/QueryTagger.php index 202c158..c5b0189 100644 --- a/src/Access/QueryTagger.php +++ b/src/Access/QueryTagger.php @@ -13,6 +13,7 @@ use Drupal\islandora_hierarchical_access\Access\QueryConjunctionTrait; use Drupal\islandora_hierarchical_access\TaggedTargetsTrait; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** * Handles tagging entity queries with access restrictions for embargoes. @@ -32,7 +33,8 @@ public function __construct( Connection $database, EntityTypeManagerInterface $entity_type_manager, TimeInterface $time, - DateFormatterInterface $date_formatter + DateFormatterInterface $date_formatter, + EventDispatcherInterface $event_dispatcher, ) { $this->user = $user; $this->currentIp = $request_stack->getCurrentRequest()->getClientIp(); @@ -40,6 +42,7 @@ public function __construct( $this->entityTypeManager = $entity_type_manager; $this->time = $time; $this->dateFormatter = $date_formatter; + $this->setEventDispatcher($event_dispatcher); } /** diff --git a/src/Cache/Context/IpRangeCacheContext.php b/src/Cache/Context/IpRangeCacheContext.php new file mode 100644 index 0000000..8f77b17 --- /dev/null +++ b/src/Cache/Context/IpRangeCacheContext.php @@ -0,0 +1,81 @@ +getRanges()); + sort($range_keys, SORT_NUMERIC); + return implode(',', $range_keys); + } + + /** + * {@inheritDoc} + */ + public function getCacheableMetadata() { + $cache_meta = new CacheableMetadata(); + + foreach ($this->getRanges() as $range) { + $cache_meta->addCacheableDependency($range); + } + + return $cache_meta; + } + + /** + * Get any IP range entities associated with the current IP address. + * + * @return \Drupal\embargo\IpRangeInterface[] + * Any relevant IP range entities. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + protected function getRanges() : array { + if (!isset($this->ranges)) { + /** @var \Drupal\embargo\IpRangeStorageInterface $embargo_ip_range_storage */ + $embargo_ip_range_storage = $this->entityTypeManager->getStorage('embargo_ip_range'); + $this->ranges = $embargo_ip_range_storage->getApplicableIpRanges($this->requestStack->getCurrentRequest() + ->getClientIp()); + } + + return $this->ranges; + } + +} diff --git a/src/EmbargoExistenceQueryTrait.php b/src/EmbargoExistenceQueryTrait.php index de6b77c..18db51b 100644 --- a/src/EmbargoExistenceQueryTrait.php +++ b/src/EmbargoExistenceQueryTrait.php @@ -10,6 +10,9 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Session\AccountProxyInterface; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface; +use Drupal\embargo\Event\TagExclusionEvent; +use Drupal\embargo\Event\TagInclusionEvent; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** * Helper trait; facilitate filtering of embargoed entities. @@ -58,6 +61,13 @@ trait EmbargoExistenceQueryTrait { */ protected DateFormatterInterface $dateFormatter; + /** + * The event dispatcher service. + * + * @var \Symfony\Contracts\EventDispatcher\EventDispatcherInterface + */ + protected EventDispatcherInterface $eventDispatcher; + /** * Helper; apply existence checks to a node(-like) table. * @@ -80,6 +90,60 @@ protected function applyExistenceQuery( ); } + /** + * Set the event dispatcher service. + * + * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $event_dispatcher + * The event dispatcher service to set. + * + * @return \Drupal\embargo\EmbargoExistenceQueryTrait|\Drupal\embargo\Access\QueryTagger|\Drupal\embargo\EventSubscriber\IslandoraHierarchicalAccessEventSubscriber + * The current instance; fluent interface. + */ + protected function setEventDispatcher(EventDispatcherInterface $event_dispatcher) : self { + $this->eventDispatcher = $event_dispatcher; + return $this; + } + + /** + * Get the event dispatcher service. + * + * @return \Symfony\Contracts\EventDispatcher\EventDispatcherInterface + * The event dispatcher service. + */ + protected function getEventDispatch() : EventDispatcherInterface { + return $this->eventDispatcher ?? \Drupal::service('event_dispatcher'); + } + + /** + * Build out condition for matching embargo entities. + * + * @param \Drupal\Core\Database\Query\SelectInterface $query + * The query in which the condition is to be attached. + * + * @return \Drupal\Core\Database\Query\ConditionInterface + * The condition to attach. + */ + protected function buildInclusionBaseCondition(SelectInterface $query) : ConditionInterface { + $dispatched_event = $this->getEventDispatch()->dispatch(new TagInclusionEvent($query)); + + return $dispatched_event->getCondition(); + } + + /** + * Build out condition for matching overriding embargo entities. + * + * @param \Drupal\Core\Database\Query\SelectInterface $query + * The query in which the condition is to be attached. + * + * @return \Drupal\Core\Database\Query\ConditionInterface + * The condition to attach. + */ + protected function buildExclusionBaseCondition(SelectInterface $query) : ConditionInterface { + $dispatched_event = $this->getEventDispatch()->dispatch(new TagExclusionEvent($query)); + + return $dispatched_event->getCondition(); + } + /** * Get query for negative assertion. * @@ -95,11 +159,10 @@ protected function getNullQuery(array $target_aliases, array $embargo_types) : S $embargo_alias = 'embargo_null'; $query = $this->database->select('embargo', $embargo_alias); $query->addExpression(1, 'embargo_null_e'); + $query->addMetaData('embargo_alias', $embargo_alias); + $query->addMetaData('embargo_target_aliases', $target_aliases); - $query->where(strtr('!field IN (!targets)', [ - '!field' => "{$embargo_alias}.embargoed_node", - '!targets' => implode(', ', $target_aliases), - ])); + $query->condition($this->buildInclusionBaseCondition($query)); $query->condition("{$embargo_alias}.embargo_type", $embargo_types, 'IN'); return $query; @@ -123,15 +186,12 @@ protected function getAccessibleEmbargoesQuery(array $target_aliases, array $emb $embargo_existence->addExpression(1, 'embargo_allowed'); $embargo_existence->addMetaData('embargo_alias', $embargo_alias); + $embargo_existence->addMetaData('embargo_target_aliases', $target_aliases); - $replacements = [ - '!field' => "{$embargo_alias}.embargoed_node", - '!targets' => implode(', ', $target_aliases), - ]; $embargo_existence->condition( $embargo_existence->orConditionGroup() ->condition($existence_condition = $embargo_existence->andConditionGroup() - ->where(strtr('!field IN (!targets)', $replacements)) + ->condition($this->buildInclusionBaseCondition($embargo_existence)) ->condition($embargo_or = $embargo_existence->orConditionGroup()) ) ); @@ -159,7 +219,10 @@ protected function getAccessibleEmbargoesQuery(array $target_aliases, array $emb $current_date = $this->dateFormatter->format($this->time->getRequestTime(), 'custom', DateTimeItemInterface::DATE_STORAGE_FORMAT); // No indefinite embargoes or embargoes expiring in the future. $unexpired_embargo_subquery = $this->database->select('embargo', 'ue') - ->where("ue.embargoed_node = {$embargo_alias}.embargoed_node") + ->addMetaData('embargo_alias', $embargo_alias) + ->addMetaData('embargo_target_aliases', $target_aliases) + ->addMetaData('embargo_unexpired_alias', 'ue'); + $unexpired_embargo_subquery->condition($this->buildExclusionBaseCondition($unexpired_embargo_subquery)) ->condition('ue.embargo_type', $embargo_types, 'IN'); $unexpired_embargo_subquery->addExpression(1, 'ueee'); $unexpired_embargo_subquery->condition($unexpired_embargo_subquery->orConditionGroup() diff --git a/src/EmbargoStorage.php b/src/EmbargoStorage.php index 9221119..5f8f0e9 100644 --- a/src/EmbargoStorage.php +++ b/src/EmbargoStorage.php @@ -2,116 +2,24 @@ namespace Drupal\embargo; -use Drupal\Core\Cache\CacheBackendInterface; -use Drupal\Core\Cache\MemoryCache\MemoryCacheInterface; -use Drupal\Core\Database\Connection; -use Drupal\Core\Entity\EntityFieldManagerInterface; -use Drupal\Core\Entity\EntityInterface; -use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Entity\EntityTypeInterface; -use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\Sql\SqlContentEntityStorage; -use Drupal\Core\Language\LanguageManagerInterface; -use Drupal\Core\Session\AccountInterface; -use Drupal\file\FileInterface; -use Drupal\islandora_hierarchical_access\LUTGeneratorInterface; -use Drupal\media\MediaInterface; -use Drupal\node\NodeInterface; use Symfony\Component\DependencyInjection\ContainerInterface; -use Symfony\Component\HttpFoundation\RequestStack; /** * Storage for embargo entities. */ class EmbargoStorage extends SqlContentEntityStorage implements EmbargoStorageInterface { - /** - * The current request. - * - * @var \Symfony\Component\HttpFoundation\Request - */ - protected $request; - - /** - * The current user. - * - * @var \Drupal\Core\Session\AccountInterface - */ - protected $user; - - /** - * Constructor. - */ - public function __construct(EntityTypeInterface $entity_type, Connection $database, EntityFieldManagerInterface $entity_field_manager, CacheBackendInterface $cache, LanguageManagerInterface $language_manager, MemoryCacheInterface $memory_cache, EntityTypeBundleInfoInterface $entity_type_bundle_info, EntityTypeManagerInterface $entity_type_manager, RequestStack $request_stack, AccountInterface $user) { - parent::__construct($entity_type, $database, $entity_field_manager, $cache, $language_manager, $memory_cache, $entity_type_bundle_info, $entity_type_manager); - $this->request = $request_stack->getCurrentRequest(); - $this->user = $user; - } + use EmbargoStorageTrait; /** * {@inheritdoc} */ public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) { - return new static( - $entity_type, - $container->get('database'), - $container->get('entity_field.manager'), - $container->get('cache.entity'), - $container->get('language_manager'), - $container->get('entity.memory_cache'), - $container->get('entity_type.bundle.info'), - $container->get('entity_type.manager'), - $container->get('request_stack'), - $container->get('current_user'), - ); - } - - /** - * {@inheritdoc} - */ - public static function applicableEntityTypes(): array { - return [ - 'node', - 'media', - 'file', - ]; - } - - /** - * {@inheritdoc} - */ - public function getApplicableEmbargoes(EntityInterface $entity): array { - if ($entity instanceof NodeInterface) { - $properties = ['embargoed_node' => $entity->id()]; - return $this->loadByProperties($properties); - } - elseif ($entity instanceof MediaInterface || $entity instanceof FileInterface) { - $query = $this->database->select('embargo', 'e') - ->fields('e', ['id']) - ->distinct(); - $lut_alias = $query->join(LUTGeneratorInterface::TABLE_NAME, 'lut', '%alias.nid = e.embargoed_node'); - $key = $entity instanceof MediaInterface ? 'mid' : 'fid'; - $query->condition("{$lut_alias}.{$key}", $entity->id()); - $ids = $query->execute()->fetchCol(); - return $this->loadMultiple($ids); - } - return []; - } - - /** - * {@inheritdoc} - */ - public function getApplicableNonExemptNonExpiredEmbargoes(EntityInterface $entity, ?int $timestamp = NULL, ?AccountInterface $user = NULL, ?string $ip = NULL): array { - $timestamp = $timestamp ?? $this->request->server->get('REQUEST_TIME'); - $user = $user ?? $this->user; - $ip = $ip ?? $this->request->getClientIp(); - return array_filter($this->getApplicableEmbargoes($entity), function ($embargo) use ($entity, $timestamp, $user, $ip): bool { - $inactive = $embargo->expiresBefore($timestamp); - $type_exempt = ($entity instanceof NodeInterface && $embargo->getEmbargoType() !== EmbargoInterface::EMBARGO_TYPE_NODE); - $user_exempt = $embargo->isUserExempt($user); - $ip_exempt = $embargo->ipIsExempt($ip); - return !($inactive || $type_exempt || $user_exempt || $ip_exempt); - }); + return parent::createInstance($container, $entity_type) + ->setRequest($container->get('request_stack')->getCurrentRequest()) + ->setUser($container->get('current_user')); } } diff --git a/src/EmbargoStorageInterface.php b/src/EmbargoStorageInterface.php index 67aa737..af1cffc 100644 --- a/src/EmbargoStorageInterface.php +++ b/src/EmbargoStorageInterface.php @@ -11,11 +11,19 @@ */ interface EmbargoStorageInterface extends ContentEntityStorageInterface { + const APPLICABLE_ENTITY_TYPES = [ + 'node', + 'media', + 'file', + ]; + /** * A list of entity types which an embargo can apply to. * * @return string[] * A list of entity types identifiers which an embargo can apply to. + * + * @obsolete */ public static function applicableEntityTypes(); diff --git a/src/EmbargoStorageTrait.php b/src/EmbargoStorageTrait.php new file mode 100644 index 0000000..ef640ff --- /dev/null +++ b/src/EmbargoStorageTrait.php @@ -0,0 +1,109 @@ + $entity->id()]; + return $this->loadByProperties($properties); + } + elseif ($entity instanceof MediaInterface || $entity instanceof FileInterface) { + $query = $this->database->select('embargo', 'e') + ->fields('e', ['id']) + ->distinct(); + $lut_alias = $query->join(LUTGeneratorInterface::TABLE_NAME, 'lut', '%alias.nid = e.embargoed_node'); + $key = $entity instanceof MediaInterface ? 'mid' : 'fid'; + $query->condition("{$lut_alias}.{$key}", $entity->id()); + $ids = $query->execute()->fetchCol(); + return $this->loadMultiple($ids); + } + return []; + } + + /** + * {@inheritdoc} + */ + public function getApplicableNonExemptNonExpiredEmbargoes(EntityInterface $entity, ?int $timestamp = NULL, ?AccountInterface $user = NULL, ?string $ip = NULL): array { + $timestamp = $timestamp ?? $this->request->server->get('REQUEST_TIME'); + $user = $user ?? $this->user; + $ip = $ip ?? $this->request->getClientIp(); + return array_filter($this->getApplicableEmbargoes($entity), function ($embargo) use ($entity, $timestamp, $user, $ip): bool { + $inactive = $embargo->expiresBefore($timestamp); + $type_exempt = ($entity instanceof NodeInterface && $embargo->getEmbargoType() !== EmbargoInterface::EMBARGO_TYPE_NODE); + $user_exempt = $embargo->isUserExempt($user); + $ip_exempt = $embargo->ipIsExempt($ip); + return !($inactive || $type_exempt || $user_exempt || $ip_exempt); + }); + } + + /** + * Set the user visible to the trait. + * + * @param \Drupal\Core\Session\AccountInterface $user + * The user with which to evaluate. + * + * @return \Drupal\embargo\EmbargoStorageInterface|\Drupal\embargo\EmbargoStorageTrait + * Fluent interface; the current object. + */ + protected function setUser(AccountInterface $user) : self { + $this->user = $user; + return $this; + } + + /** + * The request visible to the trait. + * + * @param \Symfony\Component\HttpFoundation\Request|null $request + * The request with which to evaluate. + * + * @return \Drupal\embargo\EmbargoStorageInterface|\Drupal\embargo\EmbargoStorageTrait + * Fluent interface; the current object. + */ + protected function setRequest(?Request $request) : self { + $this->request = $request; + return $this; + } + +} diff --git a/src/Entity/Embargo.php b/src/Entity/Embargo.php index 03f8b57..1201540 100644 --- a/src/Entity/Embargo.php +++ b/src/Entity/Embargo.php @@ -378,33 +378,49 @@ public function setEmbargoedNode(NodeInterface $node): EmbargoInterface { } /** - * The maximum age for which this object may be cached. - * - * @return int - * The maximum time in seconds that this object may be cached. + * {@inheritDoc} */ public function getCacheMaxAge() { + $max_age = parent::getCacheMaxAge(); + $now = time(); // Invalidate cache after a scheduled embargo expires. if ($this->getExpirationType() === static::EXPIRATION_TYPE_SCHEDULED && !$this->expiresBefore($now)) { - return $this->getExpirationDate()->getTimestamp() - $now; + $max_age = Cache::mergeMaxAges($max_age, $this->getExpirationDate()->getTimestamp() - $now); } - // Other properties of the embargo are not time dependent. - return parent::getCacheMaxAge(); + + return $max_age; } /** * {@inheritdoc} */ public function getCacheTags() { - $tags = parent::getCacheTags(); - $tags[] = "node:{$this->getEmbargoedNode()->id()}"; + $tags = Cache::mergeTags(parent::getCacheTags(), $this->getEmbargoedNode()->getCacheTags()); + if ($this->getExemptIps()) { $tags = Cache::mergeTags($tags, $this->getExemptIps()->getCacheTags()); } return $tags; } + /** + * {@inheritDoc} + */ + public function getCacheContexts() { + $contexts = Cache::mergeContexts( + parent::getCacheContexts(), + $this->getEmbargoedNode()->getCacheContexts(), + [$this->getExemptUsers() ? 'user' : 'user.permissions'], + ); + + if ($this->getExemptIps()) { + $contexts = Cache::mergeContexts($contexts, $this->getExemptIps()->getCacheContexts()); + } + + return $contexts; + } + /** * {@inheritdoc} */ diff --git a/src/Entity/IpRange.php b/src/Entity/IpRange.php index 60bea11..1590688 100644 --- a/src/Entity/IpRange.php +++ b/src/Entity/IpRange.php @@ -3,6 +3,7 @@ namespace Drupal\embargo\Entity; use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Cache\Cache; use Drupal\Core\Entity\ContentEntityBase; use Drupal\Core\Entity\EntityStorageException; use Drupal\Core\Entity\EntityStorageInterface; @@ -239,4 +240,13 @@ public static function isValidCidr(string $cidr): bool { return FALSE; } + /** + * {@inheritDoc} + */ + public function getCacheContexts() { + return Cache::mergeContexts(parent::getCacheContexts(), [ + 'ip.embargo_range', + ]); + } + } diff --git a/src/Event/AbstractTagEvent.php b/src/Event/AbstractTagEvent.php new file mode 100644 index 0000000..220c62c --- /dev/null +++ b/src/Event/AbstractTagEvent.php @@ -0,0 +1,70 @@ +condition = $this->query->orConditionGroup(); + } + + /** + * Get the query upon which to act. + * + * @return \Drupal\Core\Database\Query\SelectInterface + * The query upon which we are to act. + */ + public function getQuery() : SelectInterface { + return $this->query; + } + + /** + * Get the current condition. + * + * @return \Drupal\Core\Database\Query\ConditionInterface + * The current condition. + */ + public function getCondition() : ConditionInterface { + return $this->condition; + } + + /** + * Get the base "embargo" table alias. + * + * @return string + * The base "embargo" alias, as used in the query. + */ + public function getEmbargoAlias() : string { + return $this->query->getMetaData('embargo_alias'); + } + + /** + * Get the base query columns representing node IDs to find embargoes. + * + * @return string[] + * The column aliases representing node IDs. + */ + public function getTargetAliases() : array { + return $this->query->getMetaData('embargo_target_aliases'); + } + +} diff --git a/src/Event/EmbargoEvents.php b/src/Event/EmbargoEvents.php new file mode 100644 index 0000000..effceb4 --- /dev/null +++ b/src/Event/EmbargoEvents.php @@ -0,0 +1,14 @@ +query->getMetaData('embargo_unexpired_alias'); + } + +} diff --git a/src/Event/TagInclusionEvent.php b/src/Event/TagInclusionEvent.php new file mode 100644 index 0000000..42937a7 --- /dev/null +++ b/src/Event/TagInclusionEvent.php @@ -0,0 +1,10 @@ +get('search_api.fields_helper'), + $container->get('current_user'), + $container->get('entity_type.manager'), + $container->get('request_stack'), + ); + } + + /** + * {@inheritDoc} + */ + public static function getSubscribedEvents() { + $events = []; + + if (class_exists(SearchApiSolrEvents::class)) { + $events += [ + SearchApiSolrEvents::PRE_QUERY => 'preQuery', + ]; + } + + return $events; + } + + /** + * Event handler; respond to search_api_solr pre-query event. + * + * @param \Drupal\search_api_solr\Event\PreQueryEvent $event + * The event to which to respond. + */ + public function preQuery(PreQueryEvent $event) : void { + $search_api_query = $event->getSearchApiQuery(); + if (!$search_api_query->hasTag('embargo_join_processor')) { + return; + } + + $queries = $search_api_query->getOption('embargo_join_processor__queries', []); + + if (!$queries) { + return; + } + + $backend = $search_api_query->getIndex()->getServerInstance()->getBackend(); + assert($backend instanceof SolrBackendInterface); + $map = $backend->getSolrFieldNames($search_api_query->getIndex()); + $memoized_map = []; + $get_field_name = function (?string $datasource_id, string $property_path) use ($search_api_query, $map, &$memoized_map) { + $key = "{$datasource_id}__{$property_path}"; + if (!isset($memoized_map[$key])) { + $fields = $this->fieldsHelper->filterForPropertyPath( + $search_api_query->getIndex()->getFieldsByDatasource($datasource_id), + $datasource_id, + $property_path, + ); + /** @var \Drupal\search_api\Item\FieldInterface $field */ + $field = reset($fields); + + $memoized_map[$key] = $map[$field->getFieldIdentifier()]; + } + + return $memoized_map[$key]; + }; + + $solarium_query = $event->getSolariumQuery(); + assert($solarium_query instanceof SolariumSelectQuery); + $helper = $solarium_query->getHelper(); + + /** @var \Drupal\embargo\IpRangeInterface[] $ip_range_entities */ + $ip_range_entities = $search_api_query->getOption('embargo_join_processor__ip_ranges', []); + + foreach ($queries as $type => $info) { + $solarium_query->createFilterQuery([ + 'key' => "embargo_join:{$type}", + 'query' => strtr( + implode(' ', [ + '(*:* -!datasource_field:(!datasources))', + '(*:* -_query_:"!join*:*")', + '_query_:"!join(', + implode(' ', [ + '+(*:* -!type_field:\\"0\\")', + '+!type_field:\\"1\\"', + '+!date_field:[* TO \\"!date_value\\"]', + '+(*:* -!date_field:[\\"!next_date_value\\" TO *])', + ]), + ')"', + '!join!exempt_user_field:"!current_user"', + $ip_range_entities ? '_query_:"!join!exempt_ip_field:(!exempt_ip_ranges)"' : '', + ]), + [ + '!join' => $helper->join( + $get_field_name(NULL, $info['embargo path']), + $get_field_name(NULL, $info['node path']), + ), + '!type_field' => $get_field_name('entity:embargo', 'expiration_type'), + '!exempt_user_field' => $get_field_name('entity:embargo', 'exempt_users:entity:uid'), + '!current_user' => $this->currentUser->id(), + '!exempt_ip_field' => $get_field_name('entity:embargo', 'exempt_ips:entity:id'), + '!exempt_ip_ranges' => implode( + ' ', + array_map( + $helper->escapeTerm(...), + array_map( + function ($range) { + return $range->id(); + }, + $ip_range_entities + ) + ) + ), + '!embargo_id' => $get_field_name('entity:embargo', 'id'), + '!date_field' => $get_field_name('entity:embargo', 'expiration_date'), + '!date_value' => $helper->formatDate(strtotime('now')), + '!next_date_value' => $helper->formatDate(strtotime('now + 1day')), + '!datasource_field' => $map['search_api_datasource'], + '!datasources' => implode(',', array_map( + function (string $source_id) { + return strtr('"!source"', [ + '!source' => $source_id, + ]); + }, + $info['data sources'], + )), + ], + ), + ])->addTag("embargo_join_processor:{$type}"); + } + + } + +} diff --git a/src/EventSubscriber/IslandoraHierarchicalAccessEventSubscriber.php b/src/EventSubscriber/IslandoraHierarchicalAccessEventSubscriber.php index bad9e35..56010e3 100644 --- a/src/EventSubscriber/IslandoraHierarchicalAccessEventSubscriber.php +++ b/src/EventSubscriber/IslandoraHierarchicalAccessEventSubscriber.php @@ -42,14 +42,15 @@ public function __construct( * {@inheritDoc} */ public static function create(ContainerInterface $container) : self { - return new static( + return (new static( $container->get('current_user'), $container->get('request_stack'), $container->get('database'), $container->get('entity_type.manager'), $container->get('datetime.time'), $container->get('date.formatter'), - ); + )) + ->setEventDispatcher($container->get('event_dispatcher')); } /** diff --git a/src/EventSubscriber/TaggingEventSubscriber.php b/src/EventSubscriber/TaggingEventSubscriber.php new file mode 100644 index 0000000..0044ca5 --- /dev/null +++ b/src/EventSubscriber/TaggingEventSubscriber.php @@ -0,0 +1,51 @@ + 'inclusion', + EmbargoEvents::TAG_EXCLUSION => 'exclusion', + ]; + } + + /** + * Event handler; tagging inclusion event. + * + * @param \Drupal\embargo\Event\TagInclusionEvent $event + * The event being handled. + */ + public function inclusion(TagInclusionEvent $event) : void { + $event->getCondition()->where(strtr('!field IN (!targets)', [ + '!field' => "{$event->getEmbargoAlias()}.embargoed_node", + '!targets' => implode(', ', $event->getTargetAliases()), + ])); + } + + /** + * Event handler; tagging exclusion event. + * + * @param \Drupal\embargo\Event\TagExclusionEvent $event + * The event being handled. + */ + public function exclusion(TagExclusionEvent $event) : void { + // With traversing a single level of the hierarchy, it makes sense to + // constrain to the same node as matched in the "inclusion", instead of + // again referencing the other aliased columns dealing with node IDs. + $event->getCondition()->where("{$event->getUnexpiredAlias()}.embargoed_node = {$event->getEmbargoAlias()}.embargoed_node"); + } + +} diff --git a/src/Plugin/search_api/processor/EmbargoJoinProcessor.php b/src/Plugin/search_api/processor/EmbargoJoinProcessor.php new file mode 100644 index 0000000..90b8d3f --- /dev/null +++ b/src/Plugin/search_api/processor/EmbargoJoinProcessor.php @@ -0,0 +1,309 @@ +currentUser = $container->get('current_user'); + $instance->database = $container->get('database'); + $instance->entityTypeManager = $container->get('entity_type.manager'); + $instance->requestStack = $container->get('request_stack'); + + return $instance; + } + + /** + * {@inheritDoc} + */ + public static function supportsIndex(IndexInterface $index) { + return parent::supportsIndex($index) && + in_array('entity:embargo', $index->getDatasourceIds()) && + array_intersect( + $index->getDatasourceIds(), + array_map(function (string $type) { + return "entity:{$type}"; + }, static::ENTITY_TYPES) + ); + } + + /** + * {@inheritdoc} + */ + public function getPropertyDefinitions(DatasourceInterface $datasource = NULL) : array { + $properties = []; + + if ($datasource === NULL) { + // Represent the node(s) to which a general content entity is associated. + $properties[static::NODE_FIELD] = new ProcessorProperty([ + 'processor_id' => $this->getPluginId(), + 'is_list' => TRUE, + 'is_computed' => TRUE, + ]); + // Represent the node of which a "file" embargo is associated. + $properties[static::EMBARGO_FIELD_FILE] = new ProcessorProperty([ + 'processor_id' => $this->getPluginId(), + 'is_list' => FALSE, + 'is_computed' => TRUE, + ]); + // Represent the node of which a "node" embargo is associated. + $properties[static::EMBARGO_FIELD_NODE] = new ProcessorProperty([ + 'processor_id' => $this->getPluginId(), + 'is_list' => FALSE, + 'is_computed' => TRUE, + ]); + } + + return $properties; + } + + /** + * {@inheritdoc} + * + * Adapted from search_api's reverse_entity_references processor. + * + * @see \Drupal\search_api\Plugin\search_api\processor\ReverseEntityReferences::addFieldValues() + */ + public function addFieldValues(ItemInterface $item) : void { + if (!in_array($item->getDatasource()->getEntityTypeId(), static::ALL_ENTITY_TYPES)) { + return; + } + try { + $entity = $item->getOriginalObject()->getValue(); + } + catch (SearchApiException) { + return; + } + if (!($entity instanceof EntityInterface)) { + return; + } + + if (in_array($item->getDatasource()->getEntityTypeId(), static::ENTITY_TYPES)) { + $this->doAddNodeField($item, $entity); + } + else { + $this->doAddEmbargoField($item, $entity); + } + + } + + /** + * Find the nodes related to the given entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity in question. + * + * @return string[]|int[] + * The IDs of the related nodes. + */ + protected function findRelatedNodes(EntityInterface $entity) : array { + if ($entity->getEntityTypeId() === 'node') { + return [$entity->id()]; + } + else { + $column = match ($entity->getEntityTypeId()) { + 'media' => 'mid', + 'file' => 'fid', + }; + return $this->database->select(LUTGeneratorInterface::TABLE_NAME, 'lut') + ->fields('lut', ['nid']) + ->condition("lut.{$column}", $entity->id()) + ->execute() + ->fetchCol(); + } + } + + /** + * Helper; build out field(s) for general content entities. + * + * @param \Drupal\search_api\Item\ItemInterface $item + * The item being indexed. + * @param \Drupal\Core\Entity\EntityInterface $entity + * The content entity of the item being indexed. + */ + protected function doAddNodeField(ItemInterface $item, EntityInterface $entity) : void { + $embargo_node_fields = $this->getFieldsHelper()->filterForPropertyPath($item->getFields(FALSE), NULL, static::NODE_FIELD); + if ($embargo_node_fields) { + $nodes = array_unique($this->findRelatedNodes($entity)); + + foreach ($embargo_node_fields as $field) { + foreach ($nodes as $node_id) { + $field->addValue($node_id); + } + } + } + } + + /** + * Helper; build out field(s) for embargo entities, specifically. + * + * @param \Drupal\search_api\Item\ItemInterface $item + * The item being indexed. + * @param \Drupal\Core\Entity\EntityInterface $entity + * The content entity of the item being indexed. + */ + protected function doAddEmbargoField(ItemInterface $item, EntityInterface $entity) : void { + assert($entity instanceof EmbargoInterface); + $paths = match ($entity->getEmbargoType()) { + EmbargoInterface::EMBARGO_TYPE_FILE => [static::EMBARGO_FIELD_FILE], + EmbargoInterface::EMBARGO_TYPE_NODE => [static::EMBARGO_FIELD_NODE, static::EMBARGO_FIELD_FILE], + }; + + $fields = $item->getFields(FALSE); + foreach ($paths as $path) { + $target_fields = $this->getFieldsHelper()->filterForPropertyPath($fields, NULL, $path); + foreach ($target_fields as $target_field) { + $target_field->addValue($entity->getEmbargoedNode()->id()); + } + } + } + + /** + * {@inheritDoc} + */ + public function preIndexSave() : void { + $this->ensureField(NULL, static::NODE_FIELD, 'integer'); + $this->ensureField(NULL, static::EMBARGO_FIELD_FILE, 'integer'); + $this->ensureField(NULL, static::EMBARGO_FIELD_NODE, 'integer'); + + $this->ensureField('entity:embargo', 'id', 'integer'); + $this->ensureField('entity:embargo', 'embargoed_node:entity:nid', 'integer'); + $this->ensureField('entity:embargo', 'embargo_type', 'integer'); + $this->ensureField('entity:embargo', 'expiration_date', 'date'); + $this->ensureField('entity:embargo', 'expiration_type', 'integer'); + $this->ensureField('entity:embargo', 'exempt_ips:entity:id', 'integer'); + $this->ensureField('entity:embargo', 'exempt_users:entity:uid', 'integer'); + } + + /** + * {@inheritDoc} + */ + public function preprocessSearchQuery(QueryInterface $query) : void { + assert($query instanceof RefinableCacheableDependencyInterface); + $query->addCacheContexts(['user.permissions']); + if ($this->currentUser->hasPermission('bypass embargo access')) { + return; + } + + $queries = []; + + if (in_array('entity:node', $this->index->getDatasourceIds())) { + $queries['node'] = [ + 'data sources' => ['entity:node'], + 'embargo path' => static::EMBARGO_FIELD_NODE, + 'node path' => static::NODE_FIELD, + ]; + } + if ($intersection = array_intersect($this->index->getDatasourceIds(), ['entity:media', 'entity:file'])) { + $queries['file'] = [ + 'data sources' => $intersection, + 'embargo path' => static::EMBARGO_FIELD_FILE, + 'node path' => static::NODE_FIELD, + ]; + } + + if (!$queries) { + return; + } + + /** @var \Drupal\embargo\IpRangeInterface[] $ip_range_entities */ + $ip_range_entities = $this->entityTypeManager->getStorage('embargo_ip_range') + ->getApplicableIpRanges($this->requestStack->getCurrentRequest()->getClientIp()); + + $query->addCacheContexts([ + // Caching by groups of ranges instead of individually should promote + // cacheability. + 'ip.embargo_range', + // Exemptable users, so need to deal with them. + 'user', + ]); + // Embargo dates deal with granularity to the day. + $query->mergeCacheMaxAge(24 * 3600); + + $types = ['embargo', 'embargo_ip_range', 'media', 'file', 'node']; + foreach ($types as $type) { + /** @var \Drupal\Core\Entity\EntityTypeInterface $entity_type */ + $entity_type = $this->entityTypeManager->getDefinition($type); + $query->addCacheTags($entity_type->getListCacheTags()); + } + + $query->addTag('embargo_join_processor'); + $query->setOption('embargo_join_processor__ip_ranges', $ip_range_entities); + $query->setOption('embargo_join_processor__queries', $queries); + } + +} diff --git a/src/Plugin/search_api/processor/EmbargoProcessor.php b/src/Plugin/search_api/processor/EmbargoProcessor.php index 82f9613..278f96f 100644 --- a/src/Plugin/search_api/processor/EmbargoProcessor.php +++ b/src/Plugin/search_api/processor/EmbargoProcessor.php @@ -25,9 +25,8 @@ * * @SearchApiProcessor( * id = "embargo_processor", - * label = @Translation("Embargo access"), - * description = @Translation("Add information regarding embargo access - * constraints."), + * label = @Translation("Embargo access (deprecated)"), + * description = @Translation("Add information regarding embargo access constraints."), * stages = { * "add_properties" = 20, * "pre_index_save" = 20, @@ -87,15 +86,17 @@ public static function create(ContainerInterface $container, array $configuratio * {@inheritdoc} */ public function getPropertyDefinitions(DatasourceInterface $datasource = NULL) : array { + $properties = []; + if ($datasource === NULL) { - return []; + return $properties; } - return [ - 'embargo' => ListableEntityProcessorProperty::create('embargo') - ->setList() - ->setProcessorId($this->getPluginId()), - ]; + $properties['embargo'] = ListableEntityProcessorProperty::create('embargo') + ->setList() + ->setProcessorId($this->getPluginId()); + + return $properties; } /** @@ -135,8 +136,17 @@ public function addFieldValues(ItemInterface $item) : void { /** @var \Drupal\embargo\EmbargoStorageInterface $embargo_storage */ $embargo_storage = $this->entityTypeManager->getStorage('embargo'); $embargoes = $embargo_storage->getApplicableEmbargoes($entity); + $relevant_embargoes = array_filter( + $embargoes, + function (EmbargoInterface $embargo) use ($entity) { + return in_array($embargo->getEmbargoType(), match ($entity->getEntityTypeId()) { + 'file', 'media' => [EmbargoInterface::EMBARGO_TYPE_FILE, EmbargoInterface::EMBARGO_TYPE_NODE], + 'node' => [EmbargoInterface::EMBARGO_TYPE_NODE], + }); + } + ); - foreach ($embargoes as $embargo) { + foreach ($relevant_embargoes as $embargo) { $this->getFieldsHelper()->extractFields($embargo->getTypedData(), $to_extract); } @@ -251,7 +261,7 @@ protected function addEmbargoFilters(string $datasource_id, QueryInterface $quer $or_group->addCondition($field->getFieldIdentifier(), $ipRange->id()); $query->addCacheableDependency($ipRange); } - $query->addCacheContexts(['ip']); + $query->addCacheContexts(['ip.embargo_range']); } return (count($or_group->getConditions()) > 0) ? $or_group : NULL; diff --git a/src/SearchApiTracker.php b/src/SearchApiTracker.php new file mode 100644 index 0000000..995fabf --- /dev/null +++ b/src/SearchApiTracker.php @@ -0,0 +1,285 @@ +get('module_handler'), + $container->get('search_api.entity_datasource.tracking_manager', ContainerInterface::NULL_ON_INVALID_REFERENCE), + $container->get('search_api.tracking_helper', ContainerInterface::NULL_ON_INVALID_REFERENCE), + $container->get('entity_type.manager'), + $container->get('database'), + ); + } + + /** + * Memoize if we found an index requiring our index maintenance. + * + * @var bool + */ + protected bool $isProcessorEnabled; + + /** + * Helper; determine if our "embargo_processor" processor is enabled. + * + * If _not_ enabled, we do not have to perform the index maintenance in this + * service. + * + * @return bool + * TRUE if the "embargo_processor" processor is enabled on an index; + * otherwise, FALSE. + */ + protected function isProcessorEnabled() : bool { + if (!isset($this->isProcessorEnabled)) { + $this->isProcessorEnabled = FALSE; + if (!$this->moduleHandler->moduleExists('search_api')) { + return $this->isProcessorEnabled; + } + /** @var \Drupal\search_api\IndexInterface[] $indexes */ + $indexes = $this->entityTypeManager->getStorage('search_api_index') + ->loadMultiple(); + foreach ($indexes as $index) { + if ($index->isValidProcessor('embargo_processor')) { + $this->isProcessorEnabled = TRUE; + break; + } + } + } + + return $this->isProcessorEnabled; + } + + /** + * Track the given entity (and related entities) for indexing. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to track. + */ + public function track(EntityInterface $entity) : void { + assert($entity instanceof EmbargoInterface); + if (!$this->isProcessorEnabled()) { + return; + } + + // On updates, deal with the original value, in addition to the new. + if (isset($entity->original)) { + $this->track($entity->original); + } + + if (!($node = $entity->getEmbargoedNode())) { + // No embargoed node? + return; + } + + assert($node instanceof NodeInterface); + + $this->doTrack($node); + $this->propagateChildren($node); + } + + /** + * Actually deal with updating search_api's trackers. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The entity to track. + */ + public function doTrack(ContentEntityInterface $entity) : void { + if (!$this->isProcessorEnabled()) { + return; + } + $this->trackingManager->trackEntityChange($entity); + $this->trackingHelper->trackReferencedEntityUpdate($entity); + } + + /** + * Helper; propagate tracking updates down to related media and files. + * + * @param \Drupal\node\NodeInterface $node + * The node of which to propagate. + */ + public function propagateChildren(NodeInterface $node) : void { + $results = $this->database->select(LUTGeneratorInterface::TABLE_NAME, 'lut') + ->fields('lut', ['mid', 'fid']) + ->condition('nid', $node->id()) + ->execute(); + $media_ids = array_unique($results->fetchCol(/* 0 */)); + $file_ids = array_unique($results->fetchCol(1)); + + /** @var \Drupal\media\MediaInterface $media */ + foreach ($this->entityTypeManager->getStorage('media')->loadMultiple($media_ids) as $media) { + $this->doTrack($media); + } + /** @var \Drupal\file\FileInterface $file */ + foreach ($this->entityTypeManager->getStorage('file')->loadMultiple($file_ids) as $file) { + $this->doTrack($file); + } + } + + /** + * Helper; get the media type with its specific interface. + * + * @param \Drupal\media\MediaInterface $media + * The media of which to get the type. + * + * @return \Drupal\media\MediaTypeInterface + * The media type of the given media. + */ + protected function getMediaType(MediaInterface $media) : MediaTypeInterface { + $type = $this->entityTypeManager->getStorage('media_type')->load($media->bundle()); + assert($type instanceof MediaTypeInterface); + return $type; + } + + /** + * Determine if special tracking is required for this media. + * + * Given search_api indexes could be built specifically for files, we should + * reset any related tracking due to the islandora_hierarchical_access + * relations across the entity types. + * + * @param \Drupal\media\MediaInterface $media + * The media to test. + * + * @return bool + * TRUE if relevant; otherwise, FALSE. + */ + public function isMediaRelevant(MediaInterface $media) : bool { + if (!$this->isProcessorEnabled()) { + return FALSE; + } + // No `field_media_of`, so unrelated to IHA LUT. + if (!$media->hasField(IslandoraUtils::MEDIA_OF_FIELD)) { + return FALSE; + } + + $media_type = $this->getMediaType($media); + $media_source = $media->getSource(); + if ($media_source->getSourceFieldDefinition($media_type)->getSetting('target_type') !== 'file') { + return FALSE; + } + + return TRUE; + } + + /** + * Get the file for the media. + * + * @param \Drupal\media\MediaInterface|null $media + * The media of which to get the file. + * + * @return \Drupal\file\FileInterface|null + * The file if it could be loaded; otherwise, NULL. + */ + public function mediaGetFile(?MediaInterface $media) : ?FileInterface { + return $media ? + $this->entityTypeManager->getStorage('file')->load( + $media->getSource()->getSourceFieldValue($media) + ) : + NULL; + } + + /** + * Helper; get the containing nodes. + * + * @param \Drupal\media\MediaInterface|null $media + * The media of which to enumerate the containing node(s). + * + * @return \Drupal\node\NodeInterface[] + * The containing node(s). + */ + protected function getMediaContainers(?MediaInterface $media) : array { + /** @var \Drupal\Core\Field\EntityReferenceFieldItemList|null $containers */ + $containers = $media?->get(IslandoraUtils::MEDIA_OF_FIELD); + $entities = $containers?->referencedEntities() ?? []; + $to_return = []; + foreach ($entities as $entity) { + $to_return[$entity->id()] = $entity; + } + return $to_return; + } + + /** + * React to media create/update events. + * + * @param \Drupal\media\MediaInterface $media + * The media being operated on. + */ + public function mediaWriteReaction(MediaInterface $media) : void { + if (!$this->isMediaRelevant($media)) { + return; + } + + $original_file = $this->mediaGetFile($media->original ?? NULL); + $current_file = $this->mediaGetFile($media); + + $same_file = $original_file === $current_file; + + $original_containers = $this->getMediaContainers($media->original ?? NULL); + $current_containers = $this->getMediaContainers($media); + + $same_containers = $current_containers == array_intersect_key($current_containers, $original_containers); + + if (!($same_file && $same_containers)) { + if ($original_file) { + $this->doTrack($original_file); + } + if ($current_file) { + $this->doTrack($current_file); + } + } + } + + /** + * React to media delete events. + * + * @param \Drupal\media\MediaInterface $media + * The media entity that is/was being deleted. + */ + public function mediaDeleteReaction(MediaInterface $media) : void { + if (!$this->isMediaRelevant($media)) { + return; + } + + if ($current_file = $this->mediaGetFile($media)) { + $this->doTrack($current_file); + } + } + +} diff --git a/tests/src/Kernel/EmbargoAccessQueryTaggingAlterTest.php b/tests/src/Kernel/EmbargoAccessQueryTaggingAlterTest.php index 82c912d..e1d3703 100644 --- a/tests/src/Kernel/EmbargoAccessQueryTaggingAlterTest.php +++ b/tests/src/Kernel/EmbargoAccessQueryTaggingAlterTest.php @@ -15,6 +15,7 @@ * @group embargo */ class EmbargoAccessQueryTaggingAlterTest extends EmbargoKernelTestBase { + use DatabaseQueryTestTraits; /** @@ -101,6 +102,13 @@ class EmbargoAccessQueryTaggingAlterTest extends EmbargoKernelTestBase { public function setUp(): void { parent::setUp(); + $this->setupEntities(); + } + + /** + * Helper; build out entities with which to test. + */ + protected function setupEntities() : void { // Create two nodes one embargoed and one non-embargoed. $this->embargoedNode = $this->createNode(); $this->embargoedMedia = $this->createMedia($this->embargoedFile = $this->createFile(), $this->embargoedNode);