diff --git a/core/modules/media/config/schema/media.schema.yml b/core/modules/media/config/schema/media.schema.yml index b9156b23f8..14b56df7a5 100644 --- a/core/modules/media/config/schema/media.schema.yml +++ b/core/modules/media/config/schema/media.schema.yml @@ -40,6 +40,24 @@ 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' + allowed_html_tags: + type: string + label: 'Allowed HTML tags' + +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 +78,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_location: + type: string + label: 'Thumbnails location' + 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 8de1c64575..579702aec6 100644 --- a/core/modules/media/media.api.php +++ b/core/modules/media/media.api.php @@ -20,6 +20,18 @@ function hook_media_source_info_alter(array &$sources) { $sources['youtube']['label'] = t('Youtube rocks!'); } +/** + * Alters the parsed URL before fetching the oEmbed data. + * + * @param array $parsed_url + * UrlHelper::parse() provided array. + * @param \Drupal\media\OEmbed\Provider $provider + * The current oEmbed provider. + */ +function hook_oembed_resource_url_alter(array &$parsed_url, \Drupal\media\OEmbed\Provider $provider) { + $parsed_url['query']['foo'] = 'bar'; +} + /** * @} End of "addtogroup hooks". */ diff --git a/core/modules/media/media.module b/core/modules/media/media.module index 296945973a..fc02a0072d 100644 --- a/core/modules/media/media.module +++ b/core/modules/media/media.module @@ -13,6 +13,7 @@ use Drupal\Core\Session\AccountInterface; use Drupal\Core\Url; use Drupal\field\FieldConfigInterface; +use Drupal\media\Plugin\media\Source\OEmbedInterface; /** * Implements hook_help(). @@ -86,6 +87,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'], '.', '_'); @@ -93,6 +95,13 @@ 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) { + $provider_id = $source->getMetadata($media, 'provider_id'); + $suggestions[] = 'media__oembed'; + $suggestions[] = 'media__oembed__' . strtolower($provider_id); + } + return $suggestions; } diff --git a/core/modules/media/media.services.yml b/core/modules/media/media.services.yml index f22f90a124..395294214e 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_manager: + class: Drupal\media\OEmbed\OEmbedManager + arguments: ['@media.oembed.provider_collector', '@media.oembed.resource_fetcher', '@http_client', '@module_handler', '@cache.default'] + media.oembed.provider_collector: + class: Drupal\media\OEmbed\ProviderCollector + arguments: ['@http_client', 'https://oembed.com/providers.json', '@datetime.time', '@cache.default'] + media.oembed.resource_fetcher: + class: Drupal\media\OEmbed\ResourceFetcher + arguments: ['@http_client', '@cache.default'] diff --git a/core/modules/media/src/MediaSourceBase.php b/core/modules/media/src/MediaSourceBase.php index 1edc858450..3bb3c6699a 100644 --- a/core/modules/media/src/MediaSourceBase.php +++ b/core/modules/media/src/MediaSourceBase.php @@ -301,7 +301,9 @@ public function createSourceField(MediaTypeInterface $type) { * returned. Otherwise, a new, unused one is generated. */ protected function getSourceFieldName() { - $base_id = 'field_media_' . $this->getPluginId(); + // Some media sources are using a deriver, so their plugin IDs are + // containing a ':' which is not allowed for field names. + $base_id = 'field_media_' . str_replace(':', '_', $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 0000000000..f860d51be5 --- /dev/null +++ b/core/modules/media/src/OEmbed/Endpoint.php @@ -0,0 +1,168 @@ +url = $url; + $this->provider = $provider; + $this->schemes = $schemes; + $this->formats = $formats; + $this->supportsDiscovery = $supports_discovery; + } + + /** + * Returns the endpoint's URL. + * + * The URL will be built with the first defined format. If the endpoint + * doesn't provide a format, the JSON format will be used. + * + * @return string + * Endpoint URL. + */ + public function getUrl() { + $format = 'json'; + if (!empty($this->formats)) { + // If the endpoint specifies multiple formats we use the fist one. + $format = reset($this->formats); + } + return str_replace('{format}', $format, $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 (bool) $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/OEmbedManager.php b/core/modules/media/src/OEmbed/OEmbedManager.php new file mode 100644 index 0000000000..409f0b4602 --- /dev/null +++ b/core/modules/media/src/OEmbed/OEmbedManager.php @@ -0,0 +1,183 @@ +providerCollector = $provider_collector; + $this->resourceFetcher = $resource_fetcher; + $this->httpClient = $http_client; + $this->moduleHandler = $module_handler; + $this->cacheBackend = $cache_backend; + $this->useCaches = isset($cache_backend); + } + + /** + * Runs oEmbed discovery and returns the endpoint URL if successful. + * + * @param string $url + * The resource's URL. + * + * @return string|bool + * URL of the oEmbed endpoint, or FALSE if the discovery was not successful. + * + * @throws \Drupal\media\OEmbed\ResourceException + */ + protected function discoverResourceUrl($url) { + try { + $response = $this->httpClient->get($url); + } + catch (RequestException $e) { + throw new ResourceException('Could not fetch oEmbed resource.', $url, [], $e); + } + + $document = Html::load((string) $response->getBody()); + $xpath = new \DOMXpath($document); + + return $this->findUrl($xpath, 'json') ?: $this->findUrl($xpath, 'xml'); + } + + /** + * Tries to find the oEmbed URL in a DOM. + * + * @param \DOMXPath $xpath + * Page HTML as DOMXPath. + * @param string $format + * Format of oEmbed resource. Possible values are 'json' and 'xml'. + * + * @return bool|string + * A URL to an oEmbed resource or FALSE if not found. + */ + protected function findUrl(\DOMXPath $xpath, $format) { + $result = $xpath->query("//link[@type='application/$format+oembed']"); + return $result->length ? $result->item(0)->getAttribute('href') : FALSE; + } + + /** + * {@inheritdoc} + */ + public function getProviderByUrl($url) { + // Check the URL against every scheme of every endpoint of every provider + // until we find a match. + foreach ($this->providerCollector->getAll() as $provider_name => $provider_info) { + foreach ($provider_info->getEndpoints() as $endpoint) { + if ($endpoint->matchUrl($url)) { + return $provider_info; + } + } + } + + if ($resource_url = $this->discoverResourceUrl($url)) { + $resource = $this->resourceFetcher->fetchResource($resource_url); + return $this->providerCollector->get($resource['provider_name']); + } + throw new ResourceException('No matching provider found.', $url); + } + + /** + * {@inheritdoc} + */ + public function getResourceUrl($url, $max_width = NULL, $max_height = NULL) { + $cache_id = 'media:oembed_resource_url:' . hash('sha256', $url); + + if ($this->urlCache === NULL) { + $this->urlCache = []; + if ($cached = $this->cacheGet($cache_id)) { + $this->urlCache[$url] = $cached->data; + } + } + + if (isset($this->urlCache[$url])) { + 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']['max_width'] = $max_width; + } + if ($max_height) { + $parsed_url['query']['max_height'] = $max_height; + } + // Let other modules alter the query string, because some oEmbed providers + // are providing extra parameters. For example, Instagram also supports the + // 'omitscript' parameter. + $this->moduleHandler->alter('oembed_resource_url', $parsed_url, $provider); + $resource_url = $parsed_url['path'] . '?' . UrlHelper::buildQuery($parsed_url['query']); + + $this->urlCache[$url] = $resource_url; + $this->cacheSet($cache_id, $resource_url); + return $resource_url; + } + +} diff --git a/core/modules/media/src/OEmbed/OEmbedManagerInterface.php b/core/modules/media/src/OEmbed/OEmbedManagerInterface.php new file mode 100644 index 0000000000..4638bcb70a --- /dev/null +++ b/core/modules/media/src/OEmbed/OEmbedManagerInterface.php @@ -0,0 +1,42 @@ +name = $name; + $this->url = $url; + $this->endpoints = $endpoints; + } + + /** + * 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() { + $endpoints = []; + foreach ($this->endpoints as $endpoint) { + $endpoint += ['formats' => [], 'schemes' => [], 'discovery' => FALSE]; + $endpoints[] = new Endpoint($endpoint['url'], $this, $endpoint['schemes'], $endpoint['formats'], $endpoint['discovery']); + } + return $endpoints; + } + +} diff --git a/core/modules/media/src/OEmbed/ProviderCollector.php b/core/modules/media/src/OEmbed/ProviderCollector.php new file mode 100644 index 0000000000..9d764b4ee7 --- /dev/null +++ b/core/modules/media/src/OEmbed/ProviderCollector.php @@ -0,0 +1,116 @@ +httpClient = $http_client; + $this->providersUrl = $providers_url; + $this->time = $time; + $this->cacheBackend = $cache_backend; + } + + /** + * {@inheritdoc} + */ + public function getAll() { + $cache_id = 'media:oembed_providers'; + + $cached = $this->cacheBackend->get($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) { + $name = $provider['provider_name']; + $keyed_providers[$name] = new Provider($provider['provider_name'], $provider['provider_url'], $provider['endpoints']); + } + + $this->cacheBackend->set($cache_id, $keyed_providers, $this->time->getCurrentTime() + static::MAX_AGE); + 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/ProviderCollectorInterface.php b/core/modules/media/src/OEmbed/ProviderCollectorInterface.php new file mode 100644 index 0000000000..e70a11f12c --- /dev/null +++ b/core/modules/media/src/OEmbed/ProviderCollectorInterface.php @@ -0,0 +1,44 @@ +' 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', is_object($provider) ? $provider->getName() : '', $message); + parent::__construct($message, 0, $previous); + } + + /** + * Gets the provider information, if available. + * + * @return \Drupal\media\OEmbed\Provider + * The information about the oEmbed provider which caused the exception. + */ + public function getProvider() { + return $this->provider; + } + +} diff --git a/core/modules/media/src/OEmbed/ResourceException.php b/core/modules/media/src/OEmbed/ResourceException.php new file mode 100644 index 0000000000..f896926fac --- /dev/null +++ b/core/modules/media/src/OEmbed/ResourceException.php @@ -0,0 +1,63 @@ +url = $url; + $this->resource = $resource; + parent::__construct($message, 0, $previous); + } + + /** + * Gets the URL of the oEmbed resource which caused the exception. + * + * @return string + * The URL of the oEmbed resource which caused the exception. + */ + public function getUrl() { + return $this->url; + } + + /** + * Gets the oEmbed resource which caused the exception, if available. + * + * @return array + * The oEmbed resource which caused the exception. + */ + 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 0000000000..723c77971d --- /dev/null +++ b/core/modules/media/src/OEmbed/ResourceFetcher.php @@ -0,0 +1,77 @@ +httpClient = $http_client; + $this->cacheBackend = $cache_backend; + $this->useCaches = isset($cache_backend); + } + + /** + * {@inheritdoc} + */ + public function fetchResource($url) { + $cache_id = 'media:oembed:' . hash('sha256', $url); + + $cached = $this->cacheGet($cache_id); + if ($cached) { + return $cached->data; + } + + try { + $response = $this->httpClient->get($url); + } + catch (RequestException $e) { + throw new ResourceException('Could not retrieve the oEmbed resource.', $url, [], $e); + } + + $format = $response->getHeader('Content-Type'); + $content = (string) $response->getBody(); + + // If the response is XML, magically convert it to JSON. + if (strpos($format[0], 'text/xml') !== FALSE) { + // Load XML into SimpleXMLElement. + $content = simplexml_load_string($content, "SimpleXMLElement", LIBXML_NOCDATA); + // Convert the SimpleXMLElement to JSON. + $content = Json::encode($content); + } + // If the response wasn't XML or JSON, we are in bat country. + elseif (strpos($format[0], 'application/json') === FALSE && strpos($format[0], 'text/javascript') === FALSE) { + throw new ResourceException('The fetched resource did not have a valid Content-Type header.', $url); + } + + $resource = Json::decode($content); + $this->cacheSet($cache_id, $resource); + return $resource; + } + +} diff --git a/core/modules/media/src/OEmbed/ResourceFetcherInterface.php b/core/modules/media/src/OEmbed/ResourceFetcherInterface.php new file mode 100644 index 0000000000..3526b7fc88 --- /dev/null +++ b/core/modules/media/src/OEmbed/ResourceFetcherInterface.php @@ -0,0 +1,31 @@ +resourceFetcher = $resource_fetcher; + $this->oEmbedManager = $oembed_manager; + $this->logger = $logger_factory->get('media'); + } + + /** + * {@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('media.oembed.resource_fetcher'), + $container->get('media.oembed_manager'), + $container->get('logger.factory') + ); + } + + /** + * {@inheritdoc} + */ + public static function defaultSettings() { + return [ + 'max_width' => 0, + 'max_height' => 0, + 'allowed_html_tags' => '', + ] + parent::defaultSettings(); + } + + /** + * {@inheritdoc} + */ + public function viewElements(FieldItemListInterface $items, $langcode) { + $element = []; + + foreach ($items as $delta => $item) { + $main_property = $item->getFieldDefinition()->getFieldStorageDefinition()->getMainPropertyName(); + + if (empty($item->{$main_property})) { + continue; + } + + try { + $resource = $this->resourceFetcher->fetchResource($this->oEmbedManager->getResourceUrl($item->{$main_property}, $this->getSetting('max_width'), $this->getSetting('max_height'))); + } + catch (ResourceException $exception) { + $this->logger->error("Could not retrieve the remote URL (@url).", ['@url' => $item->{$main_property}]); + continue; + } + + switch ($resource['type']) { + case 'link': + $element[$delta] = [ + '#title' => $resource['title'], + '#type' => 'link', + '#url' => Url::fromUri($item->{$main_property}), + ]; + break; + + case 'photo': + $element[$delta] = [ + '#theme' => 'image', + '#uri' => $resource['url'], + '#width' => $resource['width'], + '#height' => $resource['height'], + ]; + break; + + case 'video': + case 'rich': + $tags = explode(',', $this->getSetting('allowed_html_tags')); + $tags = array_map('trim', $tags); + $element[$delta] = [ + '#type' => 'inline_template', + '#template' => Xss::filter((string) $resource['html'], array_merge($tags, Xss::getHtmlTagList())), + ]; + break; + + default: + $this->logger->error("Unknown oEmbed resource type @type", ['@type' => $resource['type']]); + + } + } + + 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, + ], + 'allowed_html_tags' => [ + '#type' => 'textfield', + '#title' => $this->t('Allowed HTML tags'), + '#default_value' => $this->getSetting('allowed_html_tags'), + ], + ]; + } + + /** + * {@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'), + ]); + } + if ($this->getSetting('allowed_html_tags')) { + $summary[] = $this->t('Allowed HTML tags: %tags', [ + '%tags' => $this->getSetting('allowed_html_tags'), + ]); + } + return $summary; + } + +} 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 0000000000..c464bc97fa --- /dev/null +++ b/core/modules/media/src/Plugin/Field/FieldWidget/OEmbedWidget.php @@ -0,0 +1,53 @@ +getEntity()->getSource(); + $message = t('You can link to media from the following services: @providers', ['@providers' => implode(', ', $source->getAllowedProviders())]); + + $element['value']['#description'] = [ + '#theme' => 'item_list', + '#items' => !empty($element['value']['#description']) ? [$element['value']['#description'], $message] : [$message], + ]; + return $element; + } + + /** + * {@inheritdoc} + */ + public static function isApplicable(FieldDefinitionInterface $field_definition) { + if (!parent::isApplicable($field_definition) || $field_definition->getTargetEntityTypeId() !== 'media' || !$field_definition->getTargetBundle()) { + return FALSE; + } + /** @var \Drupal\media\MediaTypeInterface $media_type */ + $media_type = \Drupal::entityTypeManager()->getStorage('media_type')->load($field_definition->getTargetBundle()); + return $media_type->getSource() instanceof OEmbedInterface; + } + +} diff --git a/core/modules/media/src/Plugin/Validation/Constraint/OEmbedProviderConstraint.php b/core/modules/media/src/Plugin/Validation/Constraint/OEmbedProviderConstraint.php new file mode 100644 index 0000000000..b908468751 --- /dev/null +++ b/core/modules/media/src/Plugin/Validation/Constraint/OEmbedProviderConstraint.php @@ -0,0 +1,38 @@ +oEmbedManager = $oembed_manager; + $this->resourceFetcher = $resource_fetcher; + $this->logger = $logger_factory->get('media'); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('media.oembed_manager'), + $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(); + if (!($media->getSource() instanceof OEmbedInterface)) { + throw new \LogicException('Media source must be of type OEmbed'); + } + $url = $media->getSource()->getSourceFieldValue($media); + + try { + $provider = $this->oEmbedManager->getProviderByUrl($url); + + // Verify that resource fetching works, because some URL's might match + // the schemes, but doesn't support oEmbed. + $endpoints = $provider->getEndpoints(); + $endpoint = reset($endpoints); + $resource_url = $endpoint->buildResourceUrl($url); + $this->resourceFetcher->fetchResource($resource_url); + } + catch (ProviderException $e) { + $this->context->addViolation($constraint->invalidMessage); + $this->logger->error($e->getMessage()); + return; + } + catch (ResourceException $e) { + $this->context->addViolation($constraint->emptyMessage); + $this->logger->error($e->getMessage()); + return; + } + + if (empty($provider)) { + $this->context->addViolation($constraint->emptyMessage); + return; + } + + if (!in_array($provider->getName(), $media->getSource()->getAllowedProviders(), TRUE)) { + $this->context->addViolation($constraint->unallowedMessage, [ + '@name' => $provider->getName(), + ]); + } + } + +} 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 0000000000..2b9d75d91c --- /dev/null +++ b/core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraint.php @@ -0,0 +1,25 @@ +oEmbedManager = $oembed_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static($container->get('media.oembed_manager')); + } + + /** + * {@inheritdoc} + */ + public function validate($value, Constraint $constraint) { + /** @var \Drupal\media\MediaInterface $media */ + $media = $value->getEntity(); + $url = $media->getSource()->getSourceFieldValue($media); + + try { + $this->oEmbedManager->getResourceUrl($url); + } + catch (ResourceException $e) { + $this->context->addViolation($constraint->message); + } + } + +} 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 0000000000..1de80214bd --- /dev/null +++ b/core/modules/media/src/Plugin/media/Source/OEmbed.php @@ -0,0 +1,352 @@ +logger = $logger; + $this->messenger = $messenger; + $this->transliteration = $transliteration; + $this->resourceFetcher = $resource_fetcher; + $this->oEmbedManager = $oembed_manager; + } + + /** + * {@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('transliteration'), + $container->get('media.oembed.resource_fetcher'), + $container->get('media.oembed_manager') + ); + } + + /** + * {@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_id' => $this->t("The provider's ID"), + 'provider_name' => $this->t("The provider's name"), + '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->oEmbedManager->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 { + $image_data = file_get_contents($this->getMetadata($media, 'thumbnail_url')); + if ($image_data) { + return file_unmanaged_save_data($image_data, $local_uri, FILE_EXISTS_REPLACE) ?: NULL; + } + } + } + return NULL; + + case 'thumbnail_local_uri': + return $this->getLocalImageUri($media, $resource_url); + + 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': + if ($uri = $this->getMetadata($media, 'thumbnail_local')) { + return $uri; + } + return parent::getMetadata($media, 'thumbnail_uri'); + + case 'provider_id': + $provider_name = $this->getMetadata($media, 'provider_name'); + return $this->transliteration->transliterate($provider_name); + + default: + if (!empty($resource[$name])) { + return $resource[$name]; + } + break; + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + + $form['thumbnails_location'] = [ + '#type' => 'textfield', + '#title' => $this->t('Thumbnails location'), + '#default_value' => $this->configuration['thumbnails_location'], + '#description' => $this->t('Thumbnails will be fetched from the provider for local usage. This is the location where they will be placed.'), + ]; + + $form['allowed_providers'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Allowed providers'), + '#default_value' => $this->configuration['allowed_providers'], + '#options' => array_combine($this->pluginDefinition['supported_providers'], $this->pluginDefinition['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); + $this->configuration['allowed_providers'] = array_filter(array_values($this->configuration['allowed_providers'])); + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + $thumbnails_location = $form_state->getValue('thumbnails_location'); + if (!file_valid_uri($thumbnails_location)) { + $form_state->setErrorByName('thumbnails_location', $this->t('@path is not a valid path.', ['@path' => $thumbnails_location])); + } + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return [ + 'thumbnails_location' => 'public://oembed_thumbnails', + 'allowed_providers' => [], + ] + parent::defaultConfiguration(); + } + + /** + * Computes the destination URI for a thumbnail. + * + * @param \Drupal\media\MediaInterface $media + * A media item. + * @param string $resource_url + * The resource URL of oEmbed item. + * + * @return string + * The local URI. + */ + protected function getLocalImageUri(MediaInterface $media, $resource_url) { + $remote_url = $this->getMetadata($media, 'thumbnail_url'); + if (!$remote_url) { + return parent::getMetadata($media, 'thumbnail_uri'); + } + + $directory = $this->configuration['thumbnails_location']; + // 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'); + } + + $filename = $this->getMetadata($media, 'provider_name') . '_' . substr(md5($resource_url), 0, 5); + $local_uri = $this->configuration['thumbnails_location'] . '/' . $filename . '.'; + $local_uri .= pathinfo($remote_url, PATHINFO_EXTENSION); + + return $local_uri; + } + + /** + * {@inheritdoc} + */ + public function getSourceFieldConstraints() { + return [ + 'oembed_resource' => [], + 'oembed_provider' => [], + ]; + } + + /** + * {@inheritdoc} + */ + public function prepareViewDisplay(MediaTypeInterface $type, EntityViewDisplayInterface $display) { + $display->setComponent($this->getSourceFieldDefinition($type)->getName(), [ + 'type' => 'oembed', + 'settings' => [ + 'allowed_html_tags' => $this->configuration['allowed_html_tags'], + ], + ]); + } + + /** + * {@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 getAllowedProviders() { + return $this->configuration['allowed_providers'] ?: $this->getSupportedProviders(); + } + + /** + * {@inheritdoc} + */ + public function getSupportedProviders() { + return $this->pluginDefinition['supported_providers']; + } + +} 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 0000000000..b3bf94e882 --- /dev/null +++ b/core/modules/media/src/Plugin/media/Source/OEmbedDeriver.php @@ -0,0 +1,29 @@ +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', + 'allowed_html_tags' => 'iframe', + ] + $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 0000000000..6689646bbe --- /dev/null +++ b/core/modules/media/src/Plugin/media/Source/OEmbedInterface.php @@ -0,0 +1,28 @@ +drupalLogin($this->drupalCreateUser(['view test entity'])); + } + + /** + * @covers ::viewElements + */ + public function testRender() { + $entity_type = $bundle = 'entity_test'; + $field_name = Unicode::strtolower($this->randomMachineName()); + + FieldStorageConfig::create([ + 'entity_type' => $entity_type, + 'field_name' => $field_name, + 'type' => 'link', + 'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED, + ])->save(); + $field_config = FieldConfig::create([ + 'entity_type' => $entity_type, + 'field_name' => $field_name, + 'bundle' => $bundle, + 'settings' => [ + 'title' => DRUPAL_DISABLED, + 'link_type' => LinkItemInterface::LINK_EXTERNAL, + ], + ]); + $field_config->save(); + + $display = entity_get_display('entity_test', 'entity_test', 'full'); + $display->setComponent($field_name, ['type' => 'oembed', 'settings' => ['allowed_html_tags' => 'script, p']])->save(); + + $entity = EntityTest::create([ + $field_config->getName() => [ + [ + 'uri' => 'https://twitter.com/drupaldevdays/status/935643039741202432', + ], + ], + ]); + $entity->save(); + + $this->drupalGet($entity->toUrl()); + $assert_session = $this->assertSession(); + $assert_session->elementExists('css', "blockquote.twitter-tweet"); + $assert_session->elementContains('css', "blockquote.twitter-tweet > p", 'Save the date for Drupal Developer Days Lisbon 2018'); + } + +} diff --git a/core/modules/media/tests/src/Functional/OEmbedProviderCollectorTest.php b/core/modules/media/tests/src/Functional/OEmbedProviderCollectorTest.php new file mode 100644 index 0000000000..75615ccfec --- /dev/null +++ b/core/modules/media/tests/src/Functional/OEmbedProviderCollectorTest.php @@ -0,0 +1,106 @@ +container->get('cache.default'); + $time = $this->container->get('datetime.time'); + + $body = $this->getMockBuilder('\GuzzleHttp\Psr7\Stream') + ->disableOriginalConstructor() + ->getMock(); + $body->method('getContents')->willReturn($content); + + $response = $this->getMockBuilder('\GuzzleHttp\Psr7\Response') + ->disableOriginalConstructor() + ->getMock(); + $response->method('getBody')->willReturn($body); + + $client = $this->getMockBuilder('\GuzzleHttp\Client') + ->getMock(); + $client->method('request')->withAnyParameters()->willReturn($response); + + $this->setExpectedException($exceptionClass, $exceptionMessage); + + $providerCollector = new ProviderCollector($client, $providerUrl, $time, $cacheBackend); + $providerCollector->getAll(); + } + + /** + * Data provider for testEmptyProviderList. + * + * @return array + * Data. + */ + public function dataProviderEmptyProviderList() { + return [ + [ + json_encode([]), + 'http://oembed.com/providers.json', + ProviderException::class, + 'Remote oEmbed providers database returned invalid or empty list.', + ], + [ + '', + 'http://oembed.com/providers.json', + ProviderException::class, + 'Remote oEmbed providers database returned invalid or empty list.', + ], + ]; + } + + /** + * Test provider discovery. + * + * @dataProvider dataProviderNonExistingProviderDatabase + */ + public function testNonExistingProviderDatabase($providerUrl, $exceptionClass, $exceptionMessage) { + $cacheBackend = $this->container->get('cache.default'); + $client = $this->container->get('http_client'); + $time = $this->container->get('datetime.time'); + + $this->setExpectedException($exceptionClass, $exceptionMessage); + + $providerCollector = new ProviderCollector($client, $providerUrl, $time, $cacheBackend); + $providerCollector->getAll(); + } + + /** + * Data provider for testEmptyProviderList. + * + * @return array + * Data. + */ + public function dataProviderNonExistingProviderDatabase() { + return [ + [ + 'http://oembed1.com/providers.json', + ProviderException::class, + 'Could not retrieve the oEmbed provider database from http://oembed1.com/providers.json', + ], + [ + 'http://oembed.com/providers1.json', + ProviderException::class, + 'Could not retrieve the oEmbed provider database from http://oembed.com/providers1.json', + ], + ]; + } + +} diff --git a/core/modules/media/tests/src/Functional/OEmbedTest.php b/core/modules/media/tests/src/Functional/OEmbedTest.php new file mode 100644 index 0000000000..09bd44337a --- /dev/null +++ b/core/modules/media/tests/src/Functional/OEmbedTest.php @@ -0,0 +1,61 @@ +container->get('cache.default'); + $module_handler = $this->container->get('module_handler'); + $client = $this->container->get('http_client'); + $provider_collector = $this->container->get('media.oembed.provider_collector'); + $resource_fetcher = $this->container->get('media.oembed.resource_fetcher'); + + $oEmbed = new OEmbedManager($provider_collector, $resource_fetcher, $client, $module_handler, $cache_backend); + + // Received by discovery. + $this->assertSame('https://vimeo.com/api/oembed.json?url=https%3A//vimeo.com/7073899', $oEmbed->getResourceUrl('https://vimeo.com/7073899')); + // Received by URL building. + $this->assertSame('https://publish.twitter.com/oembed?url=https%3A//twitter.com/drupaldevdays/status/935643039741202432', $oEmbed->getResourceUrl('https://twitter.com/drupaldevdays/status/935643039741202432')); + // URL building with format replacement. + $this->assertSame('http://www.collegehumor.com/oembed.json?url=http%3A//www.collegehumor.com/video/40002870/lets-not-get-a-drink-sometime', $oEmbed->getResourceUrl('http://www.collegehumor.com/video/40002870/lets-not-get-a-drink-sometime')); + // URL building with height and width. + $this->assertSame('http://www.flickr.com/services/oembed/?url=https%3A//www.flickr.com/photos/amazeelabs/30530130354/in/album-72157675936437840&max_width=320&max_height=480', $oEmbed->getResourceUrl('https://www.flickr.com/photos/amazeelabs/30530130354/in/album-72157675936437840', 320, 480)); + } + + /** + * Test fetchResource method. + */ + public function testFetchResource() { + $cache_backend = $this->container->get('cache.default'); + $client = $this->container->get('http_client'); + + $oEmbed = new ResourceFetcher($client, $cache_backend); + + // Fetch JSON resource. + $resource = $oEmbed->fetchResource('https://vimeo.com/api/oembed.json?url=https%3A%2F%2Fvimeo.com%2F7073899'); + $this->assertSame('video', $resource['type']); + $this->assertSame('Vimeo', $resource['provider_name']); + $this->assertSame('Drupal Rap Video - Schipulcon09', $resource['title']); + + // Fetch XML resource. + $resource = $oEmbed->fetchResource('http://www.collegehumor.com/oembed.xml?url=http%3A//www.collegehumor.com/video/40002870/lets-not-get-a-drink-sometime'); + $this->assertSame('video', $resource['type']); + $this->assertSame('CollegeHumor', $resource['provider_name']); + $this->assertSame("Let's Not Get a Drink Sometime", $resource['title']); + } + +} 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 0000000000..121dd2f238 --- /dev/null +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php @@ -0,0 +1,89 @@ +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]"); + + $page->selectFieldOption("field_map[width]", 'field_string_width'); + $page->selectFieldOption("field_map[height]", 'field_string_height'); + $page->selectFieldOption("field_map[author_name]", 'field_string_author_name'); + $page->pressButton('Save'); + + // Create a media item. + $this->drupalGet("media/add/{$media_type_id}"); + $page->fillField("Remote video", 'https://vimeo.com/7073899'); + $page->pressButton('Save'); + + $assert_session->addressEquals('media/1'); + + // Make sure the thumbnail is displayed from uploaded image. + $assert_session->elementAttributeContains('css', '.image-style-thumbnail', 'src', 'oembed_thumbnails/Vimeo_1d58b.jpg'); + + // Load the media and check that all fields are properly populated. + $media = Media::load(1); + $this->assertEquals('Drupal Rap Video - Schipulcon09', $media->getName()); + $this->assertEquals('480', $media->get('field_string_width')->value); + $this->assertEquals('360', $media->get('field_string_height')->value); + + // Try to create a Twitch item, which should not be allowed. + $this->drupalGet("media/add/{$media_type_id}"); + $page->fillField("Remote video", 'https://clips.twitch.tv/SleepyBoringLapwingRuleFive'); + $page->pressButton('Save'); + + $assert_session->pageTextContains('The Twitch provider is not allowed.'); + } + +}