aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorGravatar Alexandre Alapetite <alexandre@alapetite.fr> 2023-03-04 13:30:45 +0100
committerGravatar Alexandre Alapetite <alexandre@alapetite.fr> 2023-03-04 13:30:45 +0100
commitb3239256dc6d188cda970adab516b3fcf1b86129 (patch)
treed8e65dd9784834ba2e82ce7ee94b4718f8af19ea /lib
parent27b71ffa99f7dff013fb8d51d020ed628e0d2ce6 (diff)
parent0fe0ce894cbad09757d719dd4b400b9862c1a12a (diff)
Merge branch 'edge' into latest
Diffstat (limited to 'lib')
-rw-r--r--lib/.gitignore8
-rw-r--r--lib/Minz/Configuration.php6
-rw-r--r--lib/Minz/Migrator.php2
-rw-r--r--lib/Minz/ModelPdo.php2
-rw-r--r--lib/Minz/Translate.php6
-rw-r--r--lib/Minz/View.php60
-rw-r--r--lib/SimplePie/SimplePie.php10
-rw-r--r--lib/SimplePie/SimplePie/Enclosure.php2
-rw-r--r--lib/SimplePie/SimplePie/Item.php25
-rw-r--r--lib/SimplePie/SimplePie/Locator.php8
-rw-r--r--[-rwxr-xr-x]lib/SimplePie/SimplePie/Registry.php0
-rw-r--r--lib/composer.json3
-rw-r--r--lib/http-conditional.php2
-rw-r--r--lib/lib_install.php10
-rw-r--r--lib/lib_opml.php353
-rw-r--r--lib/lib_rss.php234
-rw-r--r--lib/marienfressinaud/lib_opml/.gitattributes8
-rw-r--r--lib/marienfressinaud/lib_opml/.gitignore2
-rw-r--r--lib/marienfressinaud/lib_opml/CHANGELOG.md63
-rw-r--r--lib/marienfressinaud/lib_opml/LICENSE21
-rw-r--r--lib/marienfressinaud/lib_opml/README.md338
-rw-r--r--lib/marienfressinaud/lib_opml/composer.json35
-rw-r--r--lib/marienfressinaud/lib_opml/src/LibOpml/Exception.php15
-rw-r--r--lib/marienfressinaud/lib_opml/src/LibOpml/LibOpml.php770
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);
+ }
+ }
+}