diff --git a/islandora.services.yml b/islandora.services.yml index df4ce606..8e24ca1c 100644 --- a/islandora.services.yml +++ b/islandora.services.yml @@ -18,6 +18,11 @@ services: arguments: ['@entity_type.manager', '@current_user'] tags: - { name: event_subscriber } + islandora.media_link_header_subscriber: + class: Drupal\islandora\EventSubscriber\MediaLinkHeaderSubscriber + arguments: ['@entity_field.manager', '@current_route_match', '@entity_type.manager'] + tags: + - { name: event_subscriber } islandora.node_link_header_subscriber: class: Drupal\islandora\EventSubscriber\NodeLinkHeaderSubscriber arguments: ['@entity_field.manager', '@current_route_match'] diff --git a/src/EventSubscriber/LinkHeaderSubscriber.php b/src/EventSubscriber/LinkHeaderSubscriber.php new file mode 100644 index 00000000..0e08ab80 --- /dev/null +++ b/src/EventSubscriber/LinkHeaderSubscriber.php @@ -0,0 +1,124 @@ +entityFieldManager = $entity_field_manager; + $this->routeMatch = $route_match; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + // Run this early so the headers get cached. + $events[KernelEvents::RESPONSE][] = ['onResponse', 129]; + + return $events; + } + + /** + * Get the Node | Media | File. + * + * @param \Symfony\Component\HttpFoundation\Response $response + * The current response object. + * @param string $object_type + * The type of entity to look for. + * + * @return Drupal\Core\Entity\ContentEntityBase|bool + * A node or media entity or FALSE if we should skip out. + */ + protected function getObject(Response $response, $object_type) { + if ($object_type != 'node' + && $object_type != 'media' + ) { + return FALSE; + } + + // Exit early if the response is already cached. + if ($response->headers->get('X-Drupal-Dynamic-Cache') == 'HIT') { + return FALSE; + } + + if (!$response->isOk()) { + return FALSE; + } + + // Hack the node out of the route. + $route_object = $this->routeMatch->getRouteObject(); + if (!$route_object) { + return FALSE; + } + + $methods = $route_object->getMethods(); + $is_get = in_array('GET', $methods); + $is_head = in_array('HEAD', $methods); + if (!($is_get || $is_head)) { + return FALSE; + } + + $route_contexts = $route_object->getOption('parameters'); + if (!$route_contexts) { + return FALSE; + } + if (!isset($route_contexts[$object_type])) { + return FALSE; + } + + $object = $this->routeMatch->getParameter($object_type); + + if (!$object) { + return FALSE; + } + + return $object; + } + + /** + * Adds resource-specific link headers to appropriate responses. + * + * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event + * Event containing the response. + */ + abstract public function onResponse(FilterResponseEvent $event); + +} diff --git a/src/EventSubscriber/MediaLinkHeaderSubscriber.php b/src/EventSubscriber/MediaLinkHeaderSubscriber.php new file mode 100644 index 00000000..6974183b --- /dev/null +++ b/src/EventSubscriber/MediaLinkHeaderSubscriber.php @@ -0,0 +1,96 @@ +mediaBundleStorage = $entity_type_manager->getStorage('media_bundle'); + parent::__construct($entity_field_manager, $route_match); + } + + /** + * {@inheritdoc} + */ + public function onResponse(FilterResponseEvent $event) { + $response = $event->getResponse(); + + $entity = $this->getObject($response, 'media'); + + if ($entity === FALSE) { + return; + } + + $media_bundle = $this->mediaBundleStorage->load($entity->bundle()); + + $type_configuration = $media_bundle->getTypeConfiguration(); + + if (!isset($type_configuration['source_field'])) { + return; + } + $source_field = $type_configuration['source_field']; + + if (empty($source_field) || + !$entity instanceof FieldableEntityInterface || + !$entity->hasField($source_field) + ) { + return; + } + + // Collect file links for the media. + $links = []; + foreach ($entity->get($source_field)->referencedEntities() as $referencedEntity) { + if ($entity->access('view')) { + $file_url = $referencedEntity->url('canonical', ['absolute' => TRUE]); + $edit_media_url = Url::fromRoute('islandora.media_source_update', ['media' => $referencedEntity->id()]) + ->setAbsolute() + ->toString(); + $links[] = "<$file_url>; rel=\"describes\"; type=\"{$referencedEntity->getMimeType()}\""; + $links[] = "<$edit_media_url>; rel=\"edit-media\""; + } + } + + // Exit early if there aren't any. + if (empty($links)) { + return; + } + + // Add the link headers to the response. + $response->headers->set('Link', $links, FALSE); + + } + +} diff --git a/src/EventSubscriber/NodeLinkHeaderSubscriber.php b/src/EventSubscriber/NodeLinkHeaderSubscriber.php index ea2eb963..3155d936 100644 --- a/src/EventSubscriber/NodeLinkHeaderSubscriber.php +++ b/src/EventSubscriber/NodeLinkHeaderSubscriber.php @@ -2,58 +2,15 @@ namespace Drupal\islandora\EventSubscriber; -use Drupal\Core\Entity\EntityFieldManager; -use Drupal\Core\Routing\RouteMatchInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; -use Symfony\Component\HttpKernel\KernelEvents; /** * Class NodeLinkHeaderSubscriber. * * @package Drupal\islandora\EventSubscriber */ -class NodeLinkHeaderSubscriber implements EventSubscriberInterface { - - /** - * The entity field manager. - * - * @var \Drupal\Core\Entity\EntityFieldManager - */ - protected $entityFieldManager; - - /** - * The route match object. - * - * @var \Drupal\Core\Routing\RouteMatchInterface - */ - protected $routeMatch; - - /** - * Constructor. - * - * @param \Drupal\Core\Entity\EntityFieldManager $entity_field_manager - * The entity field manager. - * @param \Drupal\Core\Routing\RouteMatchInterface $route_match - * The route match object. - */ - public function __construct( - EntityFieldManager $entity_field_manager, - RouteMatchInterface $route_match - ) { - $this->entityFieldManager = $entity_field_manager; - $this->routeMatch = $route_match; - } - - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents() { - // Run this early so the headers get cached. - $events[KernelEvents::RESPONSE][] = ['onResponse', 129]; - - return $events; - } +class NodeLinkHeaderSubscriber extends LinkHeaderSubscriber implements EventSubscriberInterface { /** * Adds node-specific link headers to appropriate responses. @@ -64,44 +21,14 @@ class NodeLinkHeaderSubscriber implements EventSubscriberInterface { public function onResponse(FilterResponseEvent $event) { $response = $event->getResponse(); - // Exit early if the response is already cached. - if ($response->headers->get('X-Drupal-Dynamic-Cache') == 'HIT') { - return; - } - - if (!$response->isOk()) { - return; - } - - // Hack the node out of the route. - $route_object = $this->routeMatch->getRouteObject(); - if (!$route_object) { - return; - } - - $methods = $route_object->getMethods(); - $is_get = in_array('GET', $methods); - $is_head = in_array('HEAD', $methods); - if (!($is_get || $is_head)) { - return; - } - - $route_contexts = $route_object->getOption('parameters'); - if (!$route_contexts) { - return; - } - if (!isset($route_contexts['node'])) { - return; - } - - $node = $this->routeMatch->getParameter('node'); + $entity = $this->getObject($response, 'node'); - if (!$node) { + if ($entity === FALSE) { return; } // Use the node to add link headers for each entity reference. - $bundle = $node->bundle(); + $bundle = $entity->bundle(); // Get all fields for the entity. $fields = $this->entityFieldManager->getFieldDefinitions('node', $bundle); @@ -114,7 +41,7 @@ class NodeLinkHeaderSubscriber implements EventSubscriberInterface { // Collect links for referenced entities. $links = []; foreach ($entity_reference_fields as $field_name => $field_definition) { - foreach ($node->get($field_name)->referencedEntities() as $referencedEntity) { + foreach ($entity->get($field_name)->referencedEntities() as $referencedEntity) { // Headers are subject to an access check. if ($referencedEntity->access('view')) { $entity_url = $referencedEntity->url('canonical', ['absolute' => TRUE]); diff --git a/tests/src/Functional/IslandoraFunctionalTestBase.php b/tests/src/Functional/IslandoraFunctionalTestBase.php index 34f7646e..63d2b727 100644 --- a/tests/src/Functional/IslandoraFunctionalTestBase.php +++ b/tests/src/Functional/IslandoraFunctionalTestBase.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\islandora\Functional; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Url; use Drupal\Tests\BrowserTestBase; use Drupal\Tests\TestFileCreationTrait; @@ -56,6 +57,7 @@ class IslandoraFunctionalTestBase extends BrowserTestBase { ]); $hello_world->save(); + $this->container->get('router.builder')->rebuild(); } /** @@ -94,7 +96,7 @@ class IslandoraFunctionalTestBase extends BrowserTestBase { $file = current($this->getTestFiles('image')); $values = [ 'name[0][value]' => 'Test Media', - 'files[field_image_0]' => drupal_realpath($file->uri), + 'files[field_image_0]' => \Drupal::service('file_system')->realpath($file->uri), ]; $this->drupalPostForm('media/add/tn', $values, t('Save and publish')); $values = [ @@ -102,8 +104,20 @@ class IslandoraFunctionalTestBase extends BrowserTestBase { ]; $this->getSession()->getPage()->fillField('edit-field-image-0-alt', 'alt text'); $this->getSession()->getPage()->pressButton(t('Save and publish')); - $this->assertResponse(200); - return $this->getUrl(); + $this->assertSession()->statusCodeEquals(200); + $results = $this->container->get('entity_type.manager')->getStorage('file')->loadByProperties(['filename' => $file->filename]); + $file_entity = reset($results); + $file_url = $file_entity->url('canonical', ['absolute' => TRUE]); + $rest_url = Url::fromRoute('islandora.media_source_update', ['media' => $file_entity->id()]) + ->setAbsolute() + ->toString(); + return [ + 'media' => $this->getUrl(), + 'file' => [ + 'file' => $file_url, + 'rest' => $rest_url, + ], + ]; } /** @@ -111,7 +125,7 @@ class IslandoraFunctionalTestBase extends BrowserTestBase { */ protected function postNodeAddForm($bundle_id, $values, $button_text) { $this->drupalPostForm("node/add/$bundle_id", $values, t('@text', ['@text' => $button_text])); - $this->assertResponse(200); + $this->assertSession()->statusCodeEquals(200); } /** @@ -119,7 +133,7 @@ class IslandoraFunctionalTestBase extends BrowserTestBase { */ protected function postEntityEditForm($entity_url, $values, $button_text) { $this->drupalPostForm("$entity_url/edit", $values, t('@text', ['@text' => $button_text])); - $this->assertResponse(200); + $this->assertSession()->statusCodeEquals(200); } /** @@ -144,7 +158,7 @@ class IslandoraFunctionalTestBase extends BrowserTestBase { } /** - * Checks if the collection link header contains the correct uri. + * Checks if the correct link header exists for an Entity. * * @param string $rel * The expected relation type of the link header. @@ -152,17 +166,42 @@ class IslandoraFunctionalTestBase extends BrowserTestBase { * The entity whose uri is expected in the link header. * @param string $title * The expected title of the link header. + * @param string $type + * The expected mimetype for the link header. + * + * @return int + * The number of times the correct header appears. + */ + protected function validateLinkHeaderWithEntity($rel, EntityInterface $entity, $title = '', $type = '') { + $entity_url = $entity->toUrl('canonical', ['absolute' => TRUE]) + ->toString(); + return $this->validateLinkHeaderWithUrl($rel, $entity_url, $title, $type); + } + + /** + * Checks if the correct link header exists for a string URI. + * + * @param string $rel + * The expected relation type of the link header. + * @param string $url + * The uri is expected in the link header. + * @param string $title + * The expected title of the link header. + * @param string $type + * The expected mimetype for the link header. * * @return int * The number of times the correct header appears. */ - protected function validateLinkHeader($rel, EntityInterface $entity, $title = '') { - $entity_url = $entity->url('canonical', ['absolute' => TRUE]); + protected function validateLinkHeaderWithUrl($rel, $url, $title = '', $type = '') { $regex = '/<(.*)>; rel="' . preg_quote($rel) . '"'; if (!empty($title)) { $regex .= '; title="' . preg_quote($title) . '"'; } + if (!empty($type)) { + $regex .= '; type="' . preg_quote($type, '/') . '"'; + } $regex .= '/'; $count = 0; @@ -173,7 +212,7 @@ class IslandoraFunctionalTestBase extends BrowserTestBase { $split = explode(',', $link_headers); foreach ($split as $link_header) { $matches = []; - if (preg_match($regex, $link_header, $matches) && $matches[1] == $entity_url) { + if (preg_match($regex, $link_header, $matches) && $matches[1] == $url) { $count++; } } diff --git a/tests/src/Functional/MediaLinkHeaderTest.php b/tests/src/Functional/MediaLinkHeaderTest.php new file mode 100644 index 00000000..b7b742b9 --- /dev/null +++ b/tests/src/Functional/MediaLinkHeaderTest.php @@ -0,0 +1,38 @@ +drupalCreateUser([ + 'view media', + 'create media', + 'update media', + ]); + $this->drupalLogin($account); + + $urls = $this->createThumbnailWithFile(); + + $this->drupalGet($urls['media'], [], ['Cache-Control: no-cache']); + + $this->assertTrue( + $this->validateLinkHeaderWithUrl('describes', $urls['file']['file'], '', 'image/png') == 1, + "Malformed 'describes' link header" + ); + $this->assertTrue( + $this->validateLinkHeaderWithUrl('edit-media', $urls['file']['rest'], '', '') == 1, + "Malformed 'edit-media' link header" + ); + } + +} diff --git a/tests/src/Functional/MediaSourceUpdateTest.php b/tests/src/Functional/MediaSourceUpdateTest.php index 99d93e75..1f5cc487 100644 --- a/tests/src/Functional/MediaSourceUpdateTest.php +++ b/tests/src/Functional/MediaSourceUpdateTest.php @@ -44,7 +44,8 @@ class MediaSourceUpdateTest extends IslandoraFunctionalTestBase { $this->drupalLogin($account); // Make a media and give it a png. - $url = $this->createThumbnailWithFile(); + $urls = $this->createThumbnailWithFile(); + $url = $urls['media']; // Hack out the guzzle client. $client = $this->getSession()->getDriver()->getClient()->getClient(); diff --git a/tests/src/Functional/RelatedLinkHeaderTest.php b/tests/src/Functional/RelatedLinkHeaderTest.php index ff937c7a..385c66f2 100644 --- a/tests/src/Functional/RelatedLinkHeaderTest.php +++ b/tests/src/Functional/RelatedLinkHeaderTest.php @@ -119,11 +119,11 @@ class RelatedLinkHeaderTest extends IslandoraFunctionalTestBase { // for both the referenced node and media entity. $this->drupalGet('node/' . $this->referencer->id()); $this->assertTrue( - $this->validateLinkHeader('related', $this->referenced, 'Referenced Entity') == 1, + $this->validateLinkHeaderWithEntity('related', $this->referenced, 'Referenced Entity') == 1, "Malformed related link header" ); $this->assertTrue( - $this->validateLinkHeader('related', $this->media, 'Media Entity') == 1, + $this->validateLinkHeaderWithEntity('related', $this->media, 'Media Entity') == 1, "Malformed related link header" ); @@ -135,11 +135,11 @@ class RelatedLinkHeaderTest extends IslandoraFunctionalTestBase { // for both the referenced node and media entity. $this->drupalGet('node/' . $this->referencer->id()); $this->assertTrue( - $this->validateLinkHeader('related', $this->referenced, 'Referenced Entity') == 1, + $this->validateLinkHeaderWithEntity('related', $this->referenced, 'Referenced Entity') == 1, "Malformed related link header" ); $this->assertTrue( - $this->validateLinkHeader('related', $this->media, 'Media Entity') == 0, + $this->validateLinkHeaderWithEntity('related', $this->media, 'Media Entity') == 0, "Anonymous should not be able to see media link header" ); }