Loading src/Plugin/views/style/SwiperFormatterStyle.php +47 −19 Changes for src/Plugin/views/style/SwiperFormatterStyle.php: 47 added lines, 19 removed lines. Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -215,8 +216,6 @@ class SwiperFormatterStyle extends StylePluginBase { foreach ($sets as $index => &$set) { $captions = []; foreach ($set['#rows'] as $delta => &$row) { $i = 0; Loading @@ -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()); Loading Loading @@ -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. * Loading Loading @@ -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; Loading @@ -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. Loading src/Service/Swiper.php +87 −13 Changes for src/Service/Swiper.php: 87 added lines, 13 removed lines. Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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; Loading Loading @@ -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, Loading @@ -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 Loading @@ -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} */ Loading src/Service/SwiperInterface.php +4 −0 Changes for src/Service/SwiperInterface.php: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading tests/src/Kernel/SwiperCaptionTest.php 0 → 100644 +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('<script>alert(2)</script>'); } /** * 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('<img src="x" onerror="alert(1)">', "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']); } } } } Loading
src/Plugin/views/style/SwiperFormatterStyle.php +47 −19 Changes for src/Plugin/views/style/SwiperFormatterStyle.php: 47 added lines, 19 removed lines. Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -215,8 +216,6 @@ class SwiperFormatterStyle extends StylePluginBase { foreach ($sets as $index => &$set) { $captions = []; foreach ($set['#rows'] as $delta => &$row) { $i = 0; Loading @@ -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()); Loading Loading @@ -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. * Loading Loading @@ -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; Loading @@ -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. Loading
src/Service/Swiper.php +87 −13 Changes for src/Service/Swiper.php: 87 added lines, 13 removed lines. Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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; Loading Loading @@ -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, Loading @@ -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 Loading @@ -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} */ Loading
src/Service/SwiperInterface.php +4 −0 Changes for src/Service/SwiperInterface.php: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading
tests/src/Kernel/SwiperCaptionTest.php 0 → 100644 +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('<script>alert(2)</script>'); } /** * 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('<img src="x" onerror="alert(1)">', "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']); } } } }