diff --git a/core/modules/media/media.links.task.yml b/core/modules/media/media.links.task.yml index c7a669c2c1..e5adcf48f1 100644 --- a/core/modules/media/media.links.task.yml +++ b/core/modules/media/media.links.task.yml @@ -19,6 +19,12 @@ entity.media_type.edit_form: route_name: entity.media_type.edit_form base_route: entity.media_type.edit_form +entity.media.version_history: + route_name: entity.media.version_history + base_route: entity.media.canonical + title: 'Revisions' + weight: 20 + entity.media_type.collection: title: List route_name: entity.media_type.collection diff --git a/core/modules/media/media.permissions.yml b/core/modules/media/media.permissions.yml index 530365e1f4..6bdfd1cc6d 100644 --- a/core/modules/media/media.permissions.yml +++ b/core/modules/media/media.permissions.yml @@ -23,3 +23,15 @@ 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 content item.' + +revert all media revisions: + title: 'Revert all media revisions' + description: 'To revert a revision, you also need permission to edit the content item.' + +delete all media revisions: + title: 'Delete all media revisions' + description: 'To delete a revision, you also need permission to delete the content item.' diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml index 67ea1fb81c..4be0e4305b 100644 --- a/core/modules/media/media.routing.yml +++ b/core/modules/media/media.routing.yml @@ -4,3 +4,56 @@ entity.media.multiple_delete_confirm: _form: '\Drupal\media\Form\MediaDeleteMultipleConfirmForm' requirements: _permission: 'administer media+delete any media' + +entity.media.version_history: + path: '/media/{media}/revisions' + defaults: + _title: 'Revisions' + _controller: '\Drupal\media\Controller\MediaController::revisionOverview' + requirements: + _access_media_revision: 'view' + media: \d+ + options: + _media_operation_route: TRUE + +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+ + +media.revision_revert_confirm: + path: '/media/{media}/revisions/{media_revision}/revert' + defaults: + _form: '\Drupal\media\Form\MediaRevisionRevertForm' + _title: 'Revert to earlier revision' + requirements: + _access_media_revision: 'update' + media: \d+ + options: + _media_operation_route: TRUE + +media.revision_revert_translation_confirm: + path: '/media/{media}/revisions/{media_revision}/revert/{langcode}' + defaults: + _form: '\Drupal\media\Form\MediaRevisionRevertTranslationForm' + _title: 'Revert to earlier revision of a translation' + requirements: + _access_media_revision: 'update' + media: \d+ + options: + _media_operation_route: TRUE + +media.revision_delete_confirm: + path: '/media/{media}/revisions/{media_revision}/delete' + defaults: + _form: '\Drupal\media\Form\MediaRevisionDeleteForm' + _title: 'Delete earlier revision' + requirements: + _access_media_revision: 'delete' + media: \d+ + options: + _media_operation_route: TRUE \ No newline at end of file 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..973ee7038e --- /dev/null +++ b/core/modules/media/src/Access/MediaRevisionAccessCheck.php @@ -0,0 +1,145 @@ +mediaStorage = $entity_type_manager->getStorage('media'); + $this->mediaAccess = $entity_type_manager->getAccessControlHandler('media'); + } + + /** + * Checks routing access for the media 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 revision ID. If not specified, but $media is, access + * is checked for that object's revision. + * @param \Drupal\media\MediaInterface $media + * (optional) A media object. Used for checking access to a media's 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 revision access. + * + * @param \Drupal\media\MediaInterface $media + * The media 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 revisions', + 'update' => 'revert all revisions', + 'delete' => 'delete all revisions', + ]; + $bundle = $media->bundle(); + $type_map = [ + 'view' => "view $bundle revisions", + 'update' => "revert $bundle revisions", + 'delete' => "delete $bundle revisions", + ]; + + if (!$media || !isset($map[$op]) || !isset($type_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($type_map[$op]) && !$account->hasPermission('administer medias')) { + $this->access[$cid] = FALSE; + return FALSE; + } + + // There should be at least two revisions. If the vid of the given media + // 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. + // Also, if you try to revert to or delete the default revision, that's + // not good. + if ($media->isDefaultRevision() && ($this->mediaStorage->countDefaultLanguageRevisions($media) == 1 || $op == 'update' || $op == 'delete')) { + $this->access[$cid] = FALSE; + } + elseif ($account->hasPermission('administer medias')) { + $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..2de21d82b4 --- /dev/null +++ b/core/modules/media/src/Controller/MediaController.php @@ -0,0 +1,257 @@ +entityTypeManager = $entity_type_manager; + $this->entityRepository = $entity_repository; + $this->dateFormatter = $date_formatter; + $this->renderer = $renderer; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager'), + $container->get('entity.repository'), + $container->get('date.formatter'), + $container->get('renderer') + ); + } + + /** + * Displays a media revision. + * + * @param int $media_revision + * The media revision ID. + * + * @return array + * An array suitable for drupal_render(). + */ + public function revisionShow($media_revision) { + $media = $this->entityTypeManager->getStorage('media')->loadRevision($media_revision); + $media = $this->entityRepository->getTranslationFromContext($media); + $media_view_controller = new EntityViewController($this->entityManager, $this->renderer); + $page = $media_view_controller->view($media); + unset($page['medias'][$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())]); + } + + /** + * Generates an overview table of older revisions of a media item. + * + * @param \Drupal\media\MediaInterface $media + * A media object. + * + * @return array + * An array as expected by drupal_render(). + */ + public function revisionOverview(MediaInterface $media) { + $account = $this->currentUser(); + $langcode = $media->language()->getId(); + $langname = $media->language()->getName(); + $languages = $media->getTranslationLanguages(); + $has_translations = (count($languages) > 1); + $media_storage = $this->entityTypeManager->getStorage('media'); + $type = $media->bundle(); + + $build['#title'] = $has_translations ? $this->t('@langname revisions for %title', ['@langname' => $langname, '%title' => $media->label()]) : $this->t('Revisions for %title', ['%title' => $media->label()]); + $header = [$this->t('Revision'), $this->t('Operations')]; + + $revert_permission = (($account->hasPermission('revert all media revisions') || $account->hasPermission('administer media')) && $media->access('update')); + $delete_permission = (($account->hasPermission('delete all media revisions') || $account->hasPermission('administer media')) && $media->access('delete')); + + $rows = []; + $default_revision = $media->getRevisionId(); + + foreach ($this->getRevisionIds($media, $media_storage) as $vid) { + /** @var \Drupal\media\MediaInterface $revision */ + $revision = $media_storage->loadRevision($vid); + // Only show revisions that are affected by the language that is being + // displayed. + if ($revision->hasTranslation($langcode) && $revision->getTranslation($langcode)->isRevisionTranslationAffected()) { + $username = [ + '#theme' => 'username', + '#account' => $revision->getRevisionUser(), + ]; + + // Use revision link to link to revisions that are not active. + $date = $this->dateFormatter->format($revision->revision_created->value, 'short'); + if ($vid != $media->getRevisionId()) { + $link = $this->l($date, new Url('entity.media.revision', ['media' => $media->id(), 'media_revision' => $vid])); + } + else { + $link = $media->link($date); + } + + $row = []; + $column = [ + 'data' => [ + '#type' => 'inline_template', + '#template' => '{% trans %}{{ date }} by {{ username }}{% endtrans %}{% if message %}

{{ message }}

{% endif %}', + '#context' => [ + 'date' => $link, + 'username' => $this->renderer->renderPlain($username), + 'message' => ['#markup' => $revision->revision_log->value, '#allowed_tags' => Xss::getHtmlTagList()], + ], + ], + ]; + // @todo Simplify once https://www.drupal.org/media/2334319 lands. + $this->renderer->addCacheableDependency($column['data'], $username); + $row[] = $column; + + if ($vid == $default_revision) { + $row[] = [ + 'data' => [ + '#prefix' => '', + '#markup' => $this->t('Current revision'), + '#suffix' => '', + ], + ]; + + $rows[] = [ + 'data' => $row, + 'class' => ['revision-current'], + ]; + } + else { + $links = []; + if ($revert_permission) { + $links['revert'] = [ + 'title' => $vid < $media->getRevisionId() ? $this->t('Revert') : $this->t('Set as current revision'), + 'url' => $has_translations ? + Url::fromRoute('media.revision_revert_translation_confirm', ['media' => $media->id(), 'media_revision' => $vid, 'langcode' => $langcode]) : + Url::fromRoute('media.revision_revert_confirm', ['media' => $media->id(), 'media_revision' => $vid]), + ]; + } + + if ($delete_permission) { + $links['delete'] = [ + 'title' => $this->t('Delete'), + 'url' => Url::fromRoute('media.revision_delete_confirm', ['media' => $media->id(), 'media_revision' => $vid]), + ]; + } + + $row[] = [ + 'data' => [ + '#type' => 'operations', + '#links' => $links, + ], + ]; + + $rows[] = $row; + } + } + } + + $build['media_revisions_table'] = [ + '#theme' => 'table', + '#rows' => $rows, + '#header' => $header, + '#attached' => [ + 'library' => ['media/drupal.media.admin'], + ], + '#attributes' => ['class' => 'media-revision-table'], + ]; + + $build['pager'] = ['#type' => 'pager']; + + return $build; + } + + /** + * Gets a list of media revision IDs for a specific media. + * + * @param \Drupal\media\MediaInterface $media + * The media entity. + * @param \Drupal\media\MediaStorageInterface $media_storage + * The media storage handler. + * + * @return int[] + * Media revision IDs (in descending order). + */ + protected function getRevisionIds(MediaInterface $media, MediaStorageInterface $media_storage) { + $result = $media_storage->getQuery() + ->allRevisions() + ->condition($media->getEntityType()->getKey('id'), $media->id()) + ->sort($media->getEntityType()->getKey('revision'), 'DESC') + ->pager(50) + ->execute(); + return array_keys($result); + } + +} diff --git a/core/modules/media/src/Entity/Media.php b/core/modules/media/src/Entity/Media.php index 858404112e..fd1bafd66c 100644 --- a/core/modules/media/src/Entity/Media.php +++ b/core/modules/media/src/Entity/Media.php @@ -29,7 +29,7 @@ * ), * bundle_label = @Translation("Media type"), * handlers = { - * "storage" = "Drupal\Core\Entity\Sql\SqlContentEntityStorage", + * "storage" = "Drupal\media\MediaStorage", * "view_builder" = "Drupal\Core\Entity\EntityViewBuilder", * "list_builder" = "Drupal\Core\Entity\EntityListBuilder", * "access" = "Drupal\media\MediaAccessControlHandler", @@ -76,6 +76,8 @@ * "canonical" = "/media/{media}", * "delete-form" = "/media/{media}/delete", * "edit-form" = "/media/{media}/edit", + * "version-history" = "/media/{media}/revisions", + * "revision" = "/media/{media}/revisions/{media_revision}/view", * "admin-form" = "/admin/structure/media/manage/{media_type}" * } * ) diff --git a/core/modules/media/src/Form/MediaRevisionDeleteForm.php b/core/modules/media/src/Form/MediaRevisionDeleteForm.php new file mode 100644 index 0000000000..fdbf73221b --- /dev/null +++ b/core/modules/media/src/Form/MediaRevisionDeleteForm.php @@ -0,0 +1,140 @@ +mediaStorage = $media_storage; + $this->mediaTypeStorage = $media_type_storage; + $this->connection = $connection; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + $entity_type_manager = $container->get('entity_type.manager'); + return new static( + $entity_type_manager->getStorage('media'), + $entity_type_manager->getStorage('media_type'), + $container->get('database') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'media_revision_delete_confirm'; + } + + /** + * {@inheritdoc} + */ + public function getQuestion() { + return t('Are you sure you want to delete the revision from %revision-date?', ['%revision-date' => format_date($this->revision->getRevisionCreationTime())]); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return new Url('entity.media.version_history', ['media' => $this->revision->id()]); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return t('Delete'); + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, $media_revision = NULL) { + $this->revision = $this->mediaStorage->loadRevision($media_revision); + $form = parent::buildForm($form, $form_state); + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->mediaStorage->deleteRevision($this->revision->getRevisionId()); + + $this->logger('content')->notice('@type: deleted %title revision %revision.', [ + '@type' => $this->revision->bundle(), + '%title' => $this->revision->label(), + '%revision' => $this->revision->getRevisionId(), + ]); + $media_type = $this->mediaTypeStorage->load($this->revision->bundle())->label(); + drupal_set_message(t('Revision from %revision-date of @type %title has been deleted.', [ + '%revision-date' => format_date($this->revision->getRevisionCreationTime()), + '@type' => $media_type, + '%title' => $this->revision->label(), + ])); + $form_state->setRedirect( + 'entity.media.canonical', + ['media' => $this->revision->id()] + ); + if ($this->connection->query('SELECT COUNT(DISTINCT vid) FROM {media_field_revision} WHERE mid = :mid', [':mid' => $this->revision->id()])->fetchField() > 1) { + $form_state->setRedirect( + 'entity.media.version_history', + ['media' => $this->revision->id()] + ); + } + } + +} diff --git a/core/modules/media/src/Form/MediaRevisionRevertForm.php b/core/modules/media/src/Form/MediaRevisionRevertForm.php new file mode 100644 index 0000000000..db87deaa3e --- /dev/null +++ b/core/modules/media/src/Form/MediaRevisionRevertForm.php @@ -0,0 +1,168 @@ +mediaStorage = $media_storage; + $this->dateFormatter = $date_formatter; + $this->time = $time; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager')->getStorage('media'), + $container->get('date.formatter'), + $container->get('datetime.time') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'media_revision_revert_confirm'; + } + + /** + * {@inheritdoc} + */ + public function getQuestion() { + return t('Are you sure you want to revert to the revision from %revision-date?', ['%revision-date' => $this->dateFormatter->format($this->revision->getRevisionCreationTime())]); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return new Url('entity.media.version_history', ['media' => $this->revision->id()]); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return t('Revert'); + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return ''; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, $media_revision = NULL) { + $this->revision = $this->mediaStorage->loadRevision($media_revision); + $form = parent::buildForm($form, $form_state); + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + // The revision timestamp will be updated when the revision is saved. Keep + // the original one for the confirmation message. + $original_revision_created = $this->revision->getRevisionCreationTime(); + + $this->revision = $this->prepareRevertedRevision($this->revision, $form_state); + $this->revision->revision_log = t('Copy of the revision from %date.', ['%date' => $this->dateFormatter->format($original_revision_created)]); + $this->revision->setRevisionCreationTime($this->time->getRequestTime()); + $this->revision->setChangedTime($this->time->getRequestTime()); + $this->revision->save(); + + $this->logger('content')->notice('@type: reverted %title revision %revision.', [ + '@type' => $this->revision->bundle(), + '%title' => $this->revision->label(), + '%revision' => $this->revision->getRevisionId(), + ]); + drupal_set_message(t('@type %title has been reverted to the revision from %revision-date.', [ + '@type' => MediaType::load($this->revision->bundle())->label(), + '%title' => $this->revision->label(), + '%revision-date' => $this->dateFormatter->format($original_revision_created), + ])); + $form_state->setRedirect( + 'entity.media.version_history', + ['media' => $this->revision->id()] + ); + } + + /** + * Prepares a revision to be reverted. + * + * @param \Drupal\media\MediaInterface $revision + * The revision to be reverted. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return \Drupal\media\MediaInterface + * The prepared revision ready to be stored. + */ + protected function prepareRevertedRevision(MediaInterface $revision, FormStateInterface $form_state) { + $revision->setNewRevision(); + $revision->isDefaultRevision(TRUE); + + return $revision; + } + +} diff --git a/core/modules/media/src/Form/MediaRevisionRevertTranslationForm.php b/core/modules/media/src/Form/MediaRevisionRevertTranslationForm.php new file mode 100644 index 0000000000..ed7ed779d8 --- /dev/null +++ b/core/modules/media/src/Form/MediaRevisionRevertTranslationForm.php @@ -0,0 +1,122 @@ +languageManager = $language_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager')->getStorage('media'), + $container->get('date.formatter'), + $container->get('language_manager'), + $container->get('datetime.time') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'media_revision_revert_translation_confirm'; + } + + /** + * {@inheritdoc} + */ + public function getQuestion() { + return t('Are you sure you want to revert @language translation to the revision from %revision-date?', ['@language' => $this->languageManager->getLanguageName($this->langcode), '%revision-date' => $this->dateFormatter->format($this->revision->getRevisionCreationTime())]); + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return ''; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, $media_revision = NULL, $langcode = NULL) { + $this->langcode = $langcode; + $form = parent::buildForm($form, $form_state, $media_revision); + + $form['revert_untranslated_fields'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Revert content shared among translations'), + '#default_value' => FALSE, + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + protected function prepareRevertedRevision(MediaInterface $revision, FormStateInterface $form_state) { + $revert_untranslated_fields = $form_state->getValue('revert_untranslated_fields'); + + /** @var \Drupal\media\MediaInterface $latest_revision */ + $latest_revision = $this->mediaStorage->load($revision->id()); + $latest_revision_translation = $latest_revision->getTranslation($this->langcode); + + $revision_translation = $revision->getTranslation($this->langcode); + + foreach ($latest_revision_translation->getFieldDefinitions() as $field_name => $definition) { + if ($definition->isTranslatable() || $revert_untranslated_fields) { + $latest_revision_translation->set($field_name, $revision_translation->get($field_name)->getValue()); + } + } + + $latest_revision_translation->setNewRevision(); + $latest_revision_translation->isDefaultRevision(TRUE); + + return $latest_revision_translation; + } + +} diff --git a/core/modules/media/src/MediaStorage.php b/core/modules/media/src/MediaStorage.php new file mode 100644 index 0000000000..0d01668b42 --- /dev/null +++ b/core/modules/media/src/MediaStorage.php @@ -0,0 +1,64 @@ +database->query( + 'SELECT vid FROM {media_revision} WHERE mid=:mid ORDER BY vid', + [':mid' => $media->id()] + )->fetchCol(); + } + + /** + * {@inheritdoc} + */ + public function userRevisionIds(AccountInterface $account) { + return $this->database->query( + 'SELECT vid FROM {media_field_revision} WHERE uid = :uid ORDER BY vid', + [':uid' => $account->id()] + )->fetchCol(); + } + + /** + * {@inheritdoc} + */ + public function countDefaultLanguageRevisions(MediaInterface $media) { + return $this->database->query('SELECT COUNT(*) FROM {media_field_revision} WHERE mid = :mid AND default_langcode = 1', [':mid' => $media->id()])->fetchField(); + } + + /** + * {@inheritdoc} + */ + public function updateType($old_type, $new_type) { + return $this->database->update('media') + ->fields(['type' => $new_type]) + ->condition('type', $old_type) + ->execute(); + } + + /** + * {@inheritdoc} + */ + public function clearRevisionsLanguage(LanguageInterface $language) { + return $this->database->update('media_revision') + ->fields(['langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED]) + ->condition('langcode', $language->getId()) + ->execute(); + } + +} diff --git a/core/modules/media/src/MediaStorageInterface.php b/core/modules/media/src/MediaStorageInterface.php new file mode 100644 index 0000000000..d8cf7d963d --- /dev/null +++ b/core/modules/media/src/MediaStorageInterface.php @@ -0,0 +1,68 @@ +