diff --git a/core/modules/media/media.permissions.yml b/core/modules/media/media.permissions.yml index 530365e1f4..79eadb5058 100644 --- a/core/modules/media/media.permissions.yml +++ b/core/modules/media/media.permissions.yml @@ -23,3 +23,7 @@ delete any media: create media: title: 'Create media' + +view all media revisions: + title: 'View all media revisions' + description: 'To view a revision, you also need permission to view the media item.' diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml index 67ea1fb81c..9fbadeff29 100644 --- a/core/modules/media/media.routing.yml +++ b/core/modules/media/media.routing.yml @@ -4,3 +4,18 @@ entity.media.multiple_delete_confirm: _form: '\Drupal\media\Form\MediaDeleteMultipleConfirmForm' requirements: _permission: 'administer media+delete any media' + +entity.media.revision: + path: '/media/{media}/revisions/{media_revision}/view' + defaults: + _controller: '\Drupal\Core\Entity\Controller\EntityViewController::viewRevision' + _title_callback: '\Drupal\Core\Entity\Controller\EntityController::title' + options: + parameters: + media: + type: entity:media + media_revision: + type: entity_revision:media + requirements: + _access_media_revision: 'view' + media: \d+ diff --git a/core/modules/media/media.services.yml b/core/modules/media/media.services.yml index a286b528d9..f22f90a124 100644 --- a/core/modules/media/media.services.yml +++ b/core/modules/media/media.services.yml @@ -2,3 +2,9 @@ services: plugin.manager.media.source: class: Drupal\media\MediaSourceManager parent: default_plugin_manager + + access_check.media.revision: + class: Drupal\media\Access\MediaRevisionAccessCheck + arguments: ['@entity_type.manager'] + tags: + - { name: access_check, applies_to: _access_media_revision } diff --git a/core/modules/media/src/Access/MediaRevisionAccessCheck.php b/core/modules/media/src/Access/MediaRevisionAccessCheck.php new file mode 100644 index 0000000000..fb3d560c88 --- /dev/null +++ b/core/modules/media/src/Access/MediaRevisionAccessCheck.php @@ -0,0 +1,151 @@ +mediaStorage = $entity_type_manager->getStorage('media'); + $this->mediaAccess = $entity_type_manager->getAccessControlHandler('media'); + } + + /** + * Checks routing access for the media item revision. + * + * @param \Symfony\Component\Routing\Route $route + * The route to check against. + * @param \Drupal\Core\Session\AccountInterface $account + * The currently logged in account. + * @param int $media_revision + * (optional) The media item revision ID. If not specified, but $media is, + * access is checked for that object's revision. + * @param \Drupal\media\MediaInterface $media + * (optional) A media item. Used for checking access to a media items + * default revision when $media_revision is unspecified. Ignored when + * $media_revision is specified. If neither $media_revision nor $media are + * specified, then access is denied. + * + * @return \Drupal\Core\Access\AccessResultInterface + * The access result. + */ + public function access(Route $route, AccountInterface $account, $media_revision = NULL, MediaInterface $media = NULL) { + if ($media_revision) { + $media = $this->mediaStorage->loadRevision($media_revision); + } + $operation = $route->getRequirement('_access_media_revision'); + return AccessResult::allowedIf($media && $this->checkAccess($media, $account, $operation))->cachePerPermissions()->addCacheableDependency($media); + } + + /** + * Checks media item revision access. + * + * @param \Drupal\media\MediaInterface $media + * The media item to check. + * @param \Drupal\Core\Session\AccountInterface $account + * A user object representing the user for whom the operation is to be + * performed. + * @param string $op + * (optional) The specific operation being checked. Defaults to 'view'. + * + * @return bool + * TRUE if the operation may be performed, FALSE otherwise. + */ + public function checkAccess(MediaInterface $media, AccountInterface $account, $op = 'view') { + if (!$media || $op !== 'view') { + // If there was no media to check against, or the $op was not one of the + // supported ones, we return access denied. + return FALSE; + } + + // Statically cache access by revision ID, language code, user account ID, + // and operation. + $langcode = $media->language()->getId(); + $cid = $media->getRevisionId() . ':' . $langcode . ':' . $account->id() . ':' . $op; + + if (!isset($this->access[$cid])) { + // Perform basic permission checks first. + if (!$account->hasPermission('view all media revisions') && !$account->hasPermission('administer media')) { + $this->access[$cid] = FALSE; + return FALSE; + } + + // There should be at least two revisions. If the revision ID of the + // given media item and the revision ID of the default revision differ, + // then we already have two different revisions so there is no need for a + // separate database check. + if ($media->isDefaultRevision() && ($this->countDefaultLanguageRevisions($media) == 1)) { + $this->access[$cid] = FALSE; + } + elseif ($account->hasPermission('administer media')) { + $this->access[$cid] = TRUE; + } + else { + // First check the access to the default revision and finally, if the + // media passed in is not the default revision then access to that, too. + $this->access[$cid] = $this->mediaAccess->access($this->mediaStorage->load($media->id()), $op, $account) && ($media->isDefaultRevision() || $this->mediaAccess->access($media, $op, $account)); + } + } + + return $this->access[$cid]; + } + + /** + * Counts the number of revisions in the default language. + * + * @param \Drupal\media\MediaInterface $media + * The media item for which to to count the revisions. + * + * @return int + * The number of revisions in the default language. + */ + protected function countDefaultLanguageRevisions(MediaInterface $media) { + $entity_type = $media->getEntityType(); + $count = $this->mediaStorage->getQuery() + ->allRevisions() + ->condition($entity_type->getKey('id'), $media->id()) + ->condition($entity_type->getKey('default_langcode'), 1) + ->count() + ->execute(); + return $count; + } + +} diff --git a/core/modules/media/src/Entity/Media.php b/core/modules/media/src/Entity/Media.php index 858404112e..bdb089ea9c 100644 --- a/core/modules/media/src/Entity/Media.php +++ b/core/modules/media/src/Entity/Media.php @@ -76,6 +76,7 @@ * "canonical" = "/media/{media}", * "delete-form" = "/media/{media}/delete", * "edit-form" = "/media/{media}/edit", + * "revision" = "/media/{media}/revisions/{media_revision}/view", * "admin-form" = "/admin/structure/media/manage/{media_type}" * } * ) diff --git a/core/modules/media/tests/src/Functional/MediaRevisionTest.php b/core/modules/media/tests/src/Functional/MediaRevisionTest.php index ce1897f3ce..a22050e552 100644 --- a/core/modules/media/tests/src/Functional/MediaRevisionTest.php +++ b/core/modules/media/tests/src/Functional/MediaRevisionTest.php @@ -4,6 +4,9 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\field\Entity\FieldConfig; +use Drupal\media\MediaInterface; +use Drupal\user\Entity\Role; +use Drupal\user\RoleInterface; /** * Tests the revisionability of media entities. @@ -13,6 +16,62 @@ class MediaRevisionTest extends MediaFunctionalTestBase { /** + * Checks media revision operations. + */ + public function testRevisions() { + $assert = $this->assertSession(); + + /** @var \Drupal\Core\Entity\Sql\SqlContentEntityStorage $media_storage */ + $media_storage = $this->container->get('entity.manager')->getStorage('media'); + + // Create a media type and media item. + $media_type = $this->createMediaType(); + $media = $media_storage->create([ + 'bundle' => $media_type->id(), + 'name' => 'Unnamed', + ]); + $media->save(); + + // You can't access the revision page when there is only 1 revision. + $this->drupalGet('media/' . $media->id() . '/revisions/' . $media->getRevisionId() . '/view'); + $assert->statusCodeEquals(403); + + // Create some revisions. + $media_revisions = []; + $media_revisions[] = clone $media; + $revision_count = 3; + for ($i = 0; $i < $revision_count; $i++) { + $media->revision_log = $this->randomMachineName(32); + $media = $this->createMediaRevision($media); + $media_revisions[] = clone $media; + } + + // Get the last revision for simple checks. + /** @var \Drupal\media\MediaInterface $media */ + $media = end($media_revisions); + + // Test permissions. + $this->drupalLogin($this->nonAdminUser); + /** @var \Drupal\user\RoleInterface $role */ + $role = Role::load(RoleInterface::AUTHENTICATED_ID); + + // Test 'view all media revisions' permission ('view media' permission is + // needed as well). + user_role_revoke_permissions($role->id(), ['view media', 'view all media revisions']); + $this->drupalGet('media/' . $media->id() . '/revisions/' . $media->getRevisionId() . '/view'); + $assert->statusCodeEquals(403); + $this->grantPermissions($role, ['view media', 'view all media revisions']); + $this->drupalGet('media/' . $media->id() . '/revisions/' . $media->getRevisionId() . '/view'); + $assert->statusCodeEquals(200); + + // Confirm the revision page shows the correct title. + $assert->pageTextContains($media->label()); + + // Confirm that the last revision is the default revision. + $this->assertTrue($media->isDefaultRevision(), 'Last revision is the default.'); + } + + /** * Tests creating revisions of a File media item. */ public function testFileMediaRevision() { @@ -43,6 +102,13 @@ public function testFileMediaRevision() { $page->fillField('Name', 'Foobaz'); $page->pressButton('Save and keep published'); $this->assertRevisionCount($media, 2); + + // Confirm the correct revision title appears on "view revisions" page. + $media = $this->container->get('entity_type.manager') + ->getStorage('media') + ->loadUnchanged(1); + $this->drupalGet("media/" . $media->id() . "/revisions/" . $media->getRevisionId() . "/view"); + $assert->pageTextContains('Foobaz'); } /** @@ -83,6 +149,29 @@ public function testImageMediaRevision() { $page->fillField('Name', 'Foobaz'); $page->pressButton('Save and keep published'); $this->assertRevisionCount($media, 2); + + // Confirm the correct revision title appears on "view revisions" page. + $media = $this->container->get('entity_type.manager') + ->getStorage('media') + ->loadUnchanged(1); + $this->drupalGet("media/" . $media->id() . "/revisions/" . $media->getRevisionId() . "/view"); + $assert->pageTextContains('Foobaz'); + } + + /** + * Creates a new revision for a given media item. + * + * @param \Drupal\media\MediaInterface $media + * A media object. + * + * @return \Drupal\media\MediaInterface + * A media object with up to date revision information. + */ + protected function createMediaRevision(MediaInterface $media) { + $media->set('name', $this->randomMachineName()); + $media->setNewRevision(); + $media->save(); + return $media; } /**