diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 0589725..bac290c 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -10,3 +10,10 @@ jobs: PHPUnit: uses: discoverygarden/phpunit-action/.github/workflows/phpunit.yml@v1 secrets: inherit + with: + composer_patches: |- + { + "discoverygarden/islandora_hierarchical_access": { + "dependent work from dependency": "https://github.com/discoverygarden/islandora_hierarchical_access/pull/19.patch" + } + } 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 c5b35dc..7dd6a09 100644 --- a/embargo.module +++ b/embargo.module @@ -5,15 +5,18 @@ * Hook implementations. */ +use Drupal\Core\Access\AccessResultInterface; use Drupal\Core\Database\Query\AlterableInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Session\AccountInterface; use Drupal\embargo\EmbargoStorage; +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]; @@ -24,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); @@ -33,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]); @@ -44,39 +47,23 @@ function embargo_file_download($uri) { return -1; } } -} -/** - * Implements hook_query_TAG_alter() for `node_access` tagged queries. - */ -function embargo_query_node_access_alter(AlterableInterface $query) { - /** @var \Drupal\embargo\Access\QueryTagger $tagger */ - $tagger = \Drupal::service('embargo.query_tagger'); - $tagger->tagAccess($query, 'node'); -} - -/** - * Implements hook_query_TAG_alter() for `media_access` tagged queries. - */ -function embargo_query_media_access_alter(AlterableInterface $query) { - /** @var \Drupal\embargo\Access\QueryTagger $tagger */ - $tagger = \Drupal::service('embargo.query_tagger'); - $tagger->tagAccess($query, 'media'); + return NULL; } /** - * Implements hook_query_TAG_alter() for `file_access` tagged queries. + * Implements hook_query_TAG_alter() for `node_access` tagged queries. */ -function embargo_query_file_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->tagAccess($query, 'file'); + $tagger->tagNode($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', @@ -97,3 +84,73 @@ function embargo_theme($existing, $type, $theme, $path) { ], ]; } + +/** + * Implements hook_ENTITY_TYPE_insert() for embargo entities. + */ +function embargo_embargo_insert(EntityInterface $entity) : void { + /** @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 { + /** @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 { + /** @var \Drupal\embargo\SearchApiTracker $tracker */ + $tracker = \Drupal::service('embargo.search_api_tracker_helper'); + $tracker->track($entity); +} + +/** + * Implements hook_ENTITY_TYPE_delete() for node entities. + */ +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); +} + +/** + * Implements hook_ENTITY_TYPE_insert() for media entities. + */ +function embargo_media_insert(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_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 38b4738..4bcd728 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: @@ -31,3 +32,40 @@ services: - '@url_generator' tags: - { name: 'event_subscriber' } + embargo.event_subscriber.islandora_hierarchical_access: + class: Drupal\embargo\EventSubscriber\IslandoraHierarchicalAccessEventSubscriber + factory: [null, 'create'] + arguments: + - '@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' } + cache_context.user.embargo__has_exemption: + class: Drupal\embargo\Cache\Context\UserExemptedCacheContext + arguments: + - '@current_user' + - '@entity_type.manager' + tags: + - { name: 'cache.context' } diff --git a/embargo.views.inc b/embargo.views.inc new file mode 100644 index 0000000..5128bc5 --- /dev/null +++ b/embargo.views.inc @@ -0,0 +1,39 @@ + \t('Embargoes'), + 'help' => \t('Embargoes applicable to the given node.'), + 'relationship' => [ + 'base' => 'embargo', + 'base field' => 'embargoed_node', + 'field' => 'nid', + 'id' => 'standard', + 'label' => \t('Embargoes'), + ], + ]; + $data['users_field_data']['embargo__exempt_users'] = [ + 'title' => \t('Embargo exemptions'), + 'help' => \t('Embargoes for which the given user is specifically exempt.'), + 'relationship' => [ + 'id' => 'entity_reverse', + 'field_name' => 'embargo__exempt_users', + 'entity_type' => 'embargo', + 'field table' => 'embargo__exempt_users', + 'field field' => 'exempt_users_target_id', + 'base' => 'embargo', + 'base field' => 'id', + 'label' => \t('Embargo exemptions'), + ], + ]; + +} diff --git a/modules/migrate_embargoes_to_embargo/src/Plugin/migrate/source/Entity.php b/modules/migrate_embargoes_to_embargo/src/Plugin/migrate/source/Entity.php index 19756df..9ba8ddc 100644 --- a/modules/migrate_embargoes_to_embargo/src/Plugin/migrate/source/Entity.php +++ b/modules/migrate_embargoes_to_embargo/src/Plugin/migrate/source/Entity.php @@ -2,13 +2,11 @@ namespace Drupal\migrate_embargoes_to_embargo\Plugin\migrate\source; -use Drupal\migrate\Plugin\migrate\source\SourcePluginBase; -use Drupal\migrate\Plugin\MigrationInterface; - -use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; - +use Drupal\migrate\Plugin\migrate\source\SourcePluginBase; +use Drupal\migrate\Plugin\MigrationInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -42,7 +40,7 @@ public function __construct( $plugin_id, $plugin_definition, MigrationInterface $migration, - EntityTypeManagerInterface $entity_type_manager + EntityTypeManagerInterface $entity_type_manager, ) { parent::__construct($configuration, $plugin_id, $plugin_definition, $migration); @@ -58,7 +56,7 @@ public static function create( array $configuration, $plugin_id, $plugin_definition, - MigrationInterface $migration = NULL + MigrationInterface $migration = NULL, ) { return new static( $configuration, diff --git a/src/Access/EmbargoAccessCheck.php b/src/Access/EmbargoAccessCheck.php index e643958..e1d0d1d 100644 --- a/src/Access/EmbargoAccessCheck.php +++ b/src/Access/EmbargoAccessCheck.php @@ -57,24 +57,40 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager, Req * {@inheritdoc} */ public function access(EntityInterface $entity, AccountInterface $user) { + $type = $this->entityTypeManager->getDefinition('embargo'); + $state = AccessResult::neutral(); + + if ($user->hasPermission('bypass embargo access')) { + return $state->setReason('User has embargo bypass permission.') + ->addCacheContexts(['user.permissions']); + } + /** @var \Drupal\embargo\EmbargoStorage $storage */ $storage = $this->entityTypeManager->getStorage('embargo'); + $state->addCacheTags($type->getListCacheTags()) + ->addCacheContexts($type->getListCacheContexts()); + $related_embargoes = $storage->getApplicableEmbargoes($entity); + if (empty($related_embargoes)) { + return $state->setReason('No embargo statements for the given entity.'); + } + + array_map([$state, 'addCacheableDependency'], $related_embargoes); + $embargoes = $storage->getApplicableNonExemptNonExpiredEmbargoes( $entity, $this->request->server->get('REQUEST_TIME'), $user, $this->request->getClientIp() ); - $state = AccessResult::forbiddenIf( + return $state->andIf(AccessResult::forbiddenIf( !empty($embargoes), $this->formatPlural( count($embargoes), '1 embargo preventing access.', '@count embargoes preventing access.' )->render() - ); - array_map([$state, 'addCacheableDependency'], $embargoes); - return $state; + )); + } } diff --git a/src/Access/QueryTagger.php b/src/Access/QueryTagger.php index 81f9c58..c5b0189 100644 --- a/src/Access/QueryTagger.php +++ b/src/Access/QueryTagger.php @@ -8,60 +8,21 @@ use Drupal\Core\Datetime\DateFormatterInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Session\AccountProxyInterface; -use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface; +use Drupal\embargo\EmbargoExistenceQueryTrait; use Drupal\embargo\EmbargoInterface; use Drupal\islandora_hierarchical_access\Access\QueryConjunctionTrait; -use Drupal\islandora_hierarchical_access\LUTGeneratorInterface; +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. */ class QueryTagger { + use EmbargoExistenceQueryTrait; use QueryConjunctionTrait; - - /** - * The current user. - * - * @var \Drupal\Core\Session\AccountProxyInterface - */ - protected $user; - - /** - * The IP of the request. - * - * @var string - */ - protected $currentIp; - - /** - * Instance of a Drupal database connection. - * - * @var \Drupal\Core\Database\Connection - */ - protected $database; - - /** - * The entity type manager. - * - * @var \Drupal\Core\Entity\EntityTypeManagerInterface - */ - protected $entityTypeManager; - - /** - * Time service. - * - * @var \Drupal\Component\Datetime\TimeInterface - */ - protected TimeInterface $time; - - /** - * Date formatter service. - * - * @var \Drupal\Core\Datetime\DateFormatterInterface - */ - protected DateFormatterInterface $dateFormatter; + use TaggedTargetsTrait; /** * Constructor. @@ -72,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(); @@ -80,6 +42,7 @@ public function __construct( $this->entityTypeManager = $entity_type_manager; $this->time = $time; $this->dateFormatter = $date_formatter; + $this->setEventDispatcher($event_dispatcher); } /** @@ -87,127 +50,30 @@ public function __construct( * * @param \Drupal\Core\Database\Query\SelectInterface $query * The query being executed. - * @param string $type - * Either "node" or "file". */ - public function tagAccess(SelectInterface $query, string $type) { - if (!in_array($type, ['node', 'media', 'file'])) { - throw new \InvalidArgumentException("Unrecognized type '$type'."); + public function tagNode(SelectInterface $query) : void { + if ($query->hasTag('islandora_hierarchical_access_subquery')) { + // Being run as a subquery: We do not want to touch it as we expect our + // IslandoraHierarchicalAccessEventSubscriber class to deal with it. + return; } - elseif ($this->user->hasPermission('bypass embargo access')) { + if ($this->user->hasPermission('bypass embargo access')) { return; } + $type = 'node'; static::conjunctionQuery($query); - /** @var \Drupal\Core\Entity\Sql\SqlEntityStorageInterface $storage */ - $storage = $this->entityTypeManager->getStorage($type); - $tables = $storage->getTableMapping()->getTableNames(); + $tagged_table_aliases = $query->getMetaData('embargo_tagged_table_aliases') ?? []; - foreach ($query->getTables() as $info) { - if ($info['table'] instanceof SelectInterface) { - continue; - } - elseif (in_array($info['table'], $tables)) { - $key = (strpos($info['table'], "{$type}__") === 0) ? 'entity_id' : (substr($type, 0, 1) . "id"); - $alias = $info['alias']; + $target_aliases = $this->getTaggingTargets($query, $tagged_table_aliases, $type); - $to_apply = $query; - if ($info['join type'] == 'LEFT') { - $to_apply = $query->orConditionGroup() - ->isNull("{$alias}.{$key}"); - $query->condition($to_apply); - } - if ($type === 'node') { - $to_apply->condition("{$alias}.{$key}", $this->buildInaccessibleEmbargoesCondition(), 'NOT IN'); - } - elseif ($type === 'media') { - $to_apply->condition("{$alias}.{$key}", $this->buildInaccessibleFileCondition('mid'), 'NOT IN'); - } - elseif ($type === 'file') { - $to_apply->condition("{$alias}.{$key}", $this->buildInaccessibleFileCondition('fid'), 'NOT IN'); - } - else { - throw new \InvalidArgumentException("Invalid type '$type'."); - } - } - } - } - - /** - * Builds the condition for file-typed embargoes that are inaccessible. - * - * @param string $lut_column - * The particular column of the LUT to return, as file embargoes apply to - * media ('mid') as well as files ('fid'). - * - * @return \Drupal\Core\Database\Query\SelectInterface - * The sub-query to be used that results in all file IDs that cannot be - * accessed. - */ - protected function buildInaccessibleFileCondition(string $lut_column) { - $query = $this->database->select('embargo', 'e'); - $lut_alias = $query->join(LUTGeneratorInterface::TABLE_NAME, 'lut', '%alias.nid = e.embargoed_node'); - return $query - ->fields($lut_alias, [$lut_column]) - ->condition('lut.nid', $this->buildAccessibleEmbargoesQuery(EmbargoInterface::EMBARGO_TYPE_FILE), 'NOT IN'); - } - - /** - * Get query to select accessible embargoed entities. - * - * @param int $type - * The type of embargo, expected to be one of: - * - EmbargoInterface::EMBARGO_TYPE_NODE; or, - * - EmbargoInterface::EMBARGO_TYPE_FILE. - * - * @return \Drupal\Core\Database\Query\SelectInterface - * A query returning things that should not be inaccessible. - */ - protected function buildAccessibleEmbargoesQuery($type) : SelectInterface { - $query = $this->database->select('embargo', 'e') - ->fields('e', ['embargoed_node']); - - // Things are visible if... - $group = $query->orConditionGroup() - // The selected embargo entity does not apply to the given type; or... - ->condition('e.embargo_type', $type, '!='); - - $group->condition($query->andConditionGroup() - // ... a scheduled embargo... - ->condition('e.expiration_type', EmbargoInterface::EXPIRATION_TYPE_SCHEDULED) - // ... has a date in the past. - ->condition('e.expiration_date', $this->dateFormatter->format($this->time->getRequestTime(), 'custom', DateTimeItemInterface::DATE_STORAGE_FORMAT), '<') - ); - - // ... the incoming IP is in an exempt range; or... - /** @var \Drupal\embargo\IpRangeStorageInterface $storage */ - $storage = $this->entityTypeManager->getStorage('embargo_ip_range'); - $applicable_ip_ranges = $storage->getApplicableIpRanges($this->currentIp); - if (!empty($applicable_ip_ranges)) { - $group->condition('e.exempt_ips', array_keys($applicable_ip_ranges), 'IN'); + if (empty($target_aliases)) { + return; } - // ... the specific user is exempted from the embargo. - $user_alias = $query->leftJoin('embargo__exempt_users', 'u', 'e.id = %alias.entity_id'); - $group->condition("{$user_alias}.exempt_users_target_id", $this->user->id()); - - $query->condition($group); - - return $query; - } - - /** - * Builds the condition for embargoes that are inaccessible. - * - * @return \Drupal\Core\Database\Query\SelectInterface - * The sub-query to be used that results in embargoed_node IDs that - * cannot be accessed. - */ - protected function buildInaccessibleEmbargoesCondition() : SelectInterface { - return $this->database->select('embargo', 'ein') - ->condition('ein.embargoed_node', $this->buildAccessibleEmbargoesQuery(EmbargoInterface::EMBARGO_TYPE_NODE), 'NOT IN') - ->fields('ein', ['embargoed_node']); + $query->addMetaData('embargo_tagged_table_aliases', $tagged_table_aliases); + $this->applyExistenceQuery($query, $target_aliases, [EmbargoInterface::EMBARGO_TYPE_NODE]); } } 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/Cache/Context/UserExemptedCacheContext.php b/src/Cache/Context/UserExemptedCacheContext.php new file mode 100644 index 0000000..6bcc756 --- /dev/null +++ b/src/Cache/Context/UserExemptedCacheContext.php @@ -0,0 +1,77 @@ +isExempted() ? '1' : '0'; + } + + /** + * {@inheritDoc} + */ + public function getCacheableMetadata() { + return (new CacheableMetadata()) + ->addCacheContexts([$this->isExempted() ? 'user' : 'user.permissions']) + ->addCacheTags(['embargo_list']); + } + + /** + * Determine if the current user has _any_ exemptions. + * + * @return bool + * TRUE if the user is exempt to at least one embargo; otherwise, FALSE. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + protected function isExempted() : bool { + if (!isset($this->exempted)) { + $results = $this->entityTypeManager->getStorage('embargo')->getQuery() + ->accessCheck(FALSE) + ->condition('exempt_users', $this->currentUser->id()) + ->range(0, 1) + ->execute(); + $this->exempted = !empty($results); + } + + return $this->exempted; + } + +} diff --git a/src/EmbargoExistenceQueryTrait.php b/src/EmbargoExistenceQueryTrait.php new file mode 100644 index 0000000..18db51b --- /dev/null +++ b/src/EmbargoExistenceQueryTrait.php @@ -0,0 +1,247 @@ +condition( + $existence_condition->orConditionGroup() + ->notExists($this->getNullQuery($target_aliases, $embargo_types)) + ->exists($this->getAccessibleEmbargoesQuery($target_aliases, $embargo_types)) + ); + } + + /** + * 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. + * + * @param array $target_aliases + * The target aliases on which to match. + * @param array $embargo_types + * The relevant types of embargoes to which to constrain. + * + * @return \Drupal\Core\Database\Query\SelectInterface + * The negative-asserting query. + */ + protected function getNullQuery(array $target_aliases, array $embargo_types) : SelectInterface { + $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->condition($this->buildInclusionBaseCondition($query)); + $query->condition("{$embargo_alias}.embargo_type", $embargo_types, 'IN'); + + return $query; + } + + /** + * Get query for positive assertion. + * + * @param array $target_aliases + * The target aliases on which to match. + * @param array $embargo_types + * The relevant types of embargoes to which to constrain. + * + * @return \Drupal\Core\Database\Query\SelectInterface + * The positive-asserting query. + */ + protected function getAccessibleEmbargoesQuery(array $target_aliases, array $embargo_types) : SelectInterface { + // Embargo exists for the entity, where: + $embargo_alias = 'embargo_existence'; + $embargo_existence = $this->database->select('embargo', $embargo_alias); + $embargo_existence->addExpression(1, 'embargo_allowed'); + + $embargo_existence->addMetaData('embargo_alias', $embargo_alias); + $embargo_existence->addMetaData('embargo_target_aliases', $target_aliases); + + $embargo_existence->condition( + $embargo_existence->orConditionGroup() + ->condition($existence_condition = $embargo_existence->andConditionGroup() + ->condition($this->buildInclusionBaseCondition($embargo_existence)) + ->condition($embargo_or = $embargo_existence->orConditionGroup()) + ) + ); + + $embargo_existence->addMetaData('embargo_existence_condition', $existence_condition); + + // - The request IP is exempt. + /** @var \Drupal\embargo\IpRangeStorageInterface $storage */ + $storage = $this->entityTypeManager->getStorage('embargo_ip_range'); + $applicable_ip_ranges = $storage->getApplicableIpRanges($this->currentIp); + if ($applicable_ip_ranges) { + $embargo_or->condition("{$embargo_alias}.exempt_ips", array_keys($applicable_ip_ranges), 'IN'); + } + + // - The user is exempt. + // @todo Should the IP range constraint(s) take precedence? + $user_existence = $this->database->select('embargo__exempt_users', 'eeu'); + $user_existence->addExpression(1, 'user_existence'); + $user_existence->where("eeu.entity_id = {$embargo_alias}.id") + ->condition('eeu.exempt_users_target_id', $this->user->id()); + $embargo_or->exists($user_existence); + + // - There's a scheduled embargo of an appropriate type and no other + // overriding embargo. + $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') + ->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() + ->condition('ue.expiration_type', EmbargoInterface::EXPIRATION_TYPE_INDEFINITE) + ->condition($unexpired_embargo_subquery->andConditionGroup() + ->condition('ue.expiration_type', EmbargoInterface::EXPIRATION_TYPE_SCHEDULED) + ->condition('ue.expiration_date', $current_date, '>') + ) + ); + + $embargo_or->condition( + $embargo_or->andConditionGroup() + ->condition("{$embargo_alias}.embargo_type", $embargo_types, 'IN') + ->condition("{$embargo_alias}.expiration_type", EmbargoInterface::EXPIRATION_TYPE_SCHEDULED) + ->condition("{$embargo_alias}.expiration_date", $current_date, '<=') + ->notExists($unexpired_embargo_subquery) + ); + + return $embargo_existence; + } + +} diff --git a/src/EmbargoIpRangeViewsData.php b/src/EmbargoIpRangeViewsData.php new file mode 100644 index 0000000..0816412 --- /dev/null +++ b/src/EmbargoIpRangeViewsData.php @@ -0,0 +1,12 @@ +getEntity(); + /** @var \Drupal\embargo\EmbargoStorageInterface $embargo_storage */ + $embargo_storage = $this->getEntityTypeManager()->getStorage('embargo'); + $this->setValue(array_filter($embargo_storage->getApplicableEmbargoes($entity), function (EmbargoInterface $embargo) { + return in_array($embargo->getEmbargoType(), $this->getSetting('embargo_types')); + })); + } + + /** + * Helper; get the entity type manager service. + * + * XXX: Dependency injection does not presently appear to be possible in typed + * data. + * + * @see https://www.drupal.org/node/2053415 + * @see https://www.drupal.org/project/drupal/issues/3294266 + * + * @return \Drupal\Core\Entity\EntityTypeManagerInterface + * The entity type manager service. + */ + protected function getEntityTypeManager() : EntityTypeManagerInterface { + return \Drupal::entityTypeManager(); + } + +} 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/EmbargoViewsData.php b/src/EmbargoViewsData.php new file mode 100644 index 0000000..380a83d --- /dev/null +++ b/src/EmbargoViewsData.php @@ -0,0 +1,12 @@ +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(), + ['user.embargo__has_exemption'], + ); + + if ($this->getExemptIps()) { + $contexts = Cache::mergeContexts($contexts, $this->getExemptIps()->getCacheContexts()); + } + + return $contexts; + } + /** * {@inheritdoc} */ @@ -434,4 +451,16 @@ public function ipIsExempt(string $ip): bool { return $exempt_ips && $exempt_ips->withinRanges($ip); } + /** + * {@inheritdoc} + */ + protected function getListCacheTagsToInvalidate() : array { + return array_merge( + parent::getListCacheTagsToInvalidate(), + array_map(function (string $type) { + return "{$type}_list"; + }, EmbargoStorageInterface::APPLICABLE_ENTITY_TYPES), + ); + } + } diff --git a/src/Entity/IpRange.php b/src/Entity/IpRange.php index 60bea11..1794c53 100644 --- a/src/Entity/IpRange.php +++ b/src/Entity/IpRange.php @@ -3,12 +3,14 @@ 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; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; use Drupal\Core\Field\FieldStorageDefinitionInterface; +use Drupal\embargo\EmbargoStorageInterface; use Drupal\embargo\IpRangeInterface; use Symfony\Component\HttpFoundation\IpUtils; @@ -28,6 +30,7 @@ * handlers = { * "storage" = "Drupal\embargo\IpRangeStorage", * "view_builder" = "Drupal\Core\Entity\EntityViewBuilder", + * "views_data" = "Drupal\embargo\EmbargoIpRangeViewsData", * "list_builder" = "Drupal\embargo\EmbargoListBuilder", * "form" = { * "add" = "Drupal\embargo\Form\IpRangeForm", @@ -38,7 +41,6 @@ * "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider" * }, * }, - * list_cache_tags = { "node_list", "media_list", "file_list" }, * base_table = "embargo_ip_range", * admin_permission = "administer embargo", * entity_keys = { @@ -239,4 +241,25 @@ public static function isValidCidr(string $cidr): bool { return FALSE; } + /** + * {@inheritDoc} + */ + public function getCacheContexts() { + return Cache::mergeContexts(parent::getCacheContexts(), [ + 'ip.embargo_range', + ]); + } + + /** + * {@inheritdoc} + */ + protected function getListCacheTagsToInvalidate() : array { + return array_merge( + parent::getListCacheTagsToInvalidate(), + array_map(function (string $type) { + return "{$type}_list"; + }, EmbargoStorageInterface::APPLICABLE_ENTITY_TYPES), + ); + } + } 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 new file mode 100644 index 0000000..56010e3 --- /dev/null +++ b/src/EventSubscriber/IslandoraHierarchicalAccessEventSubscriber.php @@ -0,0 +1,98 @@ +currentIp = $this->requestStack->getCurrentRequest()->getClientIp(); + } + + /** + * {@inheritDoc} + */ + public static function create(ContainerInterface $container) : self { + 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')); + } + + /** + * {@inheritDoc} + */ + public static function getSubscribedEvents() : array { + return [ + Event::class => 'processEvent', + ]; + } + + /** + * Process the islandora_hierarchical_access query alter event. + * + * @param \Drupal\islandora_hierarchical_access\Event\Event $event + * The event to process. + */ + public function processEvent(Event $event) : void { + $query = $event->getQuery(); + if ($event->getQuery()->hasTag(static::TAG)) { + return; + } + + $query->addTag(static::TAG); + + if ($this->user->hasPermission('bypass embargo access')) { + return; + } + + /** @var \Drupal\Core\Database\Query\ConditionInterface $existence_condition */ + $existence_condition = $query->getMetaData('islandora_hierarchical_access_tagged_existence_condition'); + $this->applyExistenceQuery( + $existence_condition, + ['lut_exist.nid'], + match ($event->getType()) { + 'file', 'media' => [ + EmbargoInterface::EMBARGO_TYPE_FILE, + EmbargoInterface::EMBARGO_TYPE_NODE, + ], + 'node' => [EmbargoInterface::EMBARGO_TYPE_NODE], + }, + ); + } + +} 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/Block/EmbargoNotificationBlock.php b/src/Plugin/Block/EmbargoNotificationBlock.php index 7ad8d4d..10e5eb1 100644 --- a/src/Plugin/Block/EmbargoNotificationBlock.php +++ b/src/Plugin/Block/EmbargoNotificationBlock.php @@ -5,16 +5,15 @@ use Drupal\Component\Utility\Html; use Drupal\Core\Block\BlockBase; use Drupal\Core\Cache\Cache; -use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Render\RendererInterface; use Drupal\Core\Routing\ResettableStackedRouteMatchInterface; -use Drupal\Core\Session\AccountProxyInterface; +use Drupal\Core\Session\AccountInterface; use Drupal\embargo\EmbargoInterface; use Drupal\node\NodeInterface; use Symfony\Component\DependencyInjection\ContainerInterface; -use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Request; /** * Provides a "Embargo Notifications" block. @@ -30,116 +29,81 @@ class EmbargoNotificationBlock extends BlockBase implements ContainerFactoryPlug /** * The admin email address. * - * @var string + * @var string|null */ - protected $adminMail; + protected ?string $adminMail; /** * The notification message. * * @var string */ - protected $notificationMessage; + protected string $notificationMessage; /** * A route matching interface. * * @var \Drupal\Core\Routing\ResettableStackedRouteMatchInterface */ - protected $routeMatch; + protected ResettableStackedRouteMatchInterface $routeMatch; /** * The request object. * * @var \Symfony\Component\HttpFoundation\Request */ - protected $request; - - /** - * Embargo entity storage. - * - * @var \Drupal\embargo\EmbargoStorageInterface - */ - protected $storage; + protected Request $request; /** * The current user. * - * @var \Drupal\Core\Session\AccountProxyInterface + * @var \Drupal\Core\Session\AccountInterface */ - protected $user; + protected AccountInterface $user; /** * The object renderer. * * @var \Drupal\Core\Render\RendererInterface */ - protected $renderer; + protected RendererInterface $renderer; /** - * Construct embargo notification block. - * - * @param array $configuration - * Block configuration. - * @param string $plugin_id - * The plugin ID. - * @param mixed $plugin_definition - * The plugin definition. - * @param \Drupal\Core\Routing\ResettableStackedRouteMatchInterface $route_match - * A route matching interface. - * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack - * The request being made to check access against. - * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory - * A configuration factory interface. - * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager - * An entity type manager. - * @param \Drupal\Core\Session\AccountProxyInterface $user - * The current user. - * @param \Drupal\Core\Render\RendererInterface $renderer - * The object renderer. + * Drupal's entity type manager service. * - * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException - * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @var \Drupal\Core\Entity\EntityTypeManagerInterface */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, ResettableStackedRouteMatchInterface $route_match, RequestStack $request_stack, ConfigFactoryInterface $config_factory, EntityTypeManagerInterface $entity_type_manager, AccountProxyInterface $user, RendererInterface $renderer) { - parent::__construct($configuration, $plugin_id, $plugin_definition); - $settings = $config_factory->get('embargo.settings'); - $this->adminMail = $settings->get('contact_email'); - $this->notificationMessage = $settings->get('notification_message'); - $this->storage = $entity_type_manager->getStorage('embargo'); - $this->routeMatch = $route_match; - $this->request = $request_stack->getCurrentRequest(); - $this->user = $user; - $this->renderer = $renderer; - } + protected EntityTypeManagerInterface $entityTypeManager; /** * {@inheritdoc} */ - public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { - return new static( - $configuration, - $plugin_id, - $plugin_definition, - $container->get('current_route_match'), - $container->get('request_stack'), - $container->get('config.factory'), - $container->get('entity_type.manager'), - $container->get('current_user'), - $container->get('renderer'), - ); + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) : self { + $instance = new static($configuration, $plugin_id, $plugin_definition); + + $settings = $container->get('config.factory')->get('embargo.settings'); + $instance->adminMail = $settings->get('contact_email'); + $instance->notificationMessage = $settings->get('notification_message'); + $instance->entityTypeManager = $container->get('entity_type.manager'); + $instance->routeMatch = $container->get('current_route_match'); + $instance->request = $container->get('request_stack')->getCurrentRequest(); + $instance->user = $container->get('current_user'); + $instance->renderer = $container->get('renderer'); + + return $instance; } /** * {@inheritdoc} */ - public function build() { - $node = $this->routeMatch->getParameter('node'); - if (!($node instanceof NodeInterface)) { + public function build() : array { + if (!($node = $this->getNode())) { return []; } // Displays even if the embargo is exempt in the current context. - $applicable_embargoes = $this->storage->getApplicableEmbargoes($node); + /** @var \Drupal\embargo\EmbargoStorageInterface $storage */ + $storage = $this->entityTypeManager->getStorage('embargo'); + $applicable_embargoes = $storage->getApplicableEmbargoes($node); if (empty($applicable_embargoes)) { return []; } @@ -151,7 +115,9 @@ public function build() { $expired = $embargo->expiresBefore($now); $exempt_user = $embargo->isUserExempt($this->user); $exempt_ip = $embargo->ipIsExempt($ip); + $embargoes[$id] = [ + 'actual' => $embargo, 'indefinite' => $embargo->getExpirationType() === EmbargoInterface::EXPIRATION_TYPE_INDEFINITE, 'expired' => $expired, 'exempt_user' => $exempt_user, @@ -169,6 +135,7 @@ public function build() { 'additional_emails' => $embargo->additional_emails->view('default'), ]; } + $build = [ '#theme' => 'embargo_notification', '#message' => $this->t($this->notificationMessage, ['@contact' => $this->adminMail]), // phpcs:ignore @@ -184,25 +151,54 @@ public function build() { /** * {@inheritdoc} */ - public function getCacheTags() { + public function getCacheTags() : array { + $tags = parent::getCacheTags(); + // When the given node changes (route), the block should rebuild. - if ($node = $this->routeMatch->getParameter('node')) { - return Cache::mergeTags( - parent::getCacheTags(), + if ($node = $this->getNode()) { + $tags = Cache::mergeTags( + $tags, $node->getCacheTags(), ); } - // Return default tags, if not on a node page. - return parent::getCacheTags(); + return $tags; } /** * {@inheritdoc} */ - public function getCacheContexts() { - // Ensure that with every new node/route, this block will be rebuilt. - return Cache::mergeContexts(parent::getCacheContexts(), ['route']); + public function getCacheContexts() : array { + $contexts = Cache::mergeContexts( + parent::getCacheContexts(), + // Ensure that with every new node/route, this block will be rebuilt. + [ + 'route', + 'url', + ], + ); + + if ($node = $this->getNode()) { + $contexts = Cache::mergeContexts( + $contexts, + $node->getCacheContexts(), + ); + } + + return $contexts; + } + + /** + * Helper; get the active node. + * + * @return \Drupal\node\NodeInterface|null + * Get the active node. + */ + protected function getNode() : ?NodeInterface { + $node_candidate = $this->routeMatch->getParameter('node'); + return $node_candidate instanceof NodeInterface ? + $node_candidate : + NULL; } } diff --git a/src/Plugin/search_api/processor/EmbargoJoinProcessor.php b/src/Plugin/search_api/processor/EmbargoJoinProcessor.php new file mode 100644 index 0000000..90f4cf0 --- /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__has_exemption', + ]); + // 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 new file mode 100644 index 0000000..6d31b28 --- /dev/null +++ b/src/Plugin/search_api/processor/EmbargoProcessor.php @@ -0,0 +1,297 @@ +requestStack = $container->get('request_stack'); + $instance->entityTypeManager = $container->get('entity_type.manager'); + $instance->currentUser = $container->get('current_user'); + $instance->time = $container->get('datetime.time'); + + return $instance; + } + + /** + * {@inheritdoc} + */ + public function getPropertyDefinitions(DatasourceInterface $datasource = NULL) : array { + $properties = []; + + if ($datasource === NULL) { + return $properties; + } + + $properties['embargo'] = ListableEntityProcessorProperty::create('embargo') + ->setList() + ->setProcessorId($this->getPluginId()); + + 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::ENTITY_TYPES)) { + return; + } + try { + $entity = $item->getOriginalObject()->getValue(); + } + catch (SearchApiException) { + return; + } + if (!($entity instanceof EntityInterface)) { + return; + } + + $datasource_id = $item->getDatasourceId(); + + /** @var \Drupal\search_api\Item\FieldInterface[][][] $to_extract */ + $to_extract = []; + foreach ($item->getFields(FALSE) as $field) { + $property_path = $field->getPropertyPath(); + [$direct, $nested] = Utility::splitPropertyPath($property_path, FALSE); + if ($field->getDatasourceId() === $datasource_id + && $direct === 'embargo') { + $to_extract[$nested][] = $field; + } + } + + /** @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 ($relevant_embargoes as $embargo) { + $this->getFieldsHelper()->extractFields($embargo->getTypedData(), $to_extract); + } + + } + + /** + * {@inheritDoc} + */ + public function preIndexSave() : void { + parent::preIndexSave(); + + /** @var \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager */ + $field_manager = \Drupal::service('entity_field.manager'); + $base_field_definitions = $field_manager->getBaseFieldDefinitions('embargo'); + + $ensure_label = function (FieldInterface $field) use ($base_field_definitions) { + if ($field->getLabel() === NULL) { + $label_pieces = ['Embargo:']; + + $path_components = explode(IndexInterface::PROPERTY_PATH_SEPARATOR, $field->getPropertyPath(), 3); + $base_field = $base_field_definitions[$path_components[1]]; + $label_pieces[] = $base_field->getLabel(); + + if (is_a($base_field->getClass(), EntityReferenceFieldItemListInterface::class, TRUE)) { + $label_pieces[] = 'Entity'; + $label_pieces[] = 'ID'; + } + $field->setLabel(implode(' ', $label_pieces)); + } + return $field; + }; + + foreach ($this->index->getDatasources() as $datasource_id => $datasource) { + if (!in_array($datasource->getEntityTypeId(), static::ENTITY_TYPES)) { + continue; + } + + $fields = [ + $this->ensureField($datasource_id, 'embargo:id', 'integer'), + $this->ensureField($datasource_id, 'embargo:embargo_type', 'integer'), + $this->ensureField($datasource_id, 'embargo:expiration_date', 'date'), + $this->ensureField($datasource_id, 'embargo:expiration_type', 'integer'), + $this->ensureField($datasource_id, 'embargo:exempt_ips:entity:id', 'integer'), + $this->ensureField($datasource_id, 'embargo:exempt_users:entity:uid', 'integer'), + ]; + array_map($ensure_label, $fields); + } + } + + /** + * {@inheritDoc} + */ + public function preprocessSearchQuery(QueryInterface $query) : void { + assert($query instanceof RefinableCacheableDependencyInterface); + $query->addCacheContexts(['user.permissions']); + if ($this->currentUser->hasPermission('bypass embargo access')) { + return; + } + + $datasources = $query->getIndex()->getDatasources(); + /** @var \Drupal\search_api\Datasource\DatasourceInterface[] $applicable_datasources */ + $applicable_datasources = array_filter($datasources, function (DatasourceInterface $datasource) { + return in_array($datasource->getEntityTypeId(), static::ENTITY_TYPES); + }); + if (empty($applicable_datasources)) { + return; + } + + $and_group = $query->createAndAddConditionGroup(tags: [ + 'embargo_processor', + 'embargo_access', + ]); + foreach (array_keys($applicable_datasources) as $datasource_id) { + if ($filter = $this->addEmbargoFilters($datasource_id, $query)) { + $and_group->addConditionGroup($filter); + } + } + } + + /** + * Add embargo filters to the given query, for the given datasource. + * + * @param string $datasource_id + * The ID of the datasource for which to add filters. + * @param \Drupal\search_api\Query\QueryInterface $query + * The query to which to add filters. + */ + protected function addEmbargoFilters(string $datasource_id, QueryInterface $query) : ?ConditionGroupInterface { + assert($query instanceof RefinableCacheableDependencyInterface); + $or_group = $query->createConditionGroup('OR', [ + "embargo:$datasource_id", + ]); + + // No embargo. + if ($field = $this->findField($datasource_id, 'embargo:id')) { + $or_group->addCondition($field->getFieldIdentifier(), NULL); + $query->addCacheTags(['embargo_list']); + } + + // Embargo duration/schedule. + if ($expiration_type_field = $this->findField($datasource_id, 'embargo:expiration_type')) { + $schedule_group = $query->createConditionGroup(tags: ['embargo_schedule']); + // No indefinite embargo. + $schedule_group->addCondition($expiration_type_field->getFieldIdentifier(), EmbargoInterface::EXPIRATION_TYPE_INDEFINITE, '<>'); + + // Scheduled embargo in the past and none in the future. + if ($scheduled_field = $this->findField($datasource_id, 'embargo:expiration_date')) { + $schedule_group->addCondition($expiration_type_field->getFieldIdentifier(), EmbargoInterface::EXPIRATION_TYPE_SCHEDULED); + // Embargo in the past. + $schedule_group->addCondition($scheduled_field->getFieldIdentifier(), date('Y-m-d', $this->time->getRequestTime()), '<='); + // No embargo in the future. + $schedule_group->addCondition($scheduled_field->getFieldIdentifier(), [ + 0 => date('Y-m-d', strtotime('+1 DAY', $this->time->getRequestTime())), + 1 => date('Y-m-d', PHP_INT_MAX), + ], 'NOT BETWEEN'); + // Cacheable up to a day. + $query->mergeCacheMaxAge(24 * 3600); + } + + $or_group->addConditionGroup($schedule_group); + } + + if ($this->currentUser->isAnonymous()) { + $query->addCacheContexts(['user.roles:anonymous']); + } + elseif ($field = $this->findField($datasource_id, 'embargo:exempt_users:entity:uid')) { + $or_group->addCondition($field->getFieldIdentifier(), $this->currentUser->id()); + $query->addCacheContexts(['user']); + } + + if ($field = $this->findField($datasource_id, 'embargo:exempt_ips:entity:id')) { + /** @var \Drupal\embargo\IpRangeStorageInterface $ip_range_storage */ + $ip_range_storage = $this->entityTypeManager->getStorage('embargo_ip_range'); + foreach ($ip_range_storage->getApplicableIpRanges($this->requestStack->getCurrentRequest() + ->getClientIp()) as $ipRange) { + $or_group->addCondition($field->getFieldIdentifier(), $ipRange->id()); + $query->addCacheableDependency($ipRange); + } + $query->addCacheContexts(['ip.embargo_range']); + } + + return (count($or_group->getConditions()) > 0) ? $or_group : NULL; + } + +} diff --git a/src/Plugin/search_api/processor/Property/ListableEntityProcessorProperty.php b/src/Plugin/search_api/processor/Property/ListableEntityProcessorProperty.php new file mode 100644 index 0000000..98e3a13 --- /dev/null +++ b/src/Plugin/search_api/processor/Property/ListableEntityProcessorProperty.php @@ -0,0 +1,34 @@ +definition['is_list'] = $value; + return $this; + } + + /** + * Set the processor ID. + * + * @param string $processor_id + * The processor ID to set. + */ + public function setProcessorId(string $processor_id) : self { + $this->definition['processor_id'] = $processor_id; + return $this; + } + +} 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/templates/embargo-notification.html.twig b/templates/embargo-notification.html.twig index d1c7bb8..633852a 100644 --- a/templates/embargo-notification.html.twig +++ b/templates/embargo-notification.html.twig @@ -37,7 +37,7 @@ restricted indefinitely. {% endtrans %} {% else %} - {% set expiry_date = embargo.expiration_date.getPhpDateTime|date('Y-m-d') %} + {% set expiry_date = embargo.actual.expiration_date.date.getPhpDateTime|date('Y-m-d') %} {% trans %} Access to all associated files of this resource is restricted until @@ -51,7 +51,7 @@ restricted indefinitely. {% endtrans %} {% else %} - {% set expiry_date = embargo.expiration_date.getPhpDateTime|date('Y-m-d') %} + {% set expiry_date = embargo.actual.expiration_date.date.getPhpDateTime|date('Y-m-d') %} {% trans %} Access to this resource and all associated files is restricted until diff --git a/tests/src/Kernel/EmbargoAccessQueryTaggingAlterTest.php b/tests/src/Kernel/EmbargoAccessQueryTaggingAlterTest.php index c30857f..e1d3703 100644 --- a/tests/src/Kernel/EmbargoAccessQueryTaggingAlterTest.php +++ b/tests/src/Kernel/EmbargoAccessQueryTaggingAlterTest.php @@ -3,6 +3,10 @@ namespace Drupal\Tests\embargo\Kernel; use Drupal\embargo\EmbargoInterface; +use Drupal\file\FileInterface; +use Drupal\media\Entity\Media; +use Drupal\media\MediaInterface; +use Drupal\node\NodeInterface; use Drupal\Tests\islandora_test_support\Traits\DatabaseQueryTestTraits; /** @@ -11,20 +15,120 @@ * @group embargo */ class EmbargoAccessQueryTaggingAlterTest extends EmbargoKernelTestBase { + use DatabaseQueryTestTraits; + /** + * Test embargo instance. + * + * @var \Drupal\embargo\EmbargoInterface + */ + protected EmbargoInterface $embargo; + + /** + * Embargoed node from ::setUp(). + * + * @var \Drupal\node\NodeInterface + */ + protected NodeInterface $embargoedNode; + + /** + * Embargoed media from ::setUp(). + * + * @var \Drupal\media\MediaInterface + */ + protected MediaInterface $embargoedMedia; + + /** + * Embargoed file from ::setUp(). + * + * @var \Drupal\file\FileInterface + */ + protected FileInterface $embargoedFile; + + /** + * Unembargoed node from ::setUp(). + * + * @var \Drupal\node\NodeInterface + */ + protected NodeInterface $unembargoedNode; + + /** + * Unembargoed media from ::setUp(). + * + * @var \Drupal\media\MediaInterface + */ + protected MediaInterface $unembargoedMedia; + + /** + * Unembargoed file from ::setUp(). + * + * @var \Drupal\file\FileInterface + */ + protected FileInterface $unembargoedFile; + + /** + * Unassociated node from ::setUp(). + * + * @var \Drupal\node\NodeInterface + */ + protected NodeInterface $unassociatedNode; + + /** + * Unassociated media from ::setUp(). + * + * @var \Drupal\media\MediaInterface + */ + protected MediaInterface $unassociatedMedia; + + /** + * Unassociated file from ::setUp(). + * + * @var \Drupal\file\FileInterface + */ + protected FileInterface $unassociatedFile; + + /** + * Lazily created "default thumbnail" image file for (file) media. + * + * @var \Drupal\file\FileInterface + * @see https://git.drupalcode.org/project/drupal/-/blob/cd2c8e49c861a70b0f39b17c01051b16fd6a2662/core/modules/media/src/Entity/Media.php#L203-208 + */ + protected FileInterface $mediaTypeDefaultFile; + /** * {@inheritdoc} */ 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. - $embargoedNode = $this->createNode(); - $this->createMedia($this->createFile(), $embargoedNode); - $this->embargo = $this->createEmbargo($embargoedNode); + $this->embargoedNode = $this->createNode(); + $this->embargoedMedia = $this->createMedia($this->embargoedFile = $this->createFile(), $this->embargoedNode); + $this->embargo = $this->createEmbargo($this->embargoedNode); - $this->createNode(); + $this->unembargoedNode = $this->createNode(); + $this->unembargoedMedia = $this->createMedia($this->unembargoedFile = $this->createFile(), $this->unembargoedNode); + + $this->unassociatedNode = $this->createNode(); + $this->unassociatedMedia = Media::create([ + 'bundle' => $this->createMediaType('file', ['id' => 'file_two'])->id(), + ])->setPublished(); + $this->unassociatedMedia->save(); + $this->unassociatedFile = $this->createFile(); + + // XXX: Media lazily creates a "default thumbnail" image file by default. + // @see https://git.drupalcode.org/project/drupal/-/blob/cd2c8e49c861a70b0f39b17c01051b16fd6a2662/core/modules/media/src/Entity/Media.php#L203-208 + $files = $this->storage('file')->loadByProperties(['filename' => 'generic.png']); + $this->assertCount(1, $files, 'only the one generic file.'); + $this->mediaTypeDefaultFile = reset($files); } /** @@ -35,7 +139,11 @@ public function setUp(): void { public function testEmbargoNodeQueryAlterAccess() { $query = $this->generateNodeSelectAccessQuery($this->user); $result = $query->execute()->fetchAll(); - $this->assertCount(1, $result, 'User can only view non-embargoed node.'); + + $ids = array_column($result, 'nid'); + $this->assertNotContains($this->embargoedNode->id(), $ids, 'does not contain embargoed node'); + $this->assertContains($this->unembargoedNode->id(), $ids, 'contains unembargoed node'); + $this->assertContains($this->unassociatedNode->id(), $ids, 'contains unassociated node'); } /** @@ -46,7 +154,11 @@ public function testEmbargoNodeQueryAlterAccess() { public function testNodeEmbargoReferencedMediaAccessQueryAlterAccessDenied() { $query = $this->generateMediaSelectAccessQuery($this->user); $result = $query->execute()->fetchAll(); - $this->assertCount(0, $result, 'Media of embargoed nodes cannot be viewed'); + + $ids = array_column($result, 'mid'); + $this->assertNotContains($this->embargoedMedia->id(), $ids, 'does not contain embargoed media'); + $this->assertContains($this->unembargoedMedia->id(), $ids, 'contains unembargoed media'); + $this->assertContains($this->unassociatedMedia->id(), $ids, 'contains unassociated media'); } /** @@ -57,7 +169,12 @@ public function testNodeEmbargoReferencedMediaAccessQueryAlterAccessDenied() { public function testNodeEmbargoReferencedFileAccessQueryAlterAccessDenied() { $query = $this->generateFileSelectAccessQuery($this->user); $result = $query->execute()->fetchAll(); - $this->assertCount(1, $result, 'File of embargoed nodes cannot be viewed'); + + $ids = array_column($result, 'fid'); + $this->assertNotContains($this->embargoedFile->id(), $ids, 'does not contain embargoed file'); + $this->assertContains($this->unembargoedFile->id(), $ids, 'contains unembargoed file'); + $this->assertContains($this->unassociatedFile->id(), $ids, 'contains unassociated file'); + $this->assertContains($this->mediaTypeDefaultFile->id(), $ids, 'contains default mediatype file'); } /** @@ -72,7 +189,10 @@ public function testDeletedNodeEmbargoNodeAccessQueryAlterAccessAllowed() { $query = $this->generateNodeSelectAccessQuery($this->user); $result = $query->execute()->fetchAll(); - $this->assertCount(2, $result, 'Non embargoed nodes can be viewed'); + $ids = array_column($result, 'nid'); + $this->assertContains($this->embargoedNode->id(), $ids, 'contains formerly embargoed node'); + $this->assertContains($this->unembargoedNode->id(), $ids, 'contains unembargoed node'); + $this->assertContains($this->unassociatedNode->id(), $ids, 'contains unassociated node'); } /** @@ -86,8 +206,11 @@ public function testDeletedNodeEmbargoMediaAccessQueryAlterAccessAllowed() { $this->embargo->delete(); $query = $this->generateMediaSelectAccessQuery($this->user); $result = $query->execute()->fetchAll(); - $this->assertCount(1, $result, - 'Media of non embargoed nodes can be viewed'); + + $ids = array_column($result, 'mid'); + $this->assertContains($this->embargoedMedia->id(), $ids, 'contains formerly embargoed media'); + $this->assertContains($this->unembargoedMedia->id(), $ids, 'contains unembargoed media'); + $this->assertContains($this->unassociatedMedia->id(), $ids, 'contains unassociated media'); } /** @@ -99,11 +222,15 @@ public function testDeletedNodeEmbargoMediaAccessQueryAlterAccessAllowed() { */ public function testDeletedNodeEmbargoFileAccessQueryAlterAccessAllowed() { $this->embargo->delete(); - $query = $this->generateFileSelectAccessQuery($this->user); + $query = $this->generateFileSelectAccessQuery($this->user); $result = $query->execute()->fetchAll(); - $this->assertCount(2, $result, - 'Files of non embargoed nodes can be viewed'); + + $ids = array_column($result, 'fid'); + $this->assertContains($this->embargoedFile->id(), $ids, 'contains formerly embargoed file'); + $this->assertContains($this->unembargoedFile->id(), $ids, 'contains unembargoed file'); + $this->assertContains($this->unassociatedFile->id(), $ids, 'contains unassociated file'); + $this->assertContains($this->mediaTypeDefaultFile->id(), $ids, 'contains default mediatype file'); } /** @@ -112,16 +239,19 @@ public function testDeletedNodeEmbargoFileAccessQueryAlterAccessAllowed() { * @throws \Drupal\Core\Entity\EntityStorageException */ public function testPublishScheduledEmbargoAccess() { - // Create an embargo scheduled to be unpublished in the future. + // Create an embargo scheduled to be published in the future. $this->setEmbargoFutureUnpublishDate($this->embargo); - $nodeCount = $this->generateNodeSelectAccessQuery($this->user)->execute()->fetchAll(); - $this->assertCount(1, $nodeCount, - 'Node is still embargoed.'); + $result = $this->generateNodeSelectAccessQuery($this->user)->execute()->fetchAll(); + + $ids = array_column($result, 'nid'); + $this->assertNotContains($this->embargoedNode->id(), $ids, 'does not contain embargoed node'); + $this->assertContains($this->unembargoedNode->id(), $ids, 'contains unembargoed node'); + $this->assertContains($this->unassociatedNode->id(), $ids, 'contains unassociated node'); } /** - * Tests embargo scheduled to be unpublished in the past. + * Test embargo scheduled in the past, without any other embargo. * * @throws \Drupal\Core\Entity\EntityStorageException */ @@ -130,9 +260,54 @@ public function testUnpublishScheduledEmbargoAccess() { // Create an embargo scheduled to be unpublished in the future. $this->setEmbargoPastUnpublishDate($this->embargo); - $nodeCount = $this->generateNodeSelectAccessQuery($this->user)->execute()->fetchAll(); - $this->assertCount(2, $nodeCount, - 'Embargo has been unpublished.'); + $result = $this->generateNodeSelectAccessQuery($this->user)->execute()->fetchAll(); + + $ids = array_column($result, 'nid'); + $this->assertContains($this->embargoedNode->id(), $ids, 'contains node with expired embargo'); + $this->assertContains($this->unembargoedNode->id(), $ids, 'contains unembargoed node'); + $this->assertContains($this->unassociatedNode->id(), $ids, 'contains unassociated node'); + } + + /** + * Test embargo scheduled in the past with another relevant scheduled embargo. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + public function testUnpublishScheduledWithPublishedEmbargoAccess() { + $this->embargo->setExpirationType(EmbargoInterface::EXPIRATION_TYPE_SCHEDULED)->save(); + // Create an embargo scheduled to be unpublished in the future. + $this->setEmbargoPastUnpublishDate($this->embargo); + + $embargo = $this->createEmbargo($this->embargoedNode); + $embargo->setExpirationType(EmbargoInterface::EXPIRATION_TYPE_SCHEDULED)->save(); + $this->setEmbargoFutureUnpublishDate($embargo); + + $result = $this->generateNodeSelectAccessQuery($this->user)->execute()->fetchAll(); + + $ids = array_column($result, 'nid'); + $this->assertNotContains($this->embargoedNode->id(), $ids, 'does not contain node with expired embargo having other schedule embargo in future'); + $this->assertContains($this->unembargoedNode->id(), $ids, 'contains unembargoed node'); + $this->assertContains($this->unassociatedNode->id(), $ids, 'contains unassociated node'); + } + + /** + * Test embargo scheduled in the past, but with a separate indefinite embargo. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + public function testUnpublishScheduledWithIndefiniteEmbargoAccess() { + $this->embargo->setExpirationType(EmbargoInterface::EXPIRATION_TYPE_SCHEDULED)->save(); + // Create an embargo scheduled to be unpublished in the future. + $this->setEmbargoPastUnpublishDate($this->embargo); + + $this->createEmbargo($this->embargoedNode); + + $result = $this->generateNodeSelectAccessQuery($this->user)->execute()->fetchAll(); + + $ids = array_column($result, 'nid'); + $this->assertNotContains($this->embargoedNode->id(), $ids, 'does not contain node with expired embargo having other indefinite embargo'); + $this->assertContains($this->unembargoedNode->id(), $ids, 'contains unembargoed node'); + $this->assertContains($this->unassociatedNode->id(), $ids, 'contains unassociated node'); } } diff --git a/tests/src/Kernel/EmbargoKernelTestBase.php b/tests/src/Kernel/EmbargoKernelTestBase.php index c79114b..ce81111 100644 --- a/tests/src/Kernel/EmbargoKernelTestBase.php +++ b/tests/src/Kernel/EmbargoKernelTestBase.php @@ -2,9 +2,9 @@ namespace Drupal\Tests\embargo\Kernel; -use Drupal\embargo\Entity\IpRange; use Drupal\Core\Datetime\DrupalDateTime; use Drupal\embargo\EmbargoInterface; +use Drupal\embargo\Entity\IpRange; use Drupal\embargo\IpRangeInterface; use Drupal\node\NodeInterface; use Drupal\Tests\islandora_test_support\Kernel\AbstractIslandoraKernelTestBase; diff --git a/tests/src/Kernel/FileEmbargoTest.php b/tests/src/Kernel/FileEmbargoTest.php index de399fb..90d09cf 100644 --- a/tests/src/Kernel/FileEmbargoTest.php +++ b/tests/src/Kernel/FileEmbargoTest.php @@ -73,7 +73,7 @@ public function testEmbargoedNodeRelatedMediaFileAccessDenied($operation) { * @throws \Drupal\Core\Entity\EntityStorageException */ public function testDeletedEmbargoedFileRelatedMediaFileAccessAllowed( - $operation + $operation, ) { $node = $this->createNode(); $file = $this->createFile(); diff --git a/tests/src/Kernel/IpRangeEmbargoTest.php b/tests/src/Kernel/IpRangeEmbargoTest.php index 9b29a0d..a858416 100644 --- a/tests/src/Kernel/IpRangeEmbargoTest.php +++ b/tests/src/Kernel/IpRangeEmbargoTest.php @@ -2,9 +2,10 @@ namespace Drupal\Tests\embargo\Kernel; -use Drupal\node\NodeInterface; use Drupal\embargo\EmbargoInterface; use Drupal\embargo\IpRangeInterface; +use Drupal\node\NodeInterface; +use Drupal\Tests\islandora_test_support\Traits\DatabaseQueryTestTraits; /** * Test IpRange embargo. @@ -13,6 +14,8 @@ */ class IpRangeEmbargoTest extends EmbargoKernelTestBase { + use DatabaseQueryTestTraits; + /** * Embargo for test. * @@ -105,8 +108,16 @@ public function setUp(): void { $this->embargoedNodeWithDifferentIpRange = $this->createNode(); $this->currentIpRangeEntity = $this->createIpRangeEntity($this->ipRange); $this->embargoWithoutIpRange = $this->createEmbargo($this->embargoedNodeWithoutIpRange); - $this->embargoWithCurrentIpRange = $this->createEmbargo($this->embargoedNodeWithCurrentIpRange, 1, $this->currentIpRangeEntity); - $this->embargoWithDifferentIpRange = $this->createEmbargo($this->embargoedNodeWithDifferentIpRange, 1, $this->createIpRangeEntity('0.0.0.0.1/29')); + $this->embargoWithCurrentIpRange = $this->createEmbargo( + $this->embargoedNodeWithCurrentIpRange, + EmbargoInterface::EMBARGO_TYPE_NODE, + $this->currentIpRangeEntity, + ); + $this->embargoWithDifferentIpRange = $this->createEmbargo( + $this->embargoedNodeWithDifferentIpRange, + EmbargoInterface::EMBARGO_TYPE_NODE, + $this->createIpRangeEntity('0.0.0.1/29'), + ); } /** @@ -127,4 +138,18 @@ public function testIpRangeEmbargoNodeAccess() { $this->assertTrue($this->embargoedNodeWithCurrentIpRange->access('view', $this->user)); } + /** + * Test IP range query tagging. + */ + public function testIpRangeQueryTagging() { + $results = $this->generateNodeSelectAccessQuery($this->user)->execute()->fetchAll(); + + $ids = array_column($results, 'nid'); + + $this->assertContains($this->nonEmbargoedNode->id(), $ids, 'non-embargoed node present'); + $this->assertNotContains($this->embargoedNodeWithoutIpRange->id(), $ids, 'generally embargoed node absent'); + $this->assertNotContains($this->embargoedNodeWithDifferentIpRange->id(), $ids, 'node exempted to other ranges absent'); + $this->assertContains($this->embargoedNodeWithCurrentIpRange->id(), $ids, 'node exempted to our range present'); + } + }