diff --git a/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php b/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php index 47e058d8e1..d4f0757f4c 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php @@ -23,4 +23,15 @@ */ public function createTranslation(ContentEntityInterface $entity, $langcode, array $values = []); + /** + * Counts the number of revisions in the default language. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity for which to to count the revisions. + * + * @return int + * The number of revisions in the default language. + */ + public function countDefaultLanguageRevisions(EntityInterface $entity); + } diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php index 1172406e34..ef9d24f493 100644 --- a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php +++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php @@ -809,6 +809,17 @@ public function save(EntityInterface $entity) { /** * {@inheritdoc} */ + public function countDefaultLanguageRevisions(EntityInterface $entity) { + $query = $this->database->select($this->revisionDataTable); + $query->addExpression('COUNT(*)'); + $query->condition($this->idKey, $entity->id()); + $query->condition('default_langcode', 1); + return $query->execute()->fetchField(); + } + + /** + * {@inheritdoc} + */ protected function doSaveFieldItems(ContentEntityInterface $entity, array $names = []) { $full_save = empty($names); $update = !$full_save || !$entity->isNew(); 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..c87fcad9ea 100644 --- a/core/modules/media/media.routing.yml +++ b/core/modules/media/media.routing.yml @@ -4,3 +4,12 @@ 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\media\Controller\MediaController::revisionShow' + _title_callback: '\Drupal\media\Controller\MediaController::revisionPageTitle' + 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..9ccae7c6cd --- /dev/null +++ b/core/modules/media/src/Access/MediaRevisionAccessCheck.php @@ -0,0 +1,135 @@ +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') { + $map = [ + 'view' => 'view all media revisions', + ]; + + if (!$media || !isset($map[$op])) { + // 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($map[$op]) && !$account->hasPermission('administer media')) { + $this->access[$cid] = FALSE; + return FALSE; + } + + // There should be at least two revisions. If the vid of the given media + // item and the vid 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->mediaStorage->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]; + } + +} diff --git a/core/modules/media/src/Controller/MediaController.php b/core/modules/media/src/Controller/MediaController.php new file mode 100644 index 0000000000..7d500ffce0 --- /dev/null +++ b/core/modules/media/src/Controller/MediaController.php @@ -0,0 +1,98 @@ +entityManager = $entity_manager; + $this->dateFormatter = $date_formatter; + $this->renderer = $renderer; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity.manager'), + $container->get('date.formatter'), + $container->get('renderer') + ); + } + + /** + * Displays a media item revision. + * + * @param int $media_revision + * The media item revision ID. + * + * @return array + * An array suitable for drupal_render(). + */ + public function revisionShow($media_revision) { + $media = $this->entityManager->getStorage('media')->loadRevision($media_revision); + $media = $this->entityManager->getTranslationFromContext($media); + $media_view_controller = new EntityViewController($this->entityManager, $this->renderer); + $page = $media_view_controller->view($media); + unset($page['media'][$media->id()]['#cache']); + return $page; + } + + /** + * Page title callback for a media revision. + * + * @param int $media_revision + * The media revision ID. + * + * @return string + * The page title. + */ + public function revisionPageTitle($media_revision) { + $media = $this->entityTypeManager->getStorage('media')->loadRevision($media_revision); + return $this->t('Revision of %title from %date', ['%title' => $media->label(), '%date' => $this->dateFormatter->format($media->getRevisionCreationTime())]); + } + +} 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..d4914a39c4 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() { @@ -26,6 +85,7 @@ public function testFileMediaRevision() { $page = $this->getSession()->getPage(); $page->fillField('Name', 'Foobar'); $page->attachFileToField('File', $this->container->get('file_system')->realpath($uri)); + $this->getSession()->wait(10000); $page->pressButton('Save and publish'); $assert->addressMatches('/^\/media\/[0-9]+$/'); @@ -43,6 +103,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'); } /** @@ -66,6 +133,7 @@ public function testImageMediaRevision() { $page = $this->getSession()->getPage(); $page->fillField('Name', 'Foobar'); $page->attachFileToField('Image', \Drupal::root() . '/core/modules/media/tests/fixtures/example_1.jpeg'); + $this->getSession()->wait(10000); $page->pressButton('Save and publish'); $assert->addressMatches('/^\/media\/[0-9]+$/'); @@ -83,6 +151,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; } /** diff --git a/core/modules/node/src/NodeStorage.php b/core/modules/node/src/NodeStorage.php index af85a067d9..459e85947b 100644 --- a/core/modules/node/src/NodeStorage.php +++ b/core/modules/node/src/NodeStorage.php @@ -2,6 +2,7 @@ namespace Drupal\node; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\Sql\SqlContentEntityStorage; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Language\LanguageInterface; @@ -37,8 +38,8 @@ public function userRevisionIds(AccountInterface $account) { /** * {@inheritdoc} */ - public function countDefaultLanguageRevisions(NodeInterface $node) { - return $this->database->query('SELECT COUNT(*) FROM {node_field_revision} WHERE nid = :nid AND default_langcode = 1', [':nid' => $node->id()])->fetchField(); + public function countDefaultLanguageRevisions(EntityInterface $entity) { + return $this->database->query('SELECT COUNT(*) FROM {node_field_revision} WHERE nid = :nid AND default_langcode = 1', [':nid' => $entity->id()])->fetchField(); } /** diff --git a/core/modules/node/src/NodeStorageInterface.php b/core/modules/node/src/NodeStorageInterface.php index 19668c2084..ffc1a71aeb 100644 --- a/core/modules/node/src/NodeStorageInterface.php +++ b/core/modules/node/src/NodeStorageInterface.php @@ -34,17 +34,6 @@ public function revisionIds(NodeInterface $node); public function userRevisionIds(AccountInterface $account); /** - * Counts the number of revisions in the default language. - * - * @param \Drupal\node\NodeInterface $node - * The node entity. - * - * @return int - * The number of revisions in the default language. - */ - public function countDefaultLanguageRevisions(NodeInterface $node); - - /** * Updates all nodes of one type to be of another type. * * @param string $old_type