diff --git a/composer.json b/composer.json index d8395ab68da..cb940d2fbbc 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,7 @@ "composer/package-versions-deprecated": "1.11.99.5", "composer/semver": "3.4.3", "endroid/qr-code": "5.1.0", + "ezyang/htmlpurifier": "4.17.0", "firebase/php-jwt": "6.10.1", "guzzlehttp/guzzle": "7.9.2", "jaybizzle/crawler-detect": "^1.2", diff --git a/composer.lock b/composer.lock index 034a6627e41..a376026b19e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8d386ab3bfc89dcd3e5d371af7a37be5", + "content-hash": "0b5a440adf0f633dbb78b4f6e8c91a3f", "packages": [ { "name": "ahand/mobileesp", @@ -1214,6 +1214,67 @@ ], "time": "2024-09-08T08:52:55+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.17.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "bbc513d79acf6691fa9cf10f192c90dd2957f18c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/bbc513d79acf6691fa9cf10f192c90dd2957f18c", + "reference": "bbc513d79acf6691fa9cf10f192c90dd2957f18c", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.17.0" + }, + "time": "2023-11-17T15:01:25+00:00" + }, { "name": "filp/whoops", "version": "2.16.0", diff --git a/config/vufind/config.ini b/config/vufind/config.ini index 509ed20ffa9..60e82587ebf 100644 --- a/config/vufind/config.ini +++ b/config/vufind/config.ini @@ -2642,3 +2642,24 @@ description = "The REST API provides access to search functions and records cont ; By default, VuFind sorts text in a locale-agnostic way; if this setting is ; turned on, the current user-selected locale will impact sort order. ;use_locale_sorting = true + +; Data retrieved from records is escaped by default, on the assumption that it does not contain HTML. +; This section can be used to allow limited HTML in specific contexts; set values to true to allow HTML, +; and leave false to escape content. +; See http://htmlpurifier.org/ for more information about the purifier. +; See also \VuFind\View\Helper\Root\CleanHtmlFactory for additional information. +[Allowed_HTML_Contexts] +; Title on the record page +;title = false +; Alternative titles on the record page +;title-alt = false +; Summary on the record page +;summary = false + +; \VuFind\View\Helper\Root\CleanHtmlFactory defines the defaults for HTML elements allowed in different +; rendering contexts (e.g. default, heading or link). You can customize the defaults by setting the +; context-specific list of allowed elements below. +[HTML_Rendering_Contexts] +;allowed_elements[default] = "" +;allowed_elements[heading] = "a,b,br,em,i,span,strong,sub,sup,u" +;allowed_elements[link] = "abbr,acronym,b,bdo,big,br,cite,dfn,em,i,img,q,samp,small,span,strong,sub,sup,var" diff --git a/module/VuFind/src/VuFind/Content/Summaries/Demo.php b/module/VuFind/src/VuFind/Content/Summaries/Demo.php index 2e7d91deacc..c3a22f191c9 100644 --- a/module/VuFind/src/VuFind/Content/Summaries/Demo.php +++ b/module/VuFind/src/VuFind/Content/Summaries/Demo.php @@ -29,6 +29,8 @@ namespace VuFind\Content\Summaries; +use VuFind\String\PropertyString; + /** * Demo (fake data) summaries content loader. * @@ -56,6 +58,8 @@ public function loadByIsbn($key, \VuFindCode\ISBN $isbnObj) return [ 'Demo summary key: ' . $key, 'Demo summary ISBN: ' . $isbnObj->get13(), + (new PropertyString('Demo non-HTML summary')) + ->setHtml('Demo HTML Summary:'), ]; } } diff --git a/module/VuFind/src/VuFind/Content/Summaries/Syndetics.php b/module/VuFind/src/VuFind/Content/Summaries/Syndetics.php index 35fe21a4f36..022585b3bd9 100644 --- a/module/VuFind/src/VuFind/Content/Summaries/Syndetics.php +++ b/module/VuFind/src/VuFind/Content/Summaries/Syndetics.php @@ -29,6 +29,8 @@ namespace VuFind\Content\Summaries; +use VuFind\String\PropertyString; + /** * Syndetics Summaries content loader. * @@ -110,7 +112,7 @@ public function loadByIsbn($key, \VuFindCode\ISBN $isbnObj) // If we have syndetics plus, we don't actually want the content // we'll just stick in the relevant div if ($this->usePlus) { - $summaries[] = $sourceInfo['div']; + $summaries[] = PropertyString::fromHtml($sourceInfo['div'])->setHtmlTrusted(true); } else { // Get the marc field for summaries. (520) $nodes = $xmldoc2->GetElementsbyTagName('Fld520'); diff --git a/module/VuFind/src/VuFind/RecordDriver/DefaultRecord.php b/module/VuFind/src/VuFind/RecordDriver/DefaultRecord.php index 87a645cb193..8f082c94d8d 100644 --- a/module/VuFind/src/VuFind/RecordDriver/DefaultRecord.php +++ b/module/VuFind/src/VuFind/RecordDriver/DefaultRecord.php @@ -29,6 +29,7 @@ namespace VuFind\RecordDriver; +use VuFind\String\PropertyString; use VuFind\View\Helper\Root\RecordLinker; use VuFindCode\ISBN; @@ -976,8 +977,9 @@ public function getOpenUrl($overrideSupportsOpenUrl = false) // Assemble the URL: $query = []; foreach ($params as $key => $value) { - $value = (array)$value; - foreach ($value as $sub) { + // Avoid casting since the field can be a PropertyString too (and casting would return an array of object + // properties): + foreach (is_array($value) ? $value : [$value] as $sub) { $query[] = urlencode($key) . '=' . urlencode($sub); } } @@ -1271,9 +1273,9 @@ public function getSystemDetails() public function getSummary() { // We need to return an array, so if we have a description, turn it into an - // array (it should be a flat string according to the default schema, but we - // might as well support the array case just to be on the safe side: - return (array)($this->fields['description'] ?? []); + // array (it is a flat string in the default Solr schema, but we also + // support multivalued fields for other backends): + return $this->getFieldAsArray('description'); } /** @@ -1826,4 +1828,23 @@ public function getRecordDataFormatterSpecClass(): ?string { return \VuFind\RecordDataFormatter\Specs\DefaultRecord::class; } + + /** + * Get a field as an array + * + * @param string $field Field + * + * @return array + */ + protected function getFieldAsArray(string $field): array + { + // Make sure to return only non-empty values: + $value = $this->fields[$field] ?? ''; + if ('' === $value) { + return []; + } + // Avoid casting since the field can be a PropertyString too (and casting would return an array of object + // properties): + return is_array($value) ? $value : [$value]; + } } diff --git a/module/VuFind/src/VuFind/String/PropertyString.php b/module/VuFind/src/VuFind/String/PropertyString.php new file mode 100644 index 00000000000..d064d0b2f9e --- /dev/null +++ b/module/VuFind/src/VuFind/String/PropertyString.php @@ -0,0 +1,242 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ + +namespace VuFind\String; + +use function array_key_exists; +use function in_array; + +/** + * Class for a string with additional properties. + * + * @category VuFind + * @package String + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +class PropertyString implements PropertyStringInterface +{ + /** + * Constructor + * + * @param string $string String value + * @param array $properties Associative array of any additional properties. Use a custom prefix for locally + * defined properties. Double underscore is a reserved prefix, and currently the following keys are defined: + * __ids Identifiers (e.g. subject URIs) + * __html HTML presentation + */ + public function __construct(protected string $string, protected array $properties = []) + { + } + + /** + * Create a PropertyString from an HTML string + * + * @param string $html HTML + * @param array $properties Any additional properties (see __construct) + * + * @return PropertyString + */ + public static function fromHtml(string $html, array $properties = []): PropertyString + { + return (new PropertyString(strip_tags($html), $properties))->setHtml($html); + } + + /** + * Set string value + * + * @param string $str String value + * + * @return static + */ + public function setString(string $str): static + { + $this->string = $str; + return $this; + } + + /** + * Get string value + * + * @return string + */ + public function getString(): string + { + return $this->string; + } + + /** + * Set HTML string + * + * @param string $html HTML + * + * @return static + */ + public function setHtml(string $html): static + { + $this['__html'] = $html; + return $this; + } + + /** + * Get HTML string + * + * Note: This could contain anything and must be sanitized for display unless marked trusted + * (see setHtmlTrusted/isHtmlTrusted). + * + * @return ?string + */ + public function getHtml(): ?string + { + return $this['__html']; + } + + /** + * Set flag for trusted HTML + * + * @param bool $trusted Is the HTML content trusted? + * + * @return static + */ + public function setHtmlTrusted(bool $trusted): static + { + $this['__trustedHtml'] = $trusted; + return $this; + } + + /** + * Get flag for trusted HTML + * + * @return ?bool + */ + public function isHtmlTrusted(): ?bool + { + return $this['__trustedHtml']; + } + + /** + * Add an identifier + * + * @param string $id Identifier + * + * @return static + */ + public function addId(string $id): static + { + $ids = $this->getIds() ?? []; + if (!in_array($id, $ids)) { + $ids[] = $id; + $this->setIds($ids); + } + return $this; + } + + /** + * Set identifiers + * + * @param array $ids Identifiers + * + * @return static + */ + public function setIds(array $ids): static + { + $this['__ids'] = $ids; + return $this; + } + + /** + * Get identifiers + * + * @return ?array + */ + public function getIds(): ?array + { + return $this['__ids']; + } + + /** + * Check if offset exists + * + * @param mixed $offset Offset + * + * @return bool + */ + public function offsetExists(mixed $offset): bool + { + return array_key_exists($offset, $this->properties); + } + + /** + * Return value of offset + * + * @param mixed $offset Offset + * + * @return mixed + */ + public function offsetGet(mixed $offset): mixed + { + return $this->properties[$offset] ?? null; + } + + /** + * Set value of offset + * + * @param mixed $offset Offset + * @param mixed $value Value + * + * @return void + */ + public function offsetSet(mixed $offset, mixed $value): void + { + $this->properties[$offset] = $value; + } + + /** + * Unset value of offset + * + * @param mixed $offset Offset + * + * @return void + */ + public function offsetUnset(mixed $offset): void + { + unset($this->properties[$offset]); + } + + /** + * Return string value + * + * @return string + */ + public function __toString(): string + { + return $this->string; + } +} diff --git a/module/VuFind/src/VuFind/String/PropertyStringInterface.php b/module/VuFind/src/VuFind/String/PropertyStringInterface.php new file mode 100644 index 00000000000..860c48e82ac --- /dev/null +++ b/module/VuFind/src/VuFind/String/PropertyStringInterface.php @@ -0,0 +1,118 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ + +namespace VuFind\String; + +/** + * Interface for a string with additional properties. + * + * @category VuFind + * @package String + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org Main Site + */ +interface PropertyStringInterface extends \ArrayAccess, \Stringable +{ + /** + * Set string value + * + * @param string $str String value + * + * @return static + */ + public function setString(string $str): static; + + /** + * Get string value + * + * @return string + */ + public function getString(): string; + + /** + * Set HTML string + * + * @param string $html HTML + * + * @return static + */ + public function setHtml(string $html): static; + + /** + * Get HTML string + * + * Note: This could contain anything and must be sanitized for display unless marked trusted + * (see setHtmlTrusted/isHtmlTrusted). + * + * @return ?string + */ + public function getHtml(): ?string; + + /** + * Set flag for trusted HTML + * + * @param bool $trusted Is the HTML content trusted? + * + * @return static + */ + public function setHtmlTrusted(bool $trusted): static; + + /** + * Get flag for trusted HTML + * + * @return ?bool + */ + public function isHtmlTrusted(): ?bool; + + /** + * Add an identifier + * + * @param string $id Identifier + * + * @return static + */ + public function addId(string $id): static; + + /** + * Set identifiers + * + * @param array $ids Identifiers + * + * @return static + */ + public function setIds(array $ids): static; + + /** + * Get identifiers + * + * @return ?array + */ + public function getIds(): ?array; +} diff --git a/module/VuFind/src/VuFind/View/Helper/Root/CleanHtml.php b/module/VuFind/src/VuFind/View/Helper/Root/CleanHtml.php new file mode 100644 index 00000000000..fea6d838946 --- /dev/null +++ b/module/VuFind/src/VuFind/View/Helper/Root/CleanHtml.php @@ -0,0 +1,81 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link http://vufind.org Main Site + */ + +namespace VuFind\View\Helper\Root; + +use Closure; + +/** + * HTML Cleaner view helper + * + * @category VuFind + * @package View_Helpers + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link http://vufind.org Main Site + */ +class CleanHtml extends \Laminas\View\Helper\AbstractHelper +{ + /** + * Purifiers + * + * @var \HTMLPurifier[] + */ + protected $purifiers = []; + + /** + * Constructor + * + * @param Closure $purifierFactory Purifier factory callback + */ + public function __construct(protected Closure $purifierFactory) + { + } + + /** + * Clean up HTML + * + * @param string $html HTML + * @param bool $targetBlank Whether to add target=_blank to outgoing links + * @param string $context Display context (e.g. 'heading') + * + * @return string + */ + public function __invoke(string $html, bool $targetBlank = false, string $context = 'default'): string + { + if (!str_contains($html, '<')) { + return $html; + } + $key = ($targetBlank ? 'blank' : 'noblank') . "_$context"; + if (!isset($this->purifiers[$key])) { + $this->purifiers[$key] = ($this->purifierFactory)(compact('targetBlank', 'context')); + } + return $this->purifiers[$key]->purify($html); + } +} diff --git a/module/VuFind/src/VuFind/View/Helper/Root/CleanHtmlFactory.php b/module/VuFind/src/VuFind/View/Helper/Root/CleanHtmlFactory.php new file mode 100644 index 00000000000..fe6f967b456 --- /dev/null +++ b/module/VuFind/src/VuFind/View/Helper/Root/CleanHtmlFactory.php @@ -0,0 +1,173 @@ + + * @author Aleksi Peebles + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFind\View\Helper\Root; + +use Closure; +use HTMLPurifier; +use HTMLPurifier_Config; +use Laminas\ServiceManager\Exception\ServiceNotCreatedException; +use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use Laminas\ServiceManager\Factory\FactoryInterface; +use Psr\Container\ContainerExceptionInterface as ContainerException; +use Psr\Container\ContainerInterface; +use VuFind\Config\PluginManager as ConfigPluginManager; + +/** + * CleanHtml helper factory. + * + * @category VuFind + * @package View_Helpers + * @author Ere Maijala + * @author Aleksi Peebles + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class CleanHtmlFactory implements FactoryInterface +{ + /** + * Service manager + * + * @var ContainerInterface + */ + protected ContainerInterface $container; + + /** + * Default list of allowed elements in different rendering contexts + * + * See e.g. https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements#technical_summary for more + * information on headings. Note that the defaults below are subsets of all allowed elements. + * + * @var array + */ + protected $defaultContextConfig = [ + 'default' => null, + 'heading' => 'a,b,br,em,i,span,strong,sub,sup,u', + 'link' => 'abbr,acronym,b,bdo,big,br,cite,dfn,em,i,img,q,samp,small,span,strong,sub,sup,var', + ]; + + /** + * Create an object + * + * @param ContainerInterface $container Service manager + * @param string $requestedName Service being created + * @param null|array $options Extra options (optional) + * + * @return object + * + * @throws ServiceNotFoundException if unable to resolve the service. + * @throws ServiceNotCreatedException if an exception is raised when + * creating a service. + * @throws ContainerException if any other error occurs + */ + public function __invoke( + ContainerInterface $container, + $requestedName, + ?array $options = null + ) { + if (!empty($options)) { + throw new \Exception('Unexpected options sent to factory.'); + } + + $this->container = $container; + return new $requestedName(Closure::fromCallable([$this, 'createPurifier'])); + } + + /** + * Create a purifier instance. + * + * N.B. This is a relatively slow method. + * + * @param array $options Additional options. Currently supported: + * bool targetBlank Whether to add target="_blank" to external links + * string context Rendering context ('default' or 'heading') + * + * @return HTMLPurifier + */ + protected function createPurifier(array $options): HTMLPurifier + { + $config = \HTMLPurifier_Config::createDefault(); + // Set cache path to the object cache + $cacheDir + = $this->container->get(\VuFind\Cache\Manager::class)->getCache('object')->getOptions()->getCacheDir(); + if ($cacheDir) { + $config->set('Cache.SerializerPath', $cacheDir); + } + if ($options['targetBlank'] ?? false) { + $config->set('HTML.Nofollow', 1); + $config->set('HTML.TargetBlank', 1); + } + + // Setting the following option makes purifier’s DOMLex pass the + // LIBXML_PARSEHUGE option to DOMDocument::loadHtml method. This in turn + // ensures that PHP calls htmlCtxtUseOptions (see + // github.com/php/php-src/blob/PHP-8.1.14/ext/dom/document.c#L1870), + // which ensures that the libxml2 options (namely keepBlanks) are set up + // properly, and whitespace nodes are preserved. This should not be an + // issue from libxml2 version 2.9.5, but during testing the issue was + // still intermittently present, and this setting should remain in place. + $config->set('Core.AllowParseManyTags', true); + + $this->setAdditionalConfiguration($config, $options); + return new \HTMLPurifier($config); + } + + /** + * Sets additional configuration + * + * @param HTMLPurifier_Config $config Configuration + * @param array $options Additional options + * + * @return void + */ + protected function setAdditionalConfiguration(HTMLPurifier_Config $config, array $options): void + { + // Configure allowed elements: + $context = $options['context'] ?? 'default'; + $contextConfig + = (array)($this->container->get(ConfigPluginManager::class)->get('config')->HTML_Rendering_Contexts ?? []); + $allowedElements + = $contextConfig['allowed_elements'][$context] ?? $this->defaultContextConfig[$context] ?? null; + if ($allowedElements) { + $config->set('HTML.AllowedElements', $allowedElements); + } + + // Add support for details and summary elements: + $definition = $config->getHTMLDefinition(true); + $definition->addElement( + 'details', + 'Block', + 'Flow', + 'Common', + ['open' => new \HTMLPurifier_AttrDef_HTML_Bool(true)] + ); + $definition->addElement('summary', 'Block', 'Flow', 'Common'); + } +} diff --git a/module/VuFind/src/VuFind/View/Helper/Root/EscapeOrCleanHtml.php b/module/VuFind/src/VuFind/View/Helper/Root/EscapeOrCleanHtml.php new file mode 100644 index 00000000000..d3794683a94 --- /dev/null +++ b/module/VuFind/src/VuFind/View/Helper/Root/EscapeOrCleanHtml.php @@ -0,0 +1,108 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFind\View\Helper\Root; + +use Laminas\Escaper\Escaper; +use Laminas\View\Helper\AbstractHelper; +use VuFind\String\PropertyStringInterface; + +/** + * View helper for escaping or cleaning HTML + * + * @category VuFind + * @package View_Helpers + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class EscapeOrCleanHtml extends AbstractHelper +{ + /** + * Contexts that allow HTML + * + * @var array + */ + protected array $htmlContexts; + + /** + * Constructor + * + * @param Escaper $escaper Escaper + * @param CleanHtml $cleanHtml Clean HTML helper + * @param array $config VuFind configuration + */ + public function __construct(protected Escaper $escaper, protected CleanHtml $cleanHtml, array $config) + { + $this->htmlContexts = (array)($config['Allowed_HTML_Contexts'] ?? []); + } + + /** + * Invoke this helper: escape a value + * + * @param ?string $value Value to escape + * @param ?string $dataContext Data context (for fields that allow sanitized HTML) + * @param ?bool $allowHtml Whether to allow sanitized HTML if passed a PropertyString + * @param string $renderingContext Rendering context for cleaning HTML + * + * @return mixed Given a string, returns an escaped string, otherwise returns self + */ + public function __invoke( + $value = null, + ?string $dataContext = null, + ?bool $allowHtml = null, + string $renderingContext = 'default' + ) { + if (null === $value) { + return $this; + } + if ($value instanceof PropertyStringInterface) { + if ( + ($allowHtml ?? ($dataContext && ($this->htmlContexts[$dataContext] ?? false))) + && $html = $value->getHtml() + ) { + return $value->isHtmlTrusted() ? $html : ($this->cleanHtml)($html, context: $renderingContext); + } + $value = (string)$value; + } + return $this->escape($value); + } + + /** + * Escape a string + * + * @param string $value String to escape + * + * @return string + */ + protected function escape($value) + { + return $this->escaper->escapeHtml($value); + } +} diff --git a/module/VuFind/src/VuFind/View/Helper/Root/EscapeOrCleanHtmlFactory.php b/module/VuFind/src/VuFind/View/Helper/Root/EscapeOrCleanHtmlFactory.php new file mode 100644 index 00000000000..01c58c788db --- /dev/null +++ b/module/VuFind/src/VuFind/View/Helper/Root/EscapeOrCleanHtmlFactory.php @@ -0,0 +1,77 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ + +namespace VuFind\View\Helper\Root; + +use Laminas\Escaper\Escaper; +use Laminas\ServiceManager\Exception\ServiceNotCreatedException; +use Laminas\ServiceManager\Exception\ServiceNotFoundException; +use Laminas\ServiceManager\Factory\FactoryInterface; +use Psr\Container\ContainerExceptionInterface as ContainerException; +use Psr\Container\ContainerInterface; + +/** + * EscapeOrCleanHtml helper factory. + * + * @category VuFind + * @package View_Helpers + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development Wiki + */ +class EscapeOrCleanHtmlFactory implements FactoryInterface +{ + /** + * Create an object + * + * @param ContainerInterface $container Service manager + * @param string $requestedName Service being created + * @param null|array $options Extra options (optional) + * + * @return object + * + * @throws ServiceNotFoundException if unable to resolve the service. + * @throws ServiceNotCreatedException if an exception is raised when + * creating a service. + * @throws ContainerException&\Throwable if any other error occurs + */ + public function __invoke( + ContainerInterface $container, + $requestedName, + ?array $options = null + ) { + if (!empty($options)) { + throw new \Exception('Unexpected options sent to factory.'); + } + + $helpers = $container->get('ViewHelperManager'); + $config = $container->get(\VuFind\Config\PluginManager::class)->get('config'); + return new $requestedName(new Escaper(), $helpers->get('cleanHtml'), $config->toArray()); + } +} diff --git a/module/VuFind/src/VuFind/View/Helper/Root/Record.php b/module/VuFind/src/VuFind/View/Helper/Root/Record.php index 1ffa305afc1..3f46d789bf6 100644 --- a/module/VuFind/src/VuFind/View/Helper/Root/Record.php +++ b/module/VuFind/src/VuFind/View/Helper/Root/Record.php @@ -451,16 +451,16 @@ public function getTagsFromFavorites( public function getTitleHtml($maxLength = 180) { $highlightedTitle = $this->driver->tryMethod('getHighlightedTitle'); - $title = trim($this->driver->tryMethod('getTitle')); - if (!empty($highlightedTitle)) { + $title = $this->driver->tryMethod('getTitle'); + if ('' !== $highlightedTitle) { $highlight = $this->getView()->plugin('highlight'); $addEllipsis = $this->getView()->plugin('addEllipsis'); return $highlight($addEllipsis($highlightedTitle, $title)); } - if (!empty($title)) { - $escapeHtml = $this->getView()->plugin('escapeHtml'); + if ('' !== trim($title)) { + $escape = $this->getView()->plugin('escapeOrCleanHtml'); $truncate = $this->getView()->plugin('truncate'); - return $escapeHtml($truncate($title, $maxLength)); + return $escape($truncate($title, $maxLength), dataContext: 'title', renderingContext: 'link'); } $transEsc = $this->getView()->plugin('transEsc'); return $transEsc('Title not available'); diff --git a/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter.php b/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter.php index b4a659c54db..481524b1ef2 100644 --- a/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter.php +++ b/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter.php @@ -35,6 +35,7 @@ use VuFind\RecordDataFormatter\Specs\PluginManager as SpecsManager; use VuFind\RecordDataFormatter\Specs\SpecInterface; use VuFind\RecordDriver\AbstractBase as RecordDriver; +use VuFind\String\PropertyStringInterface; use function call_user_func; use function count; @@ -112,6 +113,9 @@ protected function sortCallback(array $a, array $b): int */ protected function allowValue(mixed $value, array $options, bool $ignoreCombineAlt = false): bool { + if ($value instanceof PropertyStringInterface) { + $value = (string)$value; + } if (!empty($value) || ($ignoreCombineAlt && ($options['renderType'] ?? 'Simple') == 'CombineAlt')) { return true; } @@ -466,7 +470,9 @@ protected function renderSimple(mixed $data, array $options): string $transDomain = $options['translationTextDomain'] ?? ''; $separator = $options['separator'] ?? '
'; $retVal = ''; - $array = (array)$data; + // Avoid casting since the field can be a PropertyString too (and casting would return an array of object + // properties): + $array = null === $data ? [] : (is_array($data) ? $data : [$data]); $remaining = count($array); foreach ($array as $line) { $remaining--; diff --git a/module/VuFind/src/VuFind/View/Helper/Root/Truncate.php b/module/VuFind/src/VuFind/View/Helper/Root/Truncate.php index 4bf37e258a5..9912995d400 100644 --- a/module/VuFind/src/VuFind/View/Helper/Root/Truncate.php +++ b/module/VuFind/src/VuFind/View/Helper/Root/Truncate.php @@ -30,9 +30,7 @@ namespace VuFind\View\Helper\Root; use Laminas\View\Helper\AbstractHelper; - -use function function_exists; -use function strlen; +use VuFind\String\PropertyStringInterface; /** * Truncate view helper @@ -48,23 +46,21 @@ class Truncate extends AbstractHelper /** * Truncate a string * - * @param string $str the string to be truncated - * @param string $len how long the truncated string will be - * @param string $append what to add to the end of the string to - * indicate it's been truncated + * Note that any PropertyString with a plain string value that exceeds the maximum length is converted to a plain + * string before truncation. This means that the returned value is a plain string without e.g. any HTML content. + * + * @param string|PropertyStringInterface $str The string to be truncated + * @param int $len Maximum length of the resulting string + * @param string $append Truncation indicator to append to truncated strings * - * @return string + * @return string|PropertyStringInterface */ public function __invoke($str, $len, $append = '...') { if ($len == 0) { return ''; - } elseif (strlen($str) > $len) { - if (function_exists('mb_substr')) { - return trim(mb_substr($str, 0, $len, 'UTF-8')) . $append; - } else { - return trim(substr($str, 0, $len)) . $append; - } + } elseif (mb_strlen((string)$str, 'UTF-8') > $len) { + return trim(mb_substr((string)$str, 0, $len, 'UTF-8')) . $append; } return $str; } diff --git a/module/VuFind/src/VuFindTest/Feature/ViewTrait.php b/module/VuFind/src/VuFindTest/Feature/ViewTrait.php index 2358f13148c..fe68c2cfb39 100644 --- a/module/VuFind/src/VuFindTest/Feature/ViewTrait.php +++ b/module/VuFind/src/VuFindTest/Feature/ViewTrait.php @@ -29,6 +29,14 @@ namespace VuFindTest\Feature; +use Laminas\Cache\Storage\Adapter\AdapterOptions; +use Laminas\Cache\Storage\StorageInterface; +use Psr\Container\ContainerInterface; +use VuFind\Cache\Manager as CacheManager; +use VuFind\Config\Config; +use VuFind\Config\PluginManager as ConfigPluginManager; +use VuFind\View\Helper\Root\CleanHtml; +use VuFind\View\Helper\Root\CleanHtmlFactory; use VuFind\View\Helper\Root\SearchMemory; /** @@ -104,4 +112,50 @@ protected function getSearchMemoryViewHelper($memory = null): SearchMemory } return new \VuFind\View\Helper\Root\SearchMemory($memory); } + + /** + * Create the cleanHtml helper + * + * @return CleanHtml + */ + protected function createCleanHtmlHelper(): CleanHtml + { + // The FilesystemOptions class is final and cannot be mocked, so create our own as a workaround: + $cacheOptions = new class () extends AdapterOptions { + /** + * Get cache dir + * + * @return string + */ + public function getCacheDir(): string + { + return ''; + } + }; + $cache = $this->createMock(StorageInterface::class); + $cache->expects($this->any()) + ->method('getOptions') + ->willReturn($cacheOptions); + $cacheManager = $this->createMock(CacheManager::class); + $cacheManager->expects($this->any()) + ->method('getCache') + ->willReturn($cache); + $config = $this->createMock(Config::class); + $configPluginManager = $this->createMock(ConfigPluginManager::class); + $configPluginManager->expects($this->any()) + ->method('get') + ->willReturn($config); + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->any()) + ->method('get') + ->willReturnCallback( + function ($class) use ($cacheManager, $configPluginManager) { + return match ($class) { + CacheManager::class => $cacheManager, + ConfigPluginManager::class => $configPluginManager, + }; + } + ); + return (new CleanHtmlFactory())($container, CleanHtml::class); + } } diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/String/PropertyStringTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/String/PropertyStringTest.php new file mode 100644 index 00000000000..94bf883f538 --- /dev/null +++ b/module/VuFind/tests/unit-tests/src/VuFindTest/String/PropertyStringTest.php @@ -0,0 +1,147 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ + +namespace VuFindTest\String; + +use VuFind\String\PropertyString; + +/** + * PropertyString Test Class + * + * @category VuFind + * @package Tests + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +class PropertyStringTest extends \PHPUnit\Framework\TestCase +{ + /** + * Test getters and setters + * + * @return void + */ + public function testBasicFunctionality(): void + { + $str = new PropertyString(''); + $str + ->setString('Foo') + ->setHtml('

Foo

') + ->setIds(['id_foo', 'id_bar']); + $str['attr'] = 'bonus'; + $str['attr2'] = 'bonus2'; + + $this->assertEquals('Foo', (string)$str); + $this->assertEquals('Foo', $str->getString()); + $this->assertEquals('

Foo

', $str->getHtml()); + $this->assertEquals(['id_foo', 'id_bar'], $str->getIds()); + $this->assertEquals('bonus', $str['attr']); + $this->assertEquals('bonus2', $str['attr2']); + $this->assertTrue(isset($str['attr'])); + $this->assertFalse(isset($str['nattr'])); + + unset($str['attr']); + unset($str['nattr']); + $this->assertFalse(isset($str['attr'])); + + $str->addId('id_baz'); + $this->assertEquals(['id_foo', 'id_bar', 'id_baz'], $str->getIds()); + + $this->assertNull($str->isHtmlTrusted()); + $str->setHtmlTrusted(true); + $this->assertTrue($str->isHtmlTrusted()); + } + + /** + * Data provider for testFromHtml + * + * @return array + */ + public static function fromHtmlProvider(): array + { + return [ + 'plain string, no attributes' => [ + 'Plain string', + [], + 'Plain string', + 'Plain string', + [], + ], + 'plain string, attributes' => [ + 'Plain string', + ['foo' => 'bar', 'bar' => 'baz'], + 'Plain string', + 'Plain string', + ['foo' => 'bar', 'bar' => 'baz'], + ], + 'HTML string, array attributes' => [ + 'HTML string', + ['foo' => ['bar', 'baz']], + 'HTML string', + 'HTML string', + ['foo' => ['bar', 'baz']], + ], + 'HTML string, reserved array attributes' => [ + 'HTML string', + ['__html' => ['bar', 'baz']], + 'HTML string', + 'HTML string', + ['__html' => 'HTML string'], + ], + ]; + } + + /** + * Test the fromHtml static constructor + * + * @param string $html Input HTML + * @param array $attrs Additional attributes + * @param string $expectedPlain Expected plain text result + * @param string $expectedHtml Expected HTML result + * @param array $expectedAttrs Expected attributes + * + * @return void + * + * @dataProvider fromHtmlProvider + */ + public function testFromHtml( + string $html, + array $attrs, + string $expectedPlain, + string $expectedHtml, + array $expectedAttrs + ): void { + $str = PropertyString::fromHtml($html, $attrs); + $this->assertEquals($expectedPlain, (string)$str); + $this->assertEquals($expectedHtml, $str->getHtml()); + foreach ($expectedAttrs as $key => $value) { + $this->assertEquals($value, $str[$key]); + } + } +} diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/CleanHtmlTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/CleanHtmlTest.php new file mode 100644 index 00000000000..97e23288345 --- /dev/null +++ b/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/CleanHtmlTest.php @@ -0,0 +1,96 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ + +namespace VuFindTest\View\Helper\Root; + +use VuFindTest\Feature\ViewTrait; + +/** + * CleanHtml view helper Test Class + * + * @category VuFind + * @package Tests + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +class CleanHtmlTest extends \PHPUnit\Framework\TestCase +{ + use ViewTrait; + + /** + * Data provider for testCleanHtml + * + * @return array + */ + public static function cleanHtmlProvider(): array + { + $link = 'VuFind'; + $linkTargetBlank + = 'VuFind'; + $script = ''; + $summaryDetails = 'Summary
Details
'; + return [ + 'plain string' => ['plain string', null, null, 'plain string'], + 'link' => [$link, null, null, $link], + 'link + script' => [$link . $script, null, null, $link], + 'link + script + link' => [$link . $script . $link, null, null, $link . $link], + 'link with target="_blank"' => [$link, true, null, $linkTargetBlank], + 'link in heading' => [$link, null, 'heading', $link], + 'summary and details in default context' => [$summaryDetails, null, null, $summaryDetails], + 'summary and details in heading' => [$summaryDetails, null, 'heading', 'Summary Details'], + ]; + } + + /** + * Test cleanHtml + * + * @param string $input Input string + * @param ?bool $targetBlank Add target="_blank" to external links? + * @param ?string $context Rendering context or null for default + * @param string $expected Expected result + * + * @return void + * + * @dataProvider cleanHtmlProvider + */ + public function testCleanHtml(string $input, ?bool $targetBlank, ?string $context, $expected): void + { + $cleanHtml = $this->createCleanHtmlHelper(); + $extraParams = []; + if (null !== $targetBlank) { + $extraParams['targetBlank'] = $targetBlank; + } + if (null !== $context) { + $extraParams['context'] = $context; + } + $result = $cleanHtml($input, ...$extraParams); + $this->assertEquals($expected, $result); + } +} diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/EscapeOrCleanHtmlTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/EscapeOrCleanHtmlTest.php new file mode 100644 index 00000000000..deaaaaa911f --- /dev/null +++ b/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/EscapeOrCleanHtmlTest.php @@ -0,0 +1,122 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ + +namespace VuFindTest\View\Helper\Root; + +use VuFind\Escaper\Escaper; +use VuFind\String\PropertyString; +use VuFind\View\Helper\Root\EscapeOrCleanHtml; +use VuFindTest\Feature\ViewTrait; + +/** + * EscapeOrCleanHtml view helper Test Class + * + * @category VuFind + * @package Tests + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +class EscapeOrCleanHtmlTest extends \PHPUnit\Framework\TestCase +{ + use ViewTrait; + + /** + * Data provider for testEscapeOrCleanHtml + * + * @return array + */ + public static function escapeOrCleanHtmlProvider(): array + { + $link = 'VuFind'; + $div = '
Div
'; + return [ + 'plain string' => ['plain string', null, null, 'default', [], 'plain string'], + 'link' => [$link, null, null, 'default', [], htmlentities($link)], + 'link as PropertyString' => [PropertyString::fromHtml($link), null, null, 'default', [], 'VuFind'], + 'link as PropertyString, allow HTML' => [ + PropertyString::fromHtml($link), null, true, 'default', [], $link, + ], + 'link as PropertyString, allow by config, proper context' => [ + PropertyString::fromHtml($link), 'title', null, 'default', ['title' => true], $link, + ], + 'link as PropertyString, allow by config, wrong context' => [ + PropertyString::fromHtml($link), null, null, 'default', ['title' => true], 'VuFind', + ], + 'div as PropertyString, allow HTML' => [ + PropertyString::fromHtml($div), null, true, 'default', [], $div, + ], + 'div as PropertyString, allow HTML, rendered in heading' => [ + PropertyString::fromHtml($div), null, true, 'heading', [], 'Div', + ], + ]; + } + + /** + * Test escapeOrCleanHtml + * + * @param string|PropertyString $input Input string + * @param ?string $dataContext Data context + * @param ?bool $allowHtml Allow HTML at all? + * @param string $renderingContext Rendering context + * @param array $config Data context configuration + * @param string $expected Expected result + * + * @return void + * + * @dataProvider escapeOrCleanHtmlProvider + */ + public function testEscapeOrCleanHtml( + $input, + ?string $dataContext, + ?bool $allowHtml, + string $renderingContext, + array $config, + string $expected + ): void { + $escaper = new Escaper(false); + $cleanHtml = $this->createCleanHtmlHelper(); + $escapeOrCleanHtml = new EscapeOrCleanHtml($escaper, $cleanHtml, ['Allowed_HTML_Contexts' => $config]); + $result = $escapeOrCleanHtml($input, $dataContext, $allowHtml, $renderingContext); + $this->assertEquals($expected, $result); + } + + /** + * Test invoking without parameters + * + * @return void + */ + public function testInvokeWithoutParameters(): void + { + $escaper = new Escaper(false); + $cleanHtml = $this->createCleanHtmlHelper(); + $escapeOrCleanHtml = new EscapeOrCleanHtml($escaper, $cleanHtml, []); + $this->assertEquals($escapeOrCleanHtml, $escapeOrCleanHtml()); + } +} diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/TruncateTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/TruncateTest.php new file mode 100644 index 00000000000..52bef34cc02 --- /dev/null +++ b/module/VuFind/tests/unit-tests/src/VuFindTest/View/Helper/Root/TruncateTest.php @@ -0,0 +1,85 @@ + + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ + +namespace VuFindTest\View\Helper\Root; + +use VuFind\String\PropertyString; +use VuFind\View\Helper\Root\Truncate; + +/** + * Truncate view helper Test Class + * + * @category VuFind + * @package Tests + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +class TruncateTest extends \PHPUnit\Framework\TestCase +{ + /** + * Data provider for testTruncate + * + * @return array + */ + public static function truncateProvider(): array + { + $shortPropertyString = PropertyString::fromHtml('short'); + $longPropertyString = PropertyString::fromHtml('long string'); + return [ + 'short string, no append specified' => ['short', 5, null, 'short'], + 'long string, no append specified' => ['long string', 5, null, 'long...'], + 'long string, append specified' => ['long string', 5, '…', 'long…'], + 'long string, long limit' => ['long string', 11, null, 'long string'], + 'long string, zero limit' => ['long string', 0, null, ''], + 'short PropertyString, no append specified' => [$shortPropertyString, 5, null, $shortPropertyString], + 'long PropertyString, no append specified' => [$longPropertyString, 5, null, 'long...'], + 'long PropertyString, append specified' => [$longPropertyString, 5, '…', 'long…'], + ]; + } + + /** + * Test truncation + * + * @param string|PropertyString $input Input string + * @param int $len Maximum result string length + * @param ?string $append Truncation indicator to append or null for default + * @param string|PropertyString $expected Expected result + * + * @return void + * + * @dataProvider truncateProvider + */ + public function testTruncate($input, int $len, ?string $append, $expected): void + { + $truncate = new Truncate(); + $result = null !== $append ? $truncate($input, $len, $append) : $truncate($input, $len); + $this->assertEquals($expected, $result); + } +} diff --git a/themes/bootstrap5/templates/RecordDriver/DefaultRecord/core.phtml b/themes/bootstrap5/templates/RecordDriver/DefaultRecord/core.phtml index cd05594bf79..68b7dba32ad 100644 --- a/themes/bootstrap5/templates/RecordDriver/DefaultRecord/core.phtml +++ b/themes/bootstrap5/templates/RecordDriver/DefaultRecord/core.phtml @@ -3,13 +3,14 @@ ?>
schemaOrg()->getAttributes(['vocab' => 'http://schema.org/', 'resource' => '#record', 'typeof' => $this->schemaOrg()->getRecordTypes($this->driver)])?>> record($this->driver)->getQRCode('core'); - $largeImage = $this->record($this->driver)->getThumbnail('large'); + $recordHelper = $this->record($this->driver); + $QRCode = $recordHelper->getQRCode('core'); + $largeImage = $recordHelper->getThumbnail('large'); $linkAttributes = $largeImage ? ['href' => $largeImage, 'data-lightbox-image' => 'true'] : []; - $coverDetails = $this->record($this->driver)->getCoverDetails('core', 'medium', $linkAttributes); + $coverDetails = $recordHelper->getCoverDetails('core', 'medium', $linkAttributes); $cover = $coverDetails['html']; $preview = ($this->previewOverride ?? false) - ? $this->previewOverride : $this->record($this->driver)->getPreviews(); + ? $this->previewOverride : $recordHelper->getPreviews(); $rating = $this->driver->isRatingAllowed(); ?> @@ -28,7 +29,7 @@ - record($this->driver)->renderTemplate('rating.phtml')?> + renderTemplate('rating.phtml')?>
- schemaOrg()->getAttributes(['property' => 'name'])?>>escapeHtml($this->driver->getShortTitle() . ' ' . $this->driver->getSubtitle() . ' ' . $this->driver->getTitleSection())?> + schemaOrg()->getAttributes(['property' => 'name'])?>>escapeOrCleanHtml($this->driver->getShortTitle() . ' ' . $this->driver->getSubtitle() . ' ' . $this->driver->getTitleSection(), 'title', renderingContext: 'heading')?> driver->getExtraDetail('cached_record') && !$this->translationEmpty('cached_record_warning')): ?>
@@ -55,7 +56,7 @@ driver->tryMethod('getFullTitlesAltScript', [], []) as $altTitle): ?>
- escapeHtml($altTitle)?> + escapeOrCleanHtml($altTitle, 'title-alt')?>
@@ -65,14 +66,15 @@ searchOptions($this->driver->getSourceIdentifier())->getVersionsAction()): ?> - record($this->driver)->renderTemplate('versions-link.phtml')?> + renderTemplate('versions-link.phtml')?> driver->getSummary(); ?> - escapeHtml($summary[0]) : false): ?> -

truncate($summary, 300)?>

+ + truncate($summary, 300); ?> +

escapeOrCleanHtml($shortSummary, $summary, 'summary')?>

- 300): ?> +

transEsc('Full description')?>

@@ -86,7 +88,7 @@ record($this->driver)->renderTemplate( + $recordHelper->renderTemplate( 'core-fields.phtml', [ 'driver' => $this->driver, diff --git a/themes/bootstrap5/templates/RecordDriver/DefaultRecord/data-summary.phtml b/themes/bootstrap5/templates/RecordDriver/DefaultRecord/data-summary.phtml index bd4333cb4e5..37ace4afa6b 100644 --- a/themes/bootstrap5/templates/RecordDriver/DefaultRecord/data-summary.phtml +++ b/themes/bootstrap5/templates/RecordDriver/DefaultRecord/data-summary.phtml @@ -1,10 +1,17 @@ driver->getSummary() as $summary): ?> - escapeHtml($summary) ?>
+ escapeOrCleanHtml($summary, 'summary', true) ?>
driver->getCleanISBN(); ?> summaries($isbn) as $provider => $content): ?> - - '); ?> - escapeHtml($summary) . '
')?> - + ', + array_map( + function ($summary) { + return $this->escapeOrCleanHtml($summary, 'summary', true); + }, + $content + ) + ) + ?> diff --git a/themes/bootstrap5/templates/RecordDriver/DefaultRecord/list-entry.phtml b/themes/bootstrap5/templates/RecordDriver/DefaultRecord/list-entry.phtml index fe263bc2e5f..9ea5b472fec 100644 --- a/themes/bootstrap5/templates/RecordDriver/DefaultRecord/list-entry.phtml +++ b/themes/bootstrap5/templates/RecordDriver/DefaultRecord/list-entry.phtml @@ -41,7 +41,7 @@ driver->tryMethod('getTitlesAltScript', [], []) as $altTitle): ?>
- escapeHtml($altTitle)?> + escapeOrCleanHtml($altTitle, 'title')?>
diff --git a/themes/bootstrap5/templates/RecordDriver/DefaultRecord/result-list.phtml b/themes/bootstrap5/templates/RecordDriver/DefaultRecord/result-list.phtml index a4f68b11ea0..d70d5a97651 100644 --- a/themes/bootstrap5/templates/RecordDriver/DefaultRecord/result-list.phtml +++ b/themes/bootstrap5/templates/RecordDriver/DefaultRecord/result-list.phtml @@ -28,7 +28,7 @@ driver->tryMethod('getTitlesAltScript', [], []) as $altTitle): ?>
- escapeHtml($altTitle)?> + escapeOrCleanHtml($altTitle, 'title')?>
diff --git a/themes/root/theme.config.php b/themes/root/theme.config.php index bc3561b5273..cbd6909e9cd 100644 --- a/themes/root/theme.config.php +++ b/themes/root/theme.config.php @@ -18,6 +18,7 @@ 'VuFind\View\Helper\Root\Captcha' => 'VuFind\View\Helper\Root\CaptchaFactory', 'VuFind\View\Helper\Root\Cart' => 'VuFind\View\Helper\Root\CartFactory', 'VuFind\View\Helper\Root\Citation' => 'VuFind\View\Helper\Root\CitationFactory', + 'VuFind\View\Helper\Root\CleanHtml' => 'VuFind\View\Helper\Root\CleanHtmlFactory', 'VuFind\View\Helper\Root\Component' => 'Laminas\ServiceManager\Factory\InvokableFactory', 'VuFind\View\Helper\Root\Config' => 'VuFind\View\Helper\Root\ConfigFactory', 'VuFind\View\Helper\Root\Content' => 'VuFind\View\Helper\Root\ContentFactory', @@ -30,6 +31,7 @@ 'VuFind\View\Helper\Root\CurrentPath' => 'Laminas\ServiceManager\Factory\InvokableFactory', 'VuFind\View\Helper\Root\DateTime' => 'VuFind\View\Helper\Root\DateTimeFactory', 'VuFind\View\Helper\Root\DisplayLanguageOption' => 'VuFind\View\Helper\Root\DisplayLanguageOptionFactory', + 'VuFind\View\Helper\Root\EscapeOrCleanHtml' => 'VuFind\View\Helper\Root\EscapeOrCleanHtmlFactory', 'VuFind\View\Helper\Root\ExplainElement' => 'Laminas\ServiceManager\Factory\InvokableFactory', 'VuFind\View\Helper\Root\Export' => 'VuFind\View\Helper\Root\ExportFactory', 'VuFind\View\Helper\Root\Feedback' => 'VuFind\View\Helper\Root\FeedbackFactory', @@ -122,6 +124,7 @@ 'captcha' => 'VuFind\View\Helper\Root\Captcha', 'cart' => 'VuFind\View\Helper\Root\Cart', 'citation' => 'VuFind\View\Helper\Root\Citation', + 'cleanHtml' => 'VuFind\View\Helper\Root\CleanHtml', 'component' => 'VuFind\View\Helper\Root\Component', 'config' => 'VuFind\View\Helper\Root\Config', 'content' => 'VuFind\View\Helper\Root\Content', @@ -134,6 +137,7 @@ 'currentPath' => 'VuFind\View\Helper\Root\CurrentPath', 'dateTime' => 'VuFind\View\Helper\Root\DateTime', 'displayLanguageOption' => 'VuFind\View\Helper\Root\DisplayLanguageOption', + 'escapeOrCleanHtml' => 'VuFind\View\Helper\Root\EscapeOrCleanHtml', 'explainElement' => 'VuFind\View\Helper\Root\ExplainElement', 'export' => 'VuFind\View\Helper\Root\Export', 'feedback' => 'VuFind\View\Helper\Root\Feedback',