diff options
| author | 2023-03-04 13:30:45 +0100 | |
|---|---|---|
| committer | 2023-03-04 13:30:45 +0100 | |
| commit | b3239256dc6d188cda970adab516b3fcf1b86129 (patch) | |
| tree | d8e65dd9784834ba2e82ce7ee94b4718f8af19ea /lib | |
| parent | 27b71ffa99f7dff013fb8d51d020ed628e0d2ce6 (diff) | |
| parent | 0fe0ce894cbad09757d719dd4b400b9862c1a12a (diff) | |
Merge branch 'edge' into latest
Diffstat (limited to 'lib')
24 files changed, 1475 insertions, 508 deletions
diff --git a/lib/.gitignore b/lib/.gitignore index 812bbfe76..a1df80381 100644 --- a/lib/.gitignore +++ b/lib/.gitignore @@ -1,6 +1,14 @@ autoload.php composer.lock composer/ +marienfressinaud/lib_opml/.git/ +marienfressinaud/lib_opml/.gitlab-ci.yml +marienfressinaud/lib_opml/.gitlab/ +marienfressinaud/lib_opml/ci/ +marienfressinaud/lib_opml/examples/ +marienfressinaud/lib_opml/Makefile +marienfressinaud/lib_opml/src/functions.php +marienfressinaud/lib_opml/tests/ phpgt/cssxpath/.* phpgt/cssxpath/composer.json phpgt/cssxpath/CONTRIBUTING.md diff --git a/lib/Minz/Configuration.php b/lib/Minz/Configuration.php index 403d6ccba..6d4aed0ab 100644 --- a/lib/Minz/Configuration.php +++ b/lib/Minz/Configuration.php @@ -26,7 +26,7 @@ class Minz_Configuration { * @param object $configuration_setter an optional helper to set values in configuration */ public static function register($namespace, $config_filename, $default_filename = null, $configuration_setter = null) { - self::$config_list[$namespace] = new Minz_Configuration( + self::$config_list[$namespace] = new static( $namespace, $config_filename, $default_filename, $configuration_setter ); } @@ -51,7 +51,7 @@ class Minz_Configuration { * Return the configuration related to a given namespace. * * @param string $namespace the name of the configuration to get. - * @return Minz_Configuration object + * @return static object * @throws Minz_ConfigurationNamespaceException if the namespace does not exist. */ public static function get($namespace) { @@ -117,7 +117,7 @@ class Minz_Configuration { * @param string $default_filename the file containing default values, null by default. * @param object $configuration_setter an optional helper to set values in configuration */ - private function __construct($namespace, $config_filename, $default_filename = null, $configuration_setter = null) { + private final function __construct($namespace, $config_filename, $default_filename = null, $configuration_setter = null) { $this->namespace = $namespace; $this->config_filename = $config_filename; $this->default_filename = $default_filename; diff --git a/lib/Minz/Migrator.php b/lib/Minz/Migrator.php index 0f28237c5..ef89a3b55 100644 --- a/lib/Minz/Migrator.php +++ b/lib/Minz/Migrator.php @@ -55,7 +55,7 @@ class Minz_Migrator } $lock_path = $applied_migrations_path . '.lock'; - if (!@mkdir($lock_path)) { + if (!@mkdir($lock_path, 0770, true)) { // Someone is probably already executing the migrations (the folder // already exists). // We should probably return something else, but we don't want the diff --git a/lib/Minz/ModelPdo.php b/lib/Minz/ModelPdo.php index 0f5b9efca..85796b53a 100644 --- a/lib/Minz/ModelPdo.php +++ b/lib/Minz/ModelPdo.php @@ -26,7 +26,7 @@ class Minz_ModelPdo { private static $sharedCurrentUser; /** - * @var Minz_Pdo|null + * @var Minz_Pdo */ protected $pdo; diff --git a/lib/Minz/Translate.php b/lib/Minz/Translate.php index 19a86a00e..07d48ec08 100644 --- a/lib/Minz/Translate.php +++ b/lib/Minz/Translate.php @@ -87,10 +87,10 @@ class Minz_Translate { * preferred languages then returns the default language * @param string|null $user the connected user language (nullable) * @param array<string> $preferred an array of the preferred languages - * @param string $default the preferred language to use + * @param string|null $default the preferred language to use * @return string containing the language to use */ - public static function getLanguage($user, $preferred, $default) { + public static function getLanguage(?string $user, array $preferred, ?string $default): string { if (null !== $user) { return $user; } @@ -232,7 +232,7 @@ class Minz_Translate { } // Get the facultative arguments to replace i18n variables. - return vsprintf($translation_value, $args); + return empty($args) ? $translation_value : vsprintf($translation_value, $args); } /** diff --git a/lib/Minz/View.php b/lib/Minz/View.php index 8faeb9078..459ef1e23 100644 --- a/lib/Minz/View.php +++ b/lib/Minz/View.php @@ -202,36 +202,38 @@ class Minz_View { $styles = ''; foreach(self::$styles as $style) { - $cond = $style['cond']; - if ($cond) { - $styles .= '<!--[if ' . $cond . ']>'; - } - $styles .= '<link rel="stylesheet" ' . ($style['media'] === 'all' ? '' : 'media="' . $style['media'] . '" ') . 'href="' . $style['url'] . '" />'; - if ($cond) { - $styles .= '<![endif]-->'; - } - $styles .= "\n"; } return $styles; } - public static function prependStyle ($url, $media = 'all', $cond = false) { + /** + * Prepends a <link> element referencing stylesheet. + * + * @param string $url + * @param string $media + * @param bool $cond Conditional comment for IE, now deprecated and ignored + */ + public static function prependStyle($url, $media = 'all', $cond = false) { array_unshift (self::$styles, array ( 'url' => $url, 'media' => $media, - 'cond' => $cond )); } - public static function appendStyle ($url, $media = 'all', $cond = false) { + /** + * Append a `<link>` element referencing stylesheet. + * @param string $url + * @param string $media + * @param bool $cond Conditional comment for IE, now deprecated and ignored + */ + public static function appendStyle($url, $media = 'all', $cond = false) { self::$styles[] = array ( 'url' => $url, 'media' => $media, - 'cond' => $cond ); } @@ -242,11 +244,6 @@ class Minz_View { $scripts = ''; foreach (self::$scripts as $script) { - $cond = $script['cond']; - if ($cond) { - $scripts .= '<!--[if ' . $cond . ']>'; - } - $scripts .= '<script src="' . $script['url'] . '"'; if (!empty($script['id'])) { $scripts .= ' id="' . $script['id'] . '"'; @@ -258,29 +255,38 @@ class Minz_View { $scripts .= ' async="async"'; } $scripts .= '></script>'; - - if ($cond) { - $scripts .= '<![endif]-->'; - } - $scripts .= "\n"; } return $scripts; } - public static function prependScript ($url, $cond = false, $defer = true, $async = true, $id = '') { + /** + * Prepend a `<script>` element. + * @param string $url + * @param bool $cond Conditional comment for IE, now deprecated and ignored + * @param bool $defer Use `defer` flag + * @param bool $async Use `async` flag + * @param string $id Add a script `id` attribute + */ + public static function prependScript($url, $cond = false, $defer = true, $async = true, $id = '') { array_unshift(self::$scripts, array ( 'url' => $url, - 'cond' => $cond, 'defer' => $defer, 'async' => $async, 'id' => $id, )); } - public static function appendScript ($url, $cond = false, $defer = true, $async = true, $id = '') { +/** + * Append a `<script>` element. + * @param string $url + * @param bool $cond Conditional comment for IE, now deprecated and ignored + * @param bool $defer Use `defer` flag + * @param bool $async Use `async` flag + * @param string $id Add a script `id` attribute + */ + public static function appendScript($url, $cond = false, $defer = true, $async = true, $id = '') { self::$scripts[] = array ( 'url' => $url, - 'cond' => $cond, 'defer' => $defer, 'async' => $async, 'id' => $id, diff --git a/lib/SimplePie/SimplePie.php b/lib/SimplePie/SimplePie.php index 9983f577e..c0b2e24f6 100644 --- a/lib/SimplePie/SimplePie.php +++ b/lib/SimplePie/SimplePie.php @@ -418,6 +418,12 @@ define('SIMPLEPIE_FILE_SOURCE_FILE_GET_CONTENTS', 16); */ class SimplePie { + + /** + * @internal Default value of the HTTP Accept header when fetching/locating feeds + */ + public const DEFAULT_HTTP_ACCEPT_HEADER = 'application/atom+xml, application/rss+xml, application/rdf+xml;q=0.9, application/xml;q=0.8, text/xml;q=0.8, text/html;q=0.7, unknown/unknown;q=0.1, application/unknown;q=0.1, */*;q=0.1'; + /** * @var array Raw data * @access private @@ -1690,7 +1696,7 @@ class SimplePie else { $headers = array( - 'Accept' => 'application/atom+xml, application/rss+xml, application/rdf+xml;q=0.9, application/xml;q=0.8, text/xml;q=0.8, text/html;q=0.7, unknown/unknown;q=0.1, application/unknown;q=0.1, */*;q=0.1', + 'Accept' => SimplePie::DEFAULT_HTTP_ACCEPT_HEADER, ); if (isset($this->data['headers']['last-modified'])) { @@ -1754,7 +1760,7 @@ class SimplePie else { $headers = array( - 'Accept' => 'application/atom+xml, application/rss+xml, application/rdf+xml;q=0.9, application/xml;q=0.8, text/xml;q=0.8, text/html;q=0.7, unknown/unknown;q=0.1, application/unknown;q=0.1, */*;q=0.1', + 'Accept' => SimplePie::DEFAULT_HTTP_ACCEPT_HEADER, ); $file = $this->registry->create('File', array($this->feed_url, $this->timeout, 5, $headers, $this->useragent, $this->force_fsockopen, $this->curl_options, $this->syslog_enabled)); } diff --git a/lib/SimplePie/SimplePie/Enclosure.php b/lib/SimplePie/SimplePie/Enclosure.php index cc0d038b5..04bade09f 100644 --- a/lib/SimplePie/SimplePie/Enclosure.php +++ b/lib/SimplePie/SimplePie/Enclosure.php @@ -627,7 +627,7 @@ class SimplePie_Enclosure { if ($this->link !== null) { - return urldecode($this->link); + return $this->link; } return null; diff --git a/lib/SimplePie/SimplePie/Item.php b/lib/SimplePie/SimplePie/Item.php index 2fb1d3284..1285fd536 100644 --- a/lib/SimplePie/SimplePie/Item.php +++ b/lib/SimplePie/SimplePie/Item.php @@ -427,7 +427,16 @@ class SimplePie_Item { if ($return = $this->get_item_tags(SIMPLEPIE_NAMESPACE_MEDIARSS, 'thumbnail')) { - $this->data['thumbnail'] = $return[0]['attribs']['']; + $thumbnail = $return[0]['attribs']['']; + if (empty($thumbnail['url'])) + { + $this->data['thumbnail'] = null; + } + else + { + $thumbnail['url'] = $this->sanitize($thumbnail['url'], SIMPLEPIE_CONSTRUCT_IRI, $this->get_base($return[0])); + $this->data['thumbnail'] = $thumbnail; + } } else { @@ -2847,9 +2856,9 @@ class SimplePie_Item } } - if ($enclosure = $this->get_item_tags(SIMPLEPIE_NAMESPACE_RSS_20, 'enclosure')) + foreach ($this->get_item_tags(SIMPLEPIE_NAMESPACE_RSS_20, 'enclosure') ?? [] as $enclosure) { - if (isset($enclosure[0]['attribs']['']['url'])) + if (isset($enclosure['attribs']['']['url'])) { // Attributes $bitrate = null; @@ -2867,15 +2876,15 @@ class SimplePie_Item $url = null; $width = null; - $url = $this->sanitize($enclosure[0]['attribs']['']['url'], SIMPLEPIE_CONSTRUCT_IRI, $this->get_base($enclosure[0])); + $url = $this->sanitize($enclosure['attribs']['']['url'], SIMPLEPIE_CONSTRUCT_IRI, $this->get_base($enclosure)); $url = $this->feed->sanitize->https_url($url); - if (isset($enclosure[0]['attribs']['']['type'])) + if (isset($enclosure['attribs']['']['type'])) { - $type = $this->sanitize($enclosure[0]['attribs']['']['type'], SIMPLEPIE_CONSTRUCT_TEXT); + $type = $this->sanitize($enclosure['attribs']['']['type'], SIMPLEPIE_CONSTRUCT_TEXT); } - if (isset($enclosure[0]['attribs']['']['length'])) + if (isset($enclosure['attribs']['']['length'])) { - $length = intval($enclosure[0]['attribs']['']['length']); + $length = intval($enclosure['attribs']['']['length']); } // Since we don't have group or content for these, we'll just pass the '*_parent' variables directly to the constructor diff --git a/lib/SimplePie/SimplePie/Locator.php b/lib/SimplePie/SimplePie/Locator.php index c5fae0579..10b50cadf 100644 --- a/lib/SimplePie/SimplePie/Locator.php +++ b/lib/SimplePie/SimplePie/Locator.php @@ -256,7 +256,7 @@ class SimplePie_Locator { $this->checked_feeds++; $headers = array( - 'Accept' => 'application/atom+xml, application/rss+xml, application/rdf+xml;q=0.9, application/xml;q=0.8, text/xml;q=0.8, text/html;q=0.7, unknown/unknown;q=0.1, application/unknown;q=0.1, */*;q=0.1', + 'Accept' => SimplePie::DEFAULT_HTTP_ACCEPT_HEADER, ); $feed = $this->registry->create('File', array($href, $this->timeout, 5, $headers, $this->useragent, $this->force_fsockopen, $this->curl_options)); if ($feed->success && ($feed->method & SIMPLEPIE_FILE_SOURCE_REMOTE === 0 || ($feed->status_code === 200 || $feed->status_code > 206 && $feed->status_code < 300)) && $this->is_feed($feed, true)) @@ -386,7 +386,7 @@ class SimplePie_Locator $this->checked_feeds++; $headers = array( - 'Accept' => 'application/atom+xml, application/rss+xml, application/rdf+xml;q=0.9, application/xml;q=0.8, text/xml;q=0.8, text/html;q=0.7, unknown/unknown;q=0.1, application/unknown;q=0.1, */*;q=0.1', + 'Accept' => SimplePie::DEFAULT_HTTP_ACCEPT_HEADER, ); $feed = $this->registry->create('File', array($value, $this->timeout, 5, $headers, $this->useragent, $this->force_fsockopen, $this->curl_options)); if ($feed->success && ($feed->method & SIMPLEPIE_FILE_SOURCE_REMOTE === 0 || ($feed->status_code === 200 || $feed->status_code > 206 && $feed->status_code < 300)) && $this->is_feed($feed)) @@ -414,9 +414,9 @@ class SimplePie_Locator { $this->checked_feeds++; $headers = array( - 'Accept' => 'application/atom+xml, application/rss+xml, application/rdf+xml;q=0.9, application/xml;q=0.8, text/xml;q=0.8, text/html;q=0.7, unknown/unknown;q=0.1, application/unknown;q=0.1, */*;q=0.1', + 'Accept' => SimplePie::DEFAULT_HTTP_ACCEPT_HEADER, ); - $feed = $this->registry->create('File', array($value, $this->timeout, 5, null, $this->useragent, $this->force_fsockopen, $this->curl_options)); + $feed = $this->registry->create('File', array($value, $this->timeout, 5, $headers, $this->useragent, $this->force_fsockopen, $this->curl_options)); if ($feed->success && ($feed->method & SIMPLEPIE_FILE_SOURCE_REMOTE === 0 || ($feed->status_code === 200 || $feed->status_code > 206 && $feed->status_code < 300)) && $this->is_feed($feed)) { return array($feed); diff --git a/lib/SimplePie/SimplePie/Registry.php b/lib/SimplePie/SimplePie/Registry.php index 1aac51d07..1aac51d07 100755..100644 --- a/lib/SimplePie/SimplePie/Registry.php +++ b/lib/SimplePie/SimplePie/Registry.php diff --git a/lib/composer.json b/lib/composer.json index 017adfea6..6e9e0ee32 100644 --- a/lib/composer.json +++ b/lib/composer.json @@ -11,7 +11,8 @@ } ], "require": { - "php": ">=7.0.0", + "php": ">=7.2.0", + "marienfressinaud/lib_opml": "0.5.0", "phpgt/cssxpath": "dev-master#4fbe420aba3d9e729940107ded4236a835a1a132", "phpmailer/phpmailer": "6.6.0" }, diff --git a/lib/http-conditional.php b/lib/http-conditional.php index 853fdf983..6c7c89d32 100644 --- a/lib/http-conditional.php +++ b/lib/http-conditional.php @@ -7,7 +7,7 @@ - Possibility to control cache for client and proxies (public or private policy, life time). - When $feedMode is set to true, in the case of a RSS/ATOM feed, it puts a timestamp in the global variable $clientCacheDate to allow the sending of only the articles newer than the client's cache. - - When $compression is set to true, compress the data before sending it to the client and persitent connections are allowed. + - When $compression is set to true, compress the data before sending it to the client and persistent connections are allowed. - When $session is set to true, automatically checks if $_SESSION has been modified during the last generation the document. Interface: diff --git a/lib/lib_install.php b/lib/lib_install.php index 494ddc6dd..931de21a2 100644 --- a/lib/lib_install.php +++ b/lib/lib_install.php @@ -1,7 +1,5 @@ <?php -define('BCRYPT_COST', 9); - Minz_Configuration::register('default_system', join_path(FRESHRSS_PATH, 'config.default.php')); Minz_Configuration::register('default_user', join_path(FRESHRSS_PATH, 'config-user.default.php')); @@ -42,14 +40,14 @@ function checkRequirements($dbType = '') { $json = function_exists('json_encode'); $mbstring = extension_loaded('mbstring'); // @phpstan-ignore-next-line - $data = DATA_PATH && is_writable(DATA_PATH); + $data = DATA_PATH && touch(DATA_PATH . '/index.html'); // is_writable() is not reliable for a folder on NFS // @phpstan-ignore-next-line - $cache = CACHE_PATH && is_writable(CACHE_PATH); + $cache = CACHE_PATH && touch(CACHE_PATH . '/index.html'); // @phpstan-ignore-next-line $tmp = TMP_PATH && is_writable(TMP_PATH); // @phpstan-ignore-next-line - $users = USERS_PATH && is_writable(USERS_PATH); - $favicons = is_writable(join_path(DATA_PATH, 'favicons')); + $users = USERS_PATH && touch(USERS_PATH . '/index.html'); + $favicons = touch(DATA_PATH . '/favicons/index.html'); return array( 'php' => $php ? 'ok' : 'ko', diff --git a/lib/lib_opml.php b/lib/lib_opml.php deleted file mode 100644 index f86d780b7..000000000 --- a/lib/lib_opml.php +++ /dev/null @@ -1,353 +0,0 @@ -<?php - -/** - * lib_opml is a free library to manage OPML format in PHP. - * - * By default, it takes in consideration version 2.0 but can be compatible with - * OPML 1.0. More information on http://dev.opml.org. - * Difference is "text" attribute is optional in version 1.0. It is highly - * recommended to use this attribute. - * - * lib_opml requires SimpleXML (php.net/simplexml) and DOMDocument (php.net/domdocument) - * - * @author Marien Fressinaud <dev@marienfressinaud.fr> - * @link https://github.com/marienfressinaud/lib_opml - * @version 0.2-FreshRSS~1.20.0 - * @license public domain - * - * Usages: - * > include('lib_opml.php'); - * > $filename = 'my_opml_file.xml'; - * > $opml_array = libopml_parse_file($filename); - * > print_r($opml_array); - * - * > $opml_string = [...]; - * > $opml_array = libopml_parse_string($opml_string); - * > print_r($opml_array); - * - * > $opml_array = [...]; - * > $opml_string = libopml_render($opml_array); - * > $opml_object = libopml_render($opml_array, true); - * > echo $opml_string; - * > print_r($opml_object); - * - * You can set $strict argument to false if you want to bypass "text" attribute - * requirement. - * - * If parsing fails for any reason (e.g. not an XML string, does not match with - * the specifications), a LibOPML_Exception is raised. - * - * lib_opml array format is described here: - * $array = array( - * 'head' => array( // 'head' element is optional (but recommended) - * 'key' => 'value', // key must be a part of available OPML head elements - * ), - * 'body' => array( // body is required - * array( // this array represents an outline (at least one) - * 'text' => 'value', // 'text' element is required if $strict is true - * 'key' => 'value', // key and value are what you want (optional) - * '@outlines' = array( // @outlines is a special value and represents sub-outlines - * array( - * [...] // where [...] is a valid outline definition - * ), - * ), - * ), - * array( // other outline definitions - * [...] - * ), - * [...], - * ) - * ) - * - */ - -/** - * A simple Exception class which represents any kind of OPML problem. - * Message should precise the current problem. - */ -class LibOPML_Exception extends Exception {} - - -// Define the list of available head attributes. All of them are optional. -define('HEAD_ELEMENTS', serialize(array( - 'title', 'dateCreated', 'dateModified', 'ownerName', 'ownerEmail', - 'ownerId', 'docs', 'expansionState', 'vertScrollState', 'windowTop', - 'windowLeft', 'windowBottom', 'windowRight' -))); - - -/** - * Parse an XML object as an outline object and return corresponding array - * - * @param SimpleXMLElement $outline_xml the XML object we want to parse - * @param bool $strict true if "text" attribute is required, false else - * @return array corresponding to an outline and following format described above - * @throws LibOPML_Exception - * @access private - */ -function libopml_parse_outline($outline_xml, $strict = true) { - $outline = array(); - - // An outline may contain any kind of attributes but "text" attribute is - // required ! - $text_is_present = false; - - $elem = dom_import_simplexml($outline_xml); - /** @var DOMAttr $attr */ - foreach ($elem->attributes as $attr) { - $key = $attr->localName; - - if ($attr->namespaceURI == '') { - $outline[$key] = $attr->value; - } else { - $outline[$key] = [ - 'namespace' => $attr->namespaceURI, - 'value' => $attr->value, - ]; - } - - if ($key === 'text') { - $text_is_present = true; - } - } - - if (!$text_is_present && $strict) { - throw new LibOPML_Exception( - 'Outline does not contain any text attribute' - ); - } - - if (empty($outline['text']) && isset($outline['title'])) { - $outline['text'] = $outline['title']; - } - - foreach ($outline_xml->children() as $key => $value) { - // An outline may contain any number of outline children - if ($key === 'outline') { - $outline['@outlines'][] = libopml_parse_outline($value, $strict); - } - } - - return $outline; -} - -/** - * Reformat the XML document as a hierarchy when - * the OPML 2.0 category attribute is used - */ -function preprocessing_categories($doc) { - $outline_categories = array(); - $body = $doc->getElementsByTagName('body')->item(0); - $xpath = new DOMXpath($doc); - $outlines = $xpath->query('/opml/body/outline[@category]'); - foreach ($outlines as $outline) { - $category = trim($outline->getAttribute('category')); - if ($category != '') { - $outline_category = null; - if (!isset($outline_categories[$category])) { - $outline_category = $doc->createElement('outline'); - $outline_category->setAttribute('text', $category); - $body->insertBefore($outline_category, $body->firstChild); - $outline_categories[$category] = $outline_category; - } else { - $outline_category = $outline_categories[$category]; - } - $outline->parentNode->removeChild($outline); - $outline_category->appendChild($outline); - } - } -} - -/** - * Parse a string as a XML one and returns the corresponding array - * - * @param string $xml is the string we want to parse - * @param bool $strict true to perform some validation (e.g. require "text" attribute), false to relax - * @return array corresponding to the XML string and following format described above - * @throws LibOPML_Exception - * @access public - */ -function libopml_parse_string($xml, $strict = true) { - $dom = new DOMDocument(); - $dom->recover = true; - $dom->strictErrorChecking = false; - $dom->loadXML($xml); - $dom->encoding = 'UTF-8'; - - //Partial compatibility with the category attribute of OPML 2.0 - preprocessing_categories($dom); - - $opml = simplexml_import_dom($dom); - - if (!$opml) { - throw new LibOPML_Exception(); - } - - $array = array( - 'version' => (string)$opml['version'], - 'head' => array(), - 'body' => array() - ); - - if (isset($opml->head)) { - // We get all "head" elements. Head is required but its sub-elements are optional. - foreach ($opml->head->children() as $key => $value) { - if (in_array($key, unserialize(HEAD_ELEMENTS), true)) { - $array['head'][$key] = (string)$value; - } elseif ($strict) { - throw new LibOPML_Exception($key . ' is not part of the OPML 2.0 specification'); - } - } - } elseif ($strict) { - throw new LibOPML_Exception('Required OPML head element is missing!'); - } - - // Then, we get body oulines. Body must contain at least one outline - // element. - $at_least_one_outline = false; - foreach ($opml->body->children() as $key => $value) { - if ($key === 'outline') { - $at_least_one_outline = true; - $array['body'][] = libopml_parse_outline($value, $strict); - } - } - - if (!$at_least_one_outline) { - throw new LibOPML_Exception( - 'OPML body must contain at least one outline element' - ); - } - - return $array; -} - - -/** - * Parse a string contained into a file as a XML string and returns the corresponding array - * - * @param string $filename should indicates a valid XML file - * @param bool $strict true if "text" attribute is required, false else - * @return array corresponding to the file content and following format described above - * @throws LibOPML_Exception - * @access public - */ -function libopml_parse_file($filename, $strict = true) { - $file_content = file_get_contents($filename); - - if ($file_content === false) { - throw new LibOPML_Exception( - $filename . ' cannot be found' - ); - } - - return libopml_parse_string($file_content, $strict); -} - - -/** - * Create a XML outline object in a parent object. - * - * @param SimpleXMLElement $parent_elt is the parent object of current outline - * @param array $outline array representing an outline object - * @param bool $strict true if "text" attribute is required, false else - * @throws LibOPML_Exception - * @access private - */ -function libopml_render_outline($parent_elt, $outline, $strict) { - // Outline MUST be an array! - if (!is_array($outline)) { - throw new LibOPML_Exception( - 'Outline element must be defined as array' - ); - } - - $outline_elt = $parent_elt->addChild('outline'); - $text_is_present = false; - /** @var string|array<string,mixed> $value */ - foreach ($outline as $key => $value) { - // Only outlines can be an array and so we consider children are also - // outline elements. - if ($key === '@outlines' && is_array($value)) { - foreach ($value as $outline_child) { - libopml_render_outline($outline_elt, $outline_child, $strict); - } - } elseif (is_array($value) && !isset($value['namespace'])) { - throw new LibOPML_Exception( - 'Type of outline elements cannot be array (except for providing a namespace): ' . $key - ); - } else { - // Detect text attribute is present, that's good :) - if ($key === 'text') { - $text_is_present = true; - } - if (is_array($value)) { - if (!empty($value['namespace']) && !empty($value['value'])) { - $outline_elt->addAttribute($key, $value['value'], $value['namespace']); - } - } else { - $outline_elt->addAttribute($key, $value); - } - } - } - - if (!$text_is_present && $strict) { - throw new LibOPML_Exception( - 'You must define at least a text element for all outlines' - ); - } -} - - -/** - * Render an array as an OPML string or a XML object. - * - * @param array $array is the array we want to render and must follow structure defined above - * @param bool $as_xml_object false if function must return a string, true for a XML object - * @param bool $strict true if "text" attribute is required, false else - * @return string|SimpleXMLElement XML string corresponding to $array or XML object - * @throws LibOPML_Exception - * @access public - */ -function libopml_render($array, $as_xml_object = false, $strict = true) { - $opml = new SimpleXMLElement('<opml></opml>'); - $opml->addAttribute('version', $strict ? '2.0' : '1.0'); - - // Create head element. $array['head'] is optional but head element will - // exist in the final XML object. - $head = $opml->addChild('head'); - if (isset($array['head'])) { - foreach ($array['head'] as $key => $value) { - if (in_array($key, unserialize(HEAD_ELEMENTS), true)) { - $head->addChild($key, $value); - } - } - } - - // Check body is set and contains at least one element - if (!isset($array['body'])) { - throw new LibOPML_Exception( - '$array must contain a body element' - ); - } - if (count($array['body']) <= 0) { - throw new LibOPML_Exception( - 'Body element must contain at least one element (array)' - ); - } - - // Create outline elements - $body = $opml->addChild('body'); - foreach ($array['body'] as $outline) { - libopml_render_outline($body, $outline, $strict); - } - - // And return the final result - if ($as_xml_object) { - return $opml; - } else { - $dom = dom_import_simplexml($opml)->ownerDocument; - $dom->formatOutput = true; - $dom->encoding = 'UTF-8'; - return $dom->saveXML(); - } -} diff --git a/lib/lib_rss.php b/lib/lib_rss.php index 592ad8149..434d0f9fb 100644 --- a/lib/lib_rss.php +++ b/lib/lib_rss.php @@ -4,8 +4,8 @@ if (version_compare(PHP_VERSION, FRESHRSS_MIN_PHP_VERSION, '<')) { } if (!function_exists('mb_strcut')) { - function mb_strcut($str, $start, $length = null, $encoding = 'UTF-8') { - return substr($str, $start, $length); + function mb_strcut(string $str, int $start, ?int $length = null, string $encoding = 'UTF-8'): string { + return substr($str, $start, $length) ?: ''; } } @@ -16,11 +16,27 @@ if (!function_exists('str_starts_with')) { } } -// @phpstan-ignore-next-line -if (COPY_SYSLOG_TO_STDERR) { - openlog('FreshRSS', LOG_CONS | LOG_ODELAY | LOG_PID | LOG_PERROR, LOG_USER); -} else { - openlog('FreshRSS', LOG_CONS | LOG_ODELAY | LOG_PID, LOG_USER); +if (!function_exists('syslog')) { + // @phpstan-ignore-next-line + if (COPY_SYSLOG_TO_STDERR && !defined('STDERR')) { + define('STDERR', fopen('php://stderr', 'w')); + } + function syslog(int $priority, string $message): bool { + // @phpstan-ignore-next-line + if (COPY_SYSLOG_TO_STDERR && defined('STDERR') && STDERR) { + return fwrite(STDERR, $message . "\n") != false; + } + return false; + } +} + +if (function_exists('openlog')) { + // @phpstan-ignore-next-line + if (COPY_SYSLOG_TO_STDERR) { + openlog('FreshRSS', LOG_CONS | LOG_ODELAY | LOG_PID | LOG_PERROR, LOG_USER); + } else { + openlog('FreshRSS', LOG_CONS | LOG_ODELAY | LOG_PID, LOG_USER); + } } /** @@ -34,7 +50,7 @@ function join_path(...$path_parts): string { } //<Auto-loading> -function classAutoloader($class) { +function classAutoloader(string $class): void { if (strpos($class, 'FreshRSS') === 0) { $components = explode('_', $class); switch (count($components)) { @@ -57,6 +73,11 @@ function classAutoloader($class) { $base_dir = LIB_PATH . '/phpgt/cssxpath/src/'; $relative_class_name = substr($class, strlen($prefix)); require $base_dir . str_replace('\\', '/', $relative_class_name) . '.php'; + } elseif (str_starts_with($class, 'marienfressinaud\\LibOpml\\')) { + $prefix = 'marienfressinaud\\LibOpml\\'; + $base_dir = LIB_PATH . '/marienfressinaud/lib_opml/src/LibOpml/'; + $relative_class_name = substr($class, strlen($prefix)); + require $base_dir . str_replace('\\', '/', $relative_class_name) . '.php'; } elseif (str_starts_with($class, 'PHPMailer\\PHPMailer\\')) { $prefix = 'PHPMailer\\PHPMailer\\'; $base_dir = LIB_PATH . '/phpmailer/phpmailer/src/'; @@ -68,14 +89,10 @@ function classAutoloader($class) { spl_autoload_register('classAutoloader'); //</Auto-loading> -/** - * @param string $url - * @return string - */ -function idn_to_puny($url) { +function idn_to_puny(string $url): string { if (function_exists('idn_to_ascii')) { $idn = parse_url($url, PHP_URL_HOST); - if ($idn != '') { + if (is_string($idn) && $idn != '') { // https://wiki.php.net/rfc/deprecate-and-remove-intl_idna_variant_2003 if (defined('INTL_IDNA_VARIANT_UTS46')) { $puny = idn_to_ascii($idn, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46); @@ -85,7 +102,7 @@ function idn_to_puny($url) { $puny = idn_to_ascii($idn); } $pos = strpos($url, $idn); - if ($puny != '' && $pos !== false) { + if ($puny != false && $pos !== false) { $url = substr_replace($url, $puny, $pos, strlen($idn)); } } @@ -94,11 +111,9 @@ function idn_to_puny($url) { } /** - * @param string $url - * @param bool $fixScheme * @return string|false */ -function checkUrl($url, $fixScheme = true) { +function checkUrl(string $url, bool $fixScheme = true) { $url = trim($url); if ($url == '') { return ''; @@ -122,31 +137,19 @@ function checkUrl($url, $fixScheme = true) { * @return string */ function safe_ascii($text) { - return filter_var($text, FILTER_DEFAULT, FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH); + return filter_var($text, FILTER_DEFAULT, FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH) ?: ''; } if (function_exists('mb_convert_encoding')) { - /** - * @param string $text - * @return string - */ - function safe_utf8($text) { - return mb_convert_encoding($text, 'UTF-8', 'UTF-8'); + function safe_utf8(string $text): string { + return mb_convert_encoding($text, 'UTF-8', 'UTF-8') ?: ''; } } elseif (function_exists('iconv')) { - /** - * @param string $text - * @return string - */ - function safe_utf8($text) { - return iconv('UTF-8', 'UTF-8//IGNORE', $text); + function safe_utf8(string $text): string { + return iconv('UTF-8', 'UTF-8//IGNORE', $text) ?: ''; } } else { - /** - * @param string $text - * @return string - */ - function safe_utf8($text) { + function safe_utf8(string $text): string { return $text; } } @@ -173,14 +176,14 @@ function escapeToUnicodeAlternative($text, $extended = true) { return trim(str_replace($problem, $replace, $text)); } -function format_number($n, $precision = 0) { +function format_number(float $n, int $precision = 0): string { // number_format does not seem to be Unicode-compatible return str_replace(' ', ' ', // Thin non-breaking space number_format($n, $precision, '.', ' ') ); } -function format_bytes($bytes, $precision = 2, $system = 'IEC') { +function format_bytes(int $bytes, int $precision = 2, string $system = 'IEC'): string { if ($system === 'IEC') { $base = 1024; $units = array('B', 'KiB', 'MiB', 'GiB', 'TiB'); @@ -197,7 +200,7 @@ function format_bytes($bytes, $precision = 2, $system = 'IEC') { return format_number($bytes, $precision) . ' ' . $units[$pow]; } -function timestamptodate ($t, $hour = true) { +function timestamptodate(int $t, bool $hour = true): string { $month = _t('gen.date.' . date('M', $t)); if ($hour) { $date = _t('gen.date.format_date_hour', $month); @@ -205,14 +208,13 @@ function timestamptodate ($t, $hour = true) { $date = _t('gen.date.format_date', $month); } - return @date ($date, $t); + return @date($date, $t) ?: ''; } /** * Decode HTML entities but preserve XML entities. - * @param string|null $text */ -function html_only_entity_decode($text): string { +function html_only_entity_decode(?string $text): string { static $htmlEntitiesOnly = null; if ($htmlEntitiesOnly === null) { $htmlEntitiesOnly = array_flip(array_diff( @@ -220,13 +222,43 @@ function html_only_entity_decode($text): string { get_html_translation_table(HTML_SPECIALCHARS, ENT_NOQUOTES, 'UTF-8') //Preserve XML entities )); } - return $text == '' ? '' : strtr($text, $htmlEntitiesOnly); + return $text == null ? '' : strtr($text, $htmlEntitiesOnly); +} + +/** + * Remove passwords in FreshRSS logs. + * See also ../cli/sensitive-log.sh for Web server logs. + * @param array<string,mixed>|string $log + * @return array<string,mixed>|string + */ +function sensitive_log($log) { + if (is_array($log)) { + foreach ($log as $k => $v) { + if (in_array($k, ['api_key', 'Passwd', 'T'])) { + $log[$k] = '██'; + } elseif (is_array($v) || is_string($v)) { + $log[$k] = sensitive_log($v); + } else { + return ''; + } + } + } elseif (is_string($log)) { + $log = preg_replace([ + '/\b(auth=.*?\/)[^&]+/i', + '/\b(Passwd=)[^&]+/i', + '/\b(Authorization)[^&]+/i', + ], '$1█', $log) ?? ''; + } + return $log; } /** * @param array<string,mixed> $attributes */ function customSimplePie($attributes = array()): SimplePie { + if (FreshRSS_Context::$system_conf === null) { + throw new FreshRSS_Context_Exception('System configuration not initialised!'); + } $limits = FreshRSS_Context::$system_conf->limits; $simplePie = new SimplePie(); $simplePie->set_useragent(FRESHRSS_USERAGENT); @@ -308,13 +340,13 @@ function customSimplePie($attributes = array()): SimplePie { } /** - * @param int|false $maxLength + * @param string $data */ -function sanitizeHTML($data, string $base = '', $maxLength = false) { - if (!is_string($data) || ($maxLength !== false && $maxLength <= 0)) { +function sanitizeHTML($data, string $base = '', ?int $maxLength = null): string { + if (!is_string($data) || ($maxLength !== null && $maxLength <= 0)) { return ''; } - if ($maxLength !== false) { + if ($maxLength !== null) { $data = mb_strcut($data, 0, $maxLength, 'UTF-8'); } static $simplePie = null; @@ -323,7 +355,7 @@ function sanitizeHTML($data, string $base = '', $maxLength = false) { $simplePie->init(); } $result = html_only_entity_decode($simplePie->sanitize->sanitize($data, SIMPLEPIE_CONSTRUCT_HTML, $base)); - if ($maxLength !== false && strlen($result) > $maxLength) { + if ($maxLength !== null && strlen($result) > $maxLength) { //Sanitizing has made the result too long so try again shorter $data = mb_strcut($result, 0, (2 * $maxLength) - strlen($result) - 2, 'UTF-8'); return sanitizeHTML($data, $base, $maxLength); @@ -331,9 +363,13 @@ function sanitizeHTML($data, string $base = '', $maxLength = false) { return $result; } -function cleanCache(int $hours = 720) { +function cleanCache(int $hours = 720): void { // N.B.: GLOB_BRACE is not available on all platforms - $files = array_merge(glob(CACHE_PATH . '/*.html', GLOB_NOSORT), glob(CACHE_PATH . '/*.spc', GLOB_NOSORT)); + $files = array_merge( + glob(CACHE_PATH . '/*.html', GLOB_NOSORT) ?: [], + glob(CACHE_PATH . '/*.json', GLOB_NOSORT) ?: [], + glob(CACHE_PATH . '/*.spc', GLOB_NOSORT) ?: [], + glob(CACHE_PATH . '/*.xml', GLOB_NOSORT) ?: []); foreach ($files as $file) { if (substr($file, -10) === 'index.html') { continue; @@ -378,17 +414,20 @@ function enforceHttpEncoding(string $html, string $contentType = ''): string { } /** - * @param string $type {html,opml} + * @param string $type {html,json,opml,xml} * @param array<string,mixed> $attributes */ function httpGet(string $url, string $cachePath, string $type = 'html', array $attributes = []): string { + if (FreshRSS_Context::$system_conf === null) { + throw new FreshRSS_Context_Exception('System configuration not initialised!'); + } $limits = FreshRSS_Context::$system_conf->limits; $feed_timeout = empty($attributes['timeout']) ? 0 : intval($attributes['timeout']); $cacheMtime = @filemtime($cachePath); if ($cacheMtime !== false && $cacheMtime > time() - intval($limits['cache_duration'])) { $body = @file_get_contents($cachePath); - if ($body != '') { + if ($body != false) { syslog(LOG_DEBUG, 'FreshRSS uses cache for ' . SimplePie_Misc::url_remove_credentials($url)); return $body; } @@ -404,9 +443,15 @@ function httpGet(string $url, string $cachePath, string $type = 'html', array $a $accept = '*/*;q=0.8'; switch ($type) { + case 'json': + $accept = 'application/json,application/javascript;q=0.9,text/javascript;q=0.8,*/*;q=0.7'; + break; case 'opml': $accept = 'text/x-opml,text/xml;q=0.9,application/xml;q=0.9,*/*;q=0.8'; break; + case 'xml': + $accept = 'application/xml,application/xhtml+xml,text/xml;q=0.9,*/*;q=0.8'; + break; case 'html': default: $accept = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'; @@ -442,7 +487,7 @@ function httpGet(string $url, string $cachePath, string $type = 'html', array $a } $body = curl_exec($ch); $c_status = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $c_content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); //TODO: Check if that may be null + $c_content_type = '' . curl_getinfo($ch, CURLINFO_CONTENT_TYPE); $c_error = curl_error($ch); curl_close($ch); @@ -451,7 +496,7 @@ function httpGet(string $url, string $cachePath, string $type = 'html', array $a $body = ''; // TODO: Implement HTTP 410 Gone } - if ($body == false) { + if (!is_string($body)) { $body = ''; } else { $body = enforceHttpEncoding($body, $c_content_type); @@ -468,10 +513,9 @@ function httpGet(string $url, string $cachePath, string $type = 'html', array $a * Validate an email address, supports internationalized addresses. * * @param string $email The address to validate - * * @return bool true if email is valid, else false */ -function validateEmailAddress($email) { +function validateEmailAddress(string $email): bool { $mailer = new PHPMailer\PHPMailer\PHPMailer(); $mailer->CharSet = 'utf-8'; $punyemail = $mailer->punyencodeAddress($email); @@ -482,9 +526,8 @@ function validateEmailAddress($email) { * Add support of image lazy loading * Move content from src attribute to data-original * @param string $content is the text we want to parse - * @return string */ -function lazyimg($content) { +function lazyimg(string $content): string { return preg_replace([ '/<((?:img|iframe)[^>]+?)src="([^"]+)"([^>]*)>/i', "/<((?:img|iframe)[^>]+?)src='([^']+)'([^>]*)>/i", @@ -493,18 +536,15 @@ function lazyimg($content) { "<$1src='" . Minz_Url::display('/themes/icons/grey.gif') . "' data-original='$2'$3>", ], $content - ); + ) ?? ''; } -/** - * @return string - */ -function uTimeString() { +function uTimeString(): string { $t = @gettimeofday(); return $t['sec'] . str_pad('' . $t['usec'], 6, '0', STR_PAD_LEFT); } -function invalidateHttpCache($username = '') { +function invalidateHttpCache(string $username = ''): bool { if (!FreshRSS_user_Controller::checkUsername($username)) { Minz_Session::_param('touch', uTimeString()); $username = Minz_Session::param('currentUser', '_'); @@ -519,12 +559,12 @@ function invalidateHttpCache($username = '') { /** * @return array<string> */ -function listUsers() { +function listUsers(): array { $final_list = array(); $base_path = join_path(DATA_PATH, 'users'); $dir_list = array_values(array_diff( - scandir($base_path), - array('..', '.', '_') + scandir($base_path) ?: [], + ['..', '.', '_'] )); foreach ($dir_list as $file) { if ($file[0] !== '.' && is_dir(join_path($base_path, $file)) && file_exists(join_path($base_path, $file, 'config.php'))) { @@ -537,12 +577,14 @@ function listUsers() { /** * Return if the maximum number of registrations has been reached. - * - * Note a max_regstrations of 0 means there is no limit. + * Note a max_registrations of 0 means there is no limit. * * @return boolean true if number of users >= max registrations, false else. */ -function max_registrations_reached() { +function max_registrations_reached(): bool { + if (FreshRSS_Context::$system_conf === null) { + throw new FreshRSS_Context_Exception('System configuration not initialised!'); + } $limit_registrations = FreshRSS_Context::$system_conf->limits['max_registrations']; $number_accounts = count(listUsers()); @@ -559,7 +601,7 @@ function max_registrations_reached() { * @param string $username the name of the user of which we want the configuration. * @return FreshRSS_UserConfiguration|null object, or null if the configuration cannot be loaded. */ -function get_user_configuration($username) { +function get_user_configuration(string $username) { if (!FreshRSS_user_Controller::checkUsername($username)) { return null; } @@ -591,7 +633,7 @@ function get_user_configuration($username) { */ function ipToBits(string $ip): string { $binaryip = ''; - foreach (str_split(inet_pton($ip)) as $char) { + foreach (str_split(inet_pton($ip) ?: '') as $char) { $binaryip .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT); } return $binaryip; @@ -624,6 +666,9 @@ function checkCIDR(string $ip, string $range): bool { * @return boolean, true if the sender's IP is in one of the ranges defined in the configuration, else false */ function checkTrustedIP(): bool { + if (FreshRSS_Context::$system_conf === null) { + throw new FreshRSS_Context_Exception('System configuration not initialised!'); + } if (!empty($_SERVER['REMOTE_ADDR'])) { foreach (FreshRSS_Context::$system_conf->trusted_sources as $cidr) { if (checkCIDR($_SERVER['REMOTE_ADDR'], $cidr)) { @@ -634,10 +679,7 @@ function checkTrustedIP(): bool { return false; } -/** - * @return string - */ -function httpAuthUser() { +function httpAuthUser(): string { if (!empty($_SERVER['REMOTE_USER'])) { return $_SERVER['REMOTE_USER']; } elseif (!empty($_SERVER['HTTP_REMOTE_USER']) && checkTrustedIP()) { @@ -650,10 +692,7 @@ function httpAuthUser() { return ''; } -/** - * @return bool - */ -function cryptAvailable() { +function cryptAvailable(): bool { try { $hash = '$2y$04$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG'; return $hash === @crypt('password', $hash); @@ -669,7 +708,7 @@ function cryptAvailable() { * * @return array<string,bool> of tested values. */ -function check_install_php() { +function check_install_php(): array { $pdo_mysql = extension_loaded('pdo_mysql'); $pdo_pgsql = extension_loaded('pdo_pgsql'); $pdo_sqlite = extension_loaded('pdo_sqlite'); @@ -693,16 +732,16 @@ function check_install_php() { * * @return array<string,bool> of tested values. */ -function check_install_files() { +function check_install_files(): array { return array( // @phpstan-ignore-next-line - 'data' => DATA_PATH && is_writable(DATA_PATH), + 'data' => DATA_PATH && touch(DATA_PATH . '/index.html'), // is_writable() is not reliable for a folder on NFS // @phpstan-ignore-next-line - 'cache' => CACHE_PATH && is_writable(CACHE_PATH), + 'cache' => CACHE_PATH && touch(CACHE_PATH . '/index.html'), // @phpstan-ignore-next-line - 'users' => USERS_PATH && is_writable(USERS_PATH), - 'favicons' => is_writable(DATA_PATH . '/favicons'), - 'tokens' => is_writable(DATA_PATH . '/tokens'), + 'users' => USERS_PATH && touch(USERS_PATH . '/index.html'), + 'favicons' => touch(DATA_PATH . '/favicons/index.html'), + 'tokens' => touch(DATA_PATH . '/tokens/index.html'), ); } @@ -712,7 +751,7 @@ function check_install_files() { * * @return array<string,bool> of tested values. */ -function check_install_database() { +function check_install_database(): array { $status = array( 'connection' => true, 'tables' => false, @@ -743,17 +782,14 @@ function check_install_database() { /** * Remove a directory recursively. - * * From http://php.net/rmdir#110489 - * - * @param string $dir the directory to remove */ -function recursive_unlink($dir) { +function recursive_unlink(string $dir): bool { if (!is_dir($dir)) { return true; } - $files = array_diff(scandir($dir), array('.', '..')); + $files = array_diff(scandir($dir) ?: [], ['.', '..']); foreach ($files as $filename) { $filename = $dir . '/' . $filename; if (is_dir($filename)) { @@ -773,7 +809,7 @@ function recursive_unlink($dir) { * @param array<int,array<string,string>> $queries an array of queries. * @return array<int,array<string,string>> without queries where $get is appearing. */ -function remove_query_by_get($get, $queries) { +function remove_query_by_get(string $get, array $queries): array { $final_queries = array(); foreach ($queries as $key => $query) { if (empty($query['get']) || $query['get'] !== $get) { @@ -797,7 +833,11 @@ const SHORTCUT_KEYS = [ 'End', 'Enter', 'Escape', 'Home', 'Insert', 'PageDown', 'PageUp', 'Space', 'Tab', ]; -function getNonStandardShortcuts($shortcuts) { +/** + * @param array<string> $shortcuts + * @return array<string> + */ +function getNonStandardShortcuts(array $shortcuts): array { $standard = strtolower(implode(' ', SHORTCUT_KEYS)); $nonStandard = array_filter($shortcuts, function ($shortcut) use ($standard) { @@ -808,14 +848,14 @@ function getNonStandardShortcuts($shortcuts) { return $nonStandard; } -function errorMessageInfo($errorTitle, $error = '') { +function errorMessageInfo(string $errorTitle, string $error = ''): string { $errorTitle = htmlspecialchars($errorTitle, ENT_NOQUOTES, 'UTF-8'); $message = ''; $details = ''; // Prevent empty tags by checking if error isn not empty first if ($error) { - $error = htmlspecialchars($error, ENT_NOQUOTES, 'UTF-8'); + $error = htmlspecialchars($error, ENT_NOQUOTES, 'UTF-8') . "\n"; // First line is the main message, other lines are the details list($message, $details) = explode("\n", $error, 2); diff --git a/lib/marienfressinaud/lib_opml/.gitattributes b/lib/marienfressinaud/lib_opml/.gitattributes new file mode 100644 index 000000000..669ea8c8d --- /dev/null +++ b/lib/marienfressinaud/lib_opml/.gitattributes @@ -0,0 +1,8 @@ +/.* export-ignore + +/ci export-ignore +/examples export-ignore +/tests export-ignore + +/CHANGELOG.md export-ignore +/Makefile export-ignore diff --git a/lib/marienfressinaud/lib_opml/.gitignore b/lib/marienfressinaud/lib_opml/.gitignore new file mode 100644 index 000000000..ca9baaf91 --- /dev/null +++ b/lib/marienfressinaud/lib_opml/.gitignore @@ -0,0 +1,2 @@ +/coverage +/vendor diff --git a/lib/marienfressinaud/lib_opml/CHANGELOG.md b/lib/marienfressinaud/lib_opml/CHANGELOG.md new file mode 100644 index 000000000..ee9245e7e --- /dev/null +++ b/lib/marienfressinaud/lib_opml/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog of lib\_opml + +## 2022-07-25 - v0.5.0 + +- BREAKING CHANGE: Reverse parameters in `libopml_render()` +- BREAKING CHANGE: Validate email and URL address elements +- Add support for PHP 7.2+ +- Add a .gitattributes file +- Improve the documentation about usage +- Add a note about stability in README +- Fix a PHPDoc annotation +- Homogeneize tests with "Newspapers" examples + +## 2022-06-04 - v0.4.0 + +- Refactor the LibOpml class to be not static +- Parse or render attributes according to their types +- Add support for namespaces +- Don't require text attribute if OPML version is 1.0 +- Check that outline text attribute is not empty +- Verify that xmlUrl and url attributes are present according to the type + attribute +- Accept a version attribute in render method +- Handle OPML 1.1 as 1.0 +- Fail if version, head or body is missing +- Fail if OPML version is not supported +- Fail if head contains invalid elements +- Fail if sub-outlines are not arrays when rendering +- Make parsing less strict by default +- Don't raise most parsing errors when strict is false +- Force type attribute to lowercase +- Remove SimpleXML as a requirement +- Homogenize exception messages +- Close pre tags in the example file +- Improve documentation in the README +- Improve comments in the source code +- Add a MR checklist item about changes +- Update the description in composer.json +- Update dev dependencies + +## 2022-04-23 - v0.3.0 + +- Reorganize the architecture of code (using namespaces and classes) +- Change PHP minimum version to 7.4 +- Move to Framagit instead of GitHub +- Change the license to MIT +- Configure lib\_opml with Composer +- Add PHPUnit tests for all the methods and functions +- Add a linter to the project +- Provide a Makefile +- Configure Gitlab CI instead of Travis +- Add a merge request template +- Improve the comments, documentation and examples + +## 2014-03-31 - v0.2.0 + +- Allow to make optional the `text` attribute +- Improve and complete documentation +- Fix examples + +## 2014-03-29 - v0.1.0 + +First version diff --git a/lib/marienfressinaud/lib_opml/LICENSE b/lib/marienfressinaud/lib_opml/LICENSE new file mode 100644 index 000000000..2ad7f2db4 --- /dev/null +++ b/lib/marienfressinaud/lib_opml/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Marien Fressinaud + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/marienfressinaud/lib_opml/README.md b/lib/marienfressinaud/lib_opml/README.md new file mode 100644 index 000000000..34026bc14 --- /dev/null +++ b/lib/marienfressinaud/lib_opml/README.md @@ -0,0 +1,338 @@ +# lib\_opml + +lib\_opml is a library to read and write OPML in PHP. + +OPML is a standard designed to store and exchange outlines (i.e. a tree +structure arranged to show hierarchical relationships). It is mainly used to +exchange list of feeds between feed aggregators. The specification is +available at [opml.org](http://opml.org). + +lib\_opml has been tested with PHP 7.2+. It requires [DOMDocument](https://www.php.net/manual/book.dom.php) +to work. + +It supports versions 1.0 and 2.0 of OPML since these are the only published +versions. Version 1.1 is treated as version 1.0, as stated by the specification. + +It is licensed under the [MIT license](/LICENSE). + +## Installation + +lib\_opml is available on [Packagist](https://packagist.org/packages/marienfressinaud/lib_opml) +and it is recommended to install it with Composer: + +```console +$ composer require marienfressinaud/lib_opml +``` + +If you don’t use Composer, you can download [the ZIP archive](https://framagit.org/marienfressinaud/lib_opml/-/archive/main/lib_opml-main.zip) +and copy the content of the `src/` folder in your project. Then, load the files +manually: + +```php +<?php +require 'path/to/lib_opml/LibOpml/Exception.php'; +require 'path/to/lib_opml/LibOpml/LibOpml.php'; +require 'path/to/lib_opml/functions.php'; +``` + +## Usage + +### Parse OPML + +Let’s say that you have an OPML file named `my_opml_file.xml`: + +```xml +<?xml version="1.0" encoding="UTF-8" ?> +<opml version="2.0"> + <head> + <title>My OPML</title> + </head> + <body> + <outline text="Newspapers"> + <outline text="El País" /> + <outline text="Le Monde" /> + <outline text="The Guardian" /> + <outline text="The New York Times" /> + </outline> + </body> +</opml> +``` + +You can load it with: + +```php +$opml_array = libopml_parse_file('my_opml_file.xml'); +``` + +lib\_opml parses the file and returns an array: + +```php +[ + 'version' => '2.0', + 'namespaces' => [], + 'head' => [ + 'title' => 'My OPML' + ], + 'body' => [ // each entry of the body is an outline + [ + 'text' => 'Newspapers', + '@outlines' => [ // sub-outlines are accessible with the @outlines key + ['text' => 'El País'], + ['text' => 'Le Monde'], + ['text' => 'The Guardian'], + ['text' => 'The New York Times'] + ] + ] + ] +] +``` + +Since it's just an array, it's very simple to manipulate: + +```php +foreach ($opml_array['body'] as $outline) { + echo $outline['text']; +} +``` + +You also can load directly an OPML string: + +```php +$opml_string = '<opml>...</opml>'; +$opml_array = libopml_parse_string($opml_string); +``` + +### Render OPML + +lib\_opml is able to render an OPML string from an array. It checks that the +data is valid and respects the specification. + +```php +$opml_array = [ + 'head' => [ + 'title' => 'My OPML', + ], + 'body' => [ + [ + 'text' => 'Newspapers', + '@outlines' => [ + ['text' => 'El País'], + ['text' => 'Le Monde'], + ['text' => 'The Guardian'], + ['text' => 'The New York Times'] + ] + ] + ] +]; + +$opml_string = libopml_render($opml_array); + +file_put_contents('my_opml_file.xml', $opml_string); +``` + +### Handle errors + +If rendering (or parsing) fails for any reason (e.g. empty `body`, missing +`text` attribute, wrong element type), a `\marienfressinaud\LibOpml\Exception` +is raised: + +```php +try { + $opml_array = libopml_render([ + 'body' => [] + ]); +} catch (\marienfressinaud\LibOpml\Exception $e) { + echo $e->getMessage(); +} +``` + +### Class style + +lib\_opml can also be used with a class style: + +```php +use marienfressinaud\LibOpml; + +$libopml = new LibOpml\LibOpml(); + +$opml_array = $libopml->parseFile($filename); +$opml_array = $libopml->parseString($opml_string); +$opml_string = $libopml->render($opml_array); +``` + +### Special elements and attributes + +Some elements have special meanings according to the specification, which means +they can be parsed to a specific type by lib\_opml. In the other way, when +rendering an OPML string, you must pass these elements with their correct +types. + +Head elements: + +- `dateCreated` is parsed to a `\DateTime`; +- `dateModified` is parsed to a `\DateTime`; +- `expansionState` is parsed to an array of integers; +- `vertScrollState` is parsed to an integer; +- `windowTop` is parsed to an integer; +- `windowLeft` is parsed to an integer; +- `windowBottom` is parsed to an integer; +- `windowRight` is parsed to an integer. + +Outline attributes: + +- `created` is parsed to a `\DateTime`; +- `category` is parsed to an array of strings; +- `isComment` is parsed to a boolean; +- `isBreakpoint` is parsed to a boolean. + +If one of these elements is not of the correct type, an Exception is raised. + +Finally, there are additional checks based on the outline type attribute: + +- if `type="rss"`, then the `xmlUrl` attribute is required; +- if `type="link"`, then the `url` attribute is required; +- if `type="include"`, then the `url` attribute is required. + +Note that the `type` attribute is case-insensitive and will always be lowercased. + +### Namespaces + +OPML can be extended with namespaces: + +> An OPML file may contain elements and attributes not described on this page, +> only if those elements are defined in a namespace, as specified by the W3C. + +When rendering an OPML, you can include a `namespaces` key to specify +namespaces: + +```php +$opml_array = [ + 'namespaces' => [ + 'test' => 'https://example.com/test', + ], + 'body' => [ + ['text' => 'My outline', 'test:path' => '/some/example/path'], + ], +]; + +$opml_string = libopml_render($opml_array); +echo $opml_string; +``` + +This will output: + +```xml +<?xml version="1.0" encoding="UTF-8"?> +<opml xmlns:test="https://example.com/test" version="2.0"> + <head/> + <body> + <outline text="My outline" test:path="/some/example/path"/> + </body> +</opml> +``` + +### Strictness + +You can tell lib\_opml to be less or more strict when parsing or rendering OPML. +This is done by passing an optional `$strict` attribute to the functions. When +strict is `false`, most of the specification requirements are simply ignored +and lib\_opml will do its best to parse (or generate) an OPML. + +By default, parsing is not strict so you’ll be able to read most of the files +out there. If you want the parsing to be strict (to validate a file for +instance), pass `true` to `libopml_parse_file()` or `libopml_parse_string()`: + +```php +$opml_array = libopml_parse_file($filename, true); +$opml_array = libopml_parse_string($opml_string, true); +``` + +On the other side, reading is strict by default, so you are encouraged to +generate valid OPMLs. If you need to relax the strictness, pass `false` to +`libopml_render()`: + +```php +$opml_string = libopml_render($opml_array, false); +``` + +Please note that when using the class form, strict is passed during the object +instantiation: + +```php +use marienfressinaud\LibOpml; + +// lib_opml will be strict for both parsing and rendering! +$libopml = new LibOpml\LibOpml(true); + +$opml_array = $libopml->parseString($opml_string); +$opml_string = $libopml->render($opml_array); +``` + +## Examples and documented source code + +See the [`examples/`](/examples) folder for concrete examples. + +You are encouraged to read the source code to learn more about lib\_opml. Thus, +the full documentation is available as comments in the code: + +- [`src/LibOpml/LibOpml.php`](src/LibOpml/LibOpml.php) +- [`src/LibOpml/Exception.php`](src/LibOpml/Exception.php) +- [`src/functions.php`](src/functions.php) + +## Changelog + +See [CHANGELOG.md](/CHANGELOG.md). + +## Support and stability + +Today, lib\_opml covers all the aspects of the OPML specification. Since the +spec didn't change for more than 15 years, it is expected for the library to +not change a lot in the future. Thus, I plan to release the v1.0 in a near +future. I'm only waiting for more tests to be done on its latest version (in +particular in FreshRSS, see [FreshRSS/FreshRSS#4403](https://github.com/FreshRSS/FreshRSS/pull/4403)). +I would also wait for clarifications about the specification (see [scripting/opml.org#3](https://github.com/scripting/opml.org/issues/3)), +but it isn't a hard requirement. + +After the release of 1.0, lib\_opml will be considered as “finished”. This +means I will not add new features, nor break the existing code. However, I +commit myself to continue to support the library to fix security issues, bugs, +or to add support to new PHP versions. + +In consequence, you can expect lib\_opml to be stable. + +## Tests and linters + +This section is for developers of lib\_opml. + +To run the tests, you’ll have to install Composer first (see [the official +documentation](https://getcomposer.org/doc/00-intro.md)). Then, install the +dependencies: + +```console +$ make install +``` + +You should now have a `vendor/` folder containing the development dependencies. + +Run the tests with: + +```console +$ make test +``` + +Run the linter with: + +```console +$ make lint +$ make lint-fix +``` + +## Contributing + +Please submit bug reports and merge requests to the [Framagit repository](https://framagit.org/marienfressinaud/lib_opml). + +There’s not a lot to do, but the documentation and examples could probably be +improved. + +Merge requests require that you fill a short checklist to save me time while +reviewing your changes. You also must make sure the test suite succeeds. diff --git a/lib/marienfressinaud/lib_opml/composer.json b/lib/marienfressinaud/lib_opml/composer.json new file mode 100644 index 000000000..ba48d16ed --- /dev/null +++ b/lib/marienfressinaud/lib_opml/composer.json @@ -0,0 +1,35 @@ +{ + "name": "marienfressinaud/lib_opml", + "description": "A library to read and write OPML in PHP.", + "license": "MIT", + "authors": [ + { + "name": "Marien Fressinaud", + "email": "dev@marienfressinaud.fr" + } + ], + "require": { + "php": ">=7.2.0", + "ext-dom": "*" + }, + "config": { + "platform": { + "php": "7.2.0" + } + }, + "support": { + "issues": "https://framagit.org/marienfressinaud/lib_opml/-/issues" + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "marienfressinaud\\": "src/" + } + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.6", + "phpunit/phpunit": "^8" + } +} diff --git a/lib/marienfressinaud/lib_opml/src/LibOpml/Exception.php b/lib/marienfressinaud/lib_opml/src/LibOpml/Exception.php new file mode 100644 index 000000000..27c3287a2 --- /dev/null +++ b/lib/marienfressinaud/lib_opml/src/LibOpml/Exception.php @@ -0,0 +1,15 @@ +<?php + +namespace marienfressinaud\LibOpml; + +/** + * A simple Exception class which represents any kind of OPML problem. + * Message precises the current problem. + * + * @author Marien Fressinaud <dev@marienfressinaud.fr> + * @link https://framagit.org/marienfressinaud/lib_opml + * @license MIT + */ +class Exception extends \Exception +{ +} diff --git a/lib/marienfressinaud/lib_opml/src/LibOpml/LibOpml.php b/lib/marienfressinaud/lib_opml/src/LibOpml/LibOpml.php new file mode 100644 index 000000000..4ba0df821 --- /dev/null +++ b/lib/marienfressinaud/lib_opml/src/LibOpml/LibOpml.php @@ -0,0 +1,770 @@ +<?php + +namespace marienfressinaud\LibOpml; + +/** + * The LibOpml class provides the methods to read and write OPML files and + * strings. It transforms OPML files or strings to PHP arrays (or the reverse). + * + * How to read this file? + * + * The first methods are dedicated to the parsing, and the next ones to the + * reading. The three last methods are helpful methods, but you don't have to + * worry too much about them. + * + * The main methods are the public ones: parseFile, parseString and render. + * They call the other parse* and render* methods internally. + * + * These three main methods are available as functions (see the src/functions.php + * file). + * + * What's the array format? + * + * As said before, LibOpml transforms OPML to PHP arrays, or the reverse. The + * format is pretty simple. It contains four keys: + * + * - version: the version of the OPML; + * - namespaces: an array of namespaces used in the OPML, if any; + * - head: an array of OPML head elements, where keys are the names of the + * elements; + * - body: an array of arrays representing OPML outlines, where keys are the + * name of the attributes (the special @outlines key contains the sub-outlines). + * + * When rendering, only the body key is required (version will default to 2.0). + * + * Example: + * + * [ + * version => '2.0', + * namespaces => [], + * head => [ + * title => 'An OPML file' + * ], + * body => [ + * [ + * text => 'Newspapers', + * @outlines => [ + * [text => 'El País'], + * [text => 'Le Monde'], + * [text => 'The Guardian'], + * [text => 'The New York Times'], + * ] + * ] + * ] + * ] + * + * @see http://opml.org/spec2.opml + * + * @author Marien Fressinaud <dev@marienfressinaud.fr> + * @link https://framagit.org/marienfressinaud/lib_opml + * @license MIT + */ +class LibOpml +{ + /** + * The list of valid head elements. + */ + public const HEAD_ELEMENTS = [ + 'title', 'dateCreated', 'dateModified', 'ownerName', 'ownerEmail', + 'ownerId', 'docs', 'expansionState', 'vertScrollState', 'windowTop', + 'windowLeft', 'windowBottom', 'windowRight' + ]; + + /** + * The list of numeric head elements. + */ + public const NUMERIC_HEAD_ELEMENTS = [ + 'vertScrollState', + 'windowTop', + 'windowLeft', + 'windowBottom', + 'windowRight', + ]; + + /** @var boolean */ + private $strict = true; + + /** @var string */ + private $version = '2.0'; + + /** @var string[] */ + private $namespaces = []; + + /** + * @param bool $strict + * Set to true (default) to check for violations of the specification, + * false otherwise. + */ + public function __construct($strict = true) + { + $this->strict = $strict; + } + + /** + * Parse a XML file and return the corresponding array. + * + * @param string $filename + * The XML file to parse. + * + * @throws \marienfressinaud\LibOpml\Exception + * Raised if the file cannot be read. See also exceptions raised by the + * parseString method. + * + * @return array + * An array reflecting the OPML (the structure is described above). + */ + public function parseFile($filename) + { + $file_content = @file_get_contents($filename); + + if ($file_content === false) { + throw new Exception("OPML file {$filename} cannot be found or read"); + } + + return $this->parseString($file_content); + } + + /** + * Parse a XML string and return the corresponding array. + * + * @param string $xml + * The XML string to parse. + * + * @throws \marienfressinaud\LibOpml\Exception + * Raised if the XML cannot be parsed, if version is missing or + * invalid, if head is missing or contains invalid (or not parsable) + * elements, or if body is missing, empty or contain non outline + * elements. The exceptions (except XML parsing errors) are not raised + * if strict is false. See also exceptions raised by the parseOutline + * method. + * + * @return array + * An array reflecting the OPML (the structure is described above). + */ + public function parseString($xml) + { + $dom = new \DOMDocument(); + $dom->recover = true; + $dom->encoding = 'UTF-8'; + + try { + $result = @$dom->loadXML($xml); + } catch (\Exception | \Error $e) { + $result = false; + } + + if (!$result) { + throw new Exception('OPML string is not valid XML'); + } + + $opml_element = $dom->documentElement; + + // Load the custom namespaces of the document + $xpath = new \DOMXPath($dom); + $this->namespaces = []; + foreach ($xpath->query('//namespace::*') as $node) { + if ($node->prefix === 'xml') { + // This is the base namespace, we don't need to store it + continue; + } + + $this->namespaces[$node->prefix] = $node->namespaceURI; + } + + // Get the version of the document + $version = $opml_element->getAttribute('version'); + if (!$version) { + $this->throwExceptionIfStrict('OPML version attribute is required'); + } + + $version = trim($version); + if ($version === '1.1') { + $version = '1.0'; + } + + if ($version !== '1.0' && $version !== '2.0') { + $this->throwExceptionIfStrict('OPML supported versions are 1.0 and 2.0'); + } + + $this->version = $version; + + // Get head and body child elements + $head_elements = $opml_element->getElementsByTagName('head'); + $child_head_elements = []; + if (count($head_elements) === 1) { + $child_head_elements = $head_elements[0]->childNodes; + } else { + $this->throwExceptionIfStrict('OPML must contain one and only one head element'); + } + + $body_elements = $opml_element->getElementsByTagName('body'); + $child_body_elements = []; + if (count($body_elements) === 1) { + $child_body_elements = $body_elements[0]->childNodes; + } else { + $this->throwExceptionIfStrict('OPML must contain one and only one body element'); + } + + $array = [ + 'version' => $this->version, + 'namespaces' => $this->namespaces, + 'head' => [], + 'body' => [], + ]; + + // Load the child head elements in the head array + foreach ($child_head_elements as $child_head_element) { + if ($child_head_element->nodeType !== XML_ELEMENT_NODE) { + continue; + } + + $name = $child_head_element->nodeName; + $value = $child_head_element->nodeValue; + $namespaced = $child_head_element->namespaceURI !== null; + + if (!in_array($name, self::HEAD_ELEMENTS) && !$namespaced) { + $this->throwExceptionIfStrict( + "OPML head {$name} element is not part of the specification" + ); + } + + if ($name === 'dateCreated' || $name === 'dateModified') { + try { + $value = $this->parseDate($value); + } catch (\DomainException $e) { + $this->throwExceptionIfStrict( + "OPML head {$name} element must be a valid RFC822 or RFC1123 date" + ); + } + } elseif ($name === 'ownerEmail') { + // Testing email validity is hard. PHP filter_var() function is + // too strict compared to the RFC 822, so we can't use it. + if (strpos($value, '@') === false) { + $this->throwExceptionIfStrict( + 'OPML head ownerEmail element must be an email address' + ); + } + } elseif ($name === 'ownerId' || $name === 'docs') { + if (!$this->checkHttpAddress($value)) { + $this->throwExceptionIfStrict( + "OPML head {$name} element must be a HTTP address" + ); + } + } elseif ($name === 'expansionState') { + $numbers = explode(',', $value); + $value = array_map(function ($str_number) { + if (is_numeric($str_number)) { + return intval($str_number); + } else { + $this->throwExceptionIfStrict( + 'OPML head expansionState element must be a list of numbers' + ); + return $str_number; + } + }, $numbers); + } elseif (in_array($name, self::NUMERIC_HEAD_ELEMENTS)) { + if (is_numeric($value)) { + $value = intval($value); + } else { + $this->throwExceptionIfStrict("OPML head {$name} element must be a number"); + } + } + + $array['head'][$name] = $value; + } + + // Load the child body elements in the body array + foreach ($child_body_elements as $child_body_element) { + if ($child_body_element->nodeType !== XML_ELEMENT_NODE) { + continue; + } + + if ($child_body_element->nodeName === 'outline') { + $array['body'][] = $this->parseOutline($child_body_element); + } else { + $this->throwExceptionIfStrict( + 'OPML body element can only contain outline elements' + ); + } + } + + if (empty($array['body'])) { + $this->throwExceptionIfStrict( + 'OPML body element must contain at least one outline element' + ); + } + + return $array; + } + + /** + * Parse a XML element as an outline element and return the corresponding array. + * + * @param \DOMElement $outline_element + * The element to parse. + * + * @throws \marienfressinaud\LibOpml\Exception + * Raised if the outline contains non-outline elements, if it doesn't + * contain a text attribute (or if empty), if a special attribute is + * not parsable, or if type attribute requirements are not met. The + * exceptions are not raised if strict is false. The exception about + * missing text attribute is not raised if version is 1.0. + * + * @return array + * An array reflecting the OPML outline (the structure is described above). + */ + private function parseOutline($outline_element) + { + $outline = []; + + // Load the element attributes in the outline array + foreach ($outline_element->attributes as $outline_attribute) { + $name = $outline_attribute->nodeName; + $value = $outline_attribute->nodeValue; + + if ($name === 'created') { + try { + $value = $this->parseDate($value); + } catch (\DomainException $e) { + $this->throwExceptionIfStrict( + 'OPML outline created attribute must be a valid RFC822 or RFC1123 date' + ); + } + } elseif ($name === 'category') { + $categories = explode(',', $value); + $categories = array_map(function ($category) { + return trim($category); + }, $categories); + $value = $categories; + } elseif ($name === 'isComment' || $name === 'isBreakpoint') { + if ($value === 'true' || $value === 'false') { + $value = $value === 'true'; + } else { + $this->throwExceptionIfStrict( + "OPML outline {$name} attribute must be a boolean (true or false)" + ); + } + } elseif ($name === 'type') { + // type attribute is case-insensitive + $value = strtolower($value); + } + + $outline[$name] = $value; + } + + if (empty($outline['text']) && $this->version !== '1.0') { + $this->throwExceptionIfStrict( + 'OPML outline text attribute is required' + ); + } + + // Perform additional check based on the type of the outline + $type = $outline['type'] ?? ''; + if ($type === 'rss') { + if (empty($outline['xmlUrl'])) { + $this->throwExceptionIfStrict( + 'OPML outline xmlUrl attribute is required when type is "rss"' + ); + } elseif (!$this->checkHttpAddress($outline['xmlUrl'])) { + $this->throwExceptionIfStrict( + 'OPML outline xmlUrl attribute must be a HTTP address when type is "rss"' + ); + } + } elseif ($type === 'link' || $type === 'include') { + if (empty($outline['url'])) { + $this->throwExceptionIfStrict( + "OPML outline url attribute is required when type is \"{$type}\"" + ); + } elseif (!$this->checkHttpAddress($outline['url'])) { + $this->throwExceptionIfStrict( + "OPML outline url attribute must be a HTTP address when type is \"{$type}\"" + ); + } + } + + // Load the sub-outlines in a @outlines array + foreach ($outline_element->childNodes as $child_outline_element) { + if ($child_outline_element->nodeType !== XML_ELEMENT_NODE) { + continue; + } + + if ($child_outline_element->nodeName === 'outline') { + $outline['@outlines'][] = $this->parseOutline($child_outline_element); + } else { + $this->throwExceptionIfStrict( + 'OPML body element can only contain outline elements' + ); + } + } + + return $outline; + } + + /** + * Parse a value as a date. + * + * @param string $value + * + * @throws \DomainException + * Raised if the value cannot be parsed. + * + * @return \DateTime + */ + private function parseDate($value) + { + $formats = [ + \DateTimeInterface::RFC822, + \DateTimeInterface::RFC1123, + ]; + + foreach ($formats as $format) { + $date = date_create_from_format($format, $value); + if ($date !== false) { + return $date; + } + } + + throw new \DomainException('The argument cannot be parsed as a date'); + } + + /** + * Render an OPML array as a string or a \DOMDocument. + * + * @param array $array + * The array to render, it must follow the structure defined above. + * @param bool $as_dom_document + * Set to false (default) to return the array as a string, true to + * return as a \DOMDocument. + * + * @throws \marienfressinaud\LibOpml\Exception + * Raised if the `head` array contains unknown or invalid elements + * (i.e. not of correct type), or if the `body` array is missing or + * empty. The exceptions are not raised if strict is false. See also + * exceptions raised by the renderOutline method. + * + * @return string|\DOMDocument + * The XML string or DOM document corresponding to the given array. + */ + public function render($array, $as_dom_document = false) + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $opml_element = new \DOMElement('opml'); + $dom->appendChild($opml_element); + + // Set the version attribute of the OPML document + $version = $array['version'] ?? '2.0'; + + if ($version === '1.1') { + $version = '1.0'; + } + + if ($version !== '1.0' && $version !== '2.0') { + $this->throwExceptionIfStrict('OPML supported versions are 1.0 and 2.0'); + } + + $this->version = $version; + $opml_element->setAttribute('version', $this->version); + + // Declare the namespace on the opml element + $this->namespaces = $array['namespaces'] ?? []; + foreach ($this->namespaces as $prefix => $namespace) { + $opml_element->setAttributeNS( + 'http://www.w3.org/2000/xmlns/', + "xmlns:{$prefix}", + $namespace + ); + } + + // Add the head element to the OPML document. $array['head'] is + // optional but head tag will always exist in the final XML. + $head_element = new \DOMElement('head'); + $opml_element->appendChild($head_element); + if (isset($array['head'])) { + foreach ($array['head'] as $name => $value) { + $namespace = $this->getNamespace($name); + + if (!in_array($name, self::HEAD_ELEMENTS, true) && !$namespace) { + $this->throwExceptionIfStrict( + "OPML head {$name} element is not part of the specification" + ); + } + + if ($name === 'dateCreated' || $name === 'dateModified') { + if ($value instanceof \DateTimeInterface) { + $value = $value->format(\DateTimeInterface::RFC1123); + } else { + $this->throwExceptionIfStrict( + "OPML head {$name} element must be a DateTime" + ); + } + } elseif ($name === 'ownerEmail') { + // Testing email validity is hard. PHP filter_var() function is + // too strict compared to the RFC 822, so we can't use it. + if (strpos($value, '@') === false) { + $this->throwExceptionIfStrict( + 'OPML head ownerEmail element must be an email address' + ); + } + } elseif ($name === 'ownerId' || $name === 'docs') { + if (!$this->checkHttpAddress($value)) { + $this->throwExceptionIfStrict( + "OPML head {$name} element must be a HTTP address" + ); + } + } elseif ($name === 'expansionState') { + if (is_array($value)) { + foreach ($value as $number) { + if (!is_int($number)) { + $this->throwExceptionIfStrict( + 'OPML head expansionState element must be an array of integers' + ); + } + } + + $value = implode(', ', $value); + } else { + $this->throwExceptionIfStrict( + 'OPML head expansionState element must be an array of integers' + ); + } + } elseif (in_array($name, self::NUMERIC_HEAD_ELEMENTS)) { + if (!is_int($value)) { + $this->throwExceptionIfStrict( + "OPML head {$name} element must be an integer" + ); + } + } + + $child_head_element = new \DOMElement($name, $value, $namespace); + $head_element->appendChild($child_head_element); + } + } + + // Check body is set and contains at least one element + if (!isset($array['body'])) { + $this->throwExceptionIfStrict('OPML array must contain a body key'); + } + + $array_body = $array['body'] ?? []; + if (count($array_body) <= 0) { + $this->throwExceptionIfStrict( + 'OPML body element must contain at least one outline array' + ); + } + + // Create outline elements in the body element + $body_element = new \DOMElement('body'); + $opml_element->appendChild($body_element); + foreach ($array_body as $outline) { + $this->renderOutline($body_element, $outline); + } + + // And return the final result + if ($as_dom_document) { + return $dom; + } else { + $dom->formatOutput = true; + return $dom->saveXML(); + } + } + + /** + * Transform an outline array to a \DOMElement and add it to a parent element. + * + * @param \DOMElement $parent_element + * The DOM parent element of the current outline. + * @param array $outline + * The outline array to transform in a \DOMElement, it must follow the + * structure defined above. + * + * @throws \marienfressinaud\LibOpml\Exception + * Raised if the outline is not an array, if it doesn't contain a text + * attribute (or if empty), if the `@outlines` key is not an array, if + * a special attribute does not match its corresponding type, or if + * `type` key requirements are not met. The exceptions (except errors + * about outline or suboutlines not being arrays) are not raised if + * strict is false. The exception about missing text attribute is not + * raised if version is 1.0. + */ + private function renderOutline($parent_element, $outline) + { + // Perform initial checks to verify the outline is correctly declared + if (!is_array($outline)) { + throw new Exception( + 'OPML outline element must be defined as an array' + ); + } + + if (empty($outline['text']) && $this->version !== '1.0') { + $this->throwExceptionIfStrict( + 'OPML outline text attribute is required' + ); + } + + if (isset($outline['type'])) { + $type = strtolower($outline['type']); + + if ($type === 'rss') { + if (empty($outline['xmlUrl'])) { + $this->throwExceptionIfStrict( + 'OPML outline xmlUrl attribute is required when type is "rss"' + ); + } elseif (!$this->checkHttpAddress($outline['xmlUrl'])) { + $this->throwExceptionIfStrict( + 'OPML outline xmlUrl attribute must be a HTTP address when type is "rss"' + ); + } + } elseif ($type === 'link' || $type === 'include') { + if (empty($outline['url'])) { + $this->throwExceptionIfStrict( + "OPML outline url attribute is required when type is \"{$type}\"" + ); + } elseif (!$this->checkHttpAddress($outline['url'])) { + $this->throwExceptionIfStrict( + "OPML outline url attribute must be a HTTP address when type is \"{$type}\"" + ); + } + } + } + + // Create the outline element and add it to the parent + $outline_element = new \DOMElement('outline'); + $parent_element->appendChild($outline_element); + + // Load the sub-outlines as child elements + if (isset($outline['@outlines'])) { + $outline_children = $outline['@outlines']; + + if (!is_array($outline_children)) { + throw new Exception( + 'OPML outline element must be defined as an array' + ); + } + + foreach ($outline_children as $outline_child) { + $this->renderOutline($outline_element, $outline_child); + } + + // We don't want the sub-outlines to be loaded as attributes, so we + // remove the key from the array. + unset($outline['@outlines']); + } + + // Load the other elements of the array as attributes + foreach ($outline as $name => $value) { + $namespace = $this->getNamespace($name); + + if ($name === 'created') { + if ($value instanceof \DateTimeInterface) { + $value = $value->format(\DateTimeInterface::RFC1123); + } else { + $this->throwExceptionIfStrict( + 'OPML outline created attribute must be a DateTime' + ); + } + } elseif ($name === 'isComment' || $name === 'isBreakpoint') { + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } else { + $this->throwExceptionIfStrict( + "OPML outline {$name} attribute must be a boolean" + ); + } + } elseif (is_array($value)) { + $value = implode(', ', $value); + } + + $outline_element->setAttributeNS($namespace, $name, $value); + } + } + + /** + * Return wether a value is a valid HTTP address or not. + * + * HTTP address is not strictly defined by the OPML spec, so it is assumed: + * + * - it can be parsed by parse_url + * - it has a host part + * - scheme is http or https + * + * filter_var is not used because it would reject internationalized URLs + * (i.e. with non ASCII chars). An alternative would be to punycode such + * URLs, but it's more work to do it properly, and lib_opml needs to stay + * simple. + * + * @param string $value + * + * @return boolean + * Return true if the value is a valid HTTP address, false otherwise. + */ + public function checkHttpAddress($value) + { + $value = trim($value); + $parsed_url = parse_url($value); + if (!$parsed_url) { + return false; + } + + if ( + !isset($parsed_url['scheme']) || + !isset($parsed_url['host']) + ) { + return false; + } + + if ( + $parsed_url['scheme'] !== 'http' && + $parsed_url['scheme'] !== 'https' + ) { + return false; + } + + return true; + } + + /** + * Return the namespace of a qualified name. An empty string is returned if + * the name is not namespaced. + * + * @param string $qualified_name + * + * @throws \marienfressinaud\LibOpml\Exception + * Raised if the namespace prefix isn't declared. + * + * @return string + */ + private function getNamespace($qualified_name) + { + $split_name = explode(':', $qualified_name, 2); + // count will always be 1 or 2. + if (count($split_name) === 1) { + // If 1, there's no prefix, thus no namespace + return ''; + } else { + // If 2, it means it has a namespace prefix, so we get the + // namespace from the declared ones. + $namespace_prefix = $split_name[0]; + if (!isset($this->namespaces[$namespace_prefix])) { + throw new Exception( + "OPML namespace {$namespace_prefix} is not declared" + ); + } + + return $this->namespaces[$namespace_prefix]; + } + } + + /** + * Raise an exception only if strict is true. + * + * @param string $message + * + * @throws \marienfressinaud\LibOpml\Exception + */ + private function throwExceptionIfStrict($message) + { + if ($this->strict) { + throw new Exception($message); + } + } +} |
