diff --git a/core/modules/media/config/install/media.settings.yml b/core/modules/media/config/install/media.settings.yml
index 853e575c671726052eba07d08e3c612cd59e6293..5ad89e5e565ecbdda19e0fc9b74d126ba0e1269a 100644
--- a/core/modules/media/config/install/media.settings.yml
+++ b/core/modules/media/config/install/media.settings.yml
@@ -1 +1,3 @@
icon_base_uri: 'public://media-icons/generic'
+iframe_domain: ''
+oembed_providers_url: 'https://oembed.com/providers.json'
diff --git a/core/modules/media/config/schema/media.schema.yml b/core/modules/media/config/schema/media.schema.yml
index b9156b23f86c675750d23af16fd29f51a00fd8d9..f78793b094a4796014e9e216162528b246edf03a 100644
--- a/core/modules/media/config/schema/media.schema.yml
+++ b/core/modules/media/config/schema/media.schema.yml
@@ -5,6 +5,12 @@ media.settings:
icon_base_uri:
type: string
label: 'Full URI to a folder where the media icons will be installed'
+ iframe_domain:
+ type: uri
+ label: 'Domain from which to serve oEmbed content in an iframe'
+ oembed_providers_url:
+ type: uri
+ label: 'The URL of the oEmbed providers database in JSON format'
media.type.*:
type: config_entity
@@ -40,6 +46,21 @@ field.formatter.settings.media_thumbnail:
type: field.formatter.settings.image
label: 'Media thumbnail field display format settings'
+field.formatter.settings.oembed:
+ type: mapping
+ label: 'oEmbed display format settings'
+ mapping:
+ max_width:
+ type: integer
+ label: 'Maximum width'
+ max_height:
+ type: integer
+ label: 'Maximum height'
+
+field.widget.settings.oembed_textfield:
+ type: field.widget.settings.string_textfield
+ label: 'oEmbed widget format settings'
+
media.source.*:
type: mapping
label: 'Media source settings'
@@ -60,6 +81,20 @@ media.source.video_file:
type: media.source.field_aware
label: '"Video" media source configuration'
+media.source.oembed:*:
+ type: media.source.field_aware
+ label: 'oEmbed media source configuration'
+ mapping:
+ thumbnails_directory:
+ type: uri
+ label: 'URI of thumbnail storage directory'
+ providers:
+ type: sequence
+ label: 'Allowed oEmbed providers'
+ sequence:
+ type: string
+ label: 'Provider name'
+
media.source.field_aware:
type: mapping
mapping:
diff --git a/core/modules/media/media.api.php b/core/modules/media/media.api.php
index 8de1c645754b888c7f10ca26680176b1c69b29ff..93244f58a8a11b0450bdeec7b8df18a1e9152c2f 100644
--- a/core/modules/media/media.api.php
+++ b/core/modules/media/media.api.php
@@ -20,6 +20,23 @@ function hook_media_source_info_alter(array &$sources) {
$sources['youtube']['label'] = t('Youtube rocks!');
}
+/**
+ * Alters an oEmbed resource URL before it is fetched.
+ *
+ * @param array $parsed_url
+ * A parsed URL, as returned by \Drupal\Component\Utility\UrlHelper::parse().
+ * @param \Drupal\media\OEmbed\Provider $provider
+ * The oEmbed provider for the resource.
+ *
+ * @see \Drupal\media\OEmbed\UrlResolverInterface::getResourceUrl()
+ */
+function hook_oembed_resource_url_alter(array &$parsed_url, \Drupal\media\OEmbed\Provider $provider) {
+ // Always serve YouTube videos from youtube-nocookie.com.
+ if ($provider->getName() === 'YouTube') {
+ $parsed_url['path'] = str_replace('://youtube.com/', '://youtube-nocookie.com/', $parsed_url['path']);
+ }
+}
+
/**
* @} End of "addtogroup hooks".
*/
diff --git a/core/modules/media/media.info.yml b/core/modules/media/media.info.yml
index d7a96484c81c5432021b560061354f64ad9f2034..c5b85f50281047ac34c1d0bba233e3f7c7492930 100644
--- a/core/modules/media/media.info.yml
+++ b/core/modules/media/media.info.yml
@@ -8,3 +8,4 @@ dependencies:
- drupal:file
- drupal:image
- drupal:user
+configure: media.settings
diff --git a/core/modules/media/media.install b/core/modules/media/media.install
index 90f9d99cd4dae1cd527fe62153a1de5a7869268d..d3386ea35a77564b99955b53a040516f15170924 100644
--- a/core/modules/media/media.install
+++ b/core/modules/media/media.install
@@ -5,6 +5,9 @@
* Install, uninstall and update hooks for Media module.
*/
+use Drupal\Core\Url;
+use Drupal\media\MediaTypeInterface;
+use Drupal\media\Plugin\media\Source\OEmbedInterface;
use Drupal\user\RoleInterface;
use Drupal\user\Entity\Role;
@@ -75,6 +78,36 @@ function media_requirements($phase) {
}
}
}
+ elseif ($phase === 'runtime') {
+ // Check that oEmbed content is served in an iframe on a different domain,
+ // and complain if it isn't.
+ $domain = \Drupal::config('media.settings')->get('iframe_domain');
+
+ if (!\Drupal::service('media.oembed.iframe_url_helper')->isSecure($domain)) {
+ // Find all media types which use a source plugin that implements
+ // OEmbedInterface.
+ $media_types = \Drupal::entityTypeManager()
+ ->getStorage('media_type')
+ ->loadMultiple();
+
+ $oembed_types = array_filter($media_types, function (MediaTypeInterface $media_type) {
+ return $media_type->getSource() instanceof OEmbedInterface;
+ });
+
+ if ($oembed_types) {
+ // @todo Potentially allow site administrators to suppress this warning
+ // permanently. See https://www.drupal.org/project/drupal/issues/2962753
+ // for more information.
+ $requirements['media_insecure_iframe'] = [
+ 'title' => t('Media'),
+ 'description' => t('It is potentially insecure to display oEmbed content in a frame that is served from the same domain as your main Drupal site, as this may allow execution of third-party code. You can specify a different domain for serving oEmbed content here.', [
+ ':url' => Url::fromRoute('media.settings')->setAbsolute()->toString(),
+ ]),
+ 'severity' => REQUIREMENT_WARNING,
+ ];
+ }
+ }
+ }
return $requirements;
}
@@ -120,3 +153,13 @@ function media_update_8500() {
$role->save();
}
}
+
+/**
+ * Updates media.settings to support OEmbed.
+ */
+function media_update_8600() {
+ \Drupal::configFactory()->getEditable('media.settings')
+ ->set('iframe_domain', '')
+ ->set('oembed_providers_url', 'https://oembed.com/providers.json')
+ ->save(TRUE);
+}
diff --git a/core/modules/media/media.links.menu.yml b/core/modules/media/media.links.menu.yml
index e50c41217ba8e8f8f5affe3e0e5f880ed6631eda..1bf5fff37d18e14e9638154c23bc633bae2b2a0a 100644
--- a/core/modules/media/media.links.menu.yml
+++ b/core/modules/media/media.links.menu.yml
@@ -3,3 +3,9 @@ entity.media_type.collection:
parent: system.admin_structure
description: 'Manage media types.'
route_name: entity.media_type.collection
+
+media.settings:
+ title: 'Media settings'
+ parent: system.admin_config_media
+ description: 'Manage media settings.'
+ route_name: media.settings
diff --git a/core/modules/media/media.module b/core/modules/media/media.module
index 5079f0fa9a868bb9a76bbdcf4ecd19fd2d9e6b51..20d12b856cc5259d896d477094ea7ab8721f5c7e 100644
--- a/core/modules/media/media.module
+++ b/core/modules/media/media.module
@@ -5,6 +5,7 @@
* Provides media items.
*/
+use Drupal\Component\Plugin\DerivativeInspectionInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
@@ -15,6 +16,7 @@
use Drupal\Core\Template\Attribute;
use Drupal\Core\Url;
use Drupal\field\FieldConfigInterface;
+use Drupal\media\Plugin\media\Source\OEmbedInterface;
/**
* Implements hook_help().
@@ -72,6 +74,11 @@ function media_theme() {
'render element' => 'element',
'base hook' => 'field_multiple_value_form',
],
+ 'media_oembed_iframe' => [
+ 'variables' => [
+ 'media' => NULL,
+ ],
+ ],
];
}
@@ -92,6 +99,7 @@ function media_entity_access(EntityInterface $entity, $operation, AccountInterfa
*/
function media_theme_suggestions_media(array $variables) {
$suggestions = [];
+ /** @var \Drupal\media\MediaInterface $media */
$media = $variables['elements']['#media'];
$sanitized_view_mode = strtr($variables['elements']['#view_mode'], '.', '_');
@@ -99,6 +107,31 @@ function media_theme_suggestions_media(array $variables) {
$suggestions[] = 'media__' . $media->bundle();
$suggestions[] = 'media__' . $media->bundle() . '__' . $sanitized_view_mode;
+ // Add suggestions based on the source plugin ID.
+ $source = $media->getSource();
+ if ($source instanceof DerivativeInspectionInterface) {
+ $source_id = $source->getBaseId();
+ $derivative_id = $source->getDerivativeId();
+ if ($derivative_id) {
+ $source_id .= '__derivative_' . $derivative_id;
+ }
+ }
+ else {
+ $source_id = $source->getPluginId();
+ }
+ $suggestions[] = "media__source_$source_id";
+
+ // If the source plugin uses oEmbed, add a suggestion based on the provider
+ // name, if available.
+ if ($source instanceof OEmbedInterface) {
+ $provider_id = $source->getMetadata($media, 'provider_name');
+ if ($provider_id) {
+ $provider_id = \Drupal::transliteration()->transliterate($provider_id);
+ $provider_id = preg_replace('/[^a-z0-9_]+/', '_', mb_strtolower($provider_id));
+ $suggestions[] = end($suggestions) . "__provider_$provider_id";
+ }
+ }
+
return $suggestions;
}
diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml
index ea0858d4629802145d35091820a2f8e536265277..a9b634c89a24e7ab833a07ab54988eff5b91d2c7 100644
--- a/core/modules/media/media.routing.yml
+++ b/core/modules/media/media.routing.yml
@@ -24,3 +24,18 @@ entity.media.revision:
requirements:
_access_media_revision: 'view'
media: \d+
+
+media.oembed_iframe:
+ path: '/media/oembed'
+ defaults:
+ _controller: '\Drupal\media\Controller\OEmbedIframeController::render'
+ requirements:
+ _permission: 'view media'
+
+media.settings:
+ path: '/admin/config/media/media-settings'
+ defaults:
+ _form: '\Drupal\media\Form\MediaSettingsForm'
+ _title: 'Media settings'
+ requirements:
+ _permission: 'administer media'
diff --git a/core/modules/media/media.services.yml b/core/modules/media/media.services.yml
index f22f90a12464b7a124f25fdbc3c7314fb6ce4ac6..8ff2305a35afe0367a664010ea37dbec0b3ff754 100644
--- a/core/modules/media/media.services.yml
+++ b/core/modules/media/media.services.yml
@@ -2,9 +2,20 @@ 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 }
+ media.oembed.url_resolver:
+ class: Drupal\media\OEmbed\UrlResolver
+ arguments: ['@media.oembed.provider_repository', '@media.oembed.resource_fetcher', '@http_client', '@module_handler', '@cache.default']
+ media.oembed.provider_repository:
+ class: Drupal\media\OEmbed\ProviderRepository
+ arguments: ['@http_client', '@config.factory', '@datetime.time', '@cache.default']
+ media.oembed.resource_fetcher:
+ class: Drupal\media\OEmbed\ResourceFetcher
+ arguments: ['@http_client', '@media.oembed.provider_repository', '@cache.default']
+ media.oembed.iframe_url_helper:
+ class: Drupal\media\IFrameUrlHelper
+ arguments: ['@router.request_context', '@private_key']
diff --git a/core/modules/media/src/Controller/OEmbedIframeController.php b/core/modules/media/src/Controller/OEmbedIframeController.php
new file mode 100644
index 0000000000000000000000000000000000000000..0e45d72f043409af7185ec0c805134d015211461
--- /dev/null
+++ b/core/modules/media/src/Controller/OEmbedIframeController.php
@@ -0,0 +1,174 @@
+resourceFetcher = $resource_fetcher;
+ $this->urlResolver = $url_resolver;
+ $this->renderer = $renderer;
+ $this->logger = $logger;
+ $this->iFrameUrlHelper = $iframe_url_helper;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('media.oembed.resource_fetcher'),
+ $container->get('media.oembed.url_resolver'),
+ $container->get('renderer'),
+ $container->get('logger.factory')->get('media'),
+ $container->get('media.oembed.iframe_url_helper')
+ );
+ }
+
+ /**
+ * Renders an oEmbed resource.
+ *
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The request object.
+ *
+ * @return \Symfony\Component\HttpFoundation\Response
+ * The response object.
+ *
+ * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
+ * Will be thrown if the 'hash' parameter does not match the expected hash
+ * of the 'url' parameter.
+ */
+ public function render(Request $request) {
+ $url = $request->query->get('url');
+ $max_width = $request->query->getInt('max_width', NULL);
+ $max_height = $request->query->getInt('max_height', NULL);
+
+ // Hash the URL and max dimensions, and ensure it is equal to the hash
+ // parameter passed in the query string.
+ $hash = $this->iFrameUrlHelper->getHash($url, $max_width, $max_height);
+ if (!Crypt::hashEquals($hash, $request->query->get('hash', ''))) {
+ throw new AccessDeniedHttpException('This resource is not available');
+ }
+
+ // Return a response instead of a render array so that the frame content
+ // will not have all the blocks and page elements normally rendered by
+ // Drupal.
+ $response = new CacheableResponse();
+ $response->addCacheableDependency(Url::createFromRequest($request));
+
+ try {
+ $resource_url = $this->urlResolver->getResourceUrl($url, $max_width, $max_height);
+ $resource = $this->resourceFetcher->fetchResource($resource_url);
+
+ // Render the content in a new render context so that the cacheability
+ // metadata of the rendered HTML will be captured correctly.
+ $content = $this->renderer->executeInRenderContext(new RenderContext(), function () use ($resource) {
+ $element = [
+ '#theme' => 'media_oembed_iframe',
+ // Even though the resource HTML is untrusted, IFrameMarkup::create()
+ // will create a trusted string. The only reason this is okay is
+ // because we are serving it in an iframe, which will mitigate the
+ // potential dangers of displaying third-party markup.
+ '#media' => IFrameMarkup::create($resource->getHtml()),
+ ];
+ return $this->renderer->render($element);
+ });
+
+ $response->setContent($content)->addCacheableDependency($resource);
+ }
+ catch (ResourceException $e) {
+ // Prevent the response from being cached.
+ $response->setMaxAge(0);
+
+ // The oEmbed system makes heavy use of exception wrapping, so log the
+ // entire exception chain to help with troubleshooting.
+ do {
+ // @todo Log additional information from ResourceException, to help with
+ // debugging, in https://www.drupal.org/project/drupal/issues/2972846.
+ $this->logger->error($e->getMessage());
+ $e = $e->getPrevious();
+ } while ($e);
+ }
+
+ return $response;
+ }
+
+}
diff --git a/core/modules/media/src/Entity/Media.php b/core/modules/media/src/Entity/Media.php
index cca42de3878bbd1c6836647f7527bc6f1d7b339f..b7b274045f3caa50de5280b3064a1bbd695d5696 100644
--- a/core/modules/media/src/Entity/Media.php
+++ b/core/modules/media/src/Entity/Media.php
@@ -181,25 +181,7 @@ public function getSource() {
* https://www.drupal.org/node/2878119
*/
protected function updateThumbnail($from_queue = FALSE) {
- $file_storage = \Drupal::service('entity_type.manager')->getStorage('file');
- $thumbnail_uri = $this->getThumbnailUri($from_queue);
- $existing = $file_storage->getQuery()
- ->condition('uri', $thumbnail_uri)
- ->execute();
-
- if ($existing) {
- $this->thumbnail->target_id = reset($existing);
- }
- else {
- /** @var \Drupal\file\FileInterface $file */
- $file = $file_storage->create(['uri' => $thumbnail_uri]);
- if ($owner = $this->getOwner()) {
- $file->setOwner($owner);
- }
- $file->setPermanent();
- $file->save();
- $this->thumbnail->target_id = $file->id();
- }
+ $this->thumbnail->target_id = $this->loadThumbnail($this->getThumbnailUri($from_queue))->id();
// Set the thumbnail alt.
$media_source = $this->getSource();
@@ -222,6 +204,52 @@ protected function updateThumbnail($from_queue = FALSE) {
return $this;
}
+ /**
+ * Loads the file entity for the thumbnail.
+ *
+ * If the file entity does not exist, it will be created.
+ *
+ * @param string $thumbnail_uri
+ * (optional) The URI of the thumbnail, used to load or create the file
+ * entity. If omitted, the default thumbnail URI will be used.
+ *
+ * @return \Drupal\file\FileInterface
+ * The thumbnail file entity.
+ */
+ protected function loadThumbnail($thumbnail_uri = NULL) {
+ $values = [
+ 'uri' => $thumbnail_uri ?: $this->getDefaultThumbnailUri(),
+ ];
+
+ $file_storage = $this->entityTypeManager()->getStorage('file');
+
+ $existing = $file_storage->loadByProperties($values);
+ if ($existing) {
+ $file = reset($existing);
+ }
+ else {
+ /** @var \Drupal\file\FileInterface $file */
+ $file = $file_storage->create($values);
+ if ($owner = $this->getOwner()) {
+ $file->setOwner($owner);
+ }
+ $file->setPermanent();
+ $file->save();
+ }
+ return $file;
+ }
+
+ /**
+ * Returns the URI of the default thumbnail.
+ *
+ * @return string
+ * The default thumbnail URI.
+ */
+ protected function getDefaultThumbnailUri() {
+ $default_thumbnail_filename = $this->getSource()->getPluginDefinition()['default_thumbnail_filename'];
+ return \Drupal::config('media.settings')->get('icon_base_uri') . '/' . $default_thumbnail_filename;
+ }
+
/**
* Updates the queued thumbnail for the media item.
*
@@ -257,17 +285,14 @@ public function updateQueuedThumbnail() {
protected function getThumbnailUri($from_queue) {
$thumbnails_queued = $this->bundle->entity->thumbnailDownloadsAreQueued();
if ($thumbnails_queued && $this->isNew()) {
- $default_thumbnail_filename = $this->getSource()->getPluginDefinition()['default_thumbnail_filename'];
- $thumbnail_uri = \Drupal::service('config.factory')->get('media.settings')->get('icon_base_uri') . '/' . $default_thumbnail_filename;
+ return $this->getDefaultThumbnailUri();
}
elseif ($thumbnails_queued && !$from_queue) {
- $thumbnail_uri = $this->get('thumbnail')->entity->getFileUri();
- }
- else {
- $thumbnail_uri = $this->getSource()->getMetadata($this, $this->getSource()->getPluginDefinition()['thumbnail_uri_metadata_attribute']);
+ return $this->get('thumbnail')->entity->getFileUri();
}
- return $thumbnail_uri;
+ $source = $this->getSource();
+ return $source->getMetadata($this, $source->getPluginDefinition()['thumbnail_uri_metadata_attribute']);
}
/**
@@ -305,30 +330,9 @@ protected function shouldUpdateThumbnail($is_new = FALSE) {
public function preSave(EntityStorageInterface $storage) {
parent::preSave($storage);
- $media_source = $this->getSource();
- foreach ($this->translations as $langcode => $data) {
- if ($this->hasTranslation($langcode)) {
- $translation = $this->getTranslation($langcode);
- // Try to set fields provided by the media source and mapped in
- // media type config.
- foreach ($translation->bundle->entity->getFieldMap() as $metadata_attribute_name => $entity_field_name) {
- // Only save value in entity field if empty. Do not overwrite existing
- // data.
- if ($translation->hasField($entity_field_name) && ($translation->get($entity_field_name)->isEmpty() || $translation->hasSourceFieldChanged())) {
- $translation->set($entity_field_name, $media_source->getMetadata($translation, $metadata_attribute_name));
- }
- }
-
- // Try to set a default name for this media item if no name is provided.
- if ($translation->get('name')->isEmpty()) {
- $translation->setName($translation->getName());
- }
-
- // Set thumbnail.
- if ($translation->shouldUpdateThumbnail()) {
- $translation->updateThumbnail();
- }
- }
+ // If no thumbnail has been explicitly set, use the default thumbnail.
+ if ($this->get('thumbnail')->isEmpty()) {
+ $this->thumbnail->target_id = $this->loadThumbnail()->id();
}
}
@@ -369,6 +373,55 @@ public function preSaveRevision(EntityStorageInterface $storage, \stdClass $reco
}
}
+ /**
+ * {@inheritdoc}
+ */
+ public function save() {
+ // @todo If the source plugin talks to a remote API (e.g. oEmbed), this code
+ // might be performing a fair number of HTTP requests. This is dangerously
+ // brittle and should probably be handled by a queue, to avoid doing HTTP
+ // operations during entity save. As it is, doing this before calling
+ // parent::save() is a quick-fix to avoid doing HTTP requests in the middle
+ // of a database transaction (which begins once we call parent::save()). See
+ // https://www.drupal.org/project/drupal/issues/2976875 for more.
+
+ // In order for metadata to be mapped correctly, $this->original must be
+ // set. However, that is only set once parent::save() is called, so work
+ // around that by setting it here.
+ if (!isset($this->original) && $id = $this->id()) {
+ $this->original = $this->entityTypeManager()
+ ->getStorage('media')
+ ->loadUnchanged($id);
+ }
+
+ $media_source = $this->getSource();
+ foreach ($this->translations as $langcode => $data) {
+ if ($this->hasTranslation($langcode)) {
+ $translation = $this->getTranslation($langcode);
+ // Try to set fields provided by the media source and mapped in
+ // media type config.
+ foreach ($translation->bundle->entity->getFieldMap() as $metadata_attribute_name => $entity_field_name) {
+ // Only save value in entity field if empty. Do not overwrite existing
+ // data.
+ if ($translation->hasField($entity_field_name) && ($translation->get($entity_field_name)->isEmpty() || $translation->hasSourceFieldChanged())) {
+ $translation->set($entity_field_name, $media_source->getMetadata($translation, $metadata_attribute_name));
+ }
+ }
+
+ // Try to set a default name for this media item if no name is provided.
+ if ($translation->get('name')->isEmpty()) {
+ $translation->setName($translation->getName());
+ }
+
+ // Set thumbnail.
+ if ($translation->shouldUpdateThumbnail($this->isNew())) {
+ $translation->updateThumbnail();
+ }
+ }
+ }
+ return parent::save();
+ }
+
/**
* {@inheritdoc}
*/
diff --git a/core/modules/media/src/Form/MediaSettingsForm.php b/core/modules/media/src/Form/MediaSettingsForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..d165fca56360f076d4113b7f49b01898b2336426
--- /dev/null
+++ b/core/modules/media/src/Form/MediaSettingsForm.php
@@ -0,0 +1,108 @@
+iFrameUrlHelper = $iframe_url_helper;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('config.factory'),
+ $container->get('media.oembed.iframe_url_helper')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return 'media_settings_form';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getEditableConfigNames() {
+ return ['media.settings'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state) {
+ $domain = $this->config('media.settings')->get('iframe_domain');
+
+ if (!$this->iFrameUrlHelper->isSecure($domain)) {
+ $message = $this->t('It is potentially insecure to display oEmbed content in a frame that is served from the same domain as your main Drupal site, as this may allow execution of third-party code. Take a look here for more information.');
+ $this->messenger()->addWarning($message);
+ }
+
+ $description = '
' . $this->t('Displaying media assets from third-party services, such as YouTube or Twitter, can be risky. This is because many of these services return arbitrary HTML to represent those assets, and that HTML may contain executable JavaScript code. If handled improperly, this can increase the risk of your site being compromised.') . '
';
+ $description .= '
' . $this->t('In order to mitigate the risks, third-party assets are displayed in an iFrame, which effectively sandboxes any executable code running inside it. For even more security, the iFrame can be served from an alternate domain (that also points to your Drupal site), which you can configure on this page. This helps safeguard cookies and other sensitive information.') . '
';
+
+ $form['security'] = [
+ '#type' => 'details',
+ '#title' => $this->t('Security'),
+ '#description' => $description,
+ '#open' => TRUE,
+ ];
+ // @todo Figure out how and if we should validate that this domain actually
+ // points back to Drupal.
+ // See https://www.drupal.org/project/drupal/issues/2965979 for more info.
+ $form['security']['iframe_domain'] = [
+ '#type' => 'url',
+ '#title' => $this->t('iFrame domain'),
+ '#size' => 40,
+ '#maxlength' => 255,
+ '#default_value' => $domain,
+ '#description' => $this->t('Enter a different domain from which to serve oEmbed content, including the http:// or https:// prefix. This domain needs to point back to this site, or existing oEmbed content may not display correctly, or at all.'),
+ ];
+
+ return parent::buildForm($form, $form_state);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $this->config('media.settings')
+ ->set('iframe_domain', $form_state->getValue('iframe_domain'))
+ ->save();
+
+ parent::submitForm($form, $form_state);
+ }
+
+}
diff --git a/core/modules/media/src/IFrameMarkup.php b/core/modules/media/src/IFrameMarkup.php
new file mode 100644
index 0000000000000000000000000000000000000000..b6efbbb123603869ee4ae5164607004892ba114c
--- /dev/null
+++ b/core/modules/media/src/IFrameMarkup.php
@@ -0,0 +1,24 @@
+requestContext = $request_context;
+ $this->privateKey = $private_key;
+ }
+
+ /**
+ * Hashes an oEmbed resource URL.
+ *
+ * @param string $url
+ * The resource URL.
+ * @param int $max_width
+ * (optional) The maximum width of the resource.
+ * @param int $max_height
+ * (optional) The maximum height of the resource.
+ *
+ * @return string
+ * The hashed URL.
+ */
+ public function getHash($url, $max_width = NULL, $max_height = NULL) {
+ return Crypt::hmacBase64("$url:$max_width:$max_height", $this->privateKey->get() . Settings::getHashSalt());
+ }
+
+ /**
+ * Checks if an oEmbed URL can be securely displayed in an frame.
+ *
+ * @param string $url
+ * The URL to check.
+ *
+ * @return bool
+ * TRUE if the URL is considered secure, otherwise FALSE.
+ */
+ public function isSecure($url) {
+ if (!$url) {
+ return FALSE;
+ }
+ $url_host = parse_url($url, PHP_URL_HOST);
+ $system_host = parse_url($this->requestContext->getCompleteBaseUrl(), PHP_URL_HOST);
+
+ // The URL is secure if its domain is not the same as the domain of the base
+ // URL of the current request.
+ return $url_host && $system_host && $url_host !== $system_host;
+ }
+
+}
diff --git a/core/modules/media/src/MediaSourceBase.php b/core/modules/media/src/MediaSourceBase.php
index 1edc8584508e693a6ee274ac328afaa7edaccfd7..c01b9946ea0f8e54330acd3ba50ef7fd2d2cb8ce 100644
--- a/core/modules/media/src/MediaSourceBase.php
+++ b/core/modules/media/src/MediaSourceBase.php
@@ -301,7 +301,9 @@ public function createSourceField(MediaTypeInterface $type) {
* returned. Otherwise, a new, unused one is generated.
*/
protected function getSourceFieldName() {
- $base_id = 'field_media_' . $this->getPluginId();
+ // Some media sources are using a deriver, so their plugin IDs may contain
+ // a separator (usually ':') which is not allowed in field names.
+ $base_id = 'field_media_' . str_replace(static::DERIVATIVE_SEPARATOR, '_', $this->getPluginId());
$tries = 0;
$storage = $this->entityTypeManager->getStorage('field_storage_config');
diff --git a/core/modules/media/src/OEmbed/Endpoint.php b/core/modules/media/src/OEmbed/Endpoint.php
new file mode 100644
index 0000000000000000000000000000000000000000..38d265d5bd782cb353b8c27fa3bf58edb567834d
--- /dev/null
+++ b/core/modules/media/src/OEmbed/Endpoint.php
@@ -0,0 +1,176 @@
+provider = $provider;
+ $this->schemes = array_map('mb_strtolower', $schemes);
+
+ $this->formats = $formats = array_map('mb_strtolower', $formats);
+ // Assert that only the supported formats are present.
+ assert(array_diff($formats, ['json', 'xml']) == []);
+
+ // Use the first provided format to build the endpoint URL. If no formats
+ // are provided, default to JSON.
+ $this->url = str_replace('{format}', reset($this->formats) ?: 'json', $url);
+
+ if (!UrlHelper::isValid($this->url, TRUE) || !UrlHelper::isExternal($this->url)) {
+ throw new \InvalidArgumentException('oEmbed endpoint must have a valid external URL');
+ }
+
+ $this->supportsDiscovery = (bool) $supports_discovery;
+ }
+
+ /**
+ * Returns the endpoint URL.
+ *
+ * The URL will be built with the first available format. If the endpoint
+ * does not provide any formats, JSON will be used.
+ *
+ * @return string
+ * The endpoint URL.
+ */
+ public function getUrl() {
+ return $this->url;
+ }
+
+ /**
+ * Returns the provider this endpoint belongs to.
+ *
+ * @return \Drupal\media\OEmbed\Provider
+ * The provider object.
+ */
+ public function getProvider() {
+ return $this->provider;
+ }
+
+ /**
+ * Returns list of URL schemes supported by the provider.
+ *
+ * @return string[]
+ * List of schemes.
+ */
+ public function getSchemes() {
+ return $this->schemes;
+ }
+
+ /**
+ * Returns list of supported formats.
+ *
+ * @return string[]
+ * List of formats.
+ */
+ public function getFormats() {
+ return $this->formats;
+ }
+
+ /**
+ * Returns whether the provider supports oEmbed discovery.
+ *
+ * @return bool
+ * Returns TRUE if the provides discovery, otherwise FALSE.
+ */
+ public function supportsDiscovery() {
+ return $this->supportsDiscovery;
+ }
+
+ /**
+ * Tries to match a URL against the endpoint schemes.
+ *
+ * @param string $url
+ * Media item URL.
+ *
+ * @return bool
+ * TRUE if the URL matches against the endpoint schemes, otherwise FALSE.
+ */
+ public function matchUrl($url) {
+ foreach ($this->getSchemes() as $scheme) {
+ // Convert scheme into a valid regular expression.
+ $regexp = str_replace(['.', '*'], ['\.', '.*'], $scheme);
+ if (preg_match("|^$regexp$|", $url)) {
+ return TRUE;
+ }
+ }
+ return FALSE;
+ }
+
+ /**
+ * Builds and returns the endpoint URL.
+ *
+ * @param string $url
+ * The canonical media URL.
+ *
+ * @return string
+ * URL of the oEmbed endpoint.
+ */
+ public function buildResourceUrl($url) {
+ $query = ['url' => $url];
+ return $this->getUrl() . '?' . UrlHelper::buildQuery($query);
+ }
+
+}
diff --git a/core/modules/media/src/OEmbed/Provider.php b/core/modules/media/src/OEmbed/Provider.php
new file mode 100644
index 0000000000000000000000000000000000000000..954dac1c9fd3748b9a3024e74c6126e4d357c02b
--- /dev/null
+++ b/core/modules/media/src/OEmbed/Provider.php
@@ -0,0 +1,100 @@
+name = $name;
+ $this->url = $url;
+
+ try {
+ foreach ($endpoints as $endpoint) {
+ $endpoint += ['formats' => [], 'schemes' => [], 'discovery' => FALSE];
+ $this->endpoints[] = new Endpoint($endpoint['url'], $this, $endpoint['schemes'], $endpoint['formats'], $endpoint['discovery']);
+ }
+ }
+ catch (\InvalidArgumentException $e) {
+ // Just skip all the invalid endpoints.
+ // @todo Log the exception message to help with debugging in
+ // https://www.drupal.org/project/drupal/issues/2972846.
+ }
+
+ if (empty($this->endpoints)) {
+ throw new ProviderException('Provider @name does not define any valid endpoints.', $this);
+ }
+ }
+
+ /**
+ * Returns the provider name.
+ *
+ * @return string
+ * Name of the provider.
+ */
+ public function getName() {
+ return $this->name;
+ }
+
+ /**
+ * Returns the provider URL.
+ *
+ * @return string
+ * URL of the provider.
+ */
+ public function getUrl() {
+ return $this->url;
+ }
+
+ /**
+ * Returns the provider endpoints.
+ *
+ * @return \Drupal\media\OEmbed\Endpoint[]
+ * List of endpoints this provider exposes.
+ */
+ public function getEndpoints() {
+ return $this->endpoints;
+ }
+
+}
diff --git a/core/modules/media/src/OEmbed/ProviderException.php b/core/modules/media/src/OEmbed/ProviderException.php
new file mode 100644
index 0000000000000000000000000000000000000000..259c939ed115abfed7da9d305586fe6671373d8e
--- /dev/null
+++ b/core/modules/media/src/OEmbed/ProviderException.php
@@ -0,0 +1,40 @@
+' if not.
+ * @param \Drupal\media\OEmbed\Provider $provider
+ * (optional) The provider information.
+ * @param \Exception $previous
+ * (optional) The previous exception, if any.
+ */
+ public function __construct($message, Provider $provider = NULL, \Exception $previous = NULL) {
+ $this->provider = $provider;
+ $message = str_replace('@name', $provider ? $provider->getName() : '', $message);
+ parent::__construct($message, 0, $previous);
+ }
+
+}
diff --git a/core/modules/media/src/OEmbed/ProviderRepository.php b/core/modules/media/src/OEmbed/ProviderRepository.php
new file mode 100644
index 0000000000000000000000000000000000000000..dada7fb2553e26f000709fc524257be6a846cc16
--- /dev/null
+++ b/core/modules/media/src/OEmbed/ProviderRepository.php
@@ -0,0 +1,122 @@
+httpClient = $http_client;
+ $this->providersUrl = $config_factory->get('media.settings')->get('oembed_providers_url');
+ $this->time = $time;
+ $this->cacheBackend = $cache_backend;
+ $this->maxAge = (int) $max_age;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAll() {
+ $cache_id = 'media:oembed_providers';
+
+ $cached = $this->cacheGet($cache_id);
+ if ($cached) {
+ return $cached->data;
+ }
+
+ try {
+ $response = $this->httpClient->request('GET', $this->providersUrl);
+ }
+ catch (RequestException $e) {
+ throw new ProviderException("Could not retrieve the oEmbed provider database from $this->providersUrl", NULL, $e);
+ }
+
+ $providers = Json::decode((string) $response->getBody());
+
+ if (!is_array($providers) || empty($providers)) {
+ throw new ProviderException('Remote oEmbed providers database returned invalid or empty list.');
+ }
+
+ $keyed_providers = [];
+ foreach ($providers as $provider) {
+ try {
+ $name = (string) $provider['provider_name'];
+ $keyed_providers[$name] = new Provider($provider['provider_name'], $provider['provider_url'], $provider['endpoints']);
+ }
+ catch (ProviderException $e) {
+ // Just skip all the invalid providers.
+ // @todo Log the exception message to help with debugging.
+ }
+ }
+
+ $this->cacheSet($cache_id, $keyed_providers, $this->time->getCurrentTime() + $this->maxAge);
+ return $keyed_providers;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function get($provider_name) {
+ $providers = $this->getAll();
+
+ if (!isset($providers[$provider_name])) {
+ throw new \InvalidArgumentException("Unknown provider '$provider_name'");
+ }
+ return $providers[$provider_name];
+ }
+
+}
diff --git a/core/modules/media/src/OEmbed/ProviderRepositoryInterface.php b/core/modules/media/src/OEmbed/ProviderRepositoryInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..b6e63afacba88c7fcf2b39767d5e5c12b1bb34ed
--- /dev/null
+++ b/core/modules/media/src/OEmbed/ProviderRepositoryInterface.php
@@ -0,0 +1,40 @@
+provider = $provider;
+ $this->title = $title;
+ $this->authorName = $author_name;
+ $this->authorUrl = $author_url;
+
+ if (isset($cache_age) && is_numeric($cache_age)) {
+ // If the cache age is too big, it can overflow the 'expire' column of
+ // database cache backends, causing SQL exceptions. To prevent that,
+ // arbitrarily limit the cache age to 5 years. That should be enough.
+ $this->cacheMaxAge = Cache::mergeMaxAges((int) $cache_age, 157680000);
+ }
+
+ if ($thumbnail_url) {
+ $this->thumbnailUrl = $thumbnail_url;
+ $this->setThumbnailDimensions($thumbnail_width, $thumbnail_height);
+ }
+ }
+
+ /**
+ * Creates a link resource.
+ *
+ * @param string $url
+ * (optional) The URL of the resource.
+ * @param \Drupal\media\OEmbed\Provider $provider
+ * (optional) The resource provider.
+ * @param string $title
+ * (optional) A text title, describing the resource.
+ * @param string $author_name
+ * (optional) The name of the author/owner of the resource.
+ * @param string $author_url
+ * (optional) A URL for the author/owner of the resource.
+ * @param int $cache_age
+ * (optional) The suggested cache lifetime for this resource, in seconds.
+ * @param string $thumbnail_url
+ * (optional) A URL to a thumbnail image representing the resource. If this
+ * parameter is present, $thumbnail_width and $thumbnail_height must also be
+ * present.
+ * @param int $thumbnail_width
+ * (optional) The width of the thumbnail, in pixels. If this parameter is
+ * present, $thumbnail_url and $thumbnail_height must also be present.
+ * @param int $thumbnail_height
+ * (optional) The height of the thumbnail, in pixels. If this parameter is
+ * present, $thumbnail_url and $thumbnail_width must also be present.
+ *
+ * @return static
+ */
+ public static function link($url = NULL, Provider $provider = NULL, $title = NULL, $author_name = NULL, $author_url = NULL, $cache_age = NULL, $thumbnail_url = NULL, $thumbnail_width = NULL, $thumbnail_height = NULL) {
+ $resource = new static($provider, $title, $author_name, $author_url, $cache_age, $thumbnail_url, $thumbnail_width, $thumbnail_height);
+ $resource->type = self::TYPE_LINK;
+ $resource->url = $url;
+
+ return $resource;
+ }
+
+ /**
+ * Creates a photo resource.
+ *
+ * @param string $url
+ * The URL of the photo.
+ * @param int $width
+ * The width of the photo, in pixels.
+ * @param int $height
+ * The height of the photo, in pixels.
+ * @param \Drupal\media\OEmbed\Provider $provider
+ * (optional) The resource provider.
+ * @param string $title
+ * (optional) A text title, describing the resource.
+ * @param string $author_name
+ * (optional) The name of the author/owner of the resource.
+ * @param string $author_url
+ * (optional) A URL for the author/owner of the resource.
+ * @param int $cache_age
+ * (optional) The suggested cache lifetime for this resource, in seconds.
+ * @param string $thumbnail_url
+ * (optional) A URL to a thumbnail image representing the resource. If this
+ * parameter is present, $thumbnail_width and $thumbnail_height must also be
+ * present.
+ * @param int $thumbnail_width
+ * (optional) The width of the thumbnail, in pixels. If this parameter is
+ * present, $thumbnail_url and $thumbnail_height must also be present.
+ * @param int $thumbnail_height
+ * (optional) The height of the thumbnail, in pixels. If this parameter is
+ * present, $thumbnail_url and $thumbnail_width must also be present.
+ *
+ * @return static
+ */
+ public static function photo($url, $width, $height, Provider $provider = NULL, $title = NULL, $author_name = NULL, $author_url = NULL, $cache_age = NULL, $thumbnail_url = NULL, $thumbnail_width = NULL, $thumbnail_height = NULL) {
+ if (empty($url)) {
+ throw new \InvalidArgumentException('Photo resources must provide a URL.');
+ }
+
+ $resource = static::link($url, $provider, $title, $author_name, $author_url, $cache_age, $thumbnail_url, $thumbnail_width, $thumbnail_height);
+ $resource->type = self::TYPE_PHOTO;
+ $resource->setDimensions($width, $height);
+
+ return $resource;
+ }
+
+ /**
+ * Creates a rich resource.
+ *
+ * @param string $html
+ * The HTML representation of the resource.
+ * @param int $width
+ * The width of the resource, in pixels.
+ * @param int $height
+ * The height of the resource, in pixels.
+ * @param \Drupal\media\OEmbed\Provider $provider
+ * (optional) The resource provider.
+ * @param string $title
+ * (optional) A text title, describing the resource.
+ * @param string $author_name
+ * (optional) The name of the author/owner of the resource.
+ * @param string $author_url
+ * (optional) A URL for the author/owner of the resource.
+ * @param int $cache_age
+ * (optional) The suggested cache lifetime for this resource, in seconds.
+ * @param string $thumbnail_url
+ * (optional) A URL to a thumbnail image representing the resource. If this
+ * parameter is present, $thumbnail_width and $thumbnail_height must also be
+ * present.
+ * @param int $thumbnail_width
+ * (optional) The width of the thumbnail, in pixels. If this parameter is
+ * present, $thumbnail_url and $thumbnail_height must also be present.
+ * @param int $thumbnail_height
+ * (optional) The height of the thumbnail, in pixels. If this parameter is
+ * present, $thumbnail_url and $thumbnail_width must also be present.
+ *
+ * @return static
+ */
+ public static function rich($html, $width, $height, Provider $provider = NULL, $title = NULL, $author_name = NULL, $author_url = NULL, $cache_age = NULL, $thumbnail_url = NULL, $thumbnail_width = NULL, $thumbnail_height = NULL) {
+ if (empty($html)) {
+ throw new \InvalidArgumentException('The resource must provide an HTML representation.');
+ }
+
+ $resource = new static($provider, $title, $author_name, $author_url, $cache_age, $thumbnail_url, $thumbnail_width, $thumbnail_height);
+ $resource->type = self::TYPE_RICH;
+ $resource->html = $html;
+ $resource->setDimensions($width, $height);
+
+ return $resource;
+ }
+
+ /**
+ * Creates a video resource.
+ *
+ * @param string $html
+ * The HTML required to display the video.
+ * @param int $width
+ * The width of the video, in pixels.
+ * @param int $height
+ * The height of the video, in pixels.
+ * @param \Drupal\media\OEmbed\Provider $provider
+ * (optional) The resource provider.
+ * @param string $title
+ * (optional) A text title, describing the resource.
+ * @param string $author_name
+ * (optional) The name of the author/owner of the resource.
+ * @param string $author_url
+ * (optional) A URL for the author/owner of the resource.
+ * @param int $cache_age
+ * (optional) The suggested cache lifetime for this resource, in seconds.
+ * @param string $thumbnail_url
+ * (optional) A URL to a thumbnail image representing the resource. If this
+ * parameter is present, $thumbnail_width and $thumbnail_height must also be
+ * present.
+ * @param int $thumbnail_width
+ * (optional) The width of the thumbnail, in pixels. If this parameter is
+ * present, $thumbnail_url and $thumbnail_height must also be present.
+ * @param int $thumbnail_height
+ * (optional) The height of the thumbnail, in pixels. If this parameter is
+ * present, $thumbnail_url and $thumbnail_width must also be present.
+ *
+ * @return static
+ */
+ public static function video($html, $width, $height, Provider $provider = NULL, $title = NULL, $author_name = NULL, $author_url = NULL, $cache_age = NULL, $thumbnail_url = NULL, $thumbnail_width = NULL, $thumbnail_height = NULL) {
+ $resource = static::rich($html, $width, $height, $provider, $title, $author_name, $author_url, $cache_age, $thumbnail_url, $thumbnail_width, $thumbnail_height);
+ $resource->type = self::TYPE_VIDEO;
+
+ return $resource;
+ }
+
+ /**
+ * Returns the resource type.
+ *
+ * @return string
+ * The resource type. Will be one of the self::TYPE_* constants.
+ */
+ public function getType() {
+ return $this->type;
+ }
+
+ /**
+ * Returns the title of the resource.
+ *
+ * @return string|null
+ * The title of the resource, if known.
+ */
+ public function getTitle() {
+ return $this->title;
+ }
+
+ /**
+ * Returns the name of the resource author.
+ *
+ * @return string|null
+ * The name of the resource author, if known.
+ */
+ public function getAuthorName() {
+ return $this->authorName;
+ }
+
+ /**
+ * Returns the URL of the resource author.
+ *
+ * @return \Drupal\Core\Url|null
+ * The absolute URL of the resource author, or NULL if none is provided.
+ */
+ public function getAuthorUrl() {
+ return $this->authorUrl ? Url::fromUri($this->authorUrl)->setAbsolute() : NULL;
+ }
+
+ /**
+ * Returns the resource provider, if known.
+ *
+ * @return \Drupal\media\OEmbed\Provider|null
+ * The resource provider, or NULL if the provider is not known.
+ */
+ public function getProvider() {
+ return $this->provider;
+ }
+
+ /**
+ * Returns the URL of the resource's thumbnail image.
+ *
+ * @return \Drupal\Core\Url|null
+ * The absolute URL of the thumbnail image, or NULL if there isn't one.
+ */
+ public function getThumbnailUrl() {
+ return $this->thumbnailUrl ? Url::fromUri($this->thumbnailUrl)->setAbsolute() : NULL;
+ }
+
+ /**
+ * Returns the width of the resource's thumbnail image.
+ *
+ * @return int|null
+ * The thumbnail width in pixels, or NULL if there is no thumbnail.
+ */
+ public function getThumbnailWidth() {
+ return $this->thumbnailWidth;
+ }
+
+ /**
+ * Returns the height of the resource's thumbnail image.
+ *
+ * @return int|null
+ * The thumbnail height in pixels, or NULL if there is no thumbnail.
+ */
+ public function getThumbnailHeight() {
+ return $this->thumbnailHeight;
+ }
+
+ /**
+ * Returns the width of the resource.
+ *
+ * @return int|null
+ * The width of the resource in pixels, or NULL if the resource has no
+ * dimensions
+ */
+ public function getWidth() {
+ return $this->width;
+ }
+
+ /**
+ * Returns the height of the resource.
+ *
+ * @return int|null
+ * The height of the resource in pixels, or NULL if the resource has no
+ * dimensions.
+ */
+ public function getHeight() {
+ return $this->height;
+ }
+
+ /**
+ * Returns the URL of the resource. Only applies to 'photo' resources.
+ *
+ * @return \Drupal\Core\Url|null
+ * The resource URL, if it has one.
+ */
+ public function getUrl() {
+ if ($this->url) {
+ return Url::fromUri($this->url)->setAbsolute();
+ }
+ return NULL;
+ }
+
+ /**
+ * Returns the HTML representation of the resource.
+ *
+ * Only applies to 'rich' and 'video' resources.
+ *
+ * @return string|null
+ * The HTML representation of the resource, if it has one.
+ */
+ public function getHtml() {
+ return isset($this->html) ? (string) $this->html : NULL;
+ }
+
+ /**
+ * Sets the thumbnail dimensions.
+ *
+ * @param int $width
+ * The width of the resource.
+ * @param int $height
+ * The height of the resource.
+ *
+ * @throws \InvalidArgumentException
+ * If either $width or $height are not numbers greater than zero.
+ */
+ protected function setThumbnailDimensions($width, $height) {
+ $width = (int) $width;
+ $height = (int) $height;
+
+ if ($width > 0 && $height > 0) {
+ $this->thumbnailWidth = $width;
+ $this->thumbnailHeight = $height;
+ }
+ else {
+ throw new \InvalidArgumentException('The thumbnail dimensions must be numbers greater than zero.');
+ }
+ }
+
+ /**
+ * Sets the dimensions.
+ *
+ * @param int $width
+ * The width of the resource.
+ * @param int $height
+ * The height of the resource.
+ *
+ * @throws \InvalidArgumentException
+ * If either $width or $height are not numbers greater than zero.
+ */
+ protected function setDimensions($width, $height) {
+ $width = (int) $width;
+ $height = (int) $height;
+
+ if ($width > 0 && $height > 0) {
+ $this->width = $width;
+ $this->height = $height;
+ }
+ else {
+ throw new \InvalidArgumentException('The dimensions must be numbers greater than zero.');
+ }
+ }
+
+}
diff --git a/core/modules/media/src/OEmbed/ResourceException.php b/core/modules/media/src/OEmbed/ResourceException.php
new file mode 100644
index 0000000000000000000000000000000000000000..433867834e5452ef05898e477d3b8b2689c7fcd4
--- /dev/null
+++ b/core/modules/media/src/OEmbed/ResourceException.php
@@ -0,0 +1,67 @@
+url = $url;
+ $this->data = $data;
+ parent::__construct($message, 0, $previous);
+ }
+
+ /**
+ * Gets the URL of the resource which caused the exception.
+ *
+ * @return string
+ * The URL of the resource.
+ */
+ public function getUrl() {
+ return $this->url;
+ }
+
+ /**
+ * Gets the raw resource data, if available.
+ *
+ * @return array
+ * The resource data.
+ */
+ public function getData() {
+ return $this->data;
+ }
+
+}
diff --git a/core/modules/media/src/OEmbed/ResourceFetcher.php b/core/modules/media/src/OEmbed/ResourceFetcher.php
new file mode 100644
index 0000000000000000000000000000000000000000..0c210878feaa823f24a1c90fc1cc9f266ffa05fc
--- /dev/null
+++ b/core/modules/media/src/OEmbed/ResourceFetcher.php
@@ -0,0 +1,197 @@
+httpClient = $http_client;
+ $this->providers = $providers;
+ $this->cacheBackend = $cache_backend;
+ $this->useCaches = isset($cache_backend);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function fetchResource($url) {
+ $cache_id = "media:oembed_resource:$url";
+
+ $cached = $this->cacheGet($cache_id);
+ if ($cached) {
+ return $this->createResource($cached->data, $url);
+ }
+
+ try {
+ $response = $this->httpClient->get($url);
+ }
+ catch (RequestException $e) {
+ throw new ResourceException('Could not retrieve the oEmbed resource.', $url, [], $e);
+ }
+
+ list($format) = $response->getHeader('Content-Type');
+ $content = (string) $response->getBody();
+
+ if (strstr($format, 'text/xml') || strstr($format, 'application/xml')) {
+ $encoder = new XmlEncoder();
+ $data = $encoder->decode($content, 'xml');
+ }
+ elseif (strstr($format, 'text/javascript') || strstr($format, 'application/json')) {
+ $data = Json::decode($content);
+ }
+ // If the response is neither XML nor JSON, we are in bat country.
+ else {
+ throw new ResourceException('The fetched resource did not have a valid Content-Type header.', $url);
+ }
+
+ $this->cacheSet($cache_id, $data);
+
+ return $this->createResource($data, $url);
+ }
+
+ /**
+ * Creates a Resource object from raw resource data.
+ *
+ * @param array $data
+ * The resource data returned by the provider.
+ * @param string $url
+ * The URL of the resource.
+ *
+ * @return \Drupal\media\OEmbed\Resource
+ * A value object representing the resource.
+ *
+ * @throws \Drupal\media\OEmbed\ResourceException
+ * If the resource cannot be created.
+ */
+ protected function createResource(array $data, $url) {
+ $data += [
+ 'title' => NULL,
+ 'author_name' => NULL,
+ 'author_url' => NULL,
+ 'provider_name' => NULL,
+ 'cache_age' => NULL,
+ 'thumbnail_url' => NULL,
+ 'thumbnail_width' => NULL,
+ 'thumbnail_height' => NULL,
+ 'width' => NULL,
+ 'height' => NULL,
+ 'url' => NULL,
+ 'html' => NULL,
+ 'version' => NULL,
+ ];
+
+ if ($data['version'] !== '1.0') {
+ throw new ResourceException("Resource version must be '1.0'", $url, $data);
+ }
+
+ // Prepare the arguments to pass to the factory method.
+ $provider = $data['provider_name'] ? $this->providers->get($data['provider_name']) : NULL;
+
+ // The Resource object will validate the data we create it with and throw an
+ // exception if anything looks wrong. For better debugging, catch those
+ // exceptions and wrap them in a more specific and useful exception.
+ try {
+ switch ($data['type']) {
+ case Resource::TYPE_LINK:
+ return Resource::link(
+ $data['url'],
+ $provider,
+ $data['title'],
+ $data['author_name'],
+ $data['author_url'],
+ $data['cache_age'],
+ $data['thumbnail_url'],
+ $data['thumbnail_width'],
+ $data['thumbnail_height']
+ );
+
+ case Resource::TYPE_PHOTO:
+ return Resource::photo(
+ $data['url'],
+ $data['width'],
+ $data['height'],
+ $provider,
+ $data['title'],
+ $data['author_name'],
+ $data['author_url'],
+ $data['cache_age'],
+ $data['thumbnail_url'],
+ $data['thumbnail_width'],
+ $data['thumbnail_height']
+ );
+
+ case Resource::TYPE_RICH:
+ return Resource::rich(
+ $data['html'],
+ $data['width'],
+ $data['height'],
+ $provider,
+ $data['title'],
+ $data['author_name'],
+ $data['author_url'],
+ $data['cache_age'],
+ $data['thumbnail_url'],
+ $data['thumbnail_width'],
+ $data['thumbnail_height']
+ );
+ case Resource::TYPE_VIDEO:
+ return Resource::video(
+ $data['html'],
+ $data['width'],
+ $data['height'],
+ $provider,
+ $data['title'],
+ $data['author_name'],
+ $data['author_url'],
+ $data['cache_age'],
+ $data['thumbnail_url'],
+ $data['thumbnail_width'],
+ $data['thumbnail_height']
+ );
+
+ default:
+ throw new ResourceException('Unknown resource type: ' . $data['type'], $url, $data);
+ }
+ }
+ catch (\InvalidArgumentException $e) {
+ throw new ResourceException($e->getMessage(), $url, $data, $e);
+ }
+ }
+
+}
diff --git a/core/modules/media/src/OEmbed/ResourceFetcherInterface.php b/core/modules/media/src/OEmbed/ResourceFetcherInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..b74fb6e2d9c47fc75dde7bc91c7befda16a20611
--- /dev/null
+++ b/core/modules/media/src/OEmbed/ResourceFetcherInterface.php
@@ -0,0 +1,32 @@
+providers = $providers;
+ $this->resourceFetcher = $resource_fetcher;
+ $this->httpClient = $http_client;
+ $this->moduleHandler = $module_handler;
+ $this->cacheBackend = $cache_backend;
+ $this->useCaches = isset($cache_backend);
+ }
+
+ /**
+ * Runs oEmbed discovery and returns the endpoint URL if successful.
+ *
+ * @param string $url
+ * The resource's URL.
+ *
+ * @return string|bool
+ * URL of the oEmbed endpoint, or FALSE if the discovery was unsuccessful.
+ *
+ * @throws \Drupal\media\OEmbed\ResourceException
+ * If the resource cannot be retrieved.
+ */
+ protected function discoverResourceUrl($url) {
+ try {
+ $response = $this->httpClient->get($url);
+ }
+ catch (RequestException $e) {
+ throw new ResourceException('Could not fetch oEmbed resource.', $url, [], $e);
+ }
+
+ $document = Html::load((string) $response->getBody());
+ $xpath = new \DOMXpath($document);
+
+ return $this->findUrl($xpath, 'json') ?: $this->findUrl($xpath, 'xml');
+ }
+
+ /**
+ * Tries to find the oEmbed URL in a DOM.
+ *
+ * @param \DOMXPath $xpath
+ * Page HTML as DOMXPath.
+ * @param string $format
+ * Format of oEmbed resource. Possible values are 'json' and 'xml'.
+ *
+ * @return bool|string
+ * A URL to an oEmbed resource or FALSE if not found.
+ */
+ protected function findUrl(\DOMXPath $xpath, $format) {
+ $result = $xpath->query("//link[@type='application/$format+oembed']");
+ return $result->length ? $result->item(0)->getAttribute('href') : FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getProviderByUrl($url) {
+ // Check the URL against every scheme of every endpoint of every provider
+ // until we find a match.
+ foreach ($this->providers->getAll() as $provider_name => $provider_info) {
+ foreach ($provider_info->getEndpoints() as $endpoint) {
+ if ($endpoint->matchUrl($url)) {
+ return $provider_info;
+ }
+ }
+ }
+
+ $resource_url = $this->discoverResourceUrl($url);
+ if ($resource_url) {
+ return $this->resourceFetcher->fetchResource($resource_url)->getProvider();
+ }
+
+ throw new ResourceException('No matching provider found.', $url);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getResourceUrl($url, $max_width = NULL, $max_height = NULL) {
+ // Try to get the resource URL from the static cache.
+ if (isset($this->urlCache[$url])) {
+ return $this->urlCache[$url];
+ }
+
+ // Try to get the resource URL from the persistent cache.
+ $cache_id = "media:oembed_resource_url:$url:$max_width:$max_height";
+
+ $cached = $this->cacheGet($cache_id);
+ if ($cached) {
+ $this->urlCache[$url] = $cached->data;
+ return $this->urlCache[$url];
+ }
+
+ $provider = $this->getProviderByUrl($url);
+ $endpoints = $provider->getEndpoints();
+ $endpoint = reset($endpoints);
+ $resource_url = $endpoint->buildResourceUrl($url);
+
+ $parsed_url = UrlHelper::parse($resource_url);
+ if ($max_width) {
+ $parsed_url['query']['maxwidth'] = $max_width;
+ }
+ if ($max_height) {
+ $parsed_url['query']['maxheight'] = $max_height;
+ }
+ // Let other modules alter the resource URL, because some oEmbed providers
+ // provide extra parameters in the query string. For example, Instagram also
+ // supports the 'omitscript' parameter.
+ $this->moduleHandler->alter('oembed_resource_url', $parsed_url, $provider);
+ $resource_url = $parsed_url['path'] . '?' . UrlHelper::buildQuery($parsed_url['query']);
+
+ $this->urlCache[$url] = $resource_url;
+ $this->cacheSet($cache_id, $resource_url);
+
+ return $resource_url;
+ }
+
+}
diff --git a/core/modules/media/src/OEmbed/UrlResolverInterface.php b/core/modules/media/src/OEmbed/UrlResolverInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..b401f6201a520245fb15c6dca7c70dc31b53b493
--- /dev/null
+++ b/core/modules/media/src/OEmbed/UrlResolverInterface.php
@@ -0,0 +1,45 @@
+messenger = $messenger;
+ $this->resourceFetcher = $resource_fetcher;
+ $this->urlResolver = $url_resolver;
+ $this->logger = $logger_factory->get('media');
+ $this->config = $config_factory->get('media.settings');
+ $this->iFrameUrlHelper = $iframe_url_helper;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $plugin_id,
+ $plugin_definition,
+ $configuration['field_definition'],
+ $configuration['settings'],
+ $configuration['label'],
+ $configuration['view_mode'],
+ $configuration['third_party_settings'],
+ $container->get('messenger'),
+ $container->get('media.oembed.resource_fetcher'),
+ $container->get('media.oembed.url_resolver'),
+ $container->get('logger.factory'),
+ $container->get('config.factory'),
+ $container->get('media.oembed.iframe_url_helper')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function defaultSettings() {
+ return [
+ 'max_width' => 0,
+ 'max_height' => 0,
+ ] + parent::defaultSettings();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function viewElements(FieldItemListInterface $items, $langcode) {
+ $element = [];
+ $max_width = $this->getSetting('max_width');
+ $max_height = $this->getSetting('max_height');
+
+ foreach ($items as $delta => $item) {
+ $main_property = $item->getFieldDefinition()->getFieldStorageDefinition()->getMainPropertyName();
+ $value = $item->{$main_property};
+
+ if (empty($value)) {
+ continue;
+ }
+
+ try {
+ $resource_url = $this->urlResolver->getResourceUrl($value, $max_width, $max_height);
+ $resource = $this->resourceFetcher->fetchResource($resource_url);
+ }
+ catch (ResourceException $exception) {
+ $this->logger->error("Could not retrieve the remote URL (@url).", ['@url' => $value]);
+ continue;
+ }
+
+ if ($resource->getType() === Resource::TYPE_LINK) {
+ $element[$delta] = [
+ '#title' => $resource->getTitle(),
+ '#type' => 'link',
+ '#url' => Url::fromUri($value),
+ ];
+ }
+ elseif ($resource->getType() === Resource::TYPE_PHOTO) {
+ $element[$delta] = [
+ '#theme' => 'image',
+ '#uri' => $resource->getUrl()->toString(),
+ '#width' => $max_width ?: $resource->getWidth(),
+ '#height' => $max_height ?: $resource->getHeight(),
+ ];
+ }
+ else {
+ $url = Url::fromRoute('media.oembed_iframe', [], [
+ 'query' => [
+ 'url' => $value,
+ 'max_width' => $max_width,
+ 'max_height' => $max_height,
+ 'hash' => $this->iFrameUrlHelper->getHash($value, $max_width, $max_height),
+ ],
+ ]);
+
+ $domain = $this->config->get('iframe_domain');
+ if ($domain) {
+ $url->setOption('base_url', $domain);
+ }
+
+ // Render videos and rich content in an iframe for security reasons.
+ // @see: https://oembed.com/#section3
+ $element[$delta] = [
+ '#type' => 'html_tag',
+ '#tag' => 'iframe',
+ '#attributes' => [
+ 'src' => $url->toString(),
+ 'frameborder' => 0,
+ 'scrolling' => FALSE,
+ 'allowtransparency' => TRUE,
+ 'width' => $max_width ?: $resource->getWidth(),
+ 'height' => $max_height ?: $resource->getHeight(),
+ ],
+ ];
+
+ CacheableMetadata::createFromObject($resource)
+ ->addCacheTags($this->config->getCacheTags())
+ ->applyTo($element[$delta]);
+ }
+ }
+ return $element;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function settingsForm(array $form, FormStateInterface $form_state) {
+ return parent::settingsForm($form, $form_state) + [
+ 'max_width' => [
+ '#type' => 'number',
+ '#title' => $this->t('Maximum width'),
+ '#default_value' => $this->getSetting('max_width'),
+ '#size' => 5,
+ '#maxlength' => 5,
+ '#field_suffix' => $this->t('pixels'),
+ '#min' => 0,
+ ],
+ 'max_height' => [
+ '#type' => 'number',
+ '#title' => $this->t('Maximum height'),
+ '#default_value' => $this->getSetting('max_height'),
+ '#size' => 5,
+ '#maxlength' => 5,
+ '#field_suffix' => $this->t('pixels'),
+ '#min' => 0,
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function settingsSummary() {
+ $summary = parent::settingsSummary();
+ if ($this->getSetting('max_width') && $this->getSetting('max_height')) {
+ $summary[] = $this->t('Maximum size: %max_width x %max_height pixels', [
+ '%max_width' => $this->getSetting('max_width'),
+ '%max_height' => $this->getSetting('max_height'),
+ ]);
+ }
+ elseif ($this->getSetting('max_width')) {
+ $summary[] = $this->t('Maximum width: %max_width pixels', [
+ '%max_width' => $this->getSetting('max_width'),
+ ]);
+ }
+ elseif ($this->getSetting('max_height')) {
+ $summary[] = $this->t('Maximum height: %max_height pixels', [
+ '%max_height' => $this->getSetting('max_height'),
+ ]);
+ }
+ return $summary;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function isApplicable(FieldDefinitionInterface $field_definition) {
+ if ($field_definition->getTargetEntityTypeId() !== 'media') {
+ return FALSE;
+ }
+
+ if (parent::isApplicable($field_definition)) {
+ $media_type = $field_definition->getTargetBundle();
+
+ if ($media_type) {
+ $media_type = MediaType::load($media_type);
+ return $media_type && $media_type->getSource() instanceof OEmbedInterface;
+ }
+ }
+ return FALSE;
+ }
+
+}
diff --git a/core/modules/media/src/Plugin/Field/FieldWidget/OEmbedWidget.php b/core/modules/media/src/Plugin/Field/FieldWidget/OEmbedWidget.php
new file mode 100644
index 0000000000000000000000000000000000000000..1252265e45b89261f7bff3f5e0dfc8a16b49576a
--- /dev/null
+++ b/core/modules/media/src/Plugin/Field/FieldWidget/OEmbedWidget.php
@@ -0,0 +1,64 @@
+getEntity()->getSource();
+ $message = $this->t('You can link to media from the following services: @providers', ['@providers' => implode(', ', $source->getProviders())]);
+
+ if (!empty($element['#value']['#description'])) {
+ $element['value']['#description'] = [
+ '#theme' => 'item_list',
+ '#items' => [$element['value']['#description'], $message],
+ ];
+ }
+ else {
+ $element['value']['#description'] = $message;
+ }
+
+ return $element;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function isApplicable(FieldDefinitionInterface $field_definition) {
+ $target_bundle = $field_definition->getTargetBundle();
+
+ if (!parent::isApplicable($field_definition) || $field_definition->getTargetEntityTypeId() !== 'media' || !$target_bundle) {
+ return FALSE;
+ }
+ return MediaType::load($target_bundle)->getSource() instanceof OEmbedInterface;
+ }
+
+}
diff --git a/core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraint.php b/core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraint.php
new file mode 100644
index 0000000000000000000000000000000000000000..306353c02d233e8f3485d5d222ebd235895e2e20
--- /dev/null
+++ b/core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraint.php
@@ -0,0 +1,50 @@
+urlResolver = $url_resolver;
+ $this->resourceFetcher = $resource_fetcher;
+ $this->logger = $logger_factory->get('media');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('media.oembed.url_resolver'),
+ $container->get('media.oembed.resource_fetcher'),
+ $container->get('logger.factory')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validate($value, Constraint $constraint) {
+ /** @var \Drupal\media\MediaInterface $media */
+ $media = $value->getEntity();
+ /** @var \Drupal\media\Plugin\media\Source\OEmbedInterface $source */
+ $source = $media->getSource();
+
+ if (!($source instanceof OEmbedInterface)) {
+ throw new \LogicException('Media source must implement ' . OEmbedInterface::class);
+ }
+ $url = $source->getSourceFieldValue($media);
+
+ // Ensure that the URL matches a provider.
+ try {
+ $provider = $this->urlResolver->getProviderByUrl($url);
+ }
+ catch (ResourceException $e) {
+ $this->handleException($e, $constraint->unknownProviderMessage);
+ return;
+ }
+ catch (ProviderException $e) {
+ $this->handleException($e, $constraint->providerErrorMessage);
+ return;
+ }
+
+ // Ensure that the provider is allowed.
+ if (!in_array($provider->getName(), $source->getProviders(), TRUE)) {
+ $this->context->addViolation($constraint->disallowedProviderMessage, [
+ '@name' => $provider->getName(),
+ ]);
+ return;
+ }
+
+ // Verify that resource fetching works, because some URLs might match
+ // the schemes but don't support oEmbed.
+ try {
+ $endpoints = $provider->getEndpoints();
+ $resource_url = reset($endpoints)->buildResourceUrl($url);
+ $this->resourceFetcher->fetchResource($resource_url);
+ }
+ catch (ResourceException $e) {
+ $this->handleException($e, $constraint->invalidResourceMessage);
+ }
+ }
+
+ /**
+ * Handles exceptions that occur during validation.
+ *
+ * @param \Exception $e
+ * The caught exception.
+ * @param string $error_message
+ * (optional) The error message to set as a constraint violation.
+ */
+ protected function handleException(\Exception $e, $error_message = NULL) {
+ if ($error_message) {
+ $this->context->addViolation($error_message);
+ }
+
+ // The oEmbed system makes heavy use of exception wrapping, so log the
+ // entire exception chain to help with troubleshooting.
+ do {
+ // @todo If $e is a ProviderException or ResourceException, log additional
+ // debugging information contained in those exceptions in
+ // https://www.drupal.org/project/drupal/issues/2972846.
+ $this->logger->error($e->getMessage());
+ $e = $e->getPrevious();
+ } while ($e);
+ }
+
+}
diff --git a/core/modules/media/src/Plugin/media/Source/OEmbed.php b/core/modules/media/src/Plugin/media/Source/OEmbed.php
new file mode 100644
index 0000000000000000000000000000000000000000..a376366ce1ab248424492e669e55e745efc2a18b
--- /dev/null
+++ b/core/modules/media/src/Plugin/media/Source/OEmbed.php
@@ -0,0 +1,466 @@
+ 'artwork',
+ * 'label' => t('Artwork'),
+ * 'description' => t('Use artwork from Flickr and DeviantArt.'),
+ * 'allowed_field_types' => ['string'],
+ * 'default_thumbnail_filename' => 'no-thumbnail.png',
+ * 'providers' => ['Deviantart.com', 'Flickr'],
+ * 'class' => 'Drupal\media\Plugin\media\Source\OEmbed',
+ * ];
+ * }
+ * @endcode
+ * The "Deviantart.com" and "Flickr" provider names are specified in
+ * https://oembed.com/providers.json. The
+ * \Drupal\media\Plugin\media\Source\OEmbed class already knows how to handle
+ * standard interactions with third-party oEmbed APIs, so there is no need to
+ * define a new class which extends it. With the code above, you will able to
+ * create media types which use the "Artwork" source plugin, and use those media
+ * types to link to assets on Deviantart and Flickr.
+ *
+ * @MediaSource(
+ * id = "oembed",
+ * label = @Translation("oEmbed source"),
+ * description = @Translation("Use oEmbed URL for reusable media."),
+ * allowed_field_types = {"string"},
+ * default_thumbnail_filename = "no-thumbnail.png",
+ * deriver = "Drupal\media\Plugin\media\Source\OEmbedDeriver",
+ * providers = {},
+ * )
+ */
+class OEmbed extends MediaSourceBase implements OEmbedInterface {
+
+ /**
+ * The logger channel for media.
+ *
+ * @var \Drupal\Core\Logger\LoggerChannelInterface
+ */
+ protected $logger;
+
+ /**
+ * The messenger service.
+ *
+ * @var \Drupal\Core\Messenger\MessengerInterface
+ */
+ protected $messenger;
+
+ /**
+ * The HTTP client.
+ *
+ * @var \GuzzleHttp\Client
+ */
+ protected $httpClient;
+
+ /**
+ * The oEmbed resource fetcher service.
+ *
+ * @var \Drupal\media\OEmbed\ResourceFetcherInterface
+ */
+ protected $resourceFetcher;
+
+ /**
+ * The OEmbed manager service.
+ *
+ * @var \Drupal\media\OEmbed\UrlResolverInterface
+ */
+ protected $urlResolver;
+
+ /**
+ * The iFrame URL helper service.
+ *
+ * @var \Drupal\media\IFrameUrlHelper
+ */
+ protected $iFrameUrlHelper;
+
+ /**
+ * Constructs a new OEmbed instance.
+ *
+ * @param array $configuration
+ * A configuration array containing information about the plugin instance.
+ * @param string $plugin_id
+ * The plugin_id for the plugin instance.
+ * @param mixed $plugin_definition
+ * The plugin implementation definition.
+ * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+ * The entity type manager service.
+ * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager
+ * The entity field manager service.
+ * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+ * The config factory service.
+ * @param \Drupal\Core\Field\FieldTypePluginManagerInterface $field_type_manager
+ * The field type plugin manager service.
+ * @param \Drupal\Core\Logger\LoggerChannelInterface $logger
+ * The logger channel for media.
+ * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+ * The messenger service.
+ * @param \GuzzleHttp\ClientInterface $http_client
+ * The HTTP client.
+ * @param \Drupal\media\OEmbed\ResourceFetcherInterface $resource_fetcher
+ * The oEmbed resource fetcher service.
+ * @param \Drupal\media\OEmbed\UrlResolverInterface $url_resolver
+ * The oEmbed URL resolver service.
+ * @param \Drupal\media\IFrameUrlHelper $iframe_url_helper
+ * The iFrame URL helper service.
+ */
+ public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, ConfigFactoryInterface $config_factory, FieldTypePluginManagerInterface $field_type_manager, LoggerChannelInterface $logger, MessengerInterface $messenger, ClientInterface $http_client, ResourceFetcherInterface $resource_fetcher, UrlResolverInterface $url_resolver, IFrameUrlHelper $iframe_url_helper) {
+ parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager, $entity_field_manager, $field_type_manager, $config_factory);
+ $this->logger = $logger;
+ $this->messenger = $messenger;
+ $this->httpClient = $http_client;
+ $this->resourceFetcher = $resource_fetcher;
+ $this->urlResolver = $url_resolver;
+ $this->iFrameUrlHelper = $iframe_url_helper;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $configuration,
+ $plugin_id,
+ $plugin_definition,
+ $container->get('entity_type.manager'),
+ $container->get('entity_field.manager'),
+ $container->get('config.factory'),
+ $container->get('plugin.manager.field.field_type'),
+ $container->get('logger.factory')->get('media'),
+ $container->get('messenger'),
+ $container->get('http_client'),
+ $container->get('media.oembed.resource_fetcher'),
+ $container->get('media.oembed.url_resolver'),
+ $container->get('media.oembed.iframe_url_helper')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getMetadataAttributes() {
+ return [
+ 'type' => $this->t('Resource type'),
+ 'title' => $this->t('Resource title'),
+ 'author_name' => $this->t('The name of the author/owner'),
+ 'author_url' => $this->t('The URL of the author/owner'),
+ 'provider_name' => $this->t("The name of the provider"),
+ 'provider_url' => $this->t('The URL of the provider'),
+ 'cache_age' => $this->t('Suggested cache lifetime'),
+ 'default_name' => $this->t('Default name of the media item'),
+ 'thumbnail_uri' => $this->t('Local URI of the thumbnail'),
+ 'thumbnail_width' => $this->t('Thumbnail width'),
+ 'thumbnail_height' => $this->t('Thumbnail height'),
+ 'url' => $this->t('The source URL of the resource'),
+ 'width' => $this->t('The width of the resource'),
+ 'height' => $this->t('The height of the resource'),
+ 'html' => $this->t('The HTML representation of the resource'),
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getMetadata(MediaInterface $media, $name) {
+ $media_url = $this->getSourceFieldValue($media);
+
+ try {
+ $resource_url = $this->urlResolver->getResourceUrl($media_url);
+ $resource = $this->resourceFetcher->fetchResource($resource_url);
+ }
+ catch (ResourceException $e) {
+ $this->messenger->addError($e->getMessage());
+ return NULL;
+ }
+
+ switch ($name) {
+ case 'default_name':
+ if ($title = $this->getMetadata($media, 'title')) {
+ return $title;
+ }
+ elseif ($url = $this->getMetadata($media, 'url')) {
+ return $url;
+ }
+ return parent::getMetadata($media, 'default_name');
+
+ case 'thumbnail_uri':
+ return $this->getLocalThumbnailUri($resource) ?: parent::getMetadata($media, 'thumbnail_uri');
+
+ case 'type':
+ return $resource->getType();
+
+ case 'title':
+ return $resource->getTitle();
+
+ case 'author_name':
+ return $resource->getAuthorName();
+
+ case 'author_url':
+ return $resource->getAuthorUrl();
+
+ case 'provider_name':
+ $provider = $resource->getProvider();
+ return $provider ? $provider->getName() : '';
+
+ case 'provider_url':
+ $provider = $resource->getProvider();
+ return $provider ? $provider->getUrl() : NULL;
+
+ case 'cache_age':
+ return $resource->getCacheMaxAge();
+
+ case 'thumbnail_width':
+ return $resource->getThumbnailWidth();
+
+ case 'thumbnail_height':
+ return $resource->getThumbnailHeight();
+
+ case 'url':
+ $url = $resource->getUrl();
+ return $url ? $url->toString() : NULL;
+
+ case 'width':
+ return $resource->getWidth();
+
+ case 'height':
+ return $resource->getHeight();
+
+ case 'html':
+ return $resource->getHtml();
+
+ default:
+ break;
+ }
+ return NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+ $form = parent::buildConfigurationForm($form, $form_state);
+
+ $domain = $this->configFactory->get('media.settings')->get('iframe_domain');
+ if (!$this->iFrameUrlHelper->isSecure($domain)) {
+ array_unshift($form, [
+ '#markup' => '
' . $this->t('It is potentially insecure to display oEmbed content in a frame that is served from the same domain as your main Drupal site, as this may allow execution of third-party code. You can specify a different domain for serving oEmbed content here (opens in a new window).', [
+ ':url' => Url::fromRoute('media.settings')->setAbsolute()->toString(),
+ ]) . '
',
+ ]);
+ }
+
+ $form['thumbnails_directory'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('Thumbnails location'),
+ '#default_value' => $this->configuration['thumbnails_directory'],
+ '#description' => $this->t('Thumbnails will be fetched from the provider for local usage. This is the URI of the directory where they will be placed.'),
+ '#required' => TRUE,
+ ];
+
+ $configuration = $this->getConfiguration();
+ $plugin_definition = $this->getPluginDefinition();
+
+ $form['providers'] = [
+ '#type' => 'checkboxes',
+ '#title' => $this->t('Allowed providers'),
+ '#default_value' => $configuration['providers'],
+ '#options' => array_combine($plugin_definition['providers'], $plugin_definition['providers']),
+ '#description' => $this->t('Optionally select the allowed oEmbed providers for this media type. If left blank, all providers will be allowed.'),
+ ];
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+ parent::submitConfigurationForm($form, $form_state);
+ $configuration = $this->getConfiguration();
+ $configuration['providers'] = array_filter(array_values($configuration['providers']));
+ $this->setConfiguration($configuration);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+ $thumbnails_directory = $form_state->getValue('thumbnails_directory');
+ if (!file_valid_uri($thumbnails_directory)) {
+ $form_state->setErrorByName('thumbnails_directory', $this->t('@path is not a valid path.', [
+ '@path' => $thumbnails_directory,
+ ]));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function defaultConfiguration() {
+ return [
+ 'thumbnails_directory' => 'public://oembed_thumbnails',
+ 'providers' => [],
+ ] + parent::defaultConfiguration();
+ }
+
+ /**
+ * Returns the local URI for a resource thumbnail.
+ *
+ * If the thumbnail is not already locally stored, this method will attempt
+ * to download it.
+ *
+ * @param \Drupal\media\OEmbed\Resource $resource
+ * The oEmbed resource.
+ *
+ * @return string|null
+ * The local thumbnail URI, or NULL if it could not be downloaded, or if the
+ * resource has no thumbnail at all.
+ *
+ * @todo Determine whether or not oEmbed media thumbnails should be stored
+ * locally at all, and if so, whether that functionality should be
+ * toggle-able. See https://www.drupal.org/project/drupal/issues/2962751 for
+ * more information.
+ */
+ protected function getLocalThumbnailUri(Resource $resource) {
+ // If there is no remote thumbnail, there's nothing for us to fetch here.
+ $remote_thumbnail_url = $resource->getThumbnailUrl();
+ if (!$remote_thumbnail_url) {
+ return NULL;
+ }
+ $remote_thumbnail_url = $remote_thumbnail_url->toString();
+
+ // Compute the local thumbnail URI, regardless of whether or not it exists.
+ $configuration = $this->getConfiguration();
+ $directory = $configuration['thumbnails_directory'];
+ $local_thumbnail_uri = "$directory/" . Crypt::hashBase64($remote_thumbnail_url) . '.' . pathinfo($remote_thumbnail_url, PATHINFO_EXTENSION);
+
+ // If the local thumbnail already exists, return its URI.
+ if (file_exists($local_thumbnail_uri)) {
+ return $local_thumbnail_uri;
+ }
+
+ // The local thumbnail doesn't exist yet, so try to download it. First,
+ // ensure that the destination directory is writable, and if it's not,
+ // log an error and bail out.
+ if (!file_prepare_directory($directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS)) {
+ $this->logger->warning('Could not prepare thumbnail destination directory @dir for oEmbed media.', [
+ '@dir' => $directory,
+ ]);
+ return NULL;
+ }
+
+ $error_message = 'Could not download remote thumbnail from {url}.';
+ $error_context = [
+ 'url' => $remote_thumbnail_url,
+ ];
+ try {
+ $response = $this->httpClient->get($remote_thumbnail_url);
+ if ($response->getStatusCode() === 200) {
+ $success = file_unmanaged_save_data((string) $response->getBody(), $local_thumbnail_uri, FILE_EXISTS_REPLACE);
+
+ if ($success) {
+ return $local_thumbnail_uri;
+ }
+ else {
+ $this->logger->warning($error_message, $error_context);
+ }
+ }
+ }
+ catch (RequestException $e) {
+ $this->logger->warning($e->getMessage());
+ }
+ return NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getSourceFieldConstraints() {
+ return [
+ 'oembed_resource' => [],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function prepareViewDisplay(MediaTypeInterface $type, EntityViewDisplayInterface $display) {
+ $display->setComponent($this->getSourceFieldDefinition($type)->getName(), [
+ 'type' => 'oembed',
+ ]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function prepareFormDisplay(MediaTypeInterface $type, EntityFormDisplayInterface $display) {
+ parent::prepareFormDisplay($type, $display);
+ $source_field = $this->getSourceFieldDefinition($type)->getName();
+
+ $display->setComponent($source_field, [
+ 'type' => 'oembed_textfield',
+ 'weight' => $display->getComponent($source_field)['weight'],
+ ]);
+ $display->removeComponent('name');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getProviders() {
+ $configuration = $this->getConfiguration();
+ return $configuration['providers'] ?: $this->getPluginDefinition()['providers'];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function createSourceField(MediaTypeInterface $type) {
+ $plugin_definition = $this->getPluginDefinition();
+
+ $label = (string) $this->t('@type URL', [
+ '@type' => $plugin_definition['label'],
+ ]);
+ return parent::createSourceField($type)->set('label', $label);
+ }
+
+}
diff --git a/core/modules/media/src/Plugin/media/Source/OEmbedDeriver.php b/core/modules/media/src/Plugin/media/Source/OEmbedDeriver.php
new file mode 100644
index 0000000000000000000000000000000000000000..8da3265f5b740d678f22d41483c1898e6516567d
--- /dev/null
+++ b/core/modules/media/src/Plugin/media/Source/OEmbedDeriver.php
@@ -0,0 +1,32 @@
+derivatives = [
+ 'video' => [
+ 'id' => 'video',
+ 'label' => t('Remote video'),
+ 'description' => t('Use remote video URL for reusable media.'),
+ 'providers' => ['YouTube', 'Vimeo'],
+ 'default_thumbnail_filename' => 'video.png',
+ ] + $base_plugin_definition,
+ ];
+ return parent::getDerivativeDefinitions($base_plugin_definition);
+ }
+
+}
diff --git a/core/modules/media/src/Plugin/media/Source/OEmbedInterface.php b/core/modules/media/src/Plugin/media/Source/OEmbedInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..90d73e520662d2bbdaf6431aba38e11bd202c1e3
--- /dev/null
+++ b/core/modules/media/src/Plugin/media/Source/OEmbedInterface.php
@@ -0,0 +1,23 @@
+
+
+
+ {{ media|raw }}
+
+
diff --git a/core/modules/media/tests/fixtures/oembed/photo_flickr.html b/core/modules/media/tests/fixtures/oembed/photo_flickr.html
new file mode 100644
index 0000000000000000000000000000000000000000..6ad06d81521d4924a09069236549bb44e0e328ab
--- /dev/null
+++ b/core/modules/media/tests/fixtures/oembed/photo_flickr.html
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/core/modules/media/tests/fixtures/oembed/photo_flickr.json b/core/modules/media/tests/fixtures/oembed/photo_flickr.json
new file mode 100644
index 0000000000000000000000000000000000000000..7cdc28e05616ac4d54236a0c04574ed1dc38e670
--- /dev/null
+++ b/core/modules/media/tests/fixtures/oembed/photo_flickr.json
@@ -0,0 +1,12 @@
+{
+ "type": "photo",
+ "title": "Druplicon FTW!",
+ "width": "88",
+ "height": "100",
+ "url": "internal:\/core\/misc\/druplicon.png",
+ "thumbnail_url": "internal:\/core\/misc\/druplicon.png",
+ "thumbnail_width": 88,
+ "thumbnail_height": 100,
+ "provider_name": "Flickr",
+ "version": "1.0"
+}
diff --git a/core/modules/media/tests/fixtures/oembed/providers.json b/core/modules/media/tests/fixtures/oembed/providers.json
new file mode 100644
index 0000000000000000000000000000000000000000..e618ec40fa6672d847338f7074acb45772a2b9a0
--- /dev/null
+++ b/core/modules/media/tests/fixtures/oembed/providers.json
@@ -0,0 +1,61 @@
+[
+ {
+ "provider_name": "Vimeo",
+ "provider_url": "https:\/\/vimeo.com\/",
+ "endpoints": [
+ {
+ "schemes": [
+ "https:\/\/vimeo.com\/*",
+ "https:\/\/vimeo.com\/album\/*\/video\/*",
+ "https:\/\/vimeo.com\/channels\/*\/*",
+ "https:\/\/vimeo.com\/groups\/*\/videos\/*",
+ "https:\/\/vimeo.com\/ondemand\/*\/*",
+ "https:\/\/player.vimeo.com\/video\/*"
+ ],
+ "url": "https:\/\/vimeo.com\/api\/oembed.{format}",
+ "discovery": true
+ }
+ ]
+ },
+ {
+ "provider_name": "Twitter",
+ "provider_url": "http:\/\/www.twitter.com\/",
+ "endpoints": [
+ {
+ "schemes": [
+ "https:\/\/twitter.com\/*\/status\/*",
+ "https:\/\/*.twitter.com\/*\/status\/*"
+ ],
+ "url": "https:\/\/publish.twitter.com\/oembed"
+
+ }
+ ]
+ },
+ {
+ "provider_name": "CollegeHumor",
+ "provider_url": "http:\/\/www.collegehumor.com\/",
+ "endpoints": [
+ {
+ "schemes": [
+ "http:\/\/www.collegehumor.com\/video\/*"
+ ],
+ "url": "http:\/\/www.collegehumor.com\/oembed.{format}",
+ "discovery": true
+ }
+ ]
+ },
+ {
+ "provider_name": "Flickr",
+ "provider_url": "http:\/\/www.flickr.com\/",
+ "endpoints": [
+ {
+ "schemes": [
+ "http:\/\/*.flickr.com\/photos\/*",
+ "http:\/\/flic.kr\/p\/*"
+ ],
+ "url": "http:\/\/www.flickr.com\/services\/oembed\/",
+ "discovery": true
+ }
+ ]
+ }
+]
diff --git a/core/modules/media/tests/fixtures/oembed/rich_twitter.json b/core/modules/media/tests/fixtures/oembed/rich_twitter.json
new file mode 100644
index 0000000000000000000000000000000000000000..f27b88199f1b0abf908a38b18bc9f1b5d24a0d74
--- /dev/null
+++ b/core/modules/media/tests/fixtures/oembed/rich_twitter.json
@@ -0,0 +1,13 @@
+{
+ "url": "https:\/\/twitter.com\/drupaldevdays\/status\/935643039741202432",
+ "author_name": "Drupal Dev Days",
+ "author_url": "https:\/\/twitter.com\/drupaldevdays",
+ "html": "