summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAlex Pott2018-06-01 07:21:38 (GMT)
committerAlex Pott2018-06-01 07:21:38 (GMT)
commit8f6fcbdd5004f08e41713d3e651796ad47fabda8 (patch)
treee69b3c4b6f3d60c6306339057ee4752f6eea9cd5
parent175fa94bc8c322e75dedc094833de4cd47222cdc (diff)
Issue #2831944 by chr.fritsch, phenaproxima, marcoscano, seanB, slashrsm, robpowell, alexpott, samuel.mortenson, dawehner, tstoeckler, Wim Leers, Gábor Hojtsy, martin107, aheimlich, idebr, tedbow, larowlan, ckrina, mtodor, bkosborne: Implement media source plugin for remote video via oEmbed
-rw-r--r--core/modules/media/config/install/media.settings.yml1
-rw-r--r--core/modules/media/config/schema/media.schema.yml35
-rw-r--r--core/modules/media/media.api.php17
-rw-r--r--core/modules/media/media.info.yml1
-rw-r--r--core/modules/media/media.install42
-rw-r--r--core/modules/media/media.links.menu.yml6
-rw-r--r--core/modules/media/media.module19
-rw-r--r--core/modules/media/media.routing.yml16
-rw-r--r--core/modules/media/media.services.yml10
-rw-r--r--core/modules/media/src/Controller/OEmbedIframeController.php156
-rw-r--r--core/modules/media/src/Form/MediaSettingsForm.php108
-rw-r--r--core/modules/media/src/MediaSourceBase.php4
-rw-r--r--core/modules/media/src/OEmbed/Endpoint.php176
-rw-r--r--core/modules/media/src/OEmbed/Provider.php100
-rw-r--r--core/modules/media/src/OEmbed/ProviderException.php40
-rw-r--r--core/modules/media/src/OEmbed/ProviderRepository.php122
-rw-r--r--core/modules/media/src/OEmbed/ProviderRepositoryInterface.php40
-rw-r--r--core/modules/media/src/OEmbed/RawResourceException.php71
-rw-r--r--core/modules/media/src/OEmbed/Resource.php534
-rw-r--r--core/modules/media/src/OEmbed/ResourceException.php46
-rw-r--r--core/modules/media/src/OEmbed/ResourceFetcher.php166
-rw-r--r--core/modules/media/src/OEmbed/ResourceFetcherInterface.php32
-rw-r--r--core/modules/media/src/OEmbed/UrlResolver.php213
-rw-r--r--core/modules/media/src/OEmbed/UrlResolverInterface.php54
-rw-r--r--core/modules/media/src/Plugin/Field/FieldFormatter/OEmbedFormatter.php282
-rw-r--r--core/modules/media/src/Plugin/Field/FieldWidget/OEmbedWidget.php64
-rw-r--r--core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraint.php50
-rw-r--r--core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraintValidator.php138
-rw-r--r--core/modules/media/src/Plugin/media/Source/OEmbed.php452
-rw-r--r--core/modules/media/src/Plugin/media/Source/OEmbedDeriver.php32
-rw-r--r--core/modules/media/src/Plugin/media/Source/OEmbedInterface.php30
-rw-r--r--core/modules/media/templates/media-oembed.html.twig14
-rw-r--r--core/modules/media/tests/fixtures/oembed/photo_flickr.html8
-rw-r--r--core/modules/media/tests/fixtures/oembed/photo_flickr.json12
-rw-r--r--core/modules/media/tests/fixtures/oembed/providers.json61
-rw-r--r--core/modules/media/tests/fixtures/oembed/rich_twitter.json13
-rw-r--r--core/modules/media/tests/fixtures/oembed/video_collegehumor.html8
-rw-r--r--core/modules/media/tests/fixtures/oembed/video_collegehumor.xml18
-rw-r--r--core/modules/media/tests/fixtures/oembed/video_vimeo.html8
-rw-r--r--core/modules/media/tests/fixtures/oembed/video_vimeo.json16
-rw-r--r--core/modules/media/tests/modules/media_test_oembed/media_test_oembed.info.yml8
-rw-r--r--core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module17
-rw-r--r--core/modules/media/tests/modules/media_test_oembed/media_test_oembed.routing.yml6
-rw-r--r--core/modules/media/tests/modules/media_test_oembed/src/Controller/ResourceController.php48
-rw-r--r--core/modules/media/tests/modules/media_test_oembed/src/MediaTestOembedServiceProvider.php26
-rw-r--r--core/modules/media/tests/modules/media_test_oembed/src/ProviderRepository.php55
-rw-r--r--core/modules/media/tests/modules/media_test_oembed/src/UrlResolver.php38
-rw-r--r--core/modules/media/tests/src/Functional/FieldFormatter/OEmbedFormatterTest.php148
-rw-r--r--core/modules/media/tests/src/Functional/MediaSettingsTest.php35
-rw-r--r--core/modules/media/tests/src/Functional/ProviderRepositoryTest.php89
-rw-r--r--core/modules/media/tests/src/Functional/ResourceFetcherTest.php72
-rw-r--r--core/modules/media/tests/src/Functional/Update/MediaUpdateTest.php19
-rw-r--r--core/modules/media/tests/src/Functional/UrlResolverTest.php133
-rw-r--r--core/modules/media/tests/src/FunctionalJavascript/MediaDisplayTest.php1
-rw-r--r--core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php157
-rw-r--r--core/modules/media/tests/src/Traits/OEmbedTestTrait.php89
-rw-r--r--core/modules/media/tests/src/Unit/UrlResolverTest.php116
-rw-r--r--core/themes/stable/templates/content/media-oembed.html.twig14
58 files changed, 4284 insertions, 2 deletions
diff --git a/core/modules/media/config/install/media.settings.yml b/core/modules/media/config/install/media.settings.yml
index 853e575..0bdd0ce 100644
--- a/core/modules/media/config/install/media.settings.yml
+++ b/core/modules/media/config/install/media.settings.yml
@@ -1 +1,2 @@
icon_base_uri: 'public://media-icons/generic'
+oembed_providers: '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 b9156b2..550957d 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: string
+ label: 'Domain from which to serve oEmbed content in an iframe'
+ oembed_providers:
+ type: string
+ 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_uri:
+ type: uri
+ label: 'Thumbnail storage URI'
+ allowed_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 8de1c64..93244f5 100644
--- a/core/modules/media/media.api.php
+++ b/core/modules/media/media.api.php
@@ -21,5 +21,22 @@ function hook_media_source_info_alter(array &$sources) {
}
/**
+ * 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 d7a9648..c5b85f5 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 90f9d99..c4ddca5 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.url_resolver')->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. <a href=":url">You can specify a different domain for serving oEmbed content here</a>.', [
+ ':url' => Url::fromRoute('media.settings')->setAbsolute()->toString(),
+ ]),
+ 'severity' => REQUIREMENT_WARNING,
+ ];
+ }
+ }
+ }
return $requirements;
}
@@ -120,3 +153,12 @@ function media_update_8500() {
$role->save();
}
}
+
+/**
+ * Creates the oembed_providers config setting.
+ */
+function media_update_8600() {
+ \Drupal::configFactory()->getEditable('media.settings')
+ ->set('oembed_providers', '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 e50c412..1bf5fff 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 5079f0f..2de628b 100644
--- a/core/modules/media/media.module
+++ b/core/modules/media/media.module
@@ -15,6 +15,7 @@ use Drupal\Core\Session\AccountInterface;
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 +73,11 @@ function media_theme() {
'render element' => 'element',
'base hook' => 'field_multiple_value_form',
],
+ 'media_oembed' => [
+ 'variables' => [
+ 'post' => NULL,
+ ],
+ ],
];
}
@@ -92,6 +98,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 +106,18 @@ function media_theme_suggestions_media(array $variables) {
$suggestions[] = 'media__' . $media->bundle();
$suggestions[] = 'media__' . $media->bundle() . '__' . $sanitized_view_mode;
+ $source = $media->getSource();
+ if ($source instanceof OEmbedInterface) {
+ $suggestions[] = 'media__oembed';
+
+ $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[] = "media__oembed__$provider_id";
+ }
+ }
+
return $suggestions;
}
diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml
index ea0858d..dbf735e 100644
--- a/core/modules/media/media.routing.yml
+++ b/core/modules/media/media.routing.yml
@@ -24,3 +24,19 @@ 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: 'access content'
+ _csrf_token: 'TRUE'
+
+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 f22f90a..0c3c54c 100644
--- a/core/modules/media/media.services.yml
+++ b/core/modules/media/media.services.yml
@@ -2,9 +2,17 @@ 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', '@router.request_context', '@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']
diff --git a/core/modules/media/src/Controller/OEmbedIframeController.php b/core/modules/media/src/Controller/OEmbedIframeController.php
new file mode 100644
index 0000000..04faad0
--- /dev/null
+++ b/core/modules/media/src/Controller/OEmbedIframeController.php
@@ -0,0 +1,156 @@
+<?php
+
+namespace Drupal\media\Controller;
+
+use Drupal\Component\Utility\UrlHelper;
+use Drupal\Core\Cache\CacheableResponse;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Logger\LoggerChannelInterface;
+use Drupal\Core\Render\Markup;
+use Drupal\Core\Render\RenderContext;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\Core\Url;
+use Drupal\media\OEmbed\ResourceException;
+use Drupal\media\OEmbed\ResourceFetcherInterface;
+use Drupal\media\OEmbed\UrlResolverInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * Controller which renders an oEmbed resource in a bare page (without blocks).
+ *
+ * This controller is meant to render untrusted third-party HTML returned by
+ * an oEmbed provider in an iframe, so as to mitigate the potential dangers of
+ * of displaying third-party markup (i.e., XSS). The HTML returned by this
+ * controller should not be trusted, and should *never* be displayed outside
+ * of an iframe.
+ *
+ * @internal
+ * This is an internal part of the oEmbed system and should only be used by
+ * oEmbed-related code in Drupal core.
+ */
+class OEmbedIframeController implements ContainerInjectionInterface {
+
+ /**
+ * The oEmbed resource fetcher service.
+ *
+ * @var \Drupal\media\OEmbed\ResourceFetcherInterface
+ */
+ protected $resourceFetcher;
+
+ /**
+ * The oEmbed URL resolver service.
+ *
+ * @var \Drupal\media\OEmbed\UrlResolverInterface
+ */
+ protected $urlResolver;
+
+ /**
+ * The renderer service.
+ *
+ * @var \Drupal\Core\Render\RendererInterface
+ */
+ protected $renderer;
+
+ /**
+ * The logger channel.
+ *
+ * @var \Drupal\Core\Logger\LoggerChannelInterface
+ */
+ protected $logger;
+
+ /**
+ * Constructs an OEmbedIframeController instance.
+ *
+ * @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\Core\Render\RendererInterface $renderer
+ * The renderer service.
+ * @param \Drupal\Core\Logger\LoggerChannelInterface $logger
+ * The logger channel.
+ */
+ public function __construct(ResourceFetcherInterface $resource_fetcher, UrlResolverInterface $url_resolver, RendererInterface $renderer, LoggerChannelInterface $logger) {
+ $this->resourceFetcher = $resource_fetcher;
+ $this->urlResolver = $url_resolver;
+ $this->renderer = $renderer;
+ $this->logger = $logger;
+ }
+
+ /**
+ * {@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')
+ );
+ }
+
+ /**
+ * 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\BadRequestHttpException
+ * Will be thrown when the 'url' parameter is not specified, invalid, or not
+ * external.
+ */
+ public function render(Request $request) {
+ $url = $request->query->get('url');
+ if (!$url) {
+ throw new BadRequestHttpException('url parameter not provided');
+ }
+ if (!UrlHelper::isValid($url, TRUE)) {
+ throw new BadRequestHttpException('url parameter is invalid');
+ }
+ if (!UrlHelper::isExternal($url)) {
+ throw new BadRequestHttpException('url parameter is not external');
+ }
+
+ // 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, $request->query->getInt('max_width', NULL), $request->query->getInt('max_height', NULL));
+ $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',
+ // Even though the resource HTML is untrusted, Markup::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.
+ '#post' => Markup::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);
+ // @todo Log additional information from ResourceException, to help with
+ // debugging, in https://www.drupal.org/project/drupal/issues/2972846.
+ $this->logger->error($e->getMessage());
+ }
+
+ return $response;
+ }
+
+}
diff --git a/core/modules/media/src/Form/MediaSettingsForm.php b/core/modules/media/src/Form/MediaSettingsForm.php
new file mode 100644
index 0000000..03b2aa7
--- /dev/null
+++ b/core/modules/media/src/Form/MediaSettingsForm.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Drupal\media\Form;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Form\ConfigFormBase;
+use Drupal\media\OEmbed\UrlResolverInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a form to configure Media settings.
+ *
+ * @internal
+ */
+class MediaSettingsForm extends ConfigFormBase {
+
+ /**
+ * The oEmbed URL resolver service.
+ *
+ * @var \Drupal\media\OEmbed\UrlResolverInterface
+ */
+ protected $urlResolver;
+
+ /**
+ * MediaSettingsForm constructor.
+ *
+ * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+ * The config factory service.
+ * @param \Drupal\media\OEmbed\UrlResolverInterface $url_resolver
+ * The oEmbed URL resolver service.
+ */
+ public function __construct(ConfigFactoryInterface $config_factory, UrlResolverInterface $url_resolver) {
+ parent::__construct($config_factory);
+ $this->urlResolver = $url_resolver;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('config.factory'),
+ $container->get('media.oembed.url_resolver')
+ );
+ }
+
+ /**
+ * {@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->urlResolver->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. <a href="https://oembed.com/#section3" target="_blank">Take a look here for more information</a>.');
+ $this->messenger()->addWarning($message);
+ }
+
+ $description = '<p>' . $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.') . '</p>';
+ $description .= '<p>' . $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.') . '</p>';
+
+ $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 <em>http://</em> or <em>https://</em> 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/MediaSourceBase.php b/core/modules/media/src/MediaSourceBase.php
index 1edc858..c01b994 100644
--- a/core/modules/media/src/MediaSourceBase.php
+++ b/core/modules/media/src/MediaSourceBase.php
@@ -301,7 +301,9 @@ abstract class MediaSourceBase extends PluginBase implements MediaSourceInterfac
* 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 0000000..38d265d
--- /dev/null
+++ b/core/modules/media/src/OEmbed/Endpoint.php
@@ -0,0 +1,176 @@
+<?php
+
+namespace Drupal\media\OEmbed;
+
+use Drupal\Component\Utility\UrlHelper;
+
+/**
+ * Value object for oEmbed provider endpoints.
+ *
+ * @internal
+ * This class is an internal part of the oEmbed system and should only be
+ * instantiated by instances of Drupal\media\OEmbed\Provider.
+ */
+class Endpoint {
+
+ /**
+ * The endpoint's URL.
+ *
+ * @var string
+ */
+ protected $url;
+
+ /**
+ * The provider this endpoint belongs to.
+ *
+ * @var \Drupal\media\OEmbed\Provider
+ */
+ protected $provider;
+
+ /**
+ * List of URL schemes supported by the provider.
+ *
+ * @var string[]
+ */
+ protected $schemes;
+
+ /**
+ * List of supported formats. Only 'json' and 'xml' are allowed.
+ *
+ * @var string[]
+ *
+ * @see https://oembed.com/#section2
+ */
+ protected $formats;
+
+ /**
+ * Whether the provider supports oEmbed discovery.
+ *
+ * @var bool
+ */
+ protected $supportsDiscovery;
+
+ /**
+ * Endpoint constructor.
+ *
+ * @param string $url
+ * The endpoint URL. May contain a @code '{format}' @endcode placeholder.
+ * @param \Drupal\media\OEmbed\Provider $provider
+ * The provider this endpoint belongs to.
+ * @param string[] $schemes
+ * List of URL schemes supported by the provider.
+ * @param string[] $formats
+ * List of supported formats. Can be "json", "xml" or both.
+ * @param bool $supports_discovery
+ * Whether the provider supports oEmbed discovery.
+ *
+ * @throws \InvalidArgumentException
+ * If the endpoint URL is empty.
+ */
+ public function __construct($url, Provider $provider, array $schemes = [], array $formats = [], $supports_discovery = FALSE) {
+ $this->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 0000000..954dac1
--- /dev/null
+++ b/core/modules/media/src/OEmbed/Provider.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\media\OEmbed;
+
+use Drupal\Component\Utility\UrlHelper;
+
+/**
+ * Value object for oEmbed providers.
+ */
+class Provider {
+
+ /**
+ * The provider name.
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * The provider URL.
+ *
+ * @var string
+ */
+ protected $url;
+
+ /**
+ * The provider endpoints.
+ *
+ * @var \Drupal\media\OEmbed\Endpoint[]
+ */
+ protected $endpoints = [];
+
+ /**
+ * Provider constructor.
+ *
+ * @param string $name
+ * The provider name.
+ * @param string $url
+ * The provider URL.
+ * @param array[] $endpoints
+ * List of endpoints this provider exposes.
+ *
+ * @throws \Drupal\media\OEmbed\ProviderException
+ */
+ public function __construct($name, $url, array $endpoints) {
+ if (!UrlHelper::isValid($url, TRUE) || !UrlHelper::isExternal($url)) {
+ throw new ProviderException('Provider @name does not define a valid external URL.', $this);
+ }
+
+ $this->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 0000000..259c939
--- /dev/null
+++ b/core/modules/media/src/OEmbed/ProviderException.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\media\OEmbed;
+
+/**
+ * Exception thrown if an oEmbed provider causes an error.
+ *
+ * @internal
+ * This is an internal part of the oEmbed system and should only be used by
+ * oEmbed-related code in Drupal core.
+ */
+class ProviderException extends \Exception {
+
+ /**
+ * Information about the oEmbed provider which caused the exception.
+ *
+ * @var \Drupal\media\OEmbed\Provider
+ *
+ * @see \Drupal\media\OEmbed\ProviderRepositoryInterface::get()
+ */
+ protected $provider;
+
+ /**
+ * ProviderException constructor.
+ *
+ * @param string $message
+ * The exception message. '@name' will be replaced with the provider name
+ * if available, or '<unknown>' 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() : '<unknown>', $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 0000000..d04ac3e
--- /dev/null
+++ b/core/modules/media/src/OEmbed/ProviderRepository.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Drupal\media\OEmbed;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Cache\UseCacheBackendTrait;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\RequestException;
+
+/**
+ * Retrieves and caches information about oEmbed providers.
+ */
+class ProviderRepository implements ProviderRepositoryInterface {
+
+ use UseCacheBackendTrait;
+
+ /**
+ * How long the provider data should be cached, in seconds.
+ *
+ * @var int
+ */
+ protected $maxAge;
+
+ /**
+ * The HTTP client.
+ *
+ * @var \GuzzleHttp\Client
+ */
+ protected $httpClient;
+
+ /**
+ * URL of a JSON document which contains a database of oEmbed providers.
+ *
+ * @var string
+ */
+ protected $providersUrl;
+
+ /**
+ * The time service.
+ *
+ * @var \Drupal\Component\Datetime\TimeInterface
+ */
+ protected $time;
+
+ /**
+ * Constructs a ProviderRepository instance.
+ *
+ * @param \GuzzleHttp\ClientInterface $http_client
+ * The HTTP client.
+ * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+ * The config factory service.
+ * @param \Drupal\Component\Datetime\TimeInterface $time
+ * The time service.
+ * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+ * (optional) The cache backend.
+ * @param int $max_age
+ * (optional) How long the cache data should be kept. Defaults to a week.
+ */
+ public function __construct(ClientInterface $http_client, ConfigFactoryInterface $config_factory, TimeInterface $time, CacheBackendInterface $cache_backend = NULL, $max_age = 604800) {
+ $this->httpClient = $http_client;
+ $this->providersUrl = $config_factory->get('media.settings')->get('oembed_providers');
+ $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 0000000..b6e63af
--- /dev/null
+++ b/core/modules/media/src/OEmbed/ProviderRepositoryInterface.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\media\OEmbed;
+
+/**
+ * Defines an interface for a collection of oEmbed provider information.
+ *
+ * The provider repository is responsible for fetching information about all
+ * available oEmbed providers, most likely pulled from the online database at
+ * https://oembed.com/providers.json, and creating \Drupal\media\OEmbed\Provider
+ * value objects for each provider.
+ */
+interface ProviderRepositoryInterface {
+
+ /**
+ * Returns information on all available oEmbed providers.
+ *
+ * @return \Drupal\media\OEmbed\Provider[]
+ * Returns an array of provider value objects, keyed by provider name.
+ *
+ * @throws \Drupal\media\OEmbed\ProviderException
+ * If the oEmbed provider information cannot be retrieved.
+ */
+ public function getAll();
+
+ /**
+ * Returns information for a specific oEmbed provider.
+ *
+ * @param string $provider_name
+ * The name of the provider.
+ *
+ * @return \Drupal\media\OEmbed\Provider
+ * A value object containing information about the provider.
+ *
+ * @throws \InvalidArgumentException
+ * If there is no known oEmbed provider with the specified name.
+ */
+ public function get($provider_name);
+
+}
diff --git a/core/modules/media/src/OEmbed/RawResourceException.php b/core/modules/media/src/OEmbed/RawResourceException.php
new file mode 100644
index 0000000..9461d19
--- /dev/null
+++ b/core/modules/media/src/OEmbed/RawResourceException.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\media\OEmbed;
+
+/**
+ * Exception thrown if an oEmbed resource causes an error.
+ *
+ * This differs from \Drupal\media\OEmbed\ResourceException in that it is only
+ * thrown before a \Drupal\media\OEmbed\Resource value object has been created
+ * for the resource.
+ *
+ * @internal
+ * This is an internal part of the oEmbed system and should only be used by
+ * oEmbed-related code in Drupal core.
+ */
+class RawResourceException extends ResourceException {
+
+ /**
+ * The URL of the resource.
+ *
+ * @var string
+ */
+ protected $url;
+
+ /**
+ * The resource data.
+ *
+ * @var array
+ */
+ protected $resource = [];
+
+ /**
+ * RawResourceException constructor.
+ *
+ * @param string $message
+ * The exception message.
+ * @param string $url
+ * The URL of the resource. Can be the actual endpoint URL or the canonical
+ * URL.
+ * @param array $resource
+ * (optional) The raw resource data.
+ * @param \Exception $previous
+ * (optional) The previous exception, if any.
+ */
+ public function __construct($message, $url, array $resource = [], \Exception $previous = NULL) {
+ $this->url = $url;
+ $this->resource = $resource;
+ 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 getResource() {
+ return $this->resource;
+ }
+
+}
diff --git a/core/modules/media/src/OEmbed/Resource.php b/core/modules/media/src/OEmbed/Resource.php
new file mode 100644
index 0000000..190a013
--- /dev/null
+++ b/core/modules/media/src/OEmbed/Resource.php
@@ -0,0 +1,534 @@
+<?php
+
+namespace Drupal\media\OEmbed;
+
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\CacheableDependencyTrait;
+use Drupal\Core\Url;
+
+/**
+ * Value object representing an oEmbed resource.
+ *
+ * Data received from an oEmbed provider could be insecure. For example,
+ * resources of the 'rich' type provide an HTML representation which is not
+ * sanitized by this object in any way. Any values you retrieve from this object
+ * should be treated as potentially dangerous user input and carefully validated
+ * and sanitized before being displayed or otherwise manipulated by your code.
+ *
+ * Valid resource types are defined in the oEmbed specification and represented
+ * by the TYPE_* constants in this class.
+ *
+ * @see https://oembed.com/#section2
+ *
+ * @internal
+ * This class is an internal part of the oEmbed system and should only be
+ * instantiated by
+ * \Drupal\media\OEmbed\ResourceFetcherInterface::fetchResource().
+ */
+class Resource implements CacheableDependencyInterface {
+
+ use CacheableDependencyTrait;
+
+ /**
+ * The resource type for link resources.
+ */
+ const TYPE_LINK = 'link';
+
+ /**
+ * The resource type for photo resources.
+ */
+ const TYPE_PHOTO = 'photo';
+
+ /**
+ * The resource type for rich resources.
+ */
+ const TYPE_RICH = 'rich';
+
+ /**
+ * The resource type for video resources.
+ */
+ const TYPE_VIDEO = 'video';
+
+ /**
+ * The resource type. Can be one of the static::TYPE_* constants.
+ *
+ * @var string
+ */
+ protected $type;
+
+ /**
+ * The resource provider.
+ *
+ * @var \Drupal\media\OEmbed\Provider
+ */
+ protected $provider;
+
+ /**
+ * A text title, describing the resource.
+ *
+ * @var string
+ */
+ protected $title;
+
+ /**
+ * The name of the author/owner of the resource.
+ *
+ * @var string
+ */
+ protected $authorName;
+
+ /**
+ * A URL for the author/owner of the resource.
+ *
+ * @var string
+ */
+ protected $authorUrl;
+
+ /**
+ * A URL to a thumbnail image representing the resource.
+ *
+ * The thumbnail must respect any maxwidth and maxheight parameters passed
+ * to the oEmbed endpoint. If this parameter is present, thumbnail_width and
+ * thumbnail_height must also be present.
+ *
+ * @var string
+ *
+ * @see \Drupal\media\OEmbed\UrlResolverInterface::getResourceUrl()
+ * @see https://oembed.com/#section2
+ */
+ protected $thumbnailUrl;
+
+ /**
+ * The width of the thumbnail, in pixels.
+ *
+ * If this parameter is present, thumbnail_url and thumbnail_height must also
+ * be present.
+ *
+ * @var int
+ */
+ protected $thumbnailWidth;
+
+ /**
+ * The height of the thumbnail, in pixels.
+ *
+ * If this parameter is present, thumbnail_url and thumbnail_width must also
+ * be present.
+ *
+ * @var int
+ */
+ protected $thumbnailHeight;
+
+ /**
+ * The width of the resource, in pixels.
+ *
+ * @var int
+ */
+ protected $width;
+
+ /**
+ * The height of the resource, in pixels.
+ *
+ * @var int
+ */
+ protected $height;
+
+ /**
+ * The resource URL. Only applies to 'photo' and 'link' resources.
+ *
+ * @var string
+ */
+ protected $url;
+
+ /**
+ * The HTML representation of the resource.
+ *
+ * Only applies to 'rich' and 'video' resources.
+ *
+ * @var string
+ */
+ protected $html;
+
+ /**
+ * Resource constructor.
+ *
+ * @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.
+ */
+ protected function __construct(Provider $provider = NULL, $title = NULL, $author_name = NULL, $author_url = NULL, $cache_age = NULL, $thumbnail_url = NULL, $thumbnail_width = NULL, $thumbnail_height = NULL) {
+ $this->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 0000000..f1911ff
--- /dev/null
+++ b/core/modules/media/src/OEmbed/ResourceException.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\media\OEmbed;
+
+/**
+ * Exception thrown if an oEmbed resource causes an error.
+ *
+ * @internal
+ * This is an internal part of the oEmbed system and should only be used by
+ * oEmbed-related code in Drupal core.
+ */
+class ResourceException extends \Exception {
+
+ /**
+ * The resource which caused the exception.
+ *
+ * @var \Drupal\media\OEmbed\Resource
+ */
+ protected $resource;
+
+ /**
+ * ResourceException constructor.
+ *
+ * @param string $message
+ * The exception message.
+ * @param \Drupal\media\OEmbed\Resource $resource
+ * (optional) The value object for the resource.
+ * @param \Exception $previous
+ * (optional) The previous exception, if any.
+ */
+ public function __construct($message, Resource $resource = NULL, \Exception $previous = NULL) {
+ $this->resource = $resource;
+ parent::__construct($message, 0, $previous);
+ }
+
+ /**
+ * Gets the resource which caused the exception, if available.
+ *
+ * @return \Drupal\media\OEmbed\Resource|null
+ * The oEmbed resource.
+ */
+ public function getResource() {
+ return $this->resource;
+ }
+
+}
diff --git a/core/modules/media/src/OEmbed/ResourceFetcher.php b/core/modules/media/src/OEmbed/ResourceFetcher.php
new file mode 100644
index 0000000..0a0af99
--- /dev/null
+++ b/core/modules/media/src/OEmbed/ResourceFetcher.php
@@ -0,0 +1,166 @@
+<?php
+
+namespace Drupal\media\OEmbed;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Cache\UseCacheBackendTrait;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\RequestException;
+use Symfony\Component\Serializer\Encoder\XmlEncoder;
+
+/**
+ * Fetches and caches oEmbed resources.
+ */
+class ResourceFetcher implements ResourceFetcherInterface {
+
+ use UseCacheBackendTrait;
+
+ /**
+ * The HTTP client.
+ *
+ * @var \GuzzleHttp\Client
+ */
+ protected $httpClient;
+
+ /**
+ * The oEmbed provider repository service.
+ *
+ * @var \Drupal\media\OEmbed\ProviderRepositoryInterface
+ */
+ protected $providers;
+
+ /**
+ * Constructs a ResourceFetcher object.
+ *
+ * @param \GuzzleHttp\ClientInterface $http_client
+ * The HTTP client.
+ * @param \Drupal\media\OEmbed\ProviderRepositoryInterface $providers
+ * The oEmbed provider repository service.
+ * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+ * (optional) The cache backend.
+ */
+ public function __construct(ClientInterface $http_client, ProviderRepositoryInterface $providers, CacheBackendInterface $cache_backend = NULL) {
+ $this->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 RawResourceException('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 RawResourceException('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\RawResourceException
+ * 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 RawResourceException("Resource version must be '1.0'", $url, $data);
+ }
+
+ // Prepare the arguments to pass to the factory method.
+ $arguments = [
+ $data['provider_name'] ? $this->providers->get($data['provider_name']) : NULL,
+ $data['title'],
+ $data['author_name'],
+ $data['author_url'],
+ $data['cache_age'],
+ $data['thumbnail_url'],
+ $data['thumbnail_width'],
+ $data['thumbnail_height'],
+ ];
+
+ // All resource types except links are expected to have a width and height.
+ if ($data['type'] !== Resource::TYPE_LINK) {
+ array_unshift($arguments, $data['width'], $data['height']);
+ }
+
+ switch ($data['type']) {
+ case Resource::TYPE_LINK:
+ case Resource::TYPE_PHOTO:
+ array_unshift($arguments, $data['url']);
+ break;
+
+ case Resource::TYPE_RICH:
+ case Resource::TYPE_VIDEO:
+ array_unshift($arguments, $data['html']);
+ break;
+
+ default:
+ throw new RawResourceException('Unknown resource type: ' . $data['type'], $url, $data);
+ }
+
+ // 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 {
+ return call_user_func_array(Resource::class . '::' . $data['type'], $arguments);
+ }
+ catch (\InvalidArgumentException $e) {
+ throw new RawResourceException($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 0000000..b74fb6e
--- /dev/null
+++ b/core/modules/media/src/OEmbed/ResourceFetcherInterface.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Drupal\media\OEmbed;
+
+/**
+ * Defines an interface for an oEmbed resource fetcher service.
+ *
+ * The resource fetcher's only responsibility is to retrieve oEmbed resource
+ * data from an endpoint URL (i.e., as returned by
+ * \Drupal\media\OEmbed\UrlResolverInterface::getResourceUrl()) and return a
+ * \Drupal\media\OEmbed\Resource value object.
+ */
+interface ResourceFetcherInterface {
+
+ /**
+ * Fetches an oEmbed resource.
+ *
+ * @param string $url
+ * Endpoint-specific URL of the oEmbed resource.
+ *
+ * @return \Drupal\media\OEmbed\Resource
+ * A resource object built from the oEmbed resource data.
+ *
+ * @see https://oembed.com/#section2
+ *
+ * @throws \Drupal\media\OEmbed\ResourceException
+ * If the oEmbed endpoint is not reachable or the response returns an
+ * unexpected Content-Type header.
+ */
+ public function fetchResource($url);
+
+}
diff --git a/core/modules/media/src/OEmbed/UrlResolver.php b/core/modules/media/src/OEmbed/UrlResolver.php
new file mode 100644
index 0000000..e830486
--- /dev/null
+++ b/core/modules/media/src/OEmbed/UrlResolver.php
@@ -0,0 +1,213 @@
+<?php
+
+namespace Drupal\media\OEmbed;
+
+use Drupal\Component\Utility\Html;
+use Drupal\Component\Utility\UrlHelper;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Cache\UseCacheBackendTrait;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Routing\RequestContext;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\RequestException;
+
+/**
+ * Converts oEmbed media URLs into endpoint-specific resource URLs.
+ */
+class UrlResolver implements UrlResolverInterface {
+
+ use UseCacheBackendTrait;
+
+ /**
+ * The HTTP client.
+ *
+ * @var \GuzzleHttp\Client
+ */
+ protected $httpClient;
+
+ /**
+ * The OEmbed provider repository service.
+ *
+ * @var \Drupal\media\OEmbed\ProviderRepositoryInterface
+ */
+ protected $providers;
+
+ /**
+ * The OEmbed resource fetcher service.
+ *
+ * @var \Drupal\media\OEmbed\ResourceFetcherInterface
+ */
+ protected $resourceFetcher;
+
+ /**
+ * The module handler service.
+ *
+ * @var \Drupal\Core\Extension\ModuleHandlerInterface
+ */
+ protected $moduleHandler;
+
+ /**
+ * The request context service.
+ *
+ * @var \Drupal\Core\Routing\RequestContext
+ */
+ protected $requestContext;
+
+ /**
+ * Static cache of discovered oEmbed resource URLs, keyed by canonical URL.
+ *
+ * A discovered resource URL is the actual endpoint URL for a specific media
+ * object, fetched from its canonical URL.
+ *
+ * @var string[]
+ */
+ protected $urlCache = [];
+
+ /**
+ * Constructs a UrlResolver object.
+ *
+ * @param \Drupal\media\OEmbed\ProviderRepositoryInterface $providers
+ * The oEmbed provider repository service.
+ * @param \Drupal\media\OEmbed\ResourceFetcherInterface $resource_fetcher
+ * The OEmbed resource fetcher service.
+ * @param \GuzzleHttp\ClientInterface $http_client
+ * The HTTP client.
+ * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+ * The module handler service.
+ * @param \Drupal\Core\Routing\RequestContext $request_context
+ * The request context service.
+ * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+ * (optional) The cache backend.
+ */
+ public function __construct(ProviderRepositoryInterface $providers, ResourceFetcherInterface $resource_fetcher, ClientInterface $http_client, ModuleHandlerInterface $module_handler, RequestContext $request_context, CacheBackendInterface $cache_backend = NULL) {
+ $this->providers = $providers;
+ $this->resourceFetcher = $resource_fetcher;
+ $this->httpClient = $http_client;
+ $this->moduleHandler = $module_handler;
+ $this->requestContext = $request_context;
+ $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\RawResourceException
+ * If the resource cannot be retrieved.
+ */
+ protected function discoverResourceUrl($url) {
+ try {
+ $response = $this->httpClient->get($url);
+ }
+ catch (RequestException $e) {
+ throw new RawResourceException('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 RawResourceException('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;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ 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/OEmbed/UrlResolverInterface.php b/core/modules/media/src/OEmbed/UrlResolverInterface.php
new file mode 100644
index 0000000..ad038f0
--- /dev/null
+++ b/core/modules/media/src/OEmbed/UrlResolverInterface.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Drupal\media\OEmbed;
+
+/**
+ * Defines the interface for the oEmbed URL resolver service.
+ *
+ * The URL resolver is responsible for converting oEmbed-compatible media asset
+ * URLs into canonical resource URLs, at which an oEmbed representation of the
+ * asset can be retrieved.
+ */
+interface UrlResolverInterface {
+
+ /**
+ * Tries to determine the oEmbed provider for a media asset URL.
+ *
+ * @param string $url
+ * The media asset URL.
+ *
+ * @return \Drupal\media\OEmbed\Provider
+ * The oEmbed provider for the asset.
+ *
+ * @throws \Drupal\media\OEmbed\ResourceException
+ * If the provider cannot be determined.
+ */
+ public function getProviderByUrl($url);
+
+ /**
+ * Builds the resource URL for a media asset URL.
+ *
+ * @param string $url
+ * The media asset URL.
+ * @param int $max_width
+ * (optional) Maximum width of the oEmbed resource, in pixels.
+ * @param int $max_height
+ * (optional) Maximum height of the oEmbed resource, in pixels.
+ *
+ * @return string
+ * Returns the resource URL corresponding to the given media item URL.
+ */
+ public function getResourceUrl($url, $max_width = NULL, $max_height = NULL);
+
+ /**
+ * 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);
+
+}
diff --git a/core/modules/media/src/Plugin/Field/FieldFormatter/OEmbedFormatter.php b/core/modules/media/src/Plugin/Field/FieldFormatter/OEmbedFormatter.php
new file mode 100644
index 0000000..c1ec315
--- /dev/null
+++ b/core/modules/media/src/Plugin/Field/FieldFormatter/OEmbedFormatter.php
@@ -0,0 +1,282 @@
+<?php
+
+namespace Drupal\media\Plugin\Field\FieldFormatter;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Field\FormatterBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Logger\LoggerChannelFactoryInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Url;
+use Drupal\media\Entity\MediaType;
+use Drupal\media\OEmbed\Resource;
+use Drupal\media\OEmbed\ResourceException;
+use Drupal\media\OEmbed\ResourceFetcherInterface;
+use Drupal\media\OEmbed\UrlResolverInterface;
+use Drupal\media\Plugin\media\Source\OEmbedInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Plugin implementation of the 'oembed' formatter.
+ *
+ * @internal
+ * This is an internal part of the oEmbed system and should only be used by
+ * oEmbed-related code in Drupal core.
+ *
+ * @FieldFormatter(
+ * id = "oembed",
+ * label = @Translation("oEmbed content"),
+ * field_types = {
+ * "link",
+ * "string",
+ * "string_long",
+ * },
+ * )
+ */
+class OEmbedFormatter extends FormatterBase implements ContainerFactoryPluginInterface {
+
+ /**
+ * The messenger service.
+ *
+ * @var \Drupal\Core\Messenger\MessengerInterface
+ */
+ protected $messenger;
+
+ /**
+ * The oEmbed resource fetcher.
+ *
+ * @var \Drupal\media\OEmbed\ResourceFetcherInterface
+ */
+ protected $resourceFetcher;
+
+ /**
+ * The oEmbed URL resolver service.
+ *
+ * @var \Drupal\media\OEmbed\UrlResolverInterface
+ */
+ protected $urlResolver;
+
+ /**
+ * The logger service.
+ *
+ * @var \Drupal\Core\Logger\LoggerChannelInterface
+ */
+ protected $logger;
+
+ /**
+ * The media settings config.
+ *
+ * @var \Drupal\Core\Config\ImmutableConfig
+ */
+ protected $config;
+
+ /**
+ * Constructs an OEmbedFormatter instance.
+ *
+ * @param string $plugin_id
+ * The plugin ID for the formatter.
+ * @param mixed $plugin_definition
+ * The plugin implementation definition.
+ * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+ * The definition of the field to which the formatter is associated.
+ * @param array $settings
+ * The formatter settings.
+ * @param string $label
+ * The formatter label display setting.
+ * @param string $view_mode
+ * The view mode.
+ * @param array $third_party_settings
+ * Any third party settings.
+ * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+ * The messenger service.
+ * @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\Core\Logger\LoggerChannelFactoryInterface $logger_factory
+ * The logger factory service.
+ * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+ * The config factory service.
+ */
+ public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, $label, $view_mode, array $third_party_settings, MessengerInterface $messenger, ResourceFetcherInterface $resource_fetcher, UrlResolverInterface $url_resolver, LoggerChannelFactoryInterface $logger_factory, ConfigFactoryInterface $config_factory) {
+ parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $label, $view_mode, $third_party_settings);
+ $this->messenger = $messenger;
+ $this->resourceFetcher = $resource_fetcher;
+ $this->urlResolver = $url_resolver;
+ $this->logger = $logger_factory->get('media');
+ $this->config = $config_factory->get('media.settings');
+ }
+
+ /**
+ * {@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')
+ );
+ }
+
+ /**
+ * {@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', [
+ 'url' => $value,
+ 'max_width' => $max_width,
+ 'max_height' => $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) {
+ $applicable = parent::isApplicable($field_definition);
+
+ if ($field_definition->getTargetEntityTypeId() !== 'media') {
+ return FALSE;
+ }
+ $media_type = MediaType::load($field_definition->getTargetBundle());
+ return $applicable && $media_type && $media_type->getSource() instanceof OEmbedInterface;
+ }
+
+}
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 0000000..4af0ae0
--- /dev/null
+++ b/core/modules/media/src/Plugin/Field/FieldWidget/OEmbedWidget.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\media\Plugin\Field\FieldWidget;
+
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Field\Plugin\Field\FieldWidget\StringTextfieldWidget;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\media\Entity\MediaType;
+use Drupal\media\Plugin\media\Source\OEmbedInterface;
+
+/**
+ * Plugin implementation of the 'oembed_textfield' widget.
+ *
+ * @internal
+ * This is an internal part of the oEmbed system and should only be used by
+ * oEmbed-related code in Drupal core.
+ *
+ * @FieldWidget(
+ * id = "oembed_textfield",
+ * label = @Translation("oEmbed URL"),
+ * field_types = {
+ * "string",
+ * },
+ * )
+ */
+class OEmbedWidget extends StringTextfieldWidget {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
+ $element = parent::formElement($items, $delta, $element, $form, $form_state);
+
+ /** @var \Drupal\media\Plugin\media\Source\OEmbedInterface $source */
+ $source = $items->getEntity()->getSource();
+ $message = $this->t('You can link to media from the following services: @providers', ['@providers' => implode(', ', $source->getAllowedProviderNames())]);
+
+ 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 0000000..306353c
--- /dev/null
+++ b/core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraint.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\media\Plugin\Validation\Constraint;
+
+use Symfony\Component\Validator\Constraint;
+
+/**
+ * Checks if a value represents a valid oEmbed resource URL.
+ *
+ * @internal
+ * This is an internal part of the oEmbed system and should only be used by
+ * oEmbed-related code in Drupal core.
+ *
+ * @Constraint(
+ * id = "oembed_resource",
+ * label = @Translation("oEmbed resource", context = "Validation"),
+ * type = {"link", "string", "string_long"}
+ * )
+ */
+class OEmbedResourceConstraint extends Constraint {
+
+ /**
+ * The error message if the URL does not match any known provider.
+ *
+ * @var string
+ */
+ public $unknownProviderMessage = 'The given URL does not match any known oEmbed providers.';
+
+ /**
+ * The error message if the URL matches a disallowed provider.
+ *
+ * @var string
+ */
+ public $disallowedProviderMessage = 'Sorry, the @name provider is not allowed.';
+
+ /**
+ * The error message if the URL is not a valid oEmbed resource.
+ *
+ * @var string
+ */
+ public $invalidResourceMessage = 'The provided URL does not represent a valid oEmbed resource.';
+
+ /**
+ * The error message if an unexpected behavior occurs.
+ *
+ * @var string
+ */
+ public $providerErrorMessage = 'An error occurred while trying to retrieve the oEmbed provider database.';
+
+}
diff --git a/core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraintValidator.php b/core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraintValidator.php
new file mode 100644
index 0000000..e484bf0
--- /dev/null
+++ b/core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraintValidator.php
@@ -0,0 +1,138 @@
+<?php
+
+namespace Drupal\media\Plugin\Validation\Constraint;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Logger\LoggerChannelFactoryInterface;
+use Drupal\media\OEmbed\ProviderException;
+use Drupal\media\OEmbed\ResourceException;
+use Drupal\media\OEmbed\ResourceFetcherInterface;
+use Drupal\media\OEmbed\UrlResolverInterface;
+use Drupal\media\Plugin\media\Source\OEmbedInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+
+/**
+ * Validates oEmbed resource URLs.
+ *
+ * @internal
+ * This is an internal part of the oEmbed system and should only be used by
+ * oEmbed-related code in Drupal core.
+ */
+class OEmbedResourceConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
+
+ /**
+ * The oEmbed URL resolver service.
+ *
+ * @var \Drupal\media\OEmbed\UrlResolverInterface
+ */
+ protected $urlResolver;
+
+ /**
+ * The resource fetcher service.
+ *
+ * @var \Drupal\media\OEmbed\ResourceFetcherInterface
+ */
+ protected $resourceFetcher;
+
+ /**
+ * The logger service.
+ *
+ * @var \Drupal\Core\Logger\LoggerChannelInterface
+ */
+ protected $logger;
+
+ /**
+ * Constructs a new OEmbedResourceConstraintValidator.
+ *
+ * @param \Drupal\media\OEmbed\UrlResolverInterface $url_resolver
+ * The oEmbed URL resolver service.
+ * @param \Drupal\media\OEmbed\ResourceFetcherInterface $resource_fetcher
+ * The resource fetcher service.
+ * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
+ * The logger service.
+ */
+ public function __construct(UrlResolverInterface $url_resolver, ResourceFetcherInterface $resource_fetcher, LoggerChannelFactoryInterface $logger_factory) {
+ $this->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->getAllowedProviderNames(), 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);
+ }
+ // @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());
+ }
+
+}
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 0000000..af2e7e0
--- /dev/null
+++ b/core/modules/media/src/Plugin/media/Source/OEmbed.php
@@ -0,0 +1,452 @@
+<?php
+
+namespace Drupal\media\Plugin\media\Source;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Entity\Display\EntityFormDisplayInterface;
+use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\FieldTypePluginManagerInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Logger\LoggerChannelInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Url;
+use Drupal\media\OEmbed\ResourceException;
+use Drupal\media\MediaSourceBase;
+use Drupal\media\MediaInterface;
+use Drupal\media\MediaTypeInterface;
+use Drupal\media\OEmbed\ResourceFetcherInterface;
+use Drupal\media\OEmbed\UrlResolverInterface;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\RequestException;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a media source plugin for oEmbed resources.
+ *
+ * For security reasons, the oEmbed source (and, therefore, anything that
+ * extends it) obeys a hard-coded list of allowed third-party oEmbed providers
+ * set in its plugin definition's supported_providers array. This array is a set
+ * of supported provider names, exactly as they appear in the canonical oEmbed
+ * provider database at https://oembed.com/providers.json.
+ *
+ * You can implement support for additional providers by defining a new plugin
+ * that uses this class. This can be done in hook_media_source_info_alter().
+ * For example:
+ * @code
+ * <?php
+ *
+ * function example_media_source_info_alter(array &$sources) {
+ * $sources['artwork'] = [
+ * 'id' => 'artwork',
+ * 'label' => t('Artwork'),
+ * 'description' => t('Use artwork from Flickr and DeviantArt.'),
+ * 'allowed_field_types' => ['string'],
+ * 'default_thumbnail_filename' => 'no-thumbnail.png',
+ * 'supported_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",
+ * supported_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;
+
+ /**
+ * 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.
+ */
+ 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) {
+ 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;
+ }
+
+ /**
+ * {@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')
+ );
+ }
+
+ /**
+ * {@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'),
+ 'thumbnail_url' => $this->t('The remote URL of the thumbnail'),
+ 'thumbnail_local_uri' => $this->t('The local URI of the thumbnail'),
+ 'thumbnail_local' => $this->t('The local URL 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 'thumbnail_local':
+ $local_uri = $this->getMetadata($media, 'thumbnail_local_uri');
+
+ if ($local_uri) {
+ if (file_exists($local_uri)) {
+ return $local_uri;
+ }
+ else {
+ try {
+ $response = $this->httpClient->get($this->getMetadata($media, 'thumbnail_url'));
+ if ($response->getStatusCode() === 200) {
+ return file_unmanaged_save_data((string) $response->getBody(), $local_uri, FILE_EXISTS_REPLACE) ?: NULL;
+ }
+ }
+ catch (RequestException $e) {
+ $this->logger->warning($e->getMessage());
+ // Return NULL so the default image will be used.
+ }
+ }
+ }
+ return NULL;
+
+ case 'thumbnail_local_uri':
+ return $this->getLocalImageUri($media);
+
+ 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->getMetadata($media, 'thumbnail_local') ?: 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_url':
+ $thumbnail_url = $resource->getThumbnailUrl();
+ return $thumbnail_url ? $thumbnail_url->toString() : NULL;
+
+ 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->urlResolver->isSecure($domain)) {
+ array_unshift($form, [
+ '#markup' => '<p>' . $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. <a href=":url" target="_blank">You can specify a different domain for serving oEmbed content here</a> (opens in a new window).', [
+ ':url' => Url::fromRoute('media.settings')->setAbsolute()->toString(),
+ ]) . '</p>',
+ ]);
+ }
+
+ $form['thumbnails_uri'] = [
+ '#type' => 'textfield',
+ '#title' => $this->t('Thumbnails location'),
+ '#default_value' => $this->configuration['thumbnails_uri'],
+ '#description' => $this->t('Thumbnails will be fetched from the provider for local usage. This is the location where they will be placed.'),
+ '#required' => TRUE,
+ ];
+
+ $configuration = $this->getConfiguration();
+ $plugin_definition = $this->getPluginDefinition();
+
+ $form['allowed_providers'] = [
+ '#type' => 'checkboxes',
+ '#title' => $this->t('Allowed providers'),
+ '#default_value' => $configuration['allowed_providers'],
+ '#options' => array_combine($plugin_definition['supported_providers'], $plugin_definition['supported_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['allowed_providers'] = array_filter(array_values($configuration['allowed_providers']));
+ $this->setConfiguration($configuration);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+ $thumbnails_uri = $form_state->getValue('thumbnails_uri');
+ if (!file_valid_uri($thumbnails_uri)) {
+ $form_state->setErrorByName('thumbnails_uri', $this->t('@path is not a valid path.', ['@path' => $thumbnails_uri]));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function defaultConfiguration() {
+ return [
+ 'thumbnails_uri' => 'public://oembed_thumbnails',
+ 'allowed_providers' => [],
+ ] + parent::defaultConfiguration();
+ }
+
+ /**
+ * Computes the destination URI for a thumbnail.
+ *
+ * @param \Drupal\media\MediaInterface $media
+ * A media item.
+ *
+ * @return string
+ * The local thumbnail URI.
+ *
+ * @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 getLocalImageUri(MediaInterface $media) {
+ $remote_url = $this->getMetadata($media, 'thumbnail_url');
+ if (!$remote_url) {
+ return parent::getMetadata($media, 'thumbnail_uri');
+ }
+
+ $configuration = $this->getConfiguration();
+ $directory = $configuration['thumbnails_uri'];
+ // Ensure that the destination directory is writable. If not, log a warning
+ // and return the default thumbnail.
+ 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 parent::getMetadata($media, 'thumbnail_uri');
+ }
+
+ return $directory . '/' . $media->uuid() . '.' . pathinfo($remote_url, PATHINFO_EXTENSION);
+ }
+
+ /**
+ * {@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 getAllowedProviderNames() {
+ $configuration = $this->getConfiguration();
+ return $configuration['allowed_providers'] ?: $this->getSupportedProviderNames();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getSupportedProviderNames() {
+ return $this->getPluginDefinition()['supported_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 0000000..e346210
--- /dev/null
+++ b/core/modules/media/src/Plugin/media/Source/OEmbedDeriver.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Drupal\media\Plugin\media\Source;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+
+/**
+ * Derives media source plugin definitions for supported oEmbed providers.
+ *
+ * @internal
+ * This is an internal part of the oEmbed system and should only be used by
+ * oEmbed-related code in Drupal core.
+ */
+class OEmbedDeriver extends DeriverBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDerivativeDefinitions($base_plugin_definition) {
+ $this->derivatives = [
+ 'video' => [
+ 'id' => 'video',
+ 'label' => t('Remote video'),
+ 'description' => t('Use remote video URL for reusable media.'),
+ 'supported_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 0000000..86a950b
--- /dev/null
+++ b/core/modules/media/src/Plugin/media/Source/OEmbedInterface.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\media\Plugin\media\Source;
+
+use Drupal\media\MediaSourceFieldConstraintsInterface;
+
+/**
+ * Defines additional functionality for source plugins that use oEmbed.
+ */
+interface OEmbedInterface extends MediaSourceFieldConstraintsInterface {
+
+ /**
+ * Returns the allowed oEmbed provider names.
+ *
+ * The allowed providers will always be a subset of the supported providers.
+ *
+ * @return string[]
+ * A list of oEmbed provider names.
+ */
+ public function getAllowedProviderNames();
+
+ /**
+ * Returns the supported oEmbed provider names.
+ *
+ * @return string[]
+ * A list of oEmbed provider names.
+ */
+ public function getSupportedProviderNames();
+
+}
diff --git a/core/modules/media/templates/media-oembed.html.twig b/core/modules/media/templates/media-oembed.html.twig
new file mode 100644
index 0000000..17b7a6f
--- /dev/null
+++ b/core/modules/media/templates/media-oembed.html.twig
@@ -0,0 +1,14 @@
+{#
+/**
+ * @file
+ * Default theme implementation to display an oEmbed resource.
+ *
+ * @ingroup themeable
+ */
+#}
+<!DOCTYPE html>
+<html>
+ <body style="margin: 0">
+ {{ post|raw }}
+ </body>
+</html>
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 0000000..6ad06d8
--- /dev/null
+++ b/core/modules/media/tests/fixtures/oembed/photo_flickr.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<html lang="de">
+<head>
+ <link rel="alternate" href="photo_flickr.json"
+ type="application/json+oembed" title="Druplicon FTW!">
+</head>
+<body></body>
+</html>
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 0000000..7cdc28e
--- /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 0000000..e618ec4
--- /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 0000000..f27b881
--- /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": "<h1>By the power of Greyskull, Twitter works!</h1>",
+ "width": 550,
+ "height": 360,
+ "type": "rich",
+ "cache_age": "3153600000",
+ "provider_name": "Twitter",
+ "provider_url": "https:\/\/twitter.com",
+ "version": "1.0"
+}
diff --git a/core/modules/media/tests/fixtures/oembed/video_collegehumor.html b/core/modules/media/tests/fixtures/oembed/video_collegehumor.html
new file mode 100644
index 0000000..fc2fdfb
--- /dev/null
+++ b/core/modules/media/tests/fixtures/oembed/video_collegehumor.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<html lang="de">
+<head>
+ <link rel="alternate" href="video_collegehumor.xml"
+ type="application/xml+oembed" title="Let&#039;s Not Get a Drink Sometime">
+</head>
+<body></body>
+</html>
diff --git a/core/modules/media/tests/fixtures/oembed/video_collegehumor.xml b/core/modules/media/tests/fixtures/oembed/video_collegehumor.xml
new file mode 100644
index 0000000..696b5bf
--- /dev/null
+++ b/core/modules/media/tests/fixtures/oembed/video_collegehumor.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8" standalone="yes"?>
+<oembed>
+ <type>video</type>
+ <version>1.0</version>
+ <title>Let's Not Get a Drink Sometime</title>
+ <https/>
+ <author_name>CollegeHumor</author_name>
+ <author_url>http://www.collegehumor.com</author_url>
+ <provider_name>CollegeHumor</provider_name>
+ <provider_url>http://www.collegehumor.com</provider_url>
+ <width>610</width>
+ <height>343</height>
+ <html><h1>By the power of Greyskull, CollegeHumor works!</h1>
+ </html>
+ <thumbnail_url>internal:/core/misc/druplicon.png</thumbnail_url>
+ <thumbnail_width>88</thumbnail_width>
+ <thumbnail_height>100</thumbnail_height>
+</oembed>
diff --git a/core/modules/media/tests/fixtures/oembed/video_vimeo.html b/core/modules/media/tests/fixtures/oembed/video_vimeo.html
new file mode 100644
index 0000000..f0958d0
--- /dev/null
+++ b/core/modules/media/tests/fixtures/oembed/video_vimeo.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<html lang="de">
+<head>
+ <link rel="alternate" href="video_vimeo.json"
+ type="application/json+oembed" title="Drupal Rap Video - Schipulcon09">
+</head>
+<body></body>
+</html>
diff --git a/core/modules/media/tests/fixtures/oembed/video_vimeo.json b/core/modules/media/tests/fixtures/oembed/video_vimeo.json
new file mode 100644
index 0000000..fac8a0d
--- /dev/null
+++ b/core/modules/media/tests/fixtures/oembed/video_vimeo.json
@@ -0,0 +1,16 @@
+{
+ "type": "video",
+ "version": "1.0",
+ "provider_name": "Vimeo",
+ "provider_url": "https:\/\/vimeo.com\/",
+ "title": "Drupal Rap Video - Schipulcon09",
+ "author_name": "Tendenci - The Open Source AMS",
+ "author_url": "https:\/\/vimeo.com\/schipul",
+ "html": "<h1>By the power of Greyskull, Vimeo works!</h1>",
+ "width": 480,
+ "height": 360,
+ "description": "Special thanks to Tendenci, formerly Schipul for sponsoring this video with training, equipment and time. The open source way. All creative however was self directed by the individuals - A. Hughes (www.schipul.com\/ahughes) featuring QCait (www.schipul.com\/qcait) - Hands On Drupal\n\nDrupal is a free software package that allows an individual or a community of users to easily publish, manage and organize a wide variety of content on a website.\n\nNeed a little Drupal help or just want to geek out with us? Visit our www.schipul.com\/drupal for more info - we'd love to connect!\n\nGo here for Drupal Common Terms and Suggested Modules : http:\/\/schipul.com\/en\/helpfiles\/v\/229",
+ "thumbnail_url": "internal:\/core\/misc\/druplicon.png",
+ "thumbnail_width": 295,
+ "thumbnail_height": 221
+}
diff --git a/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.info.yml b/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.info.yml
new file mode 100644
index 0000000..2ad5d2f
--- /dev/null
+++ b/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.info.yml
@@ -0,0 +1,8 @@
+name: Media oEmbed test
+description: 'Provides functionality to mimic an oEmbed provider.'
+type: module
+package: Testing
+version: VERSION
+core: 8.x
+dependencies:
+ - drupal:media
diff --git a/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module b/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module
new file mode 100644
index 0000000..c8f8ba1
--- /dev/null
+++ b/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * @file
+ * Helper module for the Media OEmbed tests.
+ */
+
+use Drupal\media\OEmbed\Provider;
+
+/**
+ * Implements hook_oembed_resource_url_alter().
+ */
+function media_test_oembed_oembed_resource_url_alter(array &$parsed_url, Provider $provider) {
+ if ($provider->getName() === 'Vimeo') {
+ $parsed_url['query']['altered'] = 1;
+ }
+}
diff --git a/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.routing.yml b/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.routing.yml
new file mode 100644
index 0000000..75ce685
--- /dev/null
+++ b/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.routing.yml
@@ -0,0 +1,6 @@
+media_test_oembed.resource.get:
+ path: '/media_test_oembed/resource'
+ defaults:
+ _controller: '\Drupal\media_test_oembed\Controller\ResourceController::get'
+ requirements:
+ _access: 'TRUE'
diff --git a/core/modules/media/tests/modules/media_test_oembed/src/Controller/ResourceController.php b/core/modules/media/tests/modules/media_test_oembed/src/Controller/ResourceController.php
new file mode 100644
index 0000000..ca401e7
--- /dev/null
+++ b/core/modules/media/tests/modules/media_test_oembed/src/Controller/ResourceController.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Drupal\media_test_oembed\Controller;
+
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Test controller returning oEmbed resources from Media's test fixtures.
+ */
+class ResourceController {
+
+ /**
+ * Returns the contents of an oEmbed resource fixture.
+ *
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ * The request.
+ *
+ * @return \Symfony\Component\HttpFoundation\Response
+ * The JSON response.
+ */
+ public function get(Request $request) {
+ $asset_url = $request->query->get('url');
+
+ $resources = \Drupal::state()->get(static::class, []);
+
+ $content = file_get_contents($resources[$asset_url]);
+ $response = new Response($content);
+ $response->headers->set('Content-Type', 'application/json');
+
+ return $response;
+ }
+
+ /**
+ * Maps an asset URL to a local fixture representing its oEmbed resource.
+ *
+ * @param string $asset_url
+ * The asset URL.
+ * @param string $resource_path
+ * The path of the oEmbed resource representing the asset.
+ */
+ public static function setResourceUrl($asset_url, $resource_path) {
+ $resources = \Drupal::state()->get(static::class, []);
+ $resources[$asset_url] = $resource_path;
+ \Drupal::state()->set(static::class, $resources);
+ }
+
+}
diff --git a/core/modules/media/tests/modules/media_test_oembed/src/MediaTestOembedServiceProvider.php b/core/modules/media/tests/modules/media_test_oembed/src/MediaTestOembedServiceProvider.php
new file mode 100644
index 0000000..0ba3e0a
--- /dev/null
+++ b/core/modules/media/tests/modules/media_test_oembed/src/MediaTestOembedServiceProvider.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Drupal\media_test_oembed;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceProviderBase;
+
+/**
+ * Replaces oEmbed-related media services with testing versions.
+ */
+class MediaTestOembedServiceProvider extends ServiceProviderBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function alter(ContainerBuilder $container) {
+ parent::alter($container);
+
+ $container->getDefinition('media.oembed.provider_repository')
+ ->setClass(ProviderRepository::class);
+
+ $container->getDefinition('media.oembed.url_resolver')
+ ->setClass(UrlResolver::class);
+ }
+
+}
diff --git a/core/modules/media/tests/modules/media_test_oembed/src/ProviderRepository.php b/core/modules/media/tests/modules/media_test_oembed/src/ProviderRepository.php
new file mode 100644
index 0000000..dc4cb8c
--- /dev/null
+++ b/core/modules/media/tests/modules/media_test_oembed/src/ProviderRepository.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Drupal\media_test_oembed;
+
+use Drupal\media\OEmbed\Provider;
+use Drupal\media\OEmbed\ProviderRepository as BaseProviderRepository;
+
+/**
+ * Overrides the oEmbed provider repository service for testing purposes.
+ *
+ * This service does not use caching at all, and will always try to retrieve
+ * provider data from state before calling the parent methods.
+ */
+class ProviderRepository extends BaseProviderRepository {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function cacheGet($cid) {
+ return FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAll() {
+ return \Drupal::state()->get(static::class) ?: parent::getAll();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function get($provider_name) {
+ $providers = \Drupal::state()->get(static::class, []);
+
+ if (isset($providers[$provider_name])) {
+ return $providers[$provider_name];
+ }
+ return parent::get($provider_name);
+ }
+
+ /**
+ * Stores an oEmbed provider value object in state.
+ *
+ * @param \Drupal\media\OEmbed\Provider $provider
+ * The provider to store.
+ */
+ public function setProvider(Provider $provider) {
+ $providers = \Drupal::state()->get(static::class, []);
+ $name = $provider->getName();
+ $providers[$name] = $provider;
+ \Drupal::state()->set(static::class, $providers);
+ }
+
+}
diff --git a/core/modules/media/tests/modules/media_test_oembed/src/UrlResolver.php b/core/modules/media/tests/modules/media_test_oembed/src/UrlResolver.php
new file mode 100644
index 0000000..acfbf8c
--- /dev/null
+++ b/core/modules/media/tests/modules/media_test_oembed/src/UrlResolver.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Drupal\media_test_oembed;
+
+use Drupal\media\OEmbed\UrlResolver as BaseUrlResolver;
+
+/**
+ * Overrides the oEmbed URL resolver service for testing purposes.
+ */
+class UrlResolver extends BaseUrlResolver {
+
+ /**
+ * Sets the endpoint URL for an oEmbed resource URL.
+ *
+ * @param string $url
+ * The resource URL.
+ * @param string $endpoint_url
+ * The endpoint URL.
+ */
+ public static function setEndpointUrl($url, $endpoint_url) {
+ $urls = \Drupal::state()->get(static::class, []);
+ $urls[$url] = $endpoint_url;
+ \Drupal::state()->set(static::class, $urls);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getResourceUrl($url, $max_width = NULL, $max_height = NULL) {
+ $urls = \Drupal::state()->get(static::class, []);
+
+ if (isset($urls[$url])) {
+ return $urls[$url];
+ }
+ return parent::getResourceUrl($url, $max_width, $max_height);
+ }
+
+}
diff --git a/core/modules/media/tests/src/Functional/FieldFormatter/OEmbedFormatterTest.php b/core/modules/media/tests/src/Functional/FieldFormatter/OEmbedFormatterTest.php
new file mode 100644
index 0000000..2626571
--- /dev/null
+++ b/core/modules/media/tests/src/Functional/FieldFormatter/OEmbedFormatterTest.php
@@ -0,0 +1,148 @@
+<?php
+
+namespace Drupal\Tests\media\Functional\FieldFormatter;
+
+use Drupal\media\Entity\Media;
+use Drupal\media_test_oembed\Controller\ResourceController;
+use Drupal\media_test_oembed\UrlResolver;
+use Drupal\Tests\media\Functional\MediaFunctionalTestBase;
+use Drupal\Tests\media\Traits\OEmbedTestTrait;
+
+/**
+ * @covers \Drupal\media\Plugin\Field\FieldFormatter\OEmbedFormatter
+ *
+ * @group media
+ */
+class OEmbedFormatterTest extends MediaFunctionalTestBase {
+
+ use OEmbedTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = [
+ 'link',
+ 'media_test_oembed',
+ ];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+ $this->drupalLogin($this->drupalCreateUser(['view media']));
+ $this->lockHttpClientToFixtures();
+ }
+
+ /**
+ * Data provider for testRender().
+ *
+ * @see ::testRender()
+ *
+ * @return array
+ */
+ public function providerRender() {
+ return [
+ 'Vimeo video' => [
+ 'https://vimeo.com/7073899',
+ 'video_vimeo.json',
+ [],
+ [
+ 'iframe' => [
+ 'src' => '/media/oembed?url=https%3A//vimeo.com/7073899',
+ 'width' => 480,
+ 'height' => 360,
+ ],
+ ],
+ ],
+ 'Vimeo video, resized' => [
+ 'https://vimeo.com/7073899',
+ 'video_vimeo.json?maxwidth=100&maxheight=100',
+ ['max_width' => 100, 'max_height' => 100],
+ [
+ 'iframe' => [
+ 'src' => '/media/oembed?url=https%3A//vimeo.com/7073899',
+ 'width' => 100,
+ 'height' => 100,
+ ],
+ ],
+ ],
+ 'tweet' => [
+ 'https://twitter.com/drupaldevdays/status/935643039741202432',
+ 'rich_twitter.json',
+ [],
+ [
+ 'iframe' => [
+ 'src' => '/media/oembed?url=https%3A//twitter.com/drupaldevdays/status/935643039741202432',
+ 'width' => 550,
+ 'height' => 360,
+ ],
+ ],
+ ],
+ 'Flickr photo' => [
+ 'https://www.flickr.com/photos/amazeelabs/26497866357',
+ 'photo_flickr.json',
+ [],
+ [
+ 'img' => [
+ 'src' => '/core/misc/druplicon.png',
+ 'width' => 88,
+ 'height' => 100,
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Tests the oEmbed field formatter.
+ *
+ * @param string $url
+ * The canonical URL of the media asset to test.
+ * @param string $resource_url
+ * The oEmebd resource URL of the media asset to test.
+ * @param mixed $formatter_settings
+ * Settings for the oEmbed field formatter.
+ * @param array $selectors
+ * An array of arrays. Each key is a CSS selector targeting an element in
+ * the rendered output, and each value is an array of attributes, keyed by
+ * name, that the element is expected to have.
+ *
+ * @dataProvider providerRender
+ */
+ public function testRender($url, $resource_url, array $formatter_settings, array $selectors) {
+ $media_type = $this->createMediaType([], 'oembed:video');
+
+ $source = $media_type->getSource();
+ $source_field = $source->getSourceFieldDefinition($media_type);
+
+ entity_get_display('media', $media_type->id(), 'full')
+ ->removeComponent('thumbnail')
+ ->setComponent($source_field->getName(), [
+ 'type' => 'oembed',
+ 'settings' => $formatter_settings,
+ ])
+ ->save();
+
+ $this->hijackProviderEndpoints();
+
+ ResourceController::setResourceUrl($url, $this->getFixturesDirectory() . '/' . $resource_url);
+ UrlResolver::setEndpointUrl($url, $resource_url);
+
+ $entity = Media::create([
+ 'bundle' => $media_type->id(),
+ $source_field->getName() => $url,
+ ]);
+ $entity->save();
+
+ $this->drupalGet($entity->toUrl());
+ $assert = $this->assertSession();
+ $assert->statusCodeEquals(200);
+ foreach ($selectors as $selector => $attributes) {
+ foreach ($attributes as $attribute => $value) {
+ $assert->elementAttributeContains('css', $selector, $attribute, $value);
+ }
+ }
+ }
+
+}
diff --git a/core/modules/media/tests/src/Functional/MediaSettingsTest.php b/core/modules/media/tests/src/Functional/MediaSettingsTest.php
new file mode 100644
index 0000000..850d0e2
--- /dev/null
+++ b/core/modules/media/tests/src/Functional/MediaSettingsTest.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Drupal\Tests\media\Functional;
+
+/**
+ * Testing the media settings.
+ *
+ * @group media
+ */
+class MediaSettingsTest extends MediaFunctionalTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+ $this->drupalLogin($this->createUser(['administer site configuration']));
+ }
+
+ /**
+ * Test that media warning appears if oEmbed media types exists.
+ */
+ public function testStatusPage() {
+ $assert_session = $this->assertSession();
+
+ $this->drupalGet('admin/reports/status');
+ $assert_session->pageTextNotContains('It is potentially insecure to display oEmbed content in a frame');
+
+ $this->createMediaType([], 'oembed:video');
+
+ $this->drupalGet('admin/reports/status');
+ $assert_session->pageTextContains('It is potentially insecure to display oEmbed content in a frame');
+ }
+
+}
diff --git a/core/modules/media/tests/src/Functional/ProviderRepositoryTest.php b/core/modules/media/tests/src/Functional/ProviderRepositoryTest.php
new file mode 100644
index 0000000..8e010e1
--- /dev/null
+++ b/core/modules/media/tests/src/Functional/ProviderRepositoryTest.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Drupal\Tests\media\Functional;
+
+use Drupal\media\OEmbed\ProviderException;
+
+/**
+ * Tests the oEmbed provider repository.
+ *
+ * @covers \Drupal\media\OEmbed\ProviderRepository
+ *
+ * @group media
+ */
+class ProviderRepositoryTest extends MediaFunctionalTestBase {
+
+ /**
+ * Tests that provider discovery fails if the provider database is empty.
+ *
+ * @param string $content
+ * The expected JSON content of the provider database.
+ *
+ * @dataProvider providerEmptyProviderList
+ */
+ public function testEmptyProviderList($content) {
+ $response = $this->prophesize('\GuzzleHttp\Psr7\Response');
+ $response->getBody()->willReturn($content);
+
+ $client = $this->createMock('\GuzzleHttp\Client');
+ $client->method('request')->withAnyParameters()->willReturn($response->reveal());
+ $this->container->set('http_client', $client);
+
+ $this->setExpectedException(ProviderException::class, 'Remote oEmbed providers database returned invalid or empty list.');
+ $this->container->get('media.oembed.provider_repository')->getAll();
+ }
+
+ /**
+ * Data provider for testEmptyProviderList().
+ *
+ * @see ::testEmptyProviderList()
+ *
+ * @return array
+ */
+ public function providerEmptyProviderList() {
+ return [
+ 'empty array' => ['[]'],
+ 'empty string' => [''],
+ ];
+ }
+
+ /**
+ * Tests that provider discovery fails with a non-existent provider database.
+ *
+ * @param string $providers_url
+ * The URL of the provider database.
+ * @param string $exception_message
+ * The expected exception message.
+ *
+ * @dataProvider providerNonExistingProviderDatabase
+ */
+ public function testNonExistingProviderDatabase($providers_url, $exception_message) {
+ $this->config('media.settings')
+ ->set('oembed_providers', $providers_url)
+ ->save();
+
+ $this->setExpectedException(ProviderException::class, $exception_message);
+ $this->container->get('media.oembed.provider_repository')->getAll();
+ }
+
+ /**
+ * Data provider for testEmptyProviderList().
+ *
+ * @see ::testEmptyProviderList()
+ *
+ * @return array
+ */
+ public function providerNonExistingProviderDatabase() {
+ return [
+ [
+ 'http://oembed1.com/providers.json',
+ 'Could not retrieve the oEmbed provider database from http://oembed1.com/providers.json',
+ ],
+ [
+ 'http://oembed.com/providers1.json',
+ 'Could not retrieve the oEmbed provider database from http://oembed.com/providers1.json',
+ ],
+ ];
+ }
+
+}
diff --git a/core/modules/media/tests/src/Functional/ResourceFetcherTest.php b/core/modules/media/tests/src/Functional/ResourceFetcherTest.php
new file mode 100644
index 0000000..e10ef2e
--- /dev/null
+++ b/core/modules/media/tests/src/Functional/ResourceFetcherTest.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Drupal\Tests\media\Functional;
+
+use Drupal\media\OEmbed\Resource;
+use Drupal\Tests\media\Traits\OEmbedTestTrait;
+
+/**
+ * Tests the oEmbed resource fetcher service.
+ *
+ * @coversDefaultClass \Drupal\media\OEmbed\ResourceFetcher
+ *
+ * @group media
+ */
+class ResourceFetcherTest extends MediaFunctionalTestBase {
+
+ use OEmbedTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+ $this->useFixtureProviders();
+ $this->lockHttpClientToFixtures();
+ }
+
+ /**
+ * Data provider for testFetchResource().
+ *
+ * @return array
+ */
+ public function providerFetchResource() {
+ return [
+ 'JSON resource' => [
+ 'video_vimeo.json',
+ 'Vimeo',
+ 'Drupal Rap Video - Schipulcon09',
+ ],
+ 'XML resource' => [
+ 'video_collegehumor.xml',
+ 'CollegeHumor',
+ "Let's Not Get a Drink Sometime",
+ ],
+ ];
+ }
+
+ /**
+ * Tests resource fetching.
+ *
+ * @param string $resource_url
+ * The URL of the resource to fetch, relative to the base URL.
+ * @param string $provider_name
+ * The expected name of the resource provider.
+ * @param string $title
+ * The expected title of the resource.
+ *
+ * @covers ::fetchResource
+ *
+ * @dataProvider providerFetchResource
+ */
+ public function testFetchResource($resource_url, $provider_name, $title) {
+ /** @var \Drupal\media\OEmbed\Resource $resource */
+ $resource = $this->container->get('media.oembed.resource_fetcher')
+ ->fetchResource($resource_url);
+
+ $this->assertInstanceOf(Resource::class, $resource);
+ $this->assertSame($provider_name, $resource->getProvider()->getName());
+ $this->assertSame($title, $resource->getTitle());
+ }
+
+}
diff --git a/core/modules/media/tests/src/Functional/Update/MediaUpdateTest.php b/core/modules/media/tests/src/Functional/Update/MediaUpdateTest.php
index 2dbe28b..295dce0 100644
--- a/core/modules/media/tests/src/Functional/Update/MediaUpdateTest.php
+++ b/core/modules/media/tests/src/Functional/Update/MediaUpdateTest.php
@@ -53,4 +53,23 @@ class MediaUpdateTest extends UpdatePathTestBase {
}
}
+ /**
+ * Tests that the oembed_providers key is added to the media.settings config.
+ *
+ * @see media_update_8600()
+ */
+ public function testOEmbedProvidersConfig() {
+ // The drupal-8.media-enabled.php fixture installs Media and all its config,
+ // which includes the oembed_providers key in media.settings. So, in order
+ // to prove that the update actually works, delete the value from config
+ // before running the update.
+ $this->config('media.settings')->clear('oembed_providers')->save(TRUE);
+
+ $this->runUpdates();
+ $this->assertSame(
+ 'https://oembed.com/providers.json',
+ $this->config('media.settings')->get('oembed_providers')
+ );
+ }
+
}
diff --git a/core/modules/media/tests/src/Functional/UrlResolverTest.php b/core/modules/media/tests/src/Functional/UrlResolverTest.php
new file mode 100644
index 0000000..1dfe5d6
--- /dev/null
+++ b/core/modules/media/tests/src/Functional/UrlResolverTest.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Drupal\Tests\media\Functional;
+
+use Drupal\Tests\media\Traits\OEmbedTestTrait;
+
+/**
+ * Tests the oEmbed URL resolver service.
+ *
+ * @coversDefaultClass \Drupal\media\OEmbed\UrlResolver
+ *
+ * @group media
+ */
+class UrlResolverTest extends MediaFunctionalTestBase {
+
+ use OEmbedTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+ $this->lockHttpClientToFixtures();
+ $this->useFixtureProviders();
+ }
+
+ /**
+ * Data provider for testEndpointMatching().
+ *
+ * @see ::testEndpointMatching()
+ *
+ * @return array
+ */
+ public function providerEndpointMatching() {
+ return [
+ 'match by endpoint: Twitter' => [
+ 'https://twitter.com/Dries/status/999985431595880448',
+ 'https://publish.twitter.com/oembed?url=https%3A//twitter.com/Dries/status/999985431595880448',
+ ],
+ 'match by endpoint: Vimeo' => [
+ 'https://vimeo.com/14782834',
+ 'https://vimeo.com/api/oembed.json?url=https%3A//vimeo.com/14782834',
+ ],
+ 'match by endpoint: CollegeHumor' => [
+ 'http://www.collegehumor.com/video/40002870/lets-not-get-a-drink-sometime',
+ 'http://www.collegehumor.com/oembed.json?url=http%3A//www.collegehumor.com/video/40002870/lets-not-get-a-drink-sometime',
+ ],
+ ];
+ }
+
+ /**
+ * Tests resource URL resolution when the asset URL can be matched to a
+ * provider endpoint.
+ *
+ * @covers ::getProviderByUrl
+ * @covers ::getResourceUrl
+ *
+ * @param string $url
+ * The asset URL to resolve.
+ * @param string $resource_url
+ * The expected oEmbed resource URL of the asset.
+ *
+ * @dataProvider providerEndpointMatching
+ */
+ public function testEndpointMatching($url, $resource_url) {
+ $this->assertSame(
+ $resource_url,
+ $this->container->get('media.oembed.url_resolver')->getResourceUrl($url)
+ );
+ }
+
+ /**
+ * Tests that hook_oembed_resource_url_alter() is invoked.
+ *
+ * @depends testEndpointMatching
+ */
+ public function testResourceUrlAlterHook() {
+ $this->container->get('module_installer')->install(['media_test_oembed']);
+
+ $resource_url = $this->container->get('media.oembed.url_resolver')
+ ->getResourceUrl('https://vimeo.com/14782834');
+
+ $this->assertContains('altered=1', parse_url($resource_url, PHP_URL_QUERY));
+ }
+
+ /**
+ * Data provider for testUrlDiscovery().
+ *
+ * @see ::testUrlDiscovery()
+ *
+ * @return array
+ */
+ public function providerUrlDiscovery() {
+ return [
+ 'JSON resource' => [
+ 'video_vimeo.html',
+ 'https://vimeo.com/api/oembed.json?url=video_vimeo.html',
+ ],
+ 'XML resource' => [
+ 'video_collegehumor.html',
+ // The endpoint does not explicitly declare that it supports XML, so
+ // only JSON support is assumed, which is why the discovered URL
+ // contains '.json'. However, the fetched HTML file contains a
+ // relationship to an XML representation of the resource, with the
+ // application/xml+oembed MIME type.
+ 'http://www.collegehumor.com/oembed.json?url=video_collegehumor.html',
+ ],
+ ];
+ }
+
+ /**
+ * Tests URL resolution when the resource URL must be actively discovered by
+ * scanning the asset.
+ *
+ * @param string $url
+ * The asset URL to resolve.
+ * @param string $resource_url
+ * The expected oEmbed resource URL of the asset.
+ *
+ * @covers ::discoverResourceUrl
+ * @covers ::getProviderByUrl
+ * @covers ::getResourceUrl
+ *
+ * @dataProvider providerUrlDiscovery
+ */
+ public function testUrlDiscovery($url, $resource_url) {
+ $this->assertSame(
+ $this->container->get('media.oembed.url_resolver')->getResourceUrl($url),
+ $resource_url
+ );
+ }
+
+}
diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaDisplayTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaDisplayTest.php
index fd508cf..d2802df 100644
--- a/core/modules/media/tests/src/FunctionalJavascript/MediaDisplayTest.php
+++ b/core/modules/media/tests/src/FunctionalJavascript/MediaDisplayTest.php
@@ -54,6 +54,7 @@ class MediaDisplayTest extends MediaJavascriptTestBase {
// Enable the field on the display and verify it becomes visible on the UI.
$this->drupalGet("/admin/structure/media/manage/{$media_type->id()}/display");
+ $assert_session->buttonExists('Show row weights')->press();
$page->selectFieldOption('fields[name][region]', 'content');
$assert_session->waitForElementVisible('css', '#edit-fields-name-settings-edit');
$page->pressButton('Save');
diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php
new file mode 100644
index 0000000..b1d092c
--- /dev/null
+++ b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php
@@ -0,0 +1,157 @@
+<?php
+
+namespace Drupal\Tests\media\FunctionalJavascript;
+
+use Drupal\media\Entity\Media;
+use Drupal\media_test_oembed\Controller\ResourceController;
+use Drupal\Tests\media\Traits\OEmbedTestTrait;
+
+/**
+ * Tests the oembed:video media source.
+ *
+ * @group media
+ */
+class MediaSourceOEmbedVideoTest extends MediaSourceTestBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ public static $modules = ['media_test_oembed'];
+
+ use OEmbedTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+ $this->lockHttpClientToFixtures();
+ }
+
+ /**
+ * Tests the oembed media source.
+ */
+ public function testMediaOEmbedVideoSource() {
+ $media_type_id = 'test_media_oembed_type';
+ $provided_fields = [
+ 'type',
+ 'title',
+ 'author_name',
+ 'author_url',
+ 'provider_name',
+ 'provider_url',
+ 'cache_age',
+ 'thumbnail_url',
+ 'thumbnail_local_uri',
+ 'thumbnail_local',
+ 'thumbnail_width',
+ 'thumbnail_height',
+ 'url',
+ 'width',
+ 'height',
+ 'html',
+ ];
+
+ $session = $this->getSession();
+ $page = $session->getPage();
+ $assert_session = $this->assertSession();
+
+ $this->doTestCreateMediaType($media_type_id, 'oembed:video', $provided_fields);
+
+ // Create custom fields for the media type to store metadata attributes.
+ $fields = [
+ 'field_string_width' => 'string',
+ 'field_string_height' => 'string',
+ 'field_string_author_name' => 'string',
+ ];
+ $this->createMediaTypeFields($fields, $media_type_id);
+
+ // Hide the name field widget to test default name generation.
+ $this->hideMediaTypeFieldWidget('name', $media_type_id);
+
+ $this->drupalGet("admin/structure/media/manage/$media_type_id");
+ // Only accept Vimeo videos.
+ $page->checkField("source_configuration[allowed_providers][Vimeo]");
+ $assert_session->selectExists('field_map[width]')->setValue('field_string_width');
+ $assert_session->selectExists('field_map[height]')->setValue('field_string_height');
+ $assert_session->selectExists('field_map[author_name]')->setValue('field_string_author_name');
+ $assert_session->buttonExists('Save')->press();
+
+ $this->hijackProviderEndpoints();
+ $video_url = 'https://vimeo.com/7073899';
+ ResourceController::setResourceUrl($video_url, $this->getFixturesDirectory() . '/video_vimeo.json');
+
+ // Create a media item.
+ $this->drupalGet("media/add/$media_type_id");
+ $assert_session->fieldExists('Remote video URL')->setValue($video_url);
+ $assert_session->buttonExists('Save')->press();
+
+ $assert_session->addressEquals('media/1');
+ $thumbnail = Media::load(1)->uuid() . '.png';
+
+ // The thumbnail should have been downloaded.
+ $this->assertFileExists("public://oembed_thumbnails/$thumbnail");
+
+ // Make sure the video is displayed in an iframe.
+ $assert_session->elementAttributeContains('css', 'iframe', 'src', '/media/oembed?url=' . str_replace('://', '%3A//', $video_url));
+
+ // Make sure the thumbnail is displayed from uploaded image.
+ $assert_session->elementAttributeContains('css', '.image-style-thumbnail', 'src', "/oembed_thumbnails/$thumbnail");
+
+ // Load the media and check that all fields are properly populated.
+ $media = Media::load(1);
+ $this->assertSame('Drupal Rap Video - Schipulcon09', $media->getName());
+ $this->assertSame('480', $media->field_string_width->value);
+ $this->assertSame('360', $media->field_string_height->value);
+
+ // Try to create a media asset from a disallowed provider.
+ $this->drupalGet("media/add/$media_type_id");
+ $assert_session->fieldExists('Remote video URL')->setValue('http://www.collegehumor.com/video/40003213/grant-and-katie-are-starting-their-own-company');
+ $page->pressButton('Save');
+
+ $assert_session->pageTextContains('The CollegeHumor provider is not allowed.');
+ }
+
+ /**
+ * Test that a security warning appears if iFrame domain is not set.
+ */
+ public function testOEmbedSecurityWarning() {
+ $media_type_id = 'test_media_oembed_type';
+ $source_id = 'oembed:video';
+
+ $session = $this->getSession();
+ $page = $session->getPage();
+ $assert_session = $this->assertSession();
+
+ $this->drupalGet('admin/structure/media/add');
+ $page->fillField('label', $media_type_id);
+ $this->getSession()
+ ->wait(5000, "jQuery('.machine-name-value').text() === '{$media_type_id}'");
+
+ // Make sure the source is available.
+ $assert_session->fieldExists('Media source');
+ $assert_session->optionExists('Media source', $source_id);
+ $page->selectFieldOption('Media source', $source_id);
+ $result = $assert_session->waitForElementVisible('css', 'fieldset[data-drupal-selector="edit-source-configuration"]');
+ $this->assertNotEmpty($result);
+
+ $assert_session->pageTextContains('It is potentially insecure to display oEmbed content in a frame');
+
+ $this->config('media.settings')->set('iframe_domain', 'http://example.com')->save();
+
+ $this->drupalGet('admin/structure/media/add');
+ $page->fillField('label', $media_type_id);
+ $this->getSession()
+ ->wait(5000, "jQuery('.machine-name-value').text() === '{$media_type_id}'");
+
+ // Make sure the source is available.
+ $assert_session->fieldExists('Media source');
+ $assert_session->optionExists('Media source', $source_id);
+ $page->selectFieldOption('Media source', $source_id);
+ $result = $assert_session->waitForElementVisible('css', 'fieldset[data-drupal-selector="edit-source-configuration"]');
+ $this->assertNotEmpty($result);
+
+ $assert_session->pageTextNotContains('It is potentially insecure to display oEmbed content in a frame');
+ }
+
+}
diff --git a/core/modules/media/tests/src/Traits/OEmbedTestTrait.php b/core/modules/media/tests/src/Traits/OEmbedTestTrait.php
new file mode 100644
index 0000000..0eeee93
--- /dev/null
+++ b/core/modules/media/tests/src/Traits/OEmbedTestTrait.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Drupal\Tests\media\Traits;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Url;
+use Drupal\media\OEmbed\Provider;
+
+/**
+ * Contains helper functions for testing oEmbed functionality in isolation.
+ */
+trait OEmbedTestTrait {
+
+ /**
+ * Returns the relative path to the oEmbed fixtures directory.
+ *
+ * @return string
+ */
+ protected function getFixturesDirectory() {
+ return drupal_get_path('module', 'media') . '/tests/fixtures/oembed';
+ }
+
+ /**
+ * Returns the absolute URL of the oEmbed fixtures directory.
+ *
+ * @return string
+ */
+ protected function getFixturesUrl() {
+ return $this->baseUrl . '/' . $this->getFixturesDirectory();
+ }
+
+ /**
+ * Forces Media to use the provider database in the fixtures directory.
+ */
+ protected function useFixtureProviders() {
+ $this->config('media.settings')
+ ->set('oembed_providers', $this->getFixturesUrl() . '/providers.json')
+ ->save();
+ }
+
+ /**
+ * Configures the http_client service so that all requests are carried out
+ * relative to the URL of the fixtures directory. For example, after calling
+ * this method, a request for foobar.html will actually request
+ * http://test-site/path/to/fuxtures/foobar.html.
+ */
+ protected function lockHttpClientToFixtures() {
+ $this->writeSettings([
+ 'settings' => [
+ 'http_client_config' => [
+ 'base_uri' => (object) [
+ 'value' => $this->getFixturesUrl() . '/',
+ 'required' => TRUE,
+ ],
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * Ensures that all oEmbed provider endpoints defined in the fixture
+ * providers.json will use the media_test_oembed.resource.get route as their
+ * URL.
+ *
+ * This requires the media_test_oembed module in order to work.
+ */
+ protected function hijackProviderEndpoints() {
+ $providers = $this->getFixturesDirectory() . '/providers.json';
+ $providers = file_get_contents($providers);
+ $providers = Json::decode($providers);
+
+ $endpoint_url = Url::fromRoute('media_test_oembed.resource.get')
+ ->setAbsolute()
+ ->toString();
+
+ /** @var \Drupal\media_test_oembed\ProviderRepository $provider_repository */
+ $provider_repository = $this->container->get('media.oembed.provider_repository');
+
+ foreach ($providers as &$provider) {
+ foreach ($provider['endpoints'] as &$endpoint) {
+ $endpoint['url'] = $endpoint_url;
+ }
+ $provider_repository->setProvider(
+ new Provider($provider['provider_name'], $provider['provider_url'], $provider['endpoints'])
+ );
+ }
+ }
+
+}
diff --git a/core/modules/media/tests/src/Unit/UrlResolverTest.php b/core/modules/media/tests/src/Unit/UrlResolverTest.php
new file mode 100644
index 0000000..b76216e
--- /dev/null
+++ b/core/modules/media/tests/src/Unit/UrlResolverTest.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Drupal\Tests\media\Unit;
+
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Routing\RequestContext;
+use Drupal\media\OEmbed\ProviderRepositoryInterface;
+use Drupal\media\OEmbed\ResourceFetcherInterface;
+use Drupal\media\OEmbed\UrlResolver;
+use Drupal\Tests\UnitTestCase;
+use GuzzleHttp\Client;
+
+/**
+ * @coversDefaultClass \Drupal\media\OEmbed\UrlResolver
+ *
+ * @group media
+ */
+class UrlResolverTest extends UnitTestCase {
+
+ /**
+ * The mocked request context.
+ *
+ * @var \Drupal\Core\Routing\RequestContext
+ */
+ protected $requestContext;
+
+ /**
+ * The URL resolver under test.
+ *
+ * @var \Drupal\media\OEmbed\UrlResolver
+ */
+ protected $urlResolver;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ $this->requestContext = $this->prophesize(RequestContext::class);
+ $this->urlResolver = new UrlResolver(
+ $this->createMock(ProviderRepositoryInterface::class),
+ $this->createMock(ResourceFetcherInterface::class),
+ $this->createMock(Client::class),
+ $this->createMock(ModuleHandlerInterface::class),
+ $this->requestContext->reveal()
+ );
+ }
+
+ /**
+ * Data provider for testIsSecure().
+ *
+ * @see ::testIsSecure()
+ *
+ * @return array
+ */
+ public function providerIsSecure() {
+ return [
+ 'no domain' => [
+ '/path/to/media.php',
+ 'http://www.example.com/',
+ FALSE,
+ ],
+ 'no base URL domain' => [
+ 'http://www.example.com/media.php',
+ '/invalid/base/url',
+ FALSE,
+ ],
+ 'same domain' => [
+ 'http://www.example.com/media.php',
+ 'http://www.example.com/',
+ FALSE,
+ ],
+ 'different domain' => [
+ 'http://www.example.com/media.php',
+ 'http://www.example-assets.com/',
+ TRUE,
+ ],
+ 'same subdomain' => [
+ 'http://foo.example.com/media.php',
+ 'http://foo.example.com/',
+ FALSE,
+ ],
+ 'different subdomain' => [
+ 'http://assets.example.com/media.php',
+ 'http://foo.example.com/',
+ TRUE,
+ ],
+ 'subdomain and top-level domain' => [
+ 'http://assets.example.com/media.php',
+ 'http://example.com/',
+ TRUE,
+ ],
+ ];
+ }
+
+ /**
+ * Tests that isSecure() behaves properly.
+ *
+ * @param string $url
+ * The URL to test for security.
+ * @param string $base_url
+ * The base URL to compare $url against.
+ * @param bool $secure
+ * The expected result of isSecure().
+ *
+ * @covers ::isSecure
+ *
+ * @dataProvider providerIsSecure
+ */
+ public function testIsSecure($url, $base_url, $secure) {
+ $this->requestContext->getCompleteBaseUrl()->willReturn($base_url);
+ $this->assertSame($secure, $this->urlResolver->isSecure($url));
+ }
+
+}
diff --git a/core/themes/stable/templates/content/media-oembed.html.twig b/core/themes/stable/templates/content/media-oembed.html.twig
new file mode 100644
index 0000000..17b7a6f
--- /dev/null
+++ b/core/themes/stable/templates/content/media-oembed.html.twig
@@ -0,0 +1,14 @@
+{#
+/**
+ * @file
+ * Default theme implementation to display an oEmbed resource.
+ *
+ * @ingroup themeable
+ */
+#}
+<!DOCTYPE html>
+<html>
+ <body style="margin: 0">
+ {{ post|raw }}
+ </body>
+</html>