diff --git a/config/schema/media_entity_twitter.schema.yml b/config/schema/media_entity_twitter.schema.yml index 90c364b..0b23a39 100644 --- a/config/schema/media_entity_twitter.schema.yml +++ b/config/schema/media_entity_twitter.schema.yml @@ -28,3 +28,6 @@ media_entity.bundle.type.twitter: oauth_access_token_secret: type: string label: 'Field with Oauth access token secret' + generate_thumbnails: + type: boolean + label: 'Enable automatic thumbnail generation' diff --git a/media_entity_twitter.module b/media_entity_twitter.module index 363d96b..7985c2d 100644 --- a/media_entity_twitter.module +++ b/media_entity_twitter.module @@ -13,5 +13,49 @@ function media_entity_twitter_theme() { 'media_entity_twitter_tweet' => [ 'variables' => ['path' => NULL, 'attributes' => []], ], + 'media_entity_twitter_tweet_thumbnail' => [ + 'variables' => [ + 'content' => '', + 'author' => '', + 'avatar' => NULL, + ], + ], ]; } + +/** + * Preprocess function for media_entity_twitter_tweet_thumbnail theme hook. + * + * @param array $variables + * Variables to be injected into the template. + */ +function media_entity_twitter_preprocess_media_entity_twitter_tweet_thumbnail(array &$variables) { + // If the avatar exists, load it directly into memory and base64 encode it. + // For security reasons, browsers don't always allow external images xlinked + // in an SVG to be displayed when the SVG is being embedded via an tag. + // The workaround is to embed the image directly into the SVG as a base64 + // encoded string. + if ($variables['avatar']) { + $extension = pathinfo($variables['avatar'], PATHINFO_EXTENSION); + $extension = strtolower($extension); + + // Don't fetch the avatar if it has an unrecognized extension. + if (in_array($extension, ['gif', 'jpg', 'jpeg', 'png', 'webp'])) { + $data = file_get_contents($variables['avatar']); + + if ($data) { + // image/jpg is not a thing. + if ($extension == 'jpg') { + $extension = 'jpeg'; + } + $variables['avatar'] = 'data:image/' . $extension . ';base64,' . base64_encode($data); + } + else { + unset($variables['avatar']); + } + } + else { + unset($variables['avatar']); + } + } +} diff --git a/media_entity_twitter.services.yml b/media_entity_twitter.services.yml new file mode 100644 index 0000000..2391921 --- /dev/null +++ b/media_entity_twitter.services.yml @@ -0,0 +1,13 @@ +services: + media_entity_twitter.tweet_fetcher: + class: '\Drupal\media_entity_twitter\TweetFetcher' + arguments: + - '@media_entity_twitter.tweet_cache' + + media_entity_twitter.tweet_cache: + class: '\Drupal\Core\Cache\CacheBackendInterface' + tags: + - { name: cache.bin, default_backend: cache.backend.chainedfast } + factory: cache_factory:get + arguments: + - tweet_cache diff --git a/src/Plugin/MediaEntity/Type/Twitter.php b/src/Plugin/MediaEntity/Type/Twitter.php index 18fa450..e2991f9 100644 --- a/src/Plugin/MediaEntity/Type/Twitter.php +++ b/src/Plugin/MediaEntity/Type/Twitter.php @@ -6,10 +6,11 @@ use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\RendererInterface; use Drupal\media_entity\MediaInterface; use Drupal\media_entity\MediaTypeBase; use Drupal\media_entity\MediaTypeException; -use Drupal\Component\Serialization\Json; +use Drupal\media_entity_twitter\TweetFetcherInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -31,6 +32,20 @@ class Twitter extends MediaTypeBase { protected $configFactory; /** + * The renderer. + * + * @var \Drupal\Core\Render\RendererInterface + */ + protected $renderer; + + /** + * The tweet fetcher. + * + * @var \Drupal\media_entity_twitter\TweetFetcherInterface + */ + protected $tweetFetcher; + + /** * {@inheritdoc} */ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { @@ -40,7 +55,9 @@ class Twitter extends MediaTypeBase { $plugin_definition, $container->get('entity_type.manager'), $container->get('entity_field.manager'), - $container->get('config.factory') + $container->get('config.factory'), + $container->get('renderer'), + $container->get('media_entity_twitter.tweet_fetcher') ); } @@ -68,10 +85,16 @@ class Twitter extends MediaTypeBase { * Entity field manager service. * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * Config factory service. + * @param \Drupal\Core\Render\RendererInterface $renderer + * The renderer. + * @param \Drupal\media_entity_twitter\TweetFetcherInterface $tweet_fetcher + * The tweet fetcher. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, ConfigFactoryInterface $config_factory) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, ConfigFactoryInterface $config_factory, RendererInterface $renderer, TweetFetcherInterface $tweet_fetcher) { parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager, $entity_field_manager, $config_factory->get('media_entity.settings')); $this->configFactory = $config_factory; + $this->renderer = $renderer; + $this->tweetFetcher = $tweet_fetcher; } /** @@ -80,6 +103,7 @@ class Twitter extends MediaTypeBase { public function defaultConfiguration() { return [ 'use_twitter_api' => FALSE, + 'generate_thumbnails' => FALSE, ]; } @@ -138,21 +162,23 @@ class Twitter extends MediaTypeBase { return FALSE; case 'image_local': - if (isset($tweet['extended_entities']['media'][0]['media_url'])) { - $local_uri = $this->configFactory->get('media_entity_twitter.settings')->get('local_images') . '/' . $matches['id'] . '.' . pathinfo($tweet['extended_entities']['media'][0]['media_url'], PATHINFO_EXTENSION); + $local_uri = $this->getField($media, 'image_local_uri'); - if (!file_exists($local_uri)) { - file_prepare_directory($this->configFactory->get('media_entity_twitter.settings')->get('local_images'), FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS); - file_unmanaged_save_data($tweet['extended_entities']['media'][0]['media_url'], $local_uri, FILE_EXISTS_REPLACE); + if ($local_uri) { + if (file_exists($local_uri)) { + return $local_uri; + } + else { + $image_url = $this->getField($media, 'image'); + return file_unmanaged_save_data($image_url, $local_uri, FILE_EXISTS_REPLACE); } - - return $local_uri; } return FALSE; case 'image_local_uri': - if (isset($tweet['extended_entities']['media'][0]['media_url'])) { - return $this->configFactory->get('media_entity_twitter.settings')->get('local_images') . '/' . $matches['id'] . '.' . pathinfo($tweet['extended_entities']['media'][0]['media_url'], PATHINFO_EXTENSION); + $image_url = $this->getField($media, 'image'); + if ($image_url) { + return $this->getLocalImageUri($matches['id'], $image_url); } return FALSE; @@ -257,6 +283,22 @@ class Twitter extends MediaTypeBase { ), ); + $form['generate_thumbnails'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Generate thumbnails'), + '#default_value' => $this->configuration['generate_thumbnails'], + '#states' => [ + 'visible' => [ + ':input[name="type_configuration[twitter][use_twitter_api]"]' => [ + 'checked' => TRUE, + ], + ], + ], + '#description' => $this->t('If checked, Drupal will automatically generate thumbnails for tweets that do not reference any external media. In certain circumstances, this may violate Twitter\'s fair use policy. Please read it and be careful if you choose to enable this.', [ + '@policy' => 'https://dev.twitter.com/overview/terms/agreement-and-policy', + ]), + ]; + return $form; } @@ -280,6 +322,40 @@ class Twitter extends MediaTypeBase { } /** + * Computes the destination URI for a tweet image. + * + * @param mixed $id + * The tweet ID. + * @param string|null $media_url + * The URL of the media (i.e., photo, video, etc.) associated with the + * tweet. + * + * @return string + * The desired local URI. + */ + protected function getLocalImageUri($id, $media_url = NULL) { + $directory = $this->configFactory + ->get('media_entity_twitter.settings') + ->get('local_images'); + + // Ensure the destination directory is writable. (TODO: Throw an exception + // or otherwise take action if it isn't.) + file_prepare_directory($directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS); + + $local_uri = $directory . '/' . $id . '.'; + if ($media_url) { + $local_uri .= pathinfo($media_url, PATHINFO_EXTENSION); + } + else { + // If there is no media associated with the tweet, we will generate an + // SVG thumbnail. + $local_uri .= 'svg'; + } + + return $local_uri; + } + + /** * {@inheritdoc} */ public function getDefaultThumbnail() { @@ -290,11 +366,35 @@ class Twitter extends MediaTypeBase { * {@inheritdoc} */ public function thumbnail(MediaInterface $media) { + // If there's already a local image, use it. if ($local_image = $this->getField($media, 'image_local')) { return $local_image; } - return $this->getDefaultThumbnail(); + // If thumbnail generation is disabled, use the default thumbnail. + if (empty($this->configuration['generate_thumbnails'])) { + return $this->getDefaultThumbnail(); + } + + // We might need to generate a thumbnail... + $id = $this->getField($media, 'id'); + $thumbnail_uri = $this->getLocalImageUri($id); + + // ...unless we already have, in which case, use it. + if (file_exists($thumbnail_uri)) { + return $thumbnail_uri; + } + + // Render the thumbnail SVG using the theme system. + $thumbnail = [ + '#theme' => 'media_entity_twitter_tweet_thumbnail', + '#content' => $this->getField($media, 'content'), + '#author' => $this->getField($media, 'user'), + '#avatar' => $this->getField($media, 'profile_image_url_https'), + ]; + $svg = $this->renderer->renderRoot($thumbnail); + + return file_unmanaged_save_data($svg, $thumbnail_uri, FILE_EXISTS_ERROR) ?: $this->getDefaultThumbnail(); } /** @@ -327,60 +427,24 @@ class Twitter extends MediaTypeBase { } /** - * Get auth settings. - * - * @return array - * Array of auth settings. - */ - protected function getAuthSettings() { - return array( - 'consumer_key' => $this->configuration['consumer_key'], - 'consumer_secret' => $this->configuration['consumer_secret'], - 'oauth_access_token' => $this->configuration['oauth_access_token'], - 'oauth_access_token_secret' => $this->configuration['oauth_access_token_secret'], - ); - } - - /** * Get a single tweet. * * @param int $id * The tweet id. */ protected function fetchTweet($id) { - $tweet = &drupal_static(__FUNCTION__); - - if (!isset($tweet)) { - // Check for dependencies. - // @todo There is perhaps a better way to do that. - if (!class_exists('\TwitterAPIExchange')) { - drupal_set_message($this->t('Twitter library is not available. Consult the README.md for installation instructions.'), 'error'); - return; - } - - // Settings. - $auth_settings = $this->getAuthSettings(); - $request_settings = array( - 'url' => 'https://api.twitter.com/1.1/statuses/show.json', - 'method' => 'GET', - ); - $query = "?id=$id"; - - // Get the tweet. - $twitter = new \TwitterAPIExchange($auth_settings); - $result = $twitter->setGetfield($query) - ->buildOauth($request_settings['url'], $request_settings['method']) - ->performRequest(); + $this->tweetFetcher->setCredentials( + $this->configuration['consumer_key'], + $this->configuration['consumer_secret'], + $this->configuration['oauth_access_token'], + $this->configuration['oauth_access_token_secret'] + ); - if ($result) { - return Json::decode($result); - } - else { - throw new MediaTypeException(NULL, 'The tweet could not be retrived.'); - } + try { + return $this->tweetFetcher->fetchTweet($id); } - else { - return $tweet; + catch (\Exception $e) { + throw new MediaTypeException(NULL, $e->getMessage()); } } diff --git a/src/TweetFetcher.php b/src/TweetFetcher.php new file mode 100644 index 0000000..e645c58 --- /dev/null +++ b/src/TweetFetcher.php @@ -0,0 +1,90 @@ +cache = $cache; + } + + /** + * {@inheritdoc} + */ + public function fetchTweet($id) { + // Tweets don't change much, so pull it out of the cache (if we have one) + // if this one has already been fetched. + if ($this->cache && $cached_tweet = $this->cache->get($id)) { + return $cached_tweet->data; + } + + // Assume credentials have been set and query Twitter's API. + $response = $this->twitter + ->setGetfield('?id=' . $id) + ->buildOAuth('https://api.twitter.com/1.1/statues/show.json', 'GET') + ->performRequest(); + + if (empty($response)) { + throw new \Exception('Could not retrieve tweet ' . $id); + } + + $response = Json::decode($response); + // If we have a cache, store the response for future use. + if ($this->cache) { + $this->cache->set($id, $response); + } + + return $response; + } + + /** + * {@inheritdoc} + */ + public function getCredentials() { + return $this->credentials; + } + + /** + * {@inheritdoc} + */ + public function setCredentials($consumer_key, $consumer_secret, $oauth_access_token, $oauth_access_token_secret) { + $this->credentials = [ + 'consumer_key' => $consumer_key, + 'consumer_secret' => $consumer_secret, + 'oauth_access_token' => $oauth_access_token, + 'oauth_access_token_secret' => $oauth_access_token_secret, + ]; + $this->twitter = new \TwitterAPIExchange($this->credentials); + } + +} diff --git a/src/TweetFetcherInterface.php b/src/TweetFetcherInterface.php new file mode 100644 index 0000000..6ac0d32 --- /dev/null +++ b/src/TweetFetcherInterface.php @@ -0,0 +1,47 @@ + + + + {% if avatar %} + + {% endif %} + + {% if author %} + {{ author }} + {% endif %} + + {% if content %} + + +

{{ content }}

+ +
+ {% endif %} + diff --git a/tests/src/Kernel/ThumbnailTest.php b/tests/src/Kernel/ThumbnailTest.php new file mode 100644 index 0000000..d12b2d9 --- /dev/null +++ b/tests/src/Kernel/ThumbnailTest.php @@ -0,0 +1,177 @@ +installEntitySchema('file'); + $this->installEntitySchema('media'); + $this->installConfig(['media_entity_twitter', 'system']); + + $this->tweetFetcher = $this->getMock(TweetFetcherInterface::class); + + MediaBundle::create([ + 'id' => 'tweet', + 'type' => 'twitter', + 'type_configuration' => [ + 'source_field' => 'tweet', + 'use_twitter_api' => TRUE, + 'consumer_key' => $this->randomString(), + 'consumer_secret' => $this->randomString(), + 'oauth_access_token' => $this->randomString(), + 'oauth_access_token_secret' => $this->randomString(), + ], + ])->save(); + + FieldStorageConfig::create([ + 'field_name' => 'tweet', + 'entity_type' => 'media', + 'type' => 'string_long', + ])->save(); + + FieldConfig::create([ + 'field_name' => 'tweet', + 'entity_type' => 'media', + 'bundle' => 'tweet', + ])->save(); + + $this->entity = Media::create([ + 'bundle' => 'tweet', + 'tweet' => 'https://twitter.com/foobar/status/12345', + ]); + + $this->plugin = new Twitter( + MediaBundle::load('tweet')->getTypeConfiguration(), + 'twitter', + [], + $this->container->get('entity_type.manager'), + $this->container->get('entity_field.manager'), + $this->container->get('config.factory'), + $this->container->get('renderer'), + $this->tweetFetcher + ); + + $dir = $this->container + ->get('config.factory') + ->get('media_entity_twitter.settings') + ->get('local_images'); + + file_prepare_directory($dir, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS); + } + + /** + * Tests that an existing local image is used as the thumbnail. + */ + public function testLocalImagePresent() { + $this->tweetFetcher + ->method('fetchTweet') + ->willReturn([ + 'extended_entities' => [ + 'media' => [ + [ + 'media_url' => 'https://drupal.org/favicon.ico', + ], + ], + ], + ]); + + $uri = 'public://twitter-thumbnails/12345.ico'; + touch($uri); + $this->assertEquals($uri, $this->plugin->thumbnail($this->entity)); + } + + /** + * Tests that a local image is downloaded if available but not present. + */ + public function testLocalImageNotPresent() { + $this->tweetFetcher + ->method('fetchTweet') + ->willReturn([ + 'extended_entities' => [ + 'media' => [ + [ + 'media_url' => 'https://drupal.org/favicon.ico', + ], + ], + ], + ]); + + $this->plugin->thumbnail($this->entity); + $this->assertFileExists('public://twitter-thumbnails/12345.ico'); + } + + /** + * Tests that the default thumbnail is used if no local image is available. + */ + public function testNoLocalImage() { + $this->assertEquals( + $this->plugin->getDefaultThumbnail(), + $this->plugin->thumbnail($this->entity) + ); + } + + /** + * Tests that thumbnail is generated if enabled and local image not available. + */ + public function testThumbnailGeneration() { + $configuration = $this->plugin->getConfiguration(); + $configuration['generate_thumbnails'] = TRUE; + $this->plugin->setConfiguration($configuration); + + $uri = $this->plugin->thumbnail($this->entity); + $this->assertFileExists($uri); + } + +}