Verified Commit fb0e33fa authored by Drupal Lady's avatar Drupal Lady
Browse files

refactor(caption): Tidy caption handling in swiper service and Views style

parent 6796c47c
Loading
Loading
Loading
Loading
Loading
+47 −19
Changes for src/Plugin/views/style/SwiperFormatterStyle.php: 47 added lines, 19 removed lines.
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Drupal\swiper_formatter\Plugin\views\style;

use Drupal\Component\Utility\Html;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
@@ -215,8 +216,6 @@ class SwiperFormatterStyle extends StylePluginBase {

      foreach ($sets as $index => &$set) {

        $captions = [];

        foreach ($set['#rows'] as $delta => &$row) {

          $i = 0;
@@ -224,18 +223,7 @@ class SwiperFormatterStyle extends StylePluginBase {

          // Take care of the caption.
          if (isset($this->options['caption']) && !empty($this->options['caption'])) {
            $image_subfields = ['alt', 'title'];
            if ($this->options['is_image'] && in_array($this->options['caption'], $image_subfields)) {
              foreach ($entity->get($this->options['is_image'])->getValue() as $img_delta => $img_value) {
                $captions[$img_delta] = isset($entity->get($this->options['is_image'])[$img_delta]) ? ['value' => $entity->get($this->options['is_image'])[$img_delta]->{$this->options['caption']}] : [];
              }
              $row['#caption'] = $this->parseLinear($i, $delta, 'caption', $captions);
            }
            else {
              if ($entity->hasField($this->options['caption']) && !empty($entity->get($this->options['caption'])->getValue())) {
                $row['#caption'] = $this->parseLinear($i, $delta, 'caption', $entity->get($this->options['caption'])->getValue());
              }
            }
            $this->caption($row, $entity, $i, $delta);
          }
          if (isset($this->options['field_name']) && $entity->hasField($this->options['field_name']) && !empty($entity->get($this->options['field_name'])->getValue())) {
            $row['#background'] = $this->parseLinear($i, $delta, 'background', $entity->get($this->options['field_name'])->getValue());
@@ -271,6 +259,42 @@ class SwiperFormatterStyle extends StylePluginBase {
    $form_state->setValue(['style_options', 'swiper'], NULL);
  }

  /**
   * Sets the row's caption, when the viewer has view access to the field.
   *
   * @param array $row
   *   Views row array, by reference.
   * @param \Drupal\Core\Entity\FieldableEntityInterface $entity
   *   Entity holding the caption field.
   * @param int $index
   *   Custom index, see parseLinear().
   * @param int $delta
   *   Default index/delta returned from a view render.
   */
  protected function caption(array &$row, FieldableEntityInterface $entity, int &$index, int $delta): void {
    $subfield = $this->options['is_image'] && in_array($this->options['caption'], ['alt', 'title']) ? $this->options['caption'] : NULL;
    $field_name = $subfield ? $this->options['is_image'] : $this->options['caption'];
    if (!$entity->hasField($field_name)) {
      return;
    }

    $field = $entity->get($field_name);
    $access = $field->access('view', NULL, TRUE);
    CacheableMetadata::createFromRenderArray($row)
      ->addCacheableDependency($access)
      ->applyTo($row);

    $values = $field->getValue();
    if (!$access->isAllowed() || !$values) {
      return;
    }

    if ($subfield) {
      $values = array_map(fn (array $value): array => ['value' => $value[$subfield] ?? NULL], $values);
    }
    $row['#caption'] = $this->parseLinear($index, $delta, 'caption', $values);
  }

  /**
   * Find entity to which swiper field is attached within the Views row.
   *
@@ -324,11 +348,12 @@ class SwiperFormatterStyle extends StylePluginBase {
   * @param array $field_values
   *   An array of field values returned by its parent entity.
   *
   * @return null|string
   *   Either a raw caption string (to be rendered),
   *   or url of image field (for lazy loading feature).
   * @return array|string|null
   *   Either the caption's field item values - its text format included, so
   *   the caption can be filtered by it - or url of image field (for lazy
   *   loading feature).
   */
  protected function parseLinear(int &$index, int $delta, string $type, array $field_values = []): string|NULL {
  protected function parseLinear(int &$index, int $delta, string $type, array $field_values = []): array|string|NULL {

    $values = NULL;

@@ -342,7 +367,10 @@ class SwiperFormatterStyle extends StylePluginBase {
      }

      if ($type == 'caption') {
        $values = $field_values[$index]['value'] ?? NULL;
        // The whole item, not just its value: a formatted text caption has to
        // reach the caption handling with its format, or it cannot be
        // filtered by it.
        $values = isset($field_values[$index]['value']) ? $field_values[$index] : NULL;
      }
      elseif ($type == 'background') {
        // Lazy load support.
+87 −13
Changes for src/Service/Swiper.php: 87 added lines, 13 removed lines.
Original line number Diff line number Diff line
@@ -7,7 +7,7 @@ namespace Drupal\swiper_formatter\Service;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Component\Utility\Html;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Entity\EntityDisplayRepositoryInterface;
use Drupal\Core\Entity\EntityFieldManagerInterface;
@@ -19,7 +19,6 @@ use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Render\Markup;
use Drupal\Core\Routing\RedirectDestinationInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
@@ -321,10 +320,10 @@ class Swiper implements SwiperInterface {
    ];
    // Check on caption.
    if (!empty($item['#caption'])) {
      $caption_value = is_array($item['#caption']) && isset($item['#caption']['value']) ? $item['#caption']['value'] : $item['#caption'];
      $slide['#caption'] = [
        '#markup' => Markup::create($caption_value),
      ];
      $caption = $this->captionContent($item['#caption']);
      if ($caption !== []) {
        $slide['#caption'] = $caption;
      }
    }

    // Take care of some caching,
@@ -333,12 +332,9 @@ class Swiper implements SwiperInterface {
      'config:swiper_formatter.swiper_formatter.' . $settings['template'],
    ];

    if (isset($item['#cache'])) {
      $slide['#cache']['tags'] = Cache::mergeTags($item['#cache']['tags'], $cache_tags);
    }
    else {
      $slide['#cache']['tags'] = $cache_tags;
    }
    CacheableMetadata::createFromRenderArray($item)
      ->addCacheTags($cache_tags)
      ->applyTo($slide);

    // Add an extra cache key to avoid conflict with
    // an original view mode cached version as a workaround to the current
@@ -361,10 +357,88 @@ class Swiper implements SwiperInterface {
    $item['#caption'] = match ($caption_field) {
      'title' => isset($item['#item']) && $item['#item']->title ? $item['#item']->title : NULL,
      'alt' => isset($item['#item']) && $item['#item']->alt ? $item['#item']->alt : NULL,
      default => $entity && $entity->hasField($caption_field) ? ($entity->get($caption_field)->get($delta) && $entity->get($caption_field)->get($delta)->getValue() ? $entity->get($caption_field)->get($delta)->getValue() : NULL) : NULL,
      default => $this->captionFieldValue($item, $entity, $caption_field, $delta),
    };
  }

  /**
   * The caption value held in a field, when the viewer may see that field.
   *
   * A caption is the field's own content, so a viewer kept from the field -
   * by field access, or a module that implements it - is kept from the
   * caption too, however the display is configured. The title and alt arms
   * need no such check: they are subfields of the very field being displayed,
   * which the formatter has already been given.
   *
   * The decision is cacheable and is recorded on the item, so a slide cached
   * for one viewer is not served to another whose access differs.
   *
   * @param array $item
   *   The slide item, by reference, to record the decision's cacheability on.
   * @param \Drupal\Core\Entity\FieldableEntityInterface|null $entity
   *   Entity holding the caption field.
   * @param string|null $field_name
   *   The caption field's machine name.
   * @param int $delta
   *   Delta to read within the caption field.
   *
   * @return array|null
   *   The field item's values, or NULL when there are none to show.
   */
  protected function captionFieldValue(array &$item, ?FieldableEntityInterface $entity, ?string $field_name, int $delta): ?array {
    if (!$entity || !$field_name || !$entity->hasField($field_name)) {
      return NULL;
    }

    $field = $entity->get($field_name);
    $access = $field->access('view', NULL, TRUE);
    CacheableMetadata::createFromRenderArray($item)
      ->addCacheableDependency($access)
      ->applyTo($item);

    if (!$access->isAllowed()) {
      return NULL;
    }

    $values = $field->get($delta) ? $field->get($delta)->getValue() : NULL;
    return $values ?: NULL;
  }

  /**
   * The caption content as a render array, never as trusted markup.
   *
   * A value read from a formatted text field carries its text format along
   * and is run through it, exactly as the field's own formatter would, so
   * rich text keeps whatever markup that format allows. Anything else - an
   * image's alt or title, a plain string field - is plain text and is
   * escaped. Wrapping the raw value as markup instead would publish whatever
   * an editor typed, whichever format they typed it in.
   *
   * @param mixed $caption
   *   The resolved caption: a field item's values, or a plain string.
   *
   * @return array
   *   A render array for the caption content, empty when there is no text.
   */
  protected function captionContent($caption): array {
    $text = (string) (is_array($caption) ? ($caption['value'] ?? '') : $caption);
    if ($text === '') {
      return [];
    }

    // A NULL format is left to processed_text, which falls back to the site's
    // fallback format - plain text - just as a text field with none would.
    if (is_array($caption) && array_key_exists('format', $caption)) {
      return [
        '#type' => 'processed_text',
        '#text' => $text,
        '#format' => $caption['format'],
      ];
    }

    return ['#plain_text' => $text];
  }

  /**
   * {@inheritdoc}
   */
+4 −0
Changes for src/Service/SwiperInterface.php: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -172,6 +172,10 @@ interface SwiperInterface {
  /**
   * Get value for a swiper caption.
   *
   * Sets "#caption" on the item, to NULL when nothing resolves - which
   * includes a caption field the viewer may not see, whose access decision is
   * recorded on the item's cacheability.
   *
   * @param array $item
   *   Referenced render-able field item array.
   * @param string|null $caption_field
+273 −0
Changes for tests/src/Kernel/SwiperCaptionTest.php: 273 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Tests\swiper_formatter\Kernel;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Render\RenderContext;
use Drupal\filter\Entity\FilterFormat;
use Drupal\swiper_formatter\Plugin\views\style\SwiperFormatterStyle;
use Drupal\swiper_formatter\Service\SwiperInterface;
use PHPUnit\Framework\Attributes\Group;

/**
 * Tests that slide captions are filtered or escaped, never trusted as is.
 *
 * A caption is editor input. Rich text has to go through its own text format
 * and everything else has to be escaped, or any editor can put script on
 * every page the swiper renders on.
 *
 * @group swiper_formatter
 */
#[Group('swiper_formatter')]
class SwiperCaptionTest extends SwiperFormatterKernelTestBase {

  /**
   * {@inheritdoc}
   */
  protected static $modules = ['system', 'user', 'filter', 'token', 'views', 'swiper_formatter'];

  /**
   * Rich text alongside the two classic script vectors.
   */
  const PAYLOAD = '<h4>Title</h4><p>Hi <em>there</em> <a href="https://example.com">link</a></p><img src="x" onerror="alert(1)"><script>alert(2)</script>';

  /**
   * The swiper service, which owns caption handling on this branch.
   *
   * @var \Drupal\swiper_formatter\Service\SwiperInterface
   */
  protected SwiperInterface $swiper;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    // Brings the plain_text format and the fallback format setting.
    $this->installConfig(['filter']);

    FilterFormat::create([
      'format' => 'limited_html',
      'name' => 'Limited HTML',
      'filters' => [
        'filter_html' => [
          'status' => TRUE,
          'settings' => [
            'allowed_html' => '<h4> <p> <em> <a href>',
          ],
        ],
      ],
    ])->save();

    $this->swiper = $this->container->get('swiper_formatter.base');
  }

  /**
   * Builds one slide carrying the given caption.
   *
   * @param mixed $caption
   *   The resolved caption, as it would sit on the slide item.
   *
   * @return array
   *   The slide element.
   */
  protected function slide($caption): array {
    $item = [
      '#markup' => 'Slide body',
      '#caption' => $caption,
    ];
    $settings = [
      'template' => 'regular_template',
      'field_type' => 'entity_reference',
    ];

    return $this->swiper->renderSwiperSlide($this->createMock(FieldableEntityInterface::class), $settings, $item);
  }

  /**
   * Renders a slide's caption.
   *
   * @param mixed $caption
   *   The resolved caption, as it would sit on the slide item.
   *
   * @return \Drupal\Core\Render\BubbleableMetadata
   *   What the caption bubbled up while rendering.
   */
  protected function renderCaption($caption): BubbleableMetadata {
    $slide = $this->slide($caption);
    $this->assertArrayHasKey('#caption', $slide, 'A caption with text renders.');
    $element = $slide['#caption'];

    $renderer = $this->container->get('renderer');
    $context = new RenderContext();
    $this->setRawContent((string) $renderer->executeInRenderContext($context, fn () => $renderer->render($element)));
    return $context->isEmpty() ? new BubbleableMetadata() : $context->pop();
  }

  /**
   * Formatted text keeps what its format allows and loses the rest.
   */
  public function testFormattedTextIsFiltered(): void {
    $metadata = $this->renderCaption(['value' => static::PAYLOAD, 'format' => 'limited_html']);

    $this->assertRaw('<h4>Title</h4>');
    $this->assertRaw('<em>there</em>');
    $this->assertRaw('<a href="https://example.com">link</a>');
    $this->assertNoRaw('onerror');
    $this->assertNoRaw('<script>');

    // Editing the format has to reach cached swipers too.
    $this->assertContains('config:filter.format.limited_html', $metadata->getCacheTags());
  }

  /**
   * The plain text format escapes markup instead of publishing it.
   */
  public function testPlainTextFormatIsEscaped(): void {
    $this->renderCaption(['value' => static::PAYLOAD, 'format' => 'plain_text']);

    $this->assertNoRaw('<script>');
    $this->assertNoRaw('<h4>');
    $this->assertRaw('&lt;script&gt;alert(2)&lt;/script&gt;');
  }

  /**
   * Values without a text format are plain text.
   *
   * An image's alt or title arrive as a string, a string field as an item
   * without a format. Neither is markup, whatever it looks like.
   */
  public function testUnformattedValuesAreEscaped(): void {
    foreach (['alt or title' => static::PAYLOAD, 'string field' => ['value' => static::PAYLOAD]] as $source => $caption) {
      $this->renderCaption($caption);
      $this->assertNoRaw('<script>', "A caption from the $source runs no script.");
      $this->assertNoRaw('<img', "A caption from the $source adds no markup.");
      $this->assertRaw('&lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;', "A caption from the $source is shown as the text it is.");
    }
  }

  /**
   * A caption without text puts nothing on the slide.
   */
  public function testEmptyCaptionRendersNothing(): void {
    foreach ([NULL, '', [], ['value' => '', 'format' => 'limited_html'], ['target_id' => 1]] as $caption) {
      $this->assertArrayNotHasKey('#caption', $this->slide($caption));
    }
  }

  /**
   * A caption field the viewer may not see is no caption at all.
   */
  public function testForbiddenFieldIsNoCaption(): void {
    $item = [];
    $entity = $this->mockCaptionField(
      ['value' => 'Members only', 'format' => 'limited_html'],
      AccessResult::forbidden()->addCacheContexts(['user.permissions'])
    );

    $this->swiper->getCaption($item, 'field_caption', $entity);

    $this->assertNull($item['#caption'], 'A forbidden field yields no caption.');
    $this->assertArrayNotHasKey('#caption', $this->slide($item['#caption']), 'No caption reaches the slide.');
    $this->assertContains('user.permissions', $item['#cache']['contexts'], 'The decision is cached per the access it depended on.');
  }

  /**
   * A caption field the viewer may see is read, cacheability included.
   */
  public function testAllowedFieldIsRead(): void {
    $item = [];
    $values = ['value' => static::PAYLOAD, 'format' => 'limited_html'];
    $entity = $this->mockCaptionField($values, AccessResult::allowed()->addCacheContexts(['user.permissions']));

    $this->swiper->getCaption($item, 'field_caption', $entity);

    $this->assertSame($values, $item['#caption'], 'An allowed field is read as before.');
    $this->assertContains('user.permissions', $item['#cache']['contexts'], 'The decision is cached per the access it depended on.');
  }

  /**
   * An entity whose caption field answers with the given access result.
   *
   * @param array $values
   *   The field item values the caption field holds.
   * @param \Drupal\Core\Access\AccessResultInterface $access
   *   What the field answers to a "view" access check.
   *
   * @return \Drupal\Core\Entity\FieldableEntityInterface
   *   The entity to resolve a caption from.
   */
  protected function mockCaptionField(array $values, AccessResultInterface $access): FieldableEntityInterface {
    $field_item = $this->createMock(FieldItemInterface::class);
    $field_item->method('getValue')->willReturn($values);

    $field = $this->createMock(FieldItemListInterface::class);
    $field->method('access')->willReturn($access);
    $field->method('getValue')->willReturn([$values]);
    $field->method('get')->willReturn($field_item);

    $entity = $this->createMock(FieldableEntityInterface::class);
    $entity->method('hasField')->willReturn(TRUE);
    $entity->method('get')->willReturn($field);

    return $entity;
  }

  /**
   * The Views style hands the caption over with its text format.
   *
   * Handing over the value alone would leave the caption no format to filter
   * it by, and rich text would come out escaped.
   */
  public function testViewsCaptionKeepsItsFormat(): void {
    $style = (new \ReflectionClass(SwiperFormatterStyle::class))->newInstanceWithoutConstructor();
    $parse_linear = new \ReflectionMethod($style, 'parseLinear');

    $values = ['value' => static::PAYLOAD, 'format' => 'limited_html'];
    $index = 0;
    $this->assertSame($values, $parse_linear->invokeArgs($style, [&$index, 0, 'caption', [$values]]));

    $index = 0;
    $this->assertNull($parse_linear->invokeArgs($style, [&$index, 0, 'caption', [['format' => 'limited_html']]]), 'An item without a value is no caption.');
  }

  /**
   * The Views style shows a caption only to a viewer who may see its field.
   *
   * That is the caption field itself, or the image whose alt or title it is.
   * The access decision has to reach the slide's cacheability too, or a slide
   * cached for one viewer is served to another.
   */
  public function testViewsCaptionChecksFieldAccess(): void {
    $style = (new \ReflectionClass(SwiperFormatterStyle::class))->newInstanceWithoutConstructor();
    $caption = new \ReflectionMethod($style, 'caption');
    $settings = ['template' => 'regular_template', 'field_type' => 'views'];
    $text = ['value' => static::PAYLOAD, 'format' => 'limited_html'];

    // Caption option, image field shown, field values, caption when allowed.
    $cases = [
      ['field_caption', FALSE, $text, $text],
      ['alt', 'field_image', ['target_id' => 1, 'alt' => 'Members only'], ['value' => 'Members only']],
    ];
    foreach ($cases as [$option, $image, $values, $allowed]) {
      $style->options = ['caption' => $option, 'is_image' => $image];
      foreach ([[AccessResult::forbidden(), NULL], [AccessResult::allowed(), $allowed]] as [$access, $expected]) {
        $row = [];
        $index = 0;
        $entity = $this->mockCaptionField($values, $access->addCacheContexts(['user.permissions']));
        $caption->invokeArgs($style, [&$row, $entity, &$index, 0]);

        $this->assertSame($expected, $row['#caption'] ?? NULL);
        $this->assertContains('user.permissions', $this->swiper->renderSwiperSlide($entity, $settings, $row)['#cache']['contexts']);
      }
    }
  }

}