diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/Authentication.php | 89 | ||||
-rw-r--r-- | lib/AuthenticationMiddleware.php | 42 | ||||
-rw-r--r-- | lib/BridgeAbstract.php | 52 | ||||
-rw-r--r-- | lib/BridgeCard.php | 177 | ||||
-rw-r--r-- | lib/BridgeFactory.php | 4 | ||||
-rw-r--r-- | lib/BridgeList.php | 54 | ||||
-rw-r--r-- | lib/CacheInterface.php | 2 | ||||
-rw-r--r-- | lib/Configuration.php | 49 | ||||
-rw-r--r-- | lib/Debug.php | 4 | ||||
-rw-r--r-- | lib/FeedExpander.php | 32 | ||||
-rw-r--r-- | lib/FeedItem.php | 22 | ||||
-rw-r--r-- | lib/FormatAbstract.php | 29 | ||||
-rw-r--r-- | lib/ParameterValidator.php | 10 | ||||
-rw-r--r-- | lib/XPathAbstract.php | 36 | ||||
-rw-r--r-- | lib/contents.php | 72 | ||||
-rw-r--r-- | lib/error.php | 35 | ||||
-rw-r--r-- | lib/html.php | 9 | ||||
-rw-r--r-- | lib/rssbridge.php | 55 | ||||
-rw-r--r-- | lib/utils.php | 123 |
19 files changed, 411 insertions, 485 deletions
diff --git a/lib/Authentication.php b/lib/Authentication.php deleted file mode 100644 index 172836b2..00000000 --- a/lib/Authentication.php +++ /dev/null @@ -1,89 +0,0 @@ -<?php - -/** - * This file is part of RSS-Bridge, a PHP project capable of generating RSS and - * Atom feeds for websites that don't have one. - * - * For the full license information, please view the UNLICENSE file distributed - * with this source code. - * - * @package Core - * @license http://unlicense.org/ UNLICENSE - * @link https://github.com/rss-bridge/rss-bridge - */ - -/** - * Authentication module for RSS-Bridge. - * - * This class implements an authentication module for RSS-Bridge, utilizing the - * HTTP authentication capabilities of PHP. - * - * _Notice_: Authentication via HTTP does not prevent users from accessing files - * on your server. If your server supports `.htaccess`, you should globally restrict - * access to files instead. - * - * @link https://php.net/manual/en/features.http-auth.php HTTP authentication with PHP - * @link https://httpd.apache.org/docs/2.4/howto/htaccess.html Apache HTTP Server - * Tutorial: .htaccess files - * - * @todo Configuration parameters should be stored internally instead of accessing - * the configuration class directly. - * @todo Add functions to detect if a user is authenticated or not. This can be - * utilized for limiting access to authorized users only. - */ -class Authentication -{ - /** - * Throw an exception when trying to create a new instance of this class. - * Use {@see Authentication::showPromptIfNeeded()} instead! - * - * @throws \LogicException if called. - */ - public function __construct() - { - throw new \LogicException('Use ' . __CLASS__ . '::showPromptIfNeeded()!'); - } - - /** - * Requests the user for login credentials if necessary. - * - * Responds to an authentication request or returns the `WWW-Authenticate` - * header if authentication is enabled in the configuration of RSS-Bridge - * (`[authentication] enable = true`). - * - * @return void - */ - public static function showPromptIfNeeded() - { - if (Configuration::getConfig('authentication', 'enable') === true) { - if (!Authentication::verifyPrompt()) { - header('WWW-Authenticate: Basic realm="RSS-Bridge"', true, 401); - $message = 'Please authenticate in order to access this instance !'; - print $message; - exit; - } - } - } - - /** - * Verifies if an authentication request was received and compares the - * provided username and password to the configuration of RSS-Bridge - * (`[authentication] username` and `[authentication] password`). - * - * @return bool True if authentication succeeded. - */ - public static function verifyPrompt() - { - if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) { - if ( - Configuration::getConfig('authentication', 'username') === $_SERVER['PHP_AUTH_USER'] - && Configuration::getConfig('authentication', 'password') === $_SERVER['PHP_AUTH_PW'] - ) { - return true; - } else { - error_log('[RSS-Bridge] Failed authentication attempt from ' . $_SERVER['REMOTE_ADDR']); - } - } - return false; - } -} diff --git a/lib/AuthenticationMiddleware.php b/lib/AuthenticationMiddleware.php new file mode 100644 index 00000000..430f977c --- /dev/null +++ b/lib/AuthenticationMiddleware.php @@ -0,0 +1,42 @@ +<?php + +/** + * This file is part of RSS-Bridge, a PHP project capable of generating RSS and + * Atom feeds for websites that don't have one. + * + * For the full license information, please view the UNLICENSE file distributed + * with this source code. + * + * @package Core + * @license http://unlicense.org/ UNLICENSE + * @link https://github.com/rss-bridge/rss-bridge + */ + +final class AuthenticationMiddleware +{ + public function __invoke(): void + { + $user = $_SERVER['PHP_AUTH_USER'] ?? null; + $password = $_SERVER['PHP_AUTH_PW'] ?? null; + + if ($user === null || $password === null) { + $this->renderAuthenticationDialog(); + exit; + } + if ( + Configuration::getConfig('authentication', 'username') === $user + && Configuration::getConfig('authentication', 'password') === $password + ) { + return; + } + $this->renderAuthenticationDialog(); + exit; + } + + private function renderAuthenticationDialog(): void + { + http_response_code(401); + header('WWW-Authenticate: Basic realm="RSS-Bridge"'); + print render('error.html.php', ['message' => 'Please authenticate in order to access this instance !']); + } +} diff --git a/lib/BridgeAbstract.php b/lib/BridgeAbstract.php index 16e057c7..e91c8ae9 100644 --- a/lib/BridgeAbstract.php +++ b/lib/BridgeAbstract.php @@ -12,19 +12,6 @@ * @link https://github.com/rss-bridge/rss-bridge */ -/** - * An abstract class for bridges - * - * This class implements {@see BridgeInterface} with most common functions in - * order to reduce code duplication. Bridges should inherit from this class - * instead of implementing the interface manually. - * - * @todo Move constants to the interface (this is supported by PHP) - * @todo Change visibility of constants to protected - * @todo Return `self` on more functions to allow chaining - * @todo Add specification for PARAMETERS () - * @todo Add specification for $items - */ abstract class BridgeAbstract implements BridgeInterface { /** @@ -107,7 +94,7 @@ abstract class BridgeAbstract implements BridgeInterface * * @var array */ - protected $items = []; + protected array $items = []; /** * Holds the list of input parameters used by the bridge @@ -117,7 +104,7 @@ abstract class BridgeAbstract implements BridgeInterface * * @var array */ - protected $inputs = []; + protected array $inputs = []; /** * Holds the name of the queried context @@ -233,7 +220,7 @@ abstract class BridgeAbstract implements BridgeInterface if (empty(static::PARAMETERS)) { if (!empty($inputs)) { - returnClientError('Invalid parameters value(s)'); + throw new \Exception('Invalid parameters value(s)'); } return; @@ -249,10 +236,7 @@ abstract class BridgeAbstract implements BridgeInterface $validator->getInvalidParameters() ); - returnClientError( - 'Invalid parameters value(s): ' - . implode(', ', $parameters) - ); + throw new \Exception(sprintf('Invalid parameters value(s): %s', implode(', ', $parameters))); } // Guess the context from input data @@ -261,9 +245,9 @@ abstract class BridgeAbstract implements BridgeInterface } if (is_null($this->queriedContext)) { - returnClientError('Required parameter(s) missing'); + throw new \Exception('Required parameter(s) missing'); } elseif ($this->queriedContext === false) { - returnClientError('Mixed context parameters'); + throw new \Exception('Mixed context parameters'); } $this->setInputs($inputs, $this->queriedContext); @@ -289,10 +273,7 @@ abstract class BridgeAbstract implements BridgeInterface } if (isset($optionValue['required']) && $optionValue['required'] === true) { - returnServerError( - 'Missing configuration option: ' - . $optionName - ); + throw new \Exception(sprintf('Missing configuration option: %s', $optionName)); } elseif (isset($optionValue['defaultValue'])) { $this->configuration[$optionName] = $optionValue['defaultValue']; } @@ -314,17 +295,11 @@ abstract class BridgeAbstract implements BridgeInterface } /** - * Returns the value for the selected configuration - * - * @param string $input The option name - * @return mixed|null The option value or null if the input is not defined + * Get bridge configuration value */ public function getOption($name) { - if (!isset($this->configuration[$name])) { - return null; - } - return $this->configuration[$name]; + return $this->configuration[$name] ?? null; } /** {@inheritdoc} */ @@ -392,9 +367,8 @@ abstract class BridgeAbstract implements BridgeInterface && $urlMatches[3] === $bridgeUriMatches[3] ) { return []; - } else { - return null; } + return null; } /** @@ -404,13 +378,13 @@ abstract class BridgeAbstract implements BridgeInterface * @param int $duration Cache duration (optional, default: 24 hours) * @return mixed Cached value or null if the key doesn't exist or has expired */ - protected function loadCacheValue($key, $duration = 86400) + protected function loadCacheValue($key, int $duration = 86400) { $cacheFactory = new CacheFactory(); $cache = $cacheFactory->create(); // Create class name without the namespace part - $scope = (new ReflectionClass($this))->getShortName(); + $scope = (new \ReflectionClass($this))->getShortName(); $cache->setScope($scope); $cache->setKey($key); if ($cache->getTime() < time() - $duration) { @@ -430,7 +404,7 @@ abstract class BridgeAbstract implements BridgeInterface $cacheFactory = new CacheFactory(); $cache = $cacheFactory->create(); - $scope = (new ReflectionClass($this))->getShortName(); + $scope = (new \ReflectionClass($this))->getShortName(); $cache->setScope($scope); $cache->setKey($key); $cache->saveData($value); diff --git a/lib/BridgeCard.php b/lib/BridgeCard.php index dbb2a23d..900671ca 100644 --- a/lib/BridgeCard.php +++ b/lib/BridgeCard.php @@ -23,6 +23,90 @@ final class BridgeCard { /** + * Gets a single bridge card + * + * @param class-string<BridgeInterface> $bridgeClassName The bridge name + * @param array $formats A list of formats + * @param bool $isActive Indicates if the bridge is active or not + * @return string The bridge card + */ + public static function displayBridgeCard($bridgeClassName, $formats, $isActive = true) + { + $bridgeFactory = new BridgeFactory(); + + $bridge = $bridgeFactory->create($bridgeClassName); + + $isHttps = strpos($bridge->getURI(), 'https') === 0; + + $uri = $bridge->getURI(); + $name = $bridge->getName(); + $icon = $bridge->getIcon(); + $description = $bridge->getDescription(); + $parameters = $bridge->getParameters(); + if (Configuration::getConfig('proxy', 'url') && Configuration::getConfig('proxy', 'by_bridge')) { + $parameters['global']['_noproxy'] = [ + 'name' => 'Disable proxy (' . (Configuration::getConfig('proxy', 'name') ?: Configuration::getConfig('proxy', 'url')) . ')', + 'type' => 'checkbox' + ]; + } + + if (CUSTOM_CACHE_TIMEOUT) { + $parameters['global']['_cache_timeout'] = [ + 'name' => 'Cache timeout in seconds', + 'type' => 'number', + 'defaultValue' => $bridge->getCacheTimeout() + ]; + } + + $card = <<<CARD + <section id="bridge-{$bridgeClassName}" data-ref="{$name}"> + <h2><a href="{$uri}">{$name}</a></h2> + <p class="description">{$description}</p> + <input type="checkbox" class="showmore-box" id="showmore-{$bridgeClassName}" /> + <label class="showmore" for="showmore-{$bridgeClassName}">Show more</label> +CARD; + + // If we don't have any parameter for the bridge, we print a generic form to load it. + if (count($parameters) === 0) { + $card .= self::getForm($bridgeClassName, $formats, $isActive, $isHttps); + + // Display form with cache timeout and/or noproxy options (if enabled) when bridge has no parameters + } elseif (count($parameters) === 1 && array_key_exists('global', $parameters)) { + $card .= self::getForm($bridgeClassName, $formats, $isActive, $isHttps, '', $parameters['global']); + } else { + foreach ($parameters as $parameterName => $parameter) { + if (!is_numeric($parameterName) && $parameterName === 'global') { + continue; + } + + if (array_key_exists('global', $parameters)) { + $parameter = array_merge($parameter, $parameters['global']); + } + + if (!is_numeric($parameterName)) { + $card .= '<h5>' . $parameterName . '</h5>' . PHP_EOL; + } + + $card .= self::getForm($bridgeClassName, $formats, $isActive, $isHttps, $parameterName, $parameter); + } + } + + $card .= sprintf('<label class="showless" for="showmore-%s">Show less</label>', $bridgeClassName); + if ($bridge->getDonationURI() !== '' && Configuration::getConfig('admin', 'donations')) { + $card .= sprintf( + '<p class="maintainer">%s ~ <a href="%s">Donate</a></p>', + $bridge->getMaintainer(), + $bridge->getDonationURI() + ); + } else { + $card .= sprintf('<p class="maintainer">%s</p>', $bridge->getMaintainer()); + } + $card .= '</section>'; + + return $card; + } + + /** * Get the form header for a bridge card * * @param class-string<BridgeInterface> $bridgeClassName The bridge name @@ -38,9 +122,7 @@ final class BridgeCard EOD; if (!empty($parameterName)) { - $form .= <<<EOD - <input type="hidden" name="context" value="{$parameterName}" /> -EOD; + $form .= sprintf('<input type="hidden" name="context" value="%s" />', $parameterName); } if (!$isHttps) { @@ -293,93 +375,4 @@ This bridge is not fetching its content through a secure connection</div>'; . ' />' . PHP_EOL; } - - /** - * Gets a single bridge card - * - * @param class-string<BridgeInterface> $bridgeClassName The bridge name - * @param array $formats A list of formats - * @param bool $isActive Indicates if the bridge is active or not - * @return string The bridge card - */ - public static function displayBridgeCard($bridgeClassName, $formats, $isActive = true) - { - $bridgeFactory = new \BridgeFactory(); - - $bridge = $bridgeFactory->create($bridgeClassName); - - if ($bridge == false) { - return ''; - } - - $isHttps = strpos($bridge->getURI(), 'https') === 0; - - $uri = $bridge->getURI(); - $name = $bridge->getName(); - $icon = $bridge->getIcon(); - $description = $bridge->getDescription(); - $parameters = $bridge->getParameters(); - $donationUri = $bridge->getDonationURI(); - $maintainer = $bridge->getMaintainer(); - - $donationsAllowed = Configuration::getConfig('admin', 'donations'); - - if (Configuration::getConfig('proxy', 'url') && Configuration::getConfig('proxy', 'by_bridge')) { - $parameters['global']['_noproxy'] = [ - 'name' => 'Disable proxy (' . (Configuration::getConfig('proxy', 'name') ?: Configuration::getConfig('proxy', 'url')) . ')', - 'type' => 'checkbox' - ]; - } - - if (CUSTOM_CACHE_TIMEOUT) { - $parameters['global']['_cache_timeout'] = [ - 'name' => 'Cache timeout in seconds', - 'type' => 'number', - 'defaultValue' => $bridge->getCacheTimeout() - ]; - } - - $card = <<<CARD - <section id="bridge-{$bridgeClassName}" data-ref="{$name}"> - <h2><a href="{$uri}">{$name}</a></h2> - <p class="description">{$description}</p> - <input type="checkbox" class="showmore-box" id="showmore-{$bridgeClassName}" /> - <label class="showmore" for="showmore-{$bridgeClassName}">Show more</label> -CARD; - - // If we don't have any parameter for the bridge, we print a generic form to load it. - if (count($parameters) === 0) { - $card .= self::getForm($bridgeClassName, $formats, $isActive, $isHttps); - - // Display form with cache timeout and/or noproxy options (if enabled) when bridge has no parameters - } elseif (count($parameters) === 1 && array_key_exists('global', $parameters)) { - $card .= self::getForm($bridgeClassName, $formats, $isActive, $isHttps, '', $parameters['global']); - } else { - foreach ($parameters as $parameterName => $parameter) { - if (!is_numeric($parameterName) && $parameterName === 'global') { - continue; - } - - if (array_key_exists('global', $parameters)) { - $parameter = array_merge($parameter, $parameters['global']); - } - - if (!is_numeric($parameterName)) { - $card .= '<h5>' . $parameterName . '</h5>' . PHP_EOL; - } - - $card .= self::getForm($bridgeClassName, $formats, $isActive, $isHttps, $parameterName, $parameter); - } - } - - $card .= '<label class="showless" for="showmore-' . $bridgeClassName . '">Show less</label>'; - if ($donationUri !== '' && $donationsAllowed) { - $card .= '<p class="maintainer">' . $maintainer . ' ~ <a href="' . $donationUri . '">Donate</a></p>'; - } else { - $card .= '<p class="maintainer">' . $maintainer . '</p>'; - } - $card .= '</section>'; - - return $card; - } } diff --git a/lib/BridgeFactory.php b/lib/BridgeFactory.php index 34602476..e525452b 100644 --- a/lib/BridgeFactory.php +++ b/lib/BridgeFactory.php @@ -27,7 +27,8 @@ final class BridgeFactory } else { $contents = ''; } - if ($contents === '*') { // Whitelist all bridges + if ($contents === '*') { + // Whitelist all bridges $this->whitelist = $this->getBridgeClassNames(); } else { foreach (explode("\n", $contents) as $bridgeName) { @@ -97,7 +98,6 @@ final class BridgeFactory return $this->getBridgeClassNames()[$index]; } - Debug::log('Invalid bridge name specified: "' . $name . '"!'); return null; } } diff --git a/lib/BridgeList.php b/lib/BridgeList.php index f8d0b1a1..41b1f267 100644 --- a/lib/BridgeList.php +++ b/lib/BridgeList.php @@ -23,6 +23,28 @@ final class BridgeList { /** + * Create the entire home page + * + * @param bool $showInactive Inactive bridges are displayed on the home page, + * if enabled. + * @return string The home page + */ + public static function create($showInactive = true) + { + $totalBridges = 0; + $totalActiveBridges = 0; + + return '<!DOCTYPE html><html lang="en">' + . BridgeList::getHead() + . '<body onload="search()">' + . BridgeList::getHeader() + . BridgeList::getSearchbar() + . BridgeList::getBridges($showInactive, $totalBridges, $totalActiveBridges) + . BridgeList::getFooter($totalBridges, $totalActiveBridges, $showInactive) + . '</body></html>'; + } + + /** * Get the document head * * @return string The document head @@ -65,7 +87,7 @@ EOD; $totalActiveBridges = 0; $inactiveBridges = ''; - $bridgeFactory = new \BridgeFactory(); + $bridgeFactory = new BridgeFactory(); $bridgeClassNames = $bridgeFactory->getBridgeClassNames(); $formatFactory = new FormatFactory(); @@ -126,7 +148,7 @@ EOD; */ private static function getSearchbar() { - $query = filter_input(INPUT_GET, 'q', FILTER_SANITIZE_SPECIAL_CHARS); + $query = filter_input(INPUT_GET, 'q', \FILTER_SANITIZE_SPECIAL_CHARS); return <<<EOD <section class="searchbar"> @@ -167,10 +189,10 @@ EOD; $inactive = ''; if ($totalActiveBridges !== $totalBridges) { - if (!$showInactive) { - $inactive = '<a href="?show_inactive=1"><button class="small">Show inactive bridges</button></a><br>'; - } else { + if ($showInactive) { $inactive = '<a href="?show_inactive=0"><button class="small">Hide inactive bridges</button></a><br>'; + } else { + $inactive = '<a href="?show_inactive=1"><button class="small">Show inactive bridges</button></a><br>'; } } @@ -184,26 +206,4 @@ EOD; </section> EOD; } - - /** - * Create the entire home page - * - * @param bool $showInactive Inactive bridges are displayed on the home page, - * if enabled. - * @return string The home page - */ - public static function create($showInactive = true) - { - $totalBridges = 0; - $totalActiveBridges = 0; - - return '<!DOCTYPE html><html lang="en">' - . BridgeList::getHead() - . '<body onload="search()">' - . BridgeList::getHeader() - . BridgeList::getSearchbar() - . BridgeList::getBridges($showInactive, $totalBridges, $totalActiveBridges) - . BridgeList::getFooter($totalBridges, $totalActiveBridges, $showInactive) - . '</body></html>'; - } } diff --git a/lib/CacheInterface.php b/lib/CacheInterface.php index 67cee681..1c3b89ca 100644 --- a/lib/CacheInterface.php +++ b/lib/CacheInterface.php @@ -55,7 +55,7 @@ interface CacheInterface /** * Returns the timestamp for the curent cache data * - * @return int Timestamp or null + * @return ?int Timestamp */ public function getTime(); diff --git a/lib/Configuration.php b/lib/Configuration.php index 141f3d88..9f8b76bc 100644 --- a/lib/Configuration.php +++ b/lib/Configuration.php @@ -41,14 +41,8 @@ final class Configuration */ private static $config = null; - /** - * Throw an exception when trying to create a new instance of this class. - * - * @throws \LogicException if called. - */ - public function __construct() + private function __construct() { - throw new \LogicException('Can\'t create object of this class!'); } /** @@ -61,43 +55,45 @@ final class Configuration */ public static function verifyInstallation() { - // Check PHP version - // PHP Supported Versions: https://www.php.net/supported-versions.php - if (version_compare(PHP_VERSION, '7.4.0') === -1) { + if (version_compare(\PHP_VERSION, '7.4.0') === -1) { self::reportError('RSS-Bridge requires at least PHP version 7.4.0!'); } - // Extensions check + $errors = []; // OpenSSL: https://www.php.net/manual/en/book.openssl.php if (!extension_loaded('openssl')) { - self::reportError('"openssl" extension not loaded. Please check "php.ini"'); + $errors[] = 'openssl extension not loaded'; } // libxml: https://www.php.net/manual/en/book.libxml.php if (!extension_loaded('libxml')) { - self::reportError('"libxml" extension not loaded. Please check "php.ini"'); + $errors[] = 'libxml extension not loaded'; } // Multibyte String (mbstring): https://www.php.net/manual/en/book.mbstring.php if (!extension_loaded('mbstring')) { - self::reportError('"mbstring" extension not loaded. Please check "php.ini"'); + $errors[] = 'mbstring extension not loaded'; } // SimpleXML: https://www.php.net/manual/en/book.simplexml.php if (!extension_loaded('simplexml')) { - self::reportError('"simplexml" extension not loaded. Please check "php.ini"'); + $errors[] = 'simplexml extension not loaded'; } // Client URL Library (curl): https://www.php.net/manual/en/book.curl.php // Allow RSS-Bridge to run without curl module in CLI mode without root certificates if (!extension_loaded('curl') && !(php_sapi_name() === 'cli' && empty(ini_get('curl.cainfo')))) { - self::reportError('"curl" extension not loaded. Please check "php.ini"'); + $errors[] = 'curl extension not loaded'; } // JavaScript Object Notation (json): https://www.php.net/manual/en/book.json.php if (!extension_loaded('json')) { - self::reportError('"json" extension not loaded. Please check "php.ini"'); + $errors[] = 'json extension not loaded'; + } + + if ($errors) { + throw new \Exception(sprintf('Configuration error: %s', implode(', ', $errors))); } } @@ -192,11 +188,11 @@ final class Configuration self::reportConfigurationError('authentication', 'enable', 'Is not a valid Boolean'); } - if (!is_string(self::getConfig('authentication', 'username'))) { + if (!self::getConfig('authentication', 'username')) { self::reportConfigurationError('authentication', 'username', 'Is not a valid string'); } - if (!is_string(self::getConfig('authentication', 'password'))) { + if (! self::getConfig('authentication', 'password')) { self::reportConfigurationError('authentication', 'password', 'Is not a valid string'); } @@ -250,7 +246,7 @@ final class Configuration */ public static function getVersion() { - $headFile = PATH_ROOT . '.git/HEAD'; + $headFile = __DIR__ . '/../.git/HEAD'; // '@' is used to mute open_basedir warning if (@is_readable($headFile)) { @@ -295,19 +291,8 @@ final class Configuration self::reportError($report); } - /** - * Reports an error message to the user and ends execution - * - * @param string $message The error message - * - * @return void - */ private static function reportError($message) { - http_response_code(500); - print render('error.html.php', [ - 'message' => "Configuration error: $message", - ]); - exit; + throw new \Exception(sprintf('Configuration error: %s', $message)); } } diff --git a/lib/Debug.php b/lib/Debug.php index 0b05a20d..316d5595 100644 --- a/lib/Debug.php +++ b/lib/Debug.php @@ -64,8 +64,8 @@ class Debug { static $firstCall = true; // Initialized on first call - if ($firstCall && file_exists(PATH_ROOT . 'DEBUG')) { - $debug_whitelist = trim(file_get_contents(PATH_ROOT . 'DEBUG')); + if ($firstCall && file_exists(__DIR__ . '/../DEBUG')) { + $debug_whitelist = trim(file_get_contents(__DIR__ . '/../DEBUG')); self::$enabled = empty($debug_whitelist) || in_array( $_SERVER['REMOTE_ADDR'], diff --git a/lib/FeedExpander.php b/lib/FeedExpander.php index 685108b9..052bce82 100644 --- a/lib/FeedExpander.php +++ b/lib/FeedExpander.php @@ -85,10 +85,10 @@ abstract class FeedExpander extends BridgeAbstract public function collectExpandableDatas($url, $maxItems = -1) { if (empty($url)) { - returnServerError('There is no $url for this RSS expander'); + throw new \Exception('There is no $url for this RSS expander'); } - Debug::log('Loading from ' . $url); + Debug::log(sprintf('Loading from %s', $url)); /* Notice we do not use cache here on purpose: * we want a fresh view of the RSS stream each time @@ -100,8 +100,7 @@ abstract class FeedExpander extends BridgeAbstract '*/*', ]; $httpHeaders = ['Accept: ' . implode(', ', $mimeTypes)]; - $content = getContents($url, $httpHeaders) - or returnServerError('Could not request ' . $url); + $content = getContents($url, $httpHeaders); $rssContent = simplexml_load_string(trim($content)); if ($rssContent === false) { @@ -127,8 +126,7 @@ abstract class FeedExpander extends BridgeAbstract break; default: Debug::log('Unknown feed format/version'); - returnServerError('The feed format is unknown!'); - break; + throw new \Exception('The feed format is unknown!'); } return $this; @@ -151,7 +149,7 @@ abstract class FeedExpander extends BridgeAbstract { $this->loadRss2Data($rssContent->channel[0]); foreach ($rssContent->item as $item) { - Debug::log('parsing item ' . var_export($item, true)); + Debug::log(sprintf('Parsing item %s', var_export($item, true))); $tmp_item = $this->parseItem($item); if (!empty($tmp_item)) { $this->items[] = $tmp_item; @@ -453,33 +451,39 @@ abstract class FeedExpander extends BridgeAbstract switch ($this->feedType) { case self::FEED_TYPE_RSS_1_0: return $this->parseRss1Item($item); - break; case self::FEED_TYPE_RSS_2_0: return $this->parseRss2Item($item); - break; case self::FEED_TYPE_ATOM_1_0: return $this->parseATOMItem($item); - break; default: - returnClientError('Unknown version ' . $this->getInput('version') . '!'); + throw new \Exception(sprintf('Unknown version %s!', $this->getInput('version'))); } } /** {@inheritdoc} */ public function getURI() { - return !empty($this->uri) ? $this->uri : parent::getURI(); + if (!empty($this->uri)) { + return $this->uri; + } + return parent::getURI(); } /** {@inheritdoc} */ public function getName() { - return !empty($this->title) ? $this->title : parent::getName(); + if (!empty($this->title)) { + return $this->title; + } + return parent::getName(); } /** {@inheritdoc} */ public function getIcon() { - return !empty($this->icon) ? $this->icon : parent::getIcon(); + if (!empty($this->icon)) { + return $this->icon; + } + return parent::getIcon(); } } diff --git a/lib/FeedItem.php b/lib/FeedItem.php index 4bf9492c..4435e0e0 100644 --- a/lib/FeedItem.php +++ b/lib/FeedItem.php @@ -329,10 +329,10 @@ class FeedItem $content = (string)$content; } - if (!is_string($content)) { - Debug::log('Content must be a string!'); - } else { + if (is_string($content)) { $this->content = $content; + } else { + Debug::log('Content must be a string!'); } return $this; @@ -361,11 +361,9 @@ class FeedItem */ public function setEnclosures($enclosures) { - $this->enclosures = []; // Clear previous data + $this->enclosures = []; - if (!is_array($enclosures)) { - Debug::log('Enclosures must be an array!'); - } else { + if (is_array($enclosures)) { foreach ($enclosures as $enclosure) { if ( !filter_var( @@ -379,6 +377,8 @@ class FeedItem $this->enclosures[] = $enclosure; } } + } else { + Debug::log('Enclosures must be an array!'); } return $this; @@ -407,11 +407,9 @@ class FeedItem */ public function setCategories($categories) { - $this->categories = []; // Clear previous data + $this->categories = []; - if (!is_array($categories)) { - Debug::log('Categories must be an array!'); - } else { + if (is_array($categories)) { foreach ($categories as $category) { if (!is_string($category)) { Debug::log('Category must be a string!'); @@ -419,6 +417,8 @@ class FeedItem $this->categories[] = $category; } } + } else { + Debug::log('Categories must be an array!'); } return $this; diff --git a/lib/FormatAbstract.php b/lib/FormatAbstract.php index 7a4c6c92..3289d651 100644 --- a/lib/FormatAbstract.php +++ b/lib/FormatAbstract.php @@ -63,7 +63,10 @@ abstract class FormatAbstract implements FormatInterface { $charset = $this->charset; - return is_null($charset) ? static::DEFAULT_CHARSET : $charset; + if (is_null($charset)) { + return static::DEFAULT_CHARSET; + } + return $charset; } /** @@ -93,7 +96,7 @@ abstract class FormatAbstract implements FormatInterface public function getItems() { if (!is_array($this->items)) { - throw new \LogicException('Feed the ' . get_class($this) . ' with "setItems" method before !'); + throw new \LogicException(sprintf('Feed the %s with "setItems" method before !', get_class($this))); } return $this->items; @@ -126,26 +129,4 @@ abstract class FormatAbstract implements FormatInterface return $this->extraInfos; } - - /** - * Sanitize HTML while leaving it functional. - * - * Keeps HTML as-is (with clickable hyperlinks) while reducing annoying and - * potentially dangerous things. - * - * @param string $html The HTML content - * @return string The sanitized HTML content - * - * @todo This belongs into `html.php` - * @todo Maybe switch to http://htmlpurifier.org/ - * @todo Maybe switch to http://www.bioinformatics.org/phplabware/internal_utilities/htmLawed/index.php - */ - protected function sanitizeHtml(string $html): string - { - $html = str_replace('<script', '<‌script', $html); // Disable scripts, but leave them visible. - $html = str_replace('<iframe', '<‌iframe', $html); - $html = str_replace('<link', '<‌link', $html); - // We leave alone object and embed so that videos can play in RSS readers. - return $html; - } } diff --git a/lib/ParameterValidator.php b/lib/ParameterValidator.php index a903ff8d..31934432 100644 --- a/lib/ParameterValidator.php +++ b/lib/ParameterValidator.php @@ -35,7 +35,7 @@ class ParameterValidator { $this->invalid[] = [ 'name' => $name, - 'reason' => $reason + 'reason' => $reason, ]; } @@ -216,7 +216,7 @@ class ParameterValidator if (array_key_exists('global', $parameters)) { $notInContext = array_diff_key($notInContext, $parameters['global']); } - if (sizeof($notInContext) > 0) { + if (count($notInContext) > 0) { continue; } @@ -246,7 +246,8 @@ class ParameterValidator unset($queriedContexts['global']); switch (array_sum($queriedContexts)) { - case 0: // Found no match, is there a context without parameters? + case 0: + // Found no match, is there a context without parameters? if (isset($data['context'])) { return $data['context']; } @@ -256,7 +257,8 @@ class ParameterValidator } } return null; - case 1: // Found unique match + case 1: + // Found unique match return array_search(true, $queriedContexts); default: return false; diff --git a/lib/XPathAbstract.php b/lib/XPathAbstract.php index 686addf4..b78511fc 100644 --- a/lib/XPathAbstract.php +++ b/lib/XPathAbstract.php @@ -341,10 +341,10 @@ abstract class XPathAbstract extends BridgeAbstract /** * Should provide the feeds title * - * @param DOMXPath $xpath + * @param \DOMXPath $xpath * @return string */ - protected function provideFeedTitle(DOMXPath $xpath) + protected function provideFeedTitle(\DOMXPath $xpath) { $title = $xpath->query($this->getParam('feed_title')); if (count($title) === 1) { @@ -355,10 +355,10 @@ abstract class XPathAbstract extends BridgeAbstract /** * Should provide the URL of the feed's favicon * - * @param DOMXPath $xpath + * @param \DOMXPath $xpath * @return string */ - protected function provideFeedIcon(DOMXPath $xpath) + protected function provideFeedIcon(\DOMXPath $xpath) { $icon = $xpath->query($this->getParam('feed_icon')); if (count($icon) === 1) { @@ -369,10 +369,10 @@ abstract class XPathAbstract extends BridgeAbstract /** * Should provide the feed's items. * - * @param DOMXPath $xpath - * @return DOMNodeList + * @param \DOMXPath $xpath + * @return \DOMNodeList */ - protected function provideFeedItems(DOMXPath $xpath) + protected function provideFeedItems(\DOMXPath $xpath) { return @$xpath->query($this->getParam('item')); } @@ -381,13 +381,13 @@ abstract class XPathAbstract extends BridgeAbstract { $this->feedUri = $this->getParam('url'); - $webPageHtml = new DOMDocument(); + $webPageHtml = new \DOMDocument(); libxml_use_internal_errors(true); $webPageHtml->loadHTML($this->provideWebsiteContent()); libxml_clear_errors(); libxml_use_internal_errors(false); - $xpath = new DOMXPath($webPageHtml); + $xpath = new \DOMXPath($webPageHtml); $this->feedName = $this->provideFeedTitle($xpath); $this->feedIcon = $this->provideFeedIcon($xpath); @@ -398,7 +398,7 @@ abstract class XPathAbstract extends BridgeAbstract } foreach ($entries as $entry) { - $item = new \FeedItem(); + $item = new FeedItem(); foreach (['title', 'content', 'uri', 'author', 'timestamp', 'enclosures', 'categories'] as $param) { $expression = $this->getParam($param); if ('' === $expression) { @@ -408,7 +408,7 @@ abstract class XPathAbstract extends BridgeAbstract //can be a string or DOMNodeList, depending on the expression result $typedResult = @$xpath->evaluate($expression, $entry); if ( - $typedResult === false || ($typedResult instanceof DOMNodeList && count($typedResult) === 0) + $typedResult === false || ($typedResult instanceof \DOMNodeList && count($typedResult) === 0) || (is_string($typedResult) && strlen(trim($typedResult)) === 0) ) { continue; @@ -571,19 +571,19 @@ abstract class XPathAbstract extends BridgeAbstract */ protected function getItemValueOrNodeValue($typedResult) { - if ($typedResult instanceof DOMNodeList) { + if ($typedResult instanceof \DOMNodeList) { $item = $typedResult->item(0); - if ($item instanceof DOMElement) { + if ($item instanceof \DOMElement) { return trim($item->nodeValue); - } elseif ($item instanceof DOMAttr) { + } elseif ($item instanceof \DOMAttr) { return trim($item->value); - } elseif ($item instanceof DOMText) { + } elseif ($item instanceof \DOMText) { return trim($item->wholeText); } } elseif (is_string($typedResult) && strlen($typedResult) > 0) { return trim($typedResult); } - returnServerError('Unknown type of XPath expression result.'); + throw new \Exception('Unknown type of XPath expression result.'); } /** @@ -605,8 +605,8 @@ abstract class XPathAbstract extends BridgeAbstract * @param FeedItem $item * @return string|null */ - protected function generateItemId(\FeedItem $item) + protected function generateItemId(FeedItem $item) { - return null; //auto generation + return null; } } diff --git a/lib/contents.php b/lib/contents.php index 5b39bb66..c1eb5ad5 100644 --- a/lib/contents.php +++ b/lib/contents.php @@ -1,9 +1,5 @@ <?php -final class HttpException extends \Exception -{ -} - // todo: move this somewhere useful, possibly into a function const RSSBRIDGE_HTTP_STATUS_CODES = [ '100' => 'Continue', @@ -128,7 +124,8 @@ function getContents( } $cache->saveData($result['body']); break; - case 304: // Not Modified + case 304: + // Not Modified $response['content'] = $cache->loadData(); break; default: @@ -379,68 +376,3 @@ function getSimpleHTMLDOMCached( $defaultSpanText ); } - -/** - * Determines the MIME type from a URL/Path file extension. - * - * _Remarks_: - * - * * The built-in functions `mime_content_type` and `fileinfo` require fetching - * remote contents. - * * A caller can hint for a MIME type by appending `#.ext` to the URL (i.e. `#.image`). - * - * Based on https://stackoverflow.com/a/1147952 - * - * @param string $url The URL or path to the file. - * @return string The MIME type of the file. - */ -function getMimeType($url) -{ - static $mime = null; - - if (is_null($mime)) { - // Default values, overriden by /etc/mime.types when present - $mime = [ - 'jpg' => 'image/jpeg', - 'gif' => 'image/gif', - 'png' => 'image/png', - 'image' => 'image/*', - 'mp3' => 'audio/mpeg', - ]; - // '@' is used to mute open_basedir warning, see issue #818 - if (@is_readable('/etc/mime.types')) { - $file = fopen('/etc/mime.types', 'r'); - while (($line = fgets($file)) !== false) { - $line = trim(preg_replace('/#.*/', '', $line)); - if (!$line) { - continue; - } - $parts = preg_split('/\s+/', $line); - if (count($parts) == 1) { - continue; - } - $type = array_shift($parts); - foreach ($parts as $part) { - $mime[$part] = $type; - } - } - fclose($file); - } - } - - if (strpos($url, '?') !== false) { - $url_temp = substr($url, 0, strpos($url, '?')); - if (strpos($url, '#') !== false) { - $anchor = substr($url, strpos($url, '#')); - $url_temp .= $anchor; - } - $url = $url_temp; - } - - $ext = strtolower(pathinfo($url, PATHINFO_EXTENSION)); - if (!empty($mime[$ext])) { - return $mime[$ext]; - } - - return 'application/octet-stream'; -} diff --git a/lib/error.php b/lib/error.php index 1a3d294d..f6322567 100644 --- a/lib/error.php +++ b/lib/error.php @@ -64,7 +64,7 @@ function logBridgeError($bridgeName, $code) $cache->purgeCache(86400); // 24 hours if ($report = $cache->loadData()) { - $report = json_decode($report, true); + $report = Json::decode($report); $report['time'] = time(); $report['count']++; } else { @@ -75,38 +75,7 @@ function logBridgeError($bridgeName, $code) ]; } - $cache->saveData(json_encode($report)); + $cache->saveData(Json::encode($report)); return $report['count']; } - -function create_sane_stacktrace(\Throwable $e): array -{ - $frames = array_reverse($e->getTrace()); - $frames[] = [ - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]; - $stackTrace = []; - foreach ($frames as $i => $frame) { - $file = $frame['file'] ?? '(no file)'; - $line = $frame['line'] ?? '(no line)'; - $stackTrace[] = sprintf( - '#%s %s:%s', - $i, - trim_path_prefix($file), - $line, - ); - } - return $stackTrace; -} - -/** - * Trim path prefix for privacy/security reasons - * - * Example: "/var/www/rss-bridge/index.php" => "index.php" - */ -function trim_path_prefix(string $filePath): string -{ - return mb_substr($filePath, mb_strlen(dirname(__DIR__)) + 1); -} diff --git a/lib/html.php b/lib/html.php index 4a4bea1b..693504b0 100644 --- a/lib/html.php +++ b/lib/html.php @@ -98,6 +98,15 @@ function sanitize( return $htmlContent; } +function sanitize_html(string $html): string +{ + $html = str_replace('<script', '<‌script', $html); // Disable scripts, but leave them visible. + $html = str_replace('<iframe', '<‌iframe', $html); + $html = str_replace('<link', '<‌link', $html); + // We leave alone object and embed so that videos can play in RSS readers. + return $html; +} + /** * Replace background by image * diff --git a/lib/rssbridge.php b/lib/rssbridge.php index d900c910..b1261ffd 100644 --- a/lib/rssbridge.php +++ b/lib/rssbridge.php @@ -13,55 +13,56 @@ */ /** Path to the root folder of RSS-Bridge (where index.php is located) */ -define('PATH_ROOT', __DIR__ . '/../'); - -/** Path to the core library */ -define('PATH_LIB', PATH_ROOT . 'lib/'); - -/** Path to the vendor library */ -define('PATH_LIB_VENDOR', PATH_ROOT . 'vendor/'); +const PATH_ROOT = __DIR__ . '/../'; /** Path to the bridges library */ -define('PATH_LIB_BRIDGES', PATH_ROOT . 'bridges/'); +const PATH_LIB_BRIDGES = __DIR__ . '/../bridges/'; /** Path to the formats library */ -define('PATH_LIB_FORMATS', PATH_ROOT . 'formats/'); +const PATH_LIB_FORMATS = __DIR__ . '/../formats/'; /** Path to the caches library */ -define('PATH_LIB_CACHES', PATH_ROOT . 'caches/'); +const PATH_LIB_CACHES = __DIR__ . '/../caches/'; /** Path to the actions library */ -define('PATH_LIB_ACTIONS', PATH_ROOT . 'actions/'); +const PATH_LIB_ACTIONS = __DIR__ . '/../actions/'; /** Path to the cache folder */ -define('PATH_CACHE', PATH_ROOT . 'cache/'); +const PATH_CACHE = __DIR__ . '/../cache/'; /** Path to the whitelist file */ -define('WHITELIST', PATH_ROOT . 'whitelist.txt'); +const WHITELIST = __DIR__ . '/../whitelist.txt'; /** Path to the default whitelist file */ -define('WHITELIST_DEFAULT', PATH_ROOT . 'whitelist.default.txt'); +const WHITELIST_DEFAULT = __DIR__ . '/../whitelist.default.txt'; /** Path to the configuration file */ -define('FILE_CONFIG', PATH_ROOT . 'config.ini.php'); +const FILE_CONFIG = __DIR__ . '/../config.ini.php'; /** Path to the default configuration file */ -define('FILE_CONFIG_DEFAULT', PATH_ROOT . 'config.default.ini.php'); +const FILE_CONFIG_DEFAULT = __DIR__ . '/../config.default.ini.php'; /** URL to the RSS-Bridge repository */ -define('REPOSITORY', 'https://github.com/RSS-Bridge/rss-bridge/'); +const REPOSITORY = 'https://github.com/RSS-Bridge/rss-bridge/'; -// Files -require_once PATH_LIB . 'html.php'; -require_once PATH_LIB . 'error.php'; -require_once PATH_LIB . 'contents.php'; -require_once PATH_LIB . 'php8backports.php'; +// Allow larger files for simple_html_dom +const MAX_FILE_SIZE = 10000000; -// Vendor -define('MAX_FILE_SIZE', 10000000); /* Allow larger files for simple_html_dom */ -require_once PATH_LIB_VENDOR . 'parsedown/Parsedown.php'; -require_once PATH_LIB_VENDOR . 'php-urljoin/src/urljoin.php'; -require_once PATH_LIB_VENDOR . 'simplehtmldom/simple_html_dom.php'; +// Files +$files = [ + __DIR__ . '/../lib/html.php', + __DIR__ . '/../lib/error.php', + __DIR__ . '/../lib/contents.php', + __DIR__ . '/../lib/php8backports.php', + __DIR__ . '/../lib/utils.php', + // Vendor + __DIR__ . '/../vendor/parsedown/Parsedown.php', + __DIR__ . '/../vendor/php-urljoin/src/urljoin.php', + __DIR__ . '/../vendor/simplehtmldom/simple_html_dom.php', +]; +foreach ($files as $file) { + require_once $file; +} spl_autoload_register(function ($className) { $folders = [ diff --git a/lib/utils.php b/lib/utils.php new file mode 100644 index 00000000..312591f4 --- /dev/null +++ b/lib/utils.php @@ -0,0 +1,123 @@ +<?php + +final class HttpException extends \Exception +{ +} + +final class Json +{ + public static function encode($value): string + { + $flags = JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + return \json_encode($value, $flags); + } + + public static function decode(string $json, bool $assoc = true) + { + return \json_decode($json, $assoc, 512, JSON_THROW_ON_ERROR); + } +} + +function create_sane_stacktrace(\Throwable $e): array +{ + $frames = array_reverse($e->getTrace()); + $frames[] = [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]; + $stackTrace = []; + foreach ($frames as $i => $frame) { + $file = $frame['file'] ?? '(no file)'; + $line = $frame['line'] ?? '(no line)'; + $stackTrace[] = sprintf( + '#%s %s:%s', + $i, + trim_path_prefix($file), + $line, + ); + } + return $stackTrace; +} + +/** + * Trim path prefix for privacy/security reasons + * + * Example: "/var/www/rss-bridge/index.php" => "index.php" + */ +function trim_path_prefix(string $filePath): string +{ + return mb_substr($filePath, mb_strlen(dirname(__DIR__)) + 1); +} + +/** + * This is buggy because strip tags removes a lot that isn't html + */ +function is_html(string $text): bool +{ + return strlen(strip_tags($text)) !== strlen($text); +} + +/** + * Determines the MIME type from a URL/Path file extension. + * + * _Remarks_: + * + * * The built-in functions `mime_content_type` and `fileinfo` require fetching + * remote contents. + * * A caller can hint for a MIME type by appending `#.ext` to the URL (i.e. `#.image`). + * + * Based on https://stackoverflow.com/a/1147952 + * + * @param string $url The URL or path to the file. + * @return string The MIME type of the file. + */ +function parse_mime_type($url) +{ + static $mime = null; + + if (is_null($mime)) { + // Default values, overriden by /etc/mime.types when present + $mime = [ + 'jpg' => 'image/jpeg', + 'gif' => 'image/gif', + 'png' => 'image/png', + 'image' => 'image/*', + 'mp3' => 'audio/mpeg', + ]; + // '@' is used to mute open_basedir warning, see issue #818 + if (@is_readable('/etc/mime.types')) { + $file = fopen('/etc/mime.types', 'r'); + while (($line = fgets($file)) !== false) { + $line = trim(preg_replace('/#.*/', '', $line)); + if (!$line) { + continue; + } + $parts = preg_split('/\s+/', $line); + if (count($parts) == 1) { + continue; + } + $type = array_shift($parts); + foreach ($parts as $part) { + $mime[$part] = $type; + } + } + fclose($file); + } + } + + if (strpos($url, '?') !== false) { + $url_temp = substr($url, 0, strpos($url, '?')); + if (strpos($url, '#') !== false) { + $anchor = substr($url, strpos($url, '#')); + $url_temp .= $anchor; + } + $url = $url_temp; + } + + $ext = strtolower(pathinfo($url, PATHINFO_EXTENSION)); + if (!empty($mime[$ext])) { + return $mime[$ext]; + } + + return 'application/octet-stream'; +} |