diff --git a/core/modules/media/src/Plugin/media/Source/OEmbed.php b/core/modules/media/src/Plugin/media/Source/OEmbed.php index fd1d1ebaea727256f40223f3d0a331628b1a91ab..e406903df609673671f89a0e99fefc40f548144c 100644 --- a/core/modules/media/src/Plugin/media/Source/OEmbed.php +++ b/core/modules/media/src/Plugin/media/Source/OEmbed.php @@ -26,6 +26,8 @@ use GuzzleHttp\Exception\TransferException; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Mime\MimeTypes; /** * Provides a media source plugin for oEmbed resources. @@ -388,21 +390,11 @@ protected function getLocalThumbnailUri(Resource $resource) { if (!$remote_thumbnail_url) { return NULL; } - $remote_thumbnail_url = $remote_thumbnail_url->toString(); - - // Remove the query string, since we do not want to include it in the local - // thumbnail URI. - $local_thumbnail_url = parse_url($remote_thumbnail_url, PHP_URL_PATH); - // Compute the local thumbnail URI, regardless of whether or not it exists. + // Ensure that we can write to the local directory where thumbnails are + // stored. $configuration = $this->getConfiguration(); $directory = $configuration['thumbnails_directory']; - $local_thumbnail_uri = "$directory/" . Crypt::hashBase64($local_thumbnail_url) . '.' . pathinfo($local_thumbnail_url, PATHINFO_EXTENSION); - - // If the local thumbnail already exists, return its URI. - if (file_exists($local_thumbnail_uri)) { - return $local_thumbnail_uri; - } // The local thumbnail doesn't exist yet, so try to download it. First, // ensure that the destination directory is writable, and if it's not, @@ -414,8 +406,25 @@ protected function getLocalThumbnailUri(Resource $resource) { return NULL; } + // The local filename of the thumbnail is always a hash of its remote URL. + // If a file with that name already exists in the thumbnails directory, + // regardless of its extension, return its URI. + $remote_thumbnail_url = $remote_thumbnail_url->toString(); + $hash = Crypt::hashBase64($remote_thumbnail_url); + $finder = Finder::create() + ->files() + ->in($directory) + ->name("$hash.*"); + if (count($finder) > 0) { + /** @var \Symfony\Component\Finder\SplFileInfo[] $files */ + $files = iterator_to_array($finder); + return reset($files)->getPathname(); + } + + // The local thumbnail doesn't exist yet, so we need to download it. + $local_thumbnail_uri = $directory . DIRECTORY_SEPARATOR . $hash . '.' . $this->getThumbnailFileExtensionFromUrl($remote_thumbnail_url); try { - $response = $this->httpClient->get($remote_thumbnail_url); + $response = $this->httpClient->request('GET', $remote_thumbnail_url); if ($response->getStatusCode() === 200) { $this->fileSystem->saveData((string) $response->getBody(), $local_thumbnail_uri, FileSystemInterface::EXISTS_REPLACE); return $local_thumbnail_uri; @@ -432,6 +441,49 @@ protected function getLocalThumbnailUri(Resource $resource) { return NULL; } + /** + * Tries to determine the file extension of a thumbnail. + * + * @param string $thumbnail_url + * The remote URL of the thumbnail. + * + * @return string|null + * The file extension, or NULL if it could not be determined. + */ + protected function getThumbnailFileExtensionFromUrl(string $thumbnail_url): ?string { + // First, try to glean the extension from the URL path. + $path = parse_url($thumbnail_url, PHP_URL_PATH); + if ($path) { + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if ($extension) { + return $extension; + } + } + + // If the URL didn't give us any clues about the file extension, make a HEAD + // request to the thumbnail URL and see if the headers will give us a MIME + // type. + try { + $content_type = $this->httpClient->request('HEAD', $thumbnail_url) + ->getHeader('Content-Type'); + } + catch (TransferException $e) { + $this->logger->warning($e->getMessage()); + } + + // If there was no Content-Type header, there's nothing else we can do. + if (empty($content_type)) { + return NULL; + } + $extensions = MimeTypes::getDefault()->getExtensions(reset($content_type)); + if ($extensions) { + return reset($extensions); + } + // If no file extension could be determined from the Content-Type header, + // we're stumped. + return NULL; + } + /** * {@inheritdoc} */ diff --git a/core/modules/media/tests/fixtures/oembed/video_collegehumor.xml b/core/modules/media/tests/fixtures/oembed/video_collegehumor.xml index 76815ed1387de471cebe627dc2a47076e9c845f4..0894cafcf97e34f6789f0a6a6f63ddd426146bed 100644 --- a/core/modules/media/tests/fixtures/oembed/video_collegehumor.xml +++ b/core/modules/media/tests/fixtures/oembed/video_collegehumor.xml @@ -12,7 +12,10 @@ <height>343</height> <html><h1>By the power of Grayskull, CollegeHumor works!</h1> </html> - <thumbnail_url>internal:/core/misc/druplicon.png</thumbnail_url> + <!-- The thumbnail URL does not contain a file extension, so we use + this to test the oEmbed source plugin's thumbnail handling; + see \Drupal\Tests\media\FunctionalJavascript\MediaSourceOEmbedVideoTest. --> + <thumbnail_url>internal:/media_test_oembed/thumbnail</thumbnail_url> <thumbnail_width>88</thumbnail_width> <thumbnail_height>100</thumbnail_height> </oembed> 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 index 75ce685af710c04f88feb378ab3f216afd0af555..dca946e6564d7c1a8937a717e7db72257c220f8f 100644 --- 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 @@ -4,3 +4,9 @@ media_test_oembed.resource.get: _controller: '\Drupal\media_test_oembed\Controller\ResourceController::get' requirements: _access: 'TRUE' +media_test_oembed.resource.thumbnail: + path: '/media_test_oembed/thumbnail' + defaults: + _controller: '\Drupal\media_test_oembed\Controller\ResourceController::getThumbnailWithNoExtension' + 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 index ab58e2f9629c503312ed77df38a24f356de8c824..6ea9a1e5354b5cfe7c33125d06fbe8189f4c3481 100644 --- 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 @@ -2,6 +2,7 @@ namespace Drupal\media_test_oembed\Controller; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -30,12 +31,24 @@ public function get(Request $request) { else { $content = file_get_contents($resources[$asset_url]); $response = new Response($content); - $response->headers->set('Content-Type', 'application/json'); + $response->headers->set('Content-Type', 'application/' . pathinfo($resources[$asset_url], PATHINFO_EXTENSION)); } return $response; } + /** + * Returns an example thumbnail file without an extension. + * + * @return \Symfony\Component\HttpFoundation\BinaryFileResponse + * The response. + */ + public function getThumbnailWithNoExtension() { + $response = new BinaryFileResponse('core/misc/druplicon.png'); + $response->headers->set('Content-Type', 'image/png'); + return $response; + } + /** * Maps an asset URL to a local fixture representing its oEmbed resource. * diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php index a5c19ad0c7f67ed76f86a5aa5f3b2c8904a47ccc..a1dbe6e8b153b7db9768fd3a3cddbb75712ece2e 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php @@ -4,6 +4,7 @@ use Drupal\Core\Session\AccountInterface; use Drupal\media\Entity\Media; +use Drupal\media\Entity\MediaType; use Drupal\media_test_oembed\Controller\ResourceController; use Drupal\Tests\media\Traits\OEmbedTestTrait; use Drupal\user\Entity\Role; @@ -164,6 +165,29 @@ public function testMediaOEmbedVideoSource() { $assert_session->pageTextContains('The CollegeHumor provider is not allowed.'); + // Register a CollegeHumor video as a second oEmbed resource. Note that its + // thumbnail URL does not have a file extension. + $media_type = MediaType::load($media_type_id); + $source_configuration = $media_type->getSource()->getConfiguration(); + $source_configuration['providers'][] = 'CollegeHumor'; + $media_type->getSource()->setConfiguration($source_configuration); + $media_type->save(); + $video_url = 'http://www.collegehumor.com/video/40003213/let-not-get-a-drink-sometime'; + ResourceController::setResourceUrl($video_url, $this->getFixturesDirectory() . '/video_collegehumor.xml'); + + // Create a new media item using a CollegeHumor video. + $this->drupalGet("media/add/$media_type_id"); + $assert_session->fieldExists('Remote video URL')->setValue($video_url); + $assert_session->buttonExists('Save')->press(); + + /** @var \Drupal\media\MediaInterface $media */ + $media = Media::load(2); + $thumbnail = $media->getSource()->getMetadata($media, 'thumbnail_uri'); + $this->assertFileExists($thumbnail); + // Although the resource's thumbnail URL doesn't have a file extension, we + // should have deduced the correct one. + $this->assertStringEndsWith('.png', $thumbnail); + // Test anonymous access to media via iframe. $this->drupalLogout(); diff --git a/core/modules/media/tests/src/Kernel/OEmbedSourceTest.php b/core/modules/media/tests/src/Kernel/OEmbedSourceTest.php index 923a9cc00f1c88a3f1a613837d53c70465125597..314b2796e8b6157f397324264d1d3fa9fc0a7bad 100644 --- a/core/modules/media/tests/src/Kernel/OEmbedSourceTest.php +++ b/core/modules/media/tests/src/Kernel/OEmbedSourceTest.php @@ -3,7 +3,7 @@ namespace Drupal\Tests\media\Kernel; use Drupal\Component\Utility\Crypt; -use Drupal\Core\Url; +use Drupal\Core\Logger\RfcLogLevel; use Drupal\media\Entity\Media; use Drupal\media\OEmbed\Resource; use Drupal\media\OEmbed\ResourceFetcherInterface; @@ -11,6 +11,7 @@ use Drupal\media\Plugin\media\Source\OEmbed; use GuzzleHttp\Client; use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Psr7\Utils; use Prophecy\Argument; /** @@ -43,9 +44,62 @@ public function testGetMetadata() { } /** + * Data provider for ::testThumbnailUri(). + * + * @return array + * Sets of arguments to pass to the test method. + */ + public function providerThumbnailUri(): array { + return [ + 'no query string, file extension is known' => [ + 'internal:/core/misc/druplicon.png', + ], + 'with query string and file extension' => [ + 'internal:/core/misc/druplicon.png?foo=bar', + ], + 'no query string, unknown file extension' => [ + 'internal:/core/misc/druplicon', + [ + 'Content-Type' => ['image/png'], + ], + ], + 'query string, unknown file extension' => [ + 'internal:/core/misc/druplicon?pasta=ravioli', + [ + 'Content-Type' => ['image/png'], + ], + ], + 'no query string, unknown file extension, exception' => [ + 'internal:/core/misc/druplicon', + '\GuzzleHttp\Exception\TransferException', + ], + ]; + } + + /** + * Tests that remote thumbnails are downloaded correctly. + * + * @param string $remote_thumbnail_url + * The URL of the remote thumbnail. This will be wired up to a mocked + * response containing the data from core/misc/druplicon.png. + * @param array[]|string $thumbnail_headers + * (optional) If the thumbnail's file extension cannot be determined from + * its URL, a HEAD request will be made in an attempt to derive its + * extension from its Content-Type header. If this is an array, it contains + * headers that should be returned by the HEAD request, where the keys are + * header names and the values are arrays of strings. If it's the name of a + * throwable class, it is the exception class that should be thrown by the + * HTTP client. + * * @covers ::getLocalThumbnailUri + * + * @dataProvider providerThumbnailUri */ - public function testLocalThumbnailUriQueryStringIsIgnored() { + public function testThumbnailUri(string $remote_thumbnail_url, $thumbnail_headers = NULL): void { + // Create a fake resource with the given thumbnail URL. + $resource = Resource::rich('<html></html>', 16, 16, NULL, 'Test resource', NULL, NULL, NULL, $remote_thumbnail_url, 16, 16); + $thumbnail_url = $resource->getThumbnailUrl()->toString(); + // There's no need to resolve the resource URL in this test; we just need // to fetch the resource. $this->container->set( @@ -53,23 +107,54 @@ public function testLocalThumbnailUriQueryStringIsIgnored() { $this->prophesize(UrlResolverInterface::class)->reveal() ); - $thumbnail_url = Url::fromUri('internal:/core/misc/druplicon.png?foo=bar'); - - // Create a mocked resource whose thumbnail URL contains a query string. - $resource = $this->prophesize(Resource::class); - $resource->getTitle()->willReturn('Test resource'); - $resource->getThumbnailUrl()->willReturn($thumbnail_url); + // Mock the resource fetcher so that it will return our fake resource. + $resource_fetcher = $this->prophesize(ResourceFetcherInterface::class); + $resource_fetcher->fetchResource(Argument::any()) + ->willReturn($resource); + $this->container->set('media.oembed.resource_fetcher', $resource_fetcher->reveal()); // The source plugin will try to fetch the remote thumbnail, so mock the - // HTTP client to ensure that request returns an empty "OK" response. + // HTTP client to ensure that request returns a response with some valid + // image data. + $data = Utils::tryFopen($this->getDrupalRoot() . '/core/misc/druplicon.png', 'r'); + $response = new Response(200, [], Utils::streamFor($data)); $http_client = $this->prophesize(Client::class); - $http_client->get(Argument::type('string'))->willReturn(new Response()); - $this->container->set('http_client', $http_client->reveal()); + // The thumbnail should only be downloaded once. + $http_client->request('GET', $thumbnail_url)->willReturn($response) + ->shouldBeCalledOnce(); + // The extension we expect the downloaded thumbnail to have. + $expected_extension = 'png'; - // Mock the resource fetcher so that it will return our mocked resource. - $resource_fetcher = $this->prophesize(ResourceFetcherInterface::class); - $resource_fetcher->fetchResource(NULL)->willReturn($resource->reveal()); - $this->container->set('media.oembed.resource_fetcher', $resource_fetcher->reveal()); + // If the file extension cannot be derived from the URL, a single HEAD + // request should be made to try and determine its type from the + // Content-Type HTTP header. + if (is_array($thumbnail_headers)) { + $response = new Response(200, $thumbnail_headers); + $http_client->request('HEAD', $thumbnail_url) + ->willReturn($response) + ->shouldBeCalledOnce(); + } + elseif (is_a($thumbnail_headers, 'Throwable', TRUE)) { + $e = new $thumbnail_headers('Nope!'); + + $http_client->request('HEAD', $thumbnail_url) + ->willThrow($e) + ->shouldBeCalledOnce(); + + // Ensure that the exception is logged. + $logger = $this->prophesize('\Psr\Log\LoggerInterface'); + $logger->log(RfcLogLevel::WARNING, $e->getMessage(), Argument::cetera()) + ->shouldBeCalled(); + $this->container->get('logger.factory')->addLogger($logger->reveal()); + + // If the request fails, we won't be able to determine the thumbnail's + // extension. + $expected_extension = ''; + } + else { + $http_client->request('HEAD', $thumbnail_url)->shouldNotBeCalled(); + } + $this->container->set('http_client', $http_client->reveal()); $media_type = $this->createMediaType('oembed:video'); $source = $media_type->getSource(); @@ -80,11 +165,18 @@ public function testLocalThumbnailUriQueryStringIsIgnored() { ]); $media->save(); - // Get the local thumbnail URI and ensure that it does not contain any - // query string. - $local_thumbnail_uri = $media_type->getSource()->getMetadata($media, 'thumbnail_uri'); - $expected_uri = 'public://oembed_thumbnails/' . Crypt::hashBase64('/core/misc/druplicon.png') . '.png'; - $this->assertSame($expected_uri, $local_thumbnail_uri); + // The thumbnail should have a file extension, even if it wasn't in the URL. + $expected_uri = 'public://oembed_thumbnails/' . Crypt::hashBase64($thumbnail_url) . ".$expected_extension"; + $this->assertSame($expected_uri, $source->getMetadata($media, 'thumbnail_uri')); + // Even if we get the thumbnail_uri more than once, it should only be + // downloaded once (this is verified by the shouldBeCalledOnce() checks + // in the mocked HTTP client). + $source->getMetadata($media, 'thumbnail_uri'); + // The downloaded thumbnail should be usable by the image toolkit. + $this->assertFileExists($expected_uri); + /** @var \Drupal\Core\Image\Image $image */ + $image = $this->container->get('image.factory')->get($expected_uri); + $this->assertTrue($image->isValid()); } }