diff --git a/composer.json b/composer.json index 746ab326d2f..ecfd7f7eb71 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", "guzzlehttp/guzzle": "7.9.2", "jaybizzle/crawler-detect": "^1.2", "laminas/laminas-cache": "3.12.2", diff --git a/composer.lock b/composer.lock index 3ca06deea52..57a72c735af 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": "540c871f9b843a7da019964d94455bf4", + "content-hash": "1bb2a6857e13815b065e34b819acb716", "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", @@ -14408,7 +14469,7 @@ "platform": { "php": ">=8.1" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { "php": "8.1" }, diff --git a/config/vufind/config.ini b/config/vufind/config.ini index 068680c25c0..ba64c91d2f7 100644 --- a/config/vufind/config.ini +++ b/config/vufind/config.ini @@ -2641,3 +2641,10 @@ 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 + +; By default HTML is not allowed in record fields, but support for sanitized HTML +; can be enabled per field type. +[HTML_Fields] +;title = false +;title-alt = false +;summary = false 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 f3a8317daca..40f698a021c 100644 --- a/module/VuFind/src/VuFind/RecordDriver/DefaultRecord.php +++ b/module/VuFind/src/VuFind/RecordDriver/DefaultRecord.php @@ -1271,9 +1271,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'); } /** @@ -1816,4 +1816,23 @@ public function getCoordinateLabels() { return (array)($this->fields['long_lat_label'] ?? []); } + + /** + * 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['description'] ?? ''; + if ('' === $value) { + return []; + } + // Avoid casting since description 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..5341f8dda05 --- /dev/null +++ b/module/VuFind/src/VuFind/String/PropertyString.php @@ -0,0 +1,224 @@ + + * @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; + +/** + * 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/getHtmlTrusted). + * + * @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 getHtmlTrusted(): ?bool + { + return $this['__trustedHtml']; + } + + /** + * 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..0e2aec82b64 --- /dev/null +++ b/module/VuFind/src/VuFind/String/PropertyStringInterface.php @@ -0,0 +1,109 @@ + + * @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/getHtmlTrusted). + * + * @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 getHtmlTrusted(): ?bool; + + /** + * 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..abefb146acb --- /dev/null +++ b/module/VuFind/src/VuFind/View/Helper/Root/CleanHtmlFactory.php @@ -0,0 +1,156 @@ + + * @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; + +/** + * 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; + + /** + * 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 + { + $context = $options['context'] ?? 'default'; + if ('heading' === $context) { + // Limit elements allowed in headings + // (see https://developer.mozilla.org/en-US/docs/Web/HTML/Content_categories#phrasing_content for more + // information): + $config->set('HTML.AllowedElements', 'a,b,br,em,i,span'); + } else { + // 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..63b8015ad51 --- /dev/null +++ b/module/VuFind/src/VuFind/View/Helper/Root/EscapeOrCleanHtml.php @@ -0,0 +1,105 @@ + + * @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 +{ + /** + * Field types that allow HTML + * + * @var array + */ + protected array $htmlFields; + + /** + * 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->htmlFields = (array)($config['HTML_Fields'] ?? []); + } + + /** + * Invoke this helper: escape a value + * + * @param ?string $value Value to escape + * @param ?string $fieldType Field type (for fields that allow sanitized HTML) + * @param ?bool $allowHtml Whether to allow sanitized HTML if passed a PropertyString + * @param string $context Rendering context for cleaning HTML + * + * @return mixed Given a string, returns an escaped string, otherwise returns self + */ + public function __invoke( + $value = null, + ?string $fieldType = null, + ?bool $allowHtml = null, + string $context = 'default' + ) { + if (null === $value) { + return $this; + } + if ($value instanceof PropertyStringInterface) { + if (($allowHtml ?? ($fieldType && ($this->htmlFields[$fieldType] ?? false))) && $html = $value->getHtml()) { + return $value->getHtmlTrusted() ? $html : ($this->cleanHtml)($html, context: $context); + } + $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/RecordDataFormatter.php b/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter.php index b9b4eb5f988..9686d9cfb39 100644 --- a/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter.php +++ b/module/VuFind/src/VuFind/View/Helper/Root/RecordDataFormatter.php @@ -33,6 +33,7 @@ use Laminas\View\Helper\AbstractHelper; use VuFind\RecordDriver\AbstractBase as RecordDriver; +use VuFind\String\PropertyStringInterface; use function call_user_func; use function count; @@ -125,6 +126,9 @@ protected function sortCallback($a, $b) */ protected function allowValue($value, $options, $ignoreCombineAlt = false) { + if ($value instanceof PropertyStringInterface) { + $value = (string)$value; + } if (!empty($value) || ($ignoreCombineAlt && ($options['renderType'] ?? 'Simple') == 'CombineAlt')) { return true; } diff --git a/module/VuFind/src/VuFind/View/Helper/Root/Truncate.php b/module/VuFind/src/VuFind/View/Helper/Root/Truncate.php index 4bf37e258a5..d9dfc2623e2 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,19 @@ 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 + * @param string|PropertyStringInterface $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 * - * @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/themes/bootstrap3/templates/RecordDriver/DefaultRecord/core.phtml b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/core.phtml index 3d5a8741d9a..5d33519deae 100644 --- a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/core.phtml +++ b/themes/bootstrap3/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', context: '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/bootstrap3/templates/RecordDriver/DefaultRecord/data-summary.phtml b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/data-summary.phtml index bd4333cb4e5..99b13a9bc1c 100644 --- a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/data-summary.phtml +++ b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/data-summary.phtml @@ -1,10 +1,17 @@ driver->getSummary() as $summary): ?> - escapeHtml($summary) ?>
+ escapeOrCleanHtml($summary, allowHtml: true) ?>
driver->getCleanISBN(); ?> summaries($isbn) as $provider => $content): ?> - - '); ?> - escapeHtml($summary) . '
')?> - + ', + array_map( + function ($summary) { + return $this->escapeOrCleanHtml($summary, allowHtml: true); + }, + $content + ) + ) + ?> diff --git a/themes/bootstrap5/templates/RecordDriver/DefaultRecord/core.phtml b/themes/bootstrap5/templates/RecordDriver/DefaultRecord/core.phtml index 3d5a8741d9a..5d33519deae 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', context: '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..99b13a9bc1c 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, allowHtml: true) ?>
driver->getCleanISBN(); ?> summaries($isbn) as $provider => $content): ?> - - '); ?> - escapeHtml($summary) . '
')?> - + ', + array_map( + function ($summary) { + return $this->escapeOrCleanHtml($summary, allowHtml: true); + }, + $content + ) + ) + ?> diff --git a/themes/root/theme.config.php b/themes/root/theme.config.php index b168f023a36..e56f644e02b 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', @@ -31,6 +32,7 @@ 'VuFind\View\Helper\Root\DateTime' => 'VuFind\View\Helper\Root\DateTimeFactory', 'VuFind\View\Helper\Root\DisplayLanguageOption' => 'VuFind\View\Helper\Root\DisplayLanguageOptionFactory', 'VuFind\View\Helper\Root\Doi' => 'VuFind\View\Helper\Root\DoiFactory', + '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', @@ -135,6 +138,7 @@ 'dateTime' => 'VuFind\View\Helper\Root\DateTime', 'displayLanguageOption' => 'VuFind\View\Helper\Root\DisplayLanguageOption', 'doi' => 'VuFind\View\Helper\Root\Doi', + 'escapeOrCleanHtml' => 'VuFind\View\Helper\Root\EscapeOrCleanHtml', 'explainElement' => 'VuFind\View\Helper\Root\ExplainElement', 'export' => 'VuFind\View\Helper\Root\Export', 'feedback' => 'VuFind\View\Helper\Root\Feedback',