Commit 00c2069f authored by alexpott's avatar alexpott

Issue #2831944 by chr.fritsch, phenaproxima, alexpott, marcoscano, slashrsm,...

Issue #2831944 by chr.fritsch, phenaproxima, alexpott, marcoscano, slashrsm, seanB, robpowell, samuel.mortenson, tstoeckler, Wim Leers, dawehner, martin107, Gábor Hojtsy, aheimlich, tedbow, mtodor, larowlan, idebr, bkosborne, ckrina: Implement media source plugin for remote video via oEmbed
parent de724027
icon_base_uri: 'public://media-icons/generic'
iframe_domain: ''
oembed_providers_url: 'https://oembed.com/providers.json'
......@@ -5,6 +5,12 @@ media.settings:
icon_base_uri:
type: string
label: 'Full URI to a folder where the media icons will be installed'
iframe_domain:
type: uri
label: 'Domain from which to serve oEmbed content in an iframe'
oembed_providers_url:
type: uri
label: 'The URL of the oEmbed providers database in JSON format'
media.type.*:
type: config_entity
......@@ -40,6 +46,21 @@ field.formatter.settings.media_thumbnail:
type: field.formatter.settings.image
label: 'Media thumbnail field display format settings'
field.formatter.settings.oembed:
type: mapping
label: 'oEmbed display format settings'
mapping:
max_width:
type: integer
label: 'Maximum width'
max_height:
type: integer
label: 'Maximum height'
field.widget.settings.oembed_textfield:
type: field.widget.settings.string_textfield
label: 'oEmbed widget format settings'
media.source.*:
type: mapping
label: 'Media source settings'
......@@ -60,6 +81,20 @@ media.source.video_file:
type: media.source.field_aware
label: '"Video" media source configuration'
media.source.oembed:*:
type: media.source.field_aware
label: 'oEmbed media source configuration'
mapping:
thumbnails_directory:
type: uri
label: 'URI of thumbnail storage directory'
providers:
type: sequence
label: 'Allowed oEmbed providers'
sequence:
type: string
label: 'Provider name'
media.source.field_aware:
type: mapping
mapping:
......
......@@ -20,6 +20,23 @@ function hook_media_source_info_alter(array &$sources) {
$sources['youtube']['label'] = t('Youtube rocks!');
}
/**
* Alters an oEmbed resource URL before it is fetched.
*
* @param array $parsed_url
* A parsed URL, as returned by \Drupal\Component\Utility\UrlHelper::parse().
* @param \Drupal\media\OEmbed\Provider $provider
* The oEmbed provider for the resource.
*
* @see \Drupal\media\OEmbed\UrlResolverInterface::getResourceUrl()
*/
function hook_oembed_resource_url_alter(array &$parsed_url, \Drupal\media\OEmbed\Provider $provider) {
// Always serve YouTube videos from youtube-nocookie.com.
if ($provider->getName() === 'YouTube') {
$parsed_url['path'] = str_replace('://youtube.com/', '://youtube-nocookie.com/', $parsed_url['path']);
}
}
/**
* @} End of "addtogroup hooks".
*/
......@@ -8,3 +8,4 @@ dependencies:
- drupal:file
- drupal:image
- drupal:user
configure: media.settings
......@@ -5,6 +5,9 @@
* Install, uninstall and update hooks for Media module.
*/
use Drupal\Core\Url;
use Drupal\media\MediaTypeInterface;
use Drupal\media\Plugin\media\Source\OEmbedInterface;
use Drupal\user\RoleInterface;
use Drupal\user\Entity\Role;
......@@ -75,6 +78,36 @@ function media_requirements($phase) {
}
}
}
elseif ($phase === 'runtime') {
// Check that oEmbed content is served in an iframe on a different domain,
// and complain if it isn't.
$domain = \Drupal::config('media.settings')->get('iframe_domain');
if (!\Drupal::service('media.oembed.iframe_url_helper')->isSecure($domain)) {
// Find all media types which use a source plugin that implements
// OEmbedInterface.
$media_types = \Drupal::entityTypeManager()
->getStorage('media_type')
->loadMultiple();
$oembed_types = array_filter($media_types, function (MediaTypeInterface $media_type) {
return $media_type->getSource() instanceof OEmbedInterface;
});
if ($oembed_types) {
// @todo Potentially allow site administrators to suppress this warning
// permanently. See https://www.drupal.org/project/drupal/issues/2962753
// for more information.
$requirements['media_insecure_iframe'] = [
'title' => t('Media'),
'description' => t('It is potentially insecure to display oEmbed content in a frame that is served from the same domain as your main Drupal site, as this may allow execution of third-party code. <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,13 @@ function media_update_8500() {
$role->save();
}
}
/**
* Updates media.settings to support OEmbed.
*/
function media_update_8600() {
\Drupal::configFactory()->getEditable('media.settings')
->set('iframe_domain', '')
->set('oembed_providers_url', 'https://oembed.com/providers.json')
->save(TRUE);
}
......@@ -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
......@@ -5,6 +5,7 @@
* Provides media items.
*/
use Drupal\Component\Plugin\DerivativeInspectionInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
......@@ -15,6 +16,7 @@
use Drupal\Core\Template\Attribute;
use Drupal\Core\Url;
use Drupal\field\FieldConfigInterface;
use Drupal\media\Plugin\media\Source\OEmbedInterface;
/**
* Implements hook_help().
......@@ -72,6 +74,11 @@ function media_theme() {
'render element' => 'element',
'base hook' => 'field_multiple_value_form',
],
'media_oembed_iframe' => [
'variables' => [
'media' => NULL,
],
],
];
}
......@@ -92,6 +99,7 @@ function media_entity_access(EntityInterface $entity, $operation, AccountInterfa
*/
function media_theme_suggestions_media(array $variables) {
$suggestions = [];
/** @var \Drupal\media\MediaInterface $media */
$media = $variables['elements']['#media'];
$sanitized_view_mode = strtr($variables['elements']['#view_mode'], '.', '_');
......@@ -99,6 +107,31 @@ function media_theme_suggestions_media(array $variables) {
$suggestions[] = 'media__' . $media->bundle();
$suggestions[] = 'media__' . $media->bundle() . '__' . $sanitized_view_mode;
// Add suggestions based on the source plugin ID.
$source = $media->getSource();
if ($source instanceof DerivativeInspectionInterface) {
$source_id = $source->getBaseId();
$derivative_id = $source->getDerivativeId();
if ($derivative_id) {
$source_id .= '__derivative_' . $derivative_id;
}
}
else {
$source_id = $source->getPluginId();
}
$suggestions[] = "media__source_$source_id";
// If the source plugin uses oEmbed, add a suggestion based on the provider
// name, if available.
if ($source instanceof OEmbedInterface) {
$provider_id = $source->getMetadata($media, 'provider_name');
if ($provider_id) {
$provider_id = \Drupal::transliteration()->transliterate($provider_id);
$provider_id = preg_replace('/[^a-z0-9_]+/', '_', mb_strtolower($provider_id));
$suggestions[] = end($suggestions) . "__provider_$provider_id";
}
}
return $suggestions;
}
......
......@@ -24,3 +24,18 @@ entity.media.revision:
requirements:
_access_media_revision: 'view'
media: \d+
media.oembed_iframe:
path: '/media/oembed'
defaults:
_controller: '\Drupal\media\Controller\OEmbedIframeController::render'
requirements:
_permission: 'view media'
media.settings:
path: '/admin/config/media/media-settings'
defaults:
_form: '\Drupal\media\Form\MediaSettingsForm'
_title: 'Media settings'
requirements:
_permission: 'administer media'
......@@ -2,9 +2,20 @@ services:
plugin.manager.media.source:
class: Drupal\media\MediaSourceManager
parent: default_plugin_manager
access_check.media.revision:
class: Drupal\media\Access\MediaRevisionAccessCheck
arguments: ['@entity_type.manager']
tags:
- { name: access_check, applies_to: _access_media_revision }
media.oembed.url_resolver:
class: Drupal\media\OEmbed\UrlResolver
arguments: ['@media.oembed.provider_repository', '@media.oembed.resource_fetcher', '@http_client', '@module_handler', '@cache.default']
media.oembed.provider_repository:
class: Drupal\media\OEmbed\ProviderRepository
arguments: ['@http_client', '@config.factory', '@datetime.time', '@cache.default']
media.oembed.resource_fetcher:
class: Drupal\media\OEmbed\ResourceFetcher
arguments: ['@http_client', '@media.oembed.provider_repository', '@cache.default']
media.oembed.iframe_url_helper:
class: Drupal\media\IFrameUrlHelper
arguments: ['@router.request_context', '@private_key']
<?php
namespace Drupal\media\Controller;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Cache\CacheableResponse;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Url;
use Drupal\media\IFrameMarkup;
use Drupal\media\IFrameUrlHelper;
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\AccessDeniedHttpException;
/**
* 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;
/**
* The iFrame URL helper service.
*
* @var \Drupal\media\IFrameUrlHelper
*/
protected $iFrameUrlHelper;
/**
* 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.
* @param \Drupal\media\IFrameUrlHelper $iframe_url_helper
* The iFrame URL helper service.
*/
public function __construct(ResourceFetcherInterface $resource_fetcher, UrlResolverInterface $url_resolver, RendererInterface $renderer, LoggerChannelInterface $logger, IFrameUrlHelper $iframe_url_helper) {
$this->resourceFetcher = $resource_fetcher;
$this->urlResolver = $url_resolver;
$this->renderer = $renderer;
$this->logger = $logger;
$this->iFrameUrlHelper = $iframe_url_helper;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('media.oembed.resource_fetcher'),
$container->get('media.oembed.url_resolver'),
$container->get('renderer'),
$container->get('logger.factory')->get('media'),
$container->get('media.oembed.iframe_url_helper')
);
}
/**
* Renders an oEmbed resource.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*
* @return \Symfony\Component\HttpFoundation\Response
* The response object.
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
* Will be thrown if the 'hash' parameter does not match the expected hash
* of the 'url' parameter.
*/
public function render(Request $request) {
$url = $request->query->get('url');
$max_width = $request->query->getInt('max_width', NULL);
$max_height = $request->query->getInt('max_height', NULL);
// Hash the URL and max dimensions, and ensure it is equal to the hash
// parameter passed in the query string.
$hash = $this->iFrameUrlHelper->getHash($url, $max_width, $max_height);
if (!Crypt::hashEquals($hash, $request->query->get('hash', ''))) {
throw new AccessDeniedHttpException('This resource is not available');
}
// Return a response instead of a render array so that the frame content
// will not have all the blocks and page elements normally rendered by
// Drupal.
$response = new CacheableResponse();
$response->addCacheableDependency(Url::createFromRequest($request));
try {
$resource_url = $this->urlResolver->getResourceUrl($url, $max_width, $max_height);
$resource = $this->resourceFetcher->fetchResource($resource_url);
// Render the content in a new render context so that the cacheability
// metadata of the rendered HTML will be captured correctly.
$content = $this->renderer->executeInRenderContext(new RenderContext(), function () use ($resource) {
$element = [
'#theme' => 'media_oembed_iframe',
// Even though the resource HTML is untrusted, IFrameMarkup::create()
// will create a trusted string. The only reason this is okay is
// because we are serving it in an iframe, which will mitigate the
// potential dangers of displaying third-party markup.
'#media' => IFrameMarkup::create($resource->getHtml()),
];
return $this->renderer->render($element);
});
$response->setContent($content)->addCacheableDependency($resource);
}
catch (ResourceException $e) {
// Prevent the response from being cached.
$response->setMaxAge(0);
// The oEmbed system makes heavy use of exception wrapping, so log the
// entire exception chain to help with troubleshooting.
do {
// @todo Log additional information from ResourceException, to help with
// debugging, in https://www.drupal.org/project/drupal/issues/2972846.
$this->logger->error($e->getMessage());
$e = $e->getPrevious();
} while ($e);
}
return $response;
}
}
......@@ -181,25 +181,7 @@ public function getSource() {
* https://www.drupal.org/node/2878119
*/
protected function updateThumbnail($from_queue = FALSE) {
$file_storage = \Drupal::service('entity_type.manager')->getStorage('file');
$thumbnail_uri = $this->getThumbnailUri($from_queue);
$existing = $file_storage->getQuery()
->condition('uri', $thumbnail_uri)
->execute();
if ($existing) {
$this->thumbnail->target_id = reset($existing);
}
else {
/** @var \Drupal\file\FileInterface $file */
$file = $file_storage->create(['uri' => $thumbnail_uri]);
if ($owner = $this->getOwner()) {
$file->setOwner($owner);
}
$file->setPermanent();
$file->save();
$this->thumbnail->target_id = $file->id();
}
$this->thumbnail->target_id = $this->loadThumbnail($this->getThumbnailUri($from_queue))->id();
// Set the thumbnail alt.
$media_source = $this->getSource();
......@@ -222,6 +204,52 @@ protected function updateThumbnail($from_queue = FALSE) {
return $this;
}
/**
* Loads the file entity for the thumbnail.
*
* If the file entity does not exist, it will be created.
*
* @param string $thumbnail_uri
* (optional) The URI of the thumbnail, used to load or create the file
* entity. If omitted, the default thumbnail URI will be used.
*
* @return \Drupal\file\FileInterface
* The thumbnail file entity.
*/
protected function loadThumbnail($thumbnail_uri = NULL) {
$values = [
'uri' => $thumbnail_uri ?: $this->getDefaultThumbnailUri(),
];
$file_storage = $this->entityTypeManager()->getStorage('file');
$existing = $file_storage->loadByProperties($values);
if ($existing) {
$file = reset($existing);
}
else {
/** @var \Drupal\file\FileInterface $file */
$file = $file_storage->create($values);
if ($owner = $this->getOwner()) {
$file->setOwner($owner);
}
$file->setPermanent();
$file->save();
}
return $file;
}
/**
* Returns the URI of the default thumbnail.
*
* @return string
* The default thumbnail URI.
*/
protected function getDefaultThumbnailUri() {
$default_thumbnail_filename = $this->getSource()->getPluginDefinition()['default_thumbnail_filename'];
return \Drupal::config('media.settings')->get('icon_base_uri') . '/' . $default_thumbnail_filename;
}
/**
* Updates the queued thumbnail for the media item.
*
......@@ -257,17 +285,14 @@ public function updateQueuedThumbnail() {
protected function getThumbnailUri($from_queue) {
$thumbnails_queued = $this->bundle->entity->thumbnailDownloadsAreQueued();
if ($thumbnails_queued && $this->isNew()) {
$default_thumbnail_filename = $this->getSource()->getPluginDefinition()['default_thumbnail_filename'];
$thumbnail_uri = \Drupal::service('config.factory')->get('media.settings')->get('icon_base_uri') . '/' . $default_thumbnail_filename;
return $this->getDefaultThumbnailUri();
}
elseif ($thumbnails_queued && !$from_queue) {
$thumbnail_uri = $this->get('thumbnail')->entity->getFileUri();
}
else {
$thumbnail_uri = $this->getSource()->getMetadata($this, $this->getSource()->getPluginDefinition()['thumbnail_uri_metadata_attribute']);
return $this->get('thumbnail')->entity->getFileUri();
}
return $thumbnail_uri;
$source = $this->getSource();
return $source->getMetadata($this, $source->getPluginDefinition()['thumbnail_uri_metadata_attribute']);
}
/**
......@@ -305,30 +330,9 @@ protected function shouldUpdateThumbnail($is_new = FALSE) {
public function preSave(EntityStorageInterface $storage) {
parent::preSave($storage);
$media_source = $this->getSource();
foreach ($this->translations as $langcode => $data) {
if ($this->hasTranslation($langcode)) {
$translation = $this->getTranslation($langcode);
// Try to set fields provided by the media source and mapped in
// media type config.
foreach ($translation->bundle->entity->getFieldMap() as $metadata_attribute_name => $entity_field_name) {
// Only save value in entity field if empty. Do not overwrite existing
// data.
if ($translation->hasField($entity_field_name) && ($translation->get($entity_field_name)->isEmpty() || $translation->hasSourceFieldChanged())) {
$translation->set($entity_field_name, $media_source->getMetadata($translation, $metadata_attribute_name));
}
}
// Try to set a default name for this media item if no name is provided.
if ($translation->get('name')->isEmpty()) {
$translation->setName($translation->getName());
}
// Set thumbnail.
if ($translation->shouldUpdateThumbnail()) {
$translation->updateThumbnail();
}
}
// If no thumbnail has been explicitly set, use the default thumbnail.
if ($this->get('thumbnail')->isEmpty()) {
$this->thumbnail->target_id = $this->loadThumbnail()->id();
}
}
......@@ -369,6 +373,55 @@ public function preSaveRevision(EntityStorageInterface $storage, \stdClass $reco
}
}
/**
* {@inheritdoc}
*/
public function save() {
// @todo If the source plugin talks to a remote API (e.g. oEmbed), this code
// might be performing a fair number of HTTP requests. This is dangerously
// brittle and should probably be handled by a queue, to avoid doing HTTP
// operations during entity save. As it is, doing this before calling
// parent::save() is a quick-fix to avoid doing HTTP requests in the middle
// of a database transaction (which begins once we call parent::save()). See
// https://www.drupal.org/project/drupal/issues/2976875 for more.
// In order for metadata to be mapped correctly, $this->original must be
// set. However, that is only set once parent::save() is called, so work
// around that by setting it here.
if (!isset($this->original) && $id = $this->id()) {
$this->original = $this->entityTypeManager()
->getStorage('media')
->loadUnchanged($id);
}
$media_source = $this->getSource();
foreach ($this->translations as $langcode => $data) {
if ($this->hasTranslation($langcode)) {
$translation = $this->getTranslation($langcode);
// Try to set fields provided by the media source and mapped in
// media type config.
foreach ($translation->bundle->entity->getFieldMap() as $metadata_attribute_name => $entity_field_name) {
// Only save value in entity field if empty. Do not overwrite existing
// data.
if ($translation->hasField($entity_field_name) && ($translation->get($entity_field_name)->isEmpty() || $translation->hasSourceFieldChanged())) {
$translation->set($entity_field_name, $media_source->getMetadata($translation, $metadata_attribute_name));
}
}
// Try to set a default name for this media item if no name is provided.
if ($translation->get('name')->isEmpty()) {
$translation->setName($translation->getName());
}
// Set thumbnail.
if ($translation->shouldUpdateThumbnail($this->isNew())) {
$translation->updateThumbnail();
}
}
}
return parent::save();
}
/**
* {@inheritdoc}
*/
......
<?php
namespace Drupal\media\Form;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\media\IFrameUrlHelper;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a form to configure Media settings.
*
* @internal
*/
class MediaSettingsForm extends ConfigFormBase {
/**
* The iFrame URL helper service.
*
* @var \Drupal\media\IFrameUrlHelper
*/
protected $iFrameUrlHelper;
/**
* MediaSettingsForm constructor.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory service.
* @param \Drupal\media\IFrameUrlHelper $iframe_url_helper
* The iFrame URL helper service.
*/
public function __construct(ConfigFactoryInterface $config_factory, IFrameUrlHelper $iframe_url_helper) {
parent::__construct($config_factory);
$this->iFrameUrlHelper = $iframe_url_helper;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('config.factory'),
$container->get('media.oembed.iframe_url_helper')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'media_settings_form';
}
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames() {
return ['media.settings'];
}