diff options
author | 2022-08-06 22:46:28 +0200 | |
---|---|---|
committer | 2022-08-06 22:46:28 +0200 | |
commit | 2bbce8ebef8cf4f88392431aabe84a15482dc933 (patch) | |
tree | 1f5027ca69b1dfa2364bd9319e8536b86a41e928 | |
parent | b042412416cc4ecc71c3f9c13239661a0dd588a6 (diff) | |
download | rss-bridge-2bbce8ebef8cf4f88392431aabe84a15482dc933.tar.gz rss-bridge-2bbce8ebef8cf4f88392431aabe84a15482dc933.tar.zst rss-bridge-2bbce8ebef8cf4f88392431aabe84a15482dc933.zip |
refactor: general code base refactor (#2950)
* refactor
* fix: bug in previous refactor
* chore: exclude phpcompat sniff due to bug in phpcompat
* fix: do not leak absolute paths
* refactor/fix: batch extensions checking, fix DOS issue
45 files changed, 677 insertions, 825 deletions
diff --git a/actions/ConnectivityAction.php b/actions/ConnectivityAction.php index ac86fa1b..9ebd640c 100644 --- a/actions/ConnectivityAction.php +++ b/actions/ConnectivityAction.php @@ -28,23 +28,21 @@ class ConnectivityAction implements ActionInterface public function __construct() { - $this->bridgeFactory = new \BridgeFactory(); + $this->bridgeFactory = new BridgeFactory(); } public function execute(array $request) { if (!Debug::isEnabled()) { - returnError('This action is only available in debug mode!', 400); + throw new \Exception('This action is only available in debug mode!'); } if (!isset($request['bridge'])) { - $this->returnEntryPage(); + print render_template('connectivity.html.php'); return; } - $bridgeName = $request['bridge']; - - $bridgeClassName = $this->bridgeFactory->sanitizeBridgeName($bridgeName); + $bridgeClassName = $this->bridgeFactory->sanitizeBridgeName($request['bridge']); if ($bridgeClassName === null) { throw new \InvalidArgumentException('Bridge name invalid!'); @@ -53,28 +51,12 @@ class ConnectivityAction implements ActionInterface $this->reportBridgeConnectivity($bridgeClassName); } - /** - * Generates a report about the bridge connectivity status and sends it back - * to the user. - * - * The report is generated as Json-formatted string in the format - * { - * "bridge": "<bridge-name>", - * "successful": true/false - * } - * - * @param class-string<BridgeInterface> $bridgeClassName Name of the bridge to generate the report for - * @return void - */ private function reportBridgeConnectivity($bridgeClassName) { if (!$this->bridgeFactory->isWhitelisted($bridgeClassName)) { - header('Content-Type: text/html'); - returnServerError('Bridge is not whitelisted!'); + throw new \Exception('Bridge is not whitelisted!'); } - header('Content-Type: text/json'); - $retVal = [ 'bridge' => $bridgeClassName, 'successful' => false, @@ -82,16 +64,9 @@ class ConnectivityAction implements ActionInterface ]; $bridge = $this->bridgeFactory->create($bridgeClassName); - - if ($bridge === false) { - echo json_encode($retVal); - return; - } - $curl_opts = [ CURLOPT_CONNECTTIMEOUT => 5 ]; - try { $reply = getContents($bridge::URI, [], $curl_opts, true); @@ -101,45 +76,11 @@ class ConnectivityAction implements ActionInterface $retVal['http_code'] = 301; } } - } catch (Exception $e) { + } catch (\Exception $e) { $retVal['successful'] = false; } - echo json_encode($retVal); - } - - private function returnEntryPage() - { - echo <<<EOD -<!DOCTYPE html> - -<html> - <head> - <link rel="stylesheet" href="static/bootstrap.min.css"> - <link - rel="stylesheet" - href="https://use.fontawesome.com/releases/v5.6.3/css/all.css" - integrity="sha384-UHRtZLI+pbxtHCWp1t77Bi1L4ZtiqrqD80Kn4Z8NTSRyMA2Fd33n5dQ8lWUE00s/" - crossorigin="anonymous"> - <link rel="stylesheet" href="static/connectivity.css"> - <script src="static/connectivity.js" type="text/javascript"></script> - </head> - <body> - <div id="main-content" class="container"> - <div class="progress"> - <div class="progress-bar" role="progressbar" aria-valuenow="75" aria-valuemin="0" aria-valuemax="100"></div> - </div> - <div id="status-message" class="sticky-top alert alert-primary alert-dismissible fade show" role="alert"> - <i id="status-icon" class="fas fa-sync"></i> - <span>...</span> - <button type="button" class="close" data-dismiss="alert" aria-label="Close" onclick="stopConnectivityChecks()"> - <span aria-hidden="true">×</span> - </button> - </div> - <input type="text" class="form-control" id="search" onkeyup="search()" placeholder="Search for bridge.."> - </div> - </body> -</html> -EOD; + header('Content-Type: text/json'); + print Json::encode($retVal); } } diff --git a/actions/DetectAction.php b/actions/DetectAction.php index 1ed002af..71060bb8 100644 --- a/actions/DetectAction.php +++ b/actions/DetectAction.php @@ -16,13 +16,17 @@ class DetectAction implements ActionInterface { public function execute(array $request) { - $targetURL = $request['url'] - or returnClientError('You must specify a url!'); + $targetURL = $request['url'] ?? null; + $format = $request['format'] ?? null; - $format = $request['format'] - or returnClientError('You must specify a format!'); + if (!$targetURL) { + throw new \Exception('You must specify a url!'); + } + if (!$format) { + throw new \Exception('You must specify a format!'); + } - $bridgeFactory = new \BridgeFactory(); + $bridgeFactory = new BridgeFactory(); foreach ($bridgeFactory->getBridgeClassNames() as $bridgeClassName) { if (!$bridgeFactory->isWhitelisted($bridgeClassName)) { @@ -31,10 +35,6 @@ class DetectAction implements ActionInterface $bridge = $bridgeFactory->create($bridgeClassName); - if ($bridge === false) { - continue; - } - $bridgeParams = $bridge->detectParameters($targetURL); if (is_null($bridgeParams)) { @@ -45,9 +45,9 @@ class DetectAction implements ActionInterface $bridgeParams['format'] = $format; header('Location: ?action=display&' . http_build_query($bridgeParams), true, 301); - exit; + return; } - returnClientError('No bridge found for given URL: ' . $targetURL); + throw new \Exception('No bridge found for given URL: ' . $targetURL); } } diff --git a/actions/DisplayAction.php b/actions/DisplayAction.php index c2f536d1..e8912f09 100644 --- a/actions/DisplayAction.php +++ b/actions/DisplayAction.php @@ -16,7 +16,7 @@ class DisplayAction implements ActionInterface { public function execute(array $request) { - $bridgeFactory = new \BridgeFactory(); + $bridgeFactory = new BridgeFactory(); $bridgeClassName = null; if (isset($request['bridge'])) { @@ -27,16 +27,14 @@ class DisplayAction implements ActionInterface throw new \InvalidArgumentException('Bridge name invalid!'); } - $format = $request['format'] - or returnClientError('You must specify a format!'); - - // whitelist control + $format = $request['format'] ?? null; + if (!$format) { + throw new \Exception('You must specify a format!'); + } if (!$bridgeFactory->isWhitelisted($bridgeClassName)) { - throw new \Exception('This bridge is not whitelisted', 401); - die; + throw new \Exception('This bridge is not whitelisted'); } - // Data retrieval $bridge = $bridgeFactory->create($bridgeClassName); $bridge->loadConfiguration(); @@ -47,14 +45,12 @@ class DisplayAction implements ActionInterface define('NOPROXY', true); } - // Cache timeout - $cache_timeout = -1; if (array_key_exists('_cache_timeout', $request)) { if (!CUSTOM_CACHE_TIMEOUT) { unset($request['_cache_timeout']); $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) . '?' . http_build_query($request); header('Location: ' . $uri, true, 301); - exit; + return; } $cache_timeout = filter_var($request['_cache_timeout'], FILTER_VALIDATE_INT); @@ -93,7 +89,6 @@ class DisplayAction implements ActionInterface ) ); - // Initialize cache $cacheFactory = new CacheFactory(); $cache = $cacheFactory->create(); @@ -109,15 +104,17 @@ class DisplayAction implements ActionInterface $mtime !== false && (time() - $cache_timeout < $mtime) && !Debug::isEnabled() - ) { // Load cached data + ) { + // Load cached data // Send "Not Modified" response if client supports it // Implementation based on https://stackoverflow.com/a/10847262 if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { $stime = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']); - if ($mtime <= $stime) { // Cached data is older or same + if ($mtime <= $stime) { + // Cached data is older or same header('Last-Modified: ' . gmdate('D, d M Y H:i:s ', $mtime) . 'GMT', true, 304); - exit; + return; } } @@ -125,27 +122,24 @@ class DisplayAction implements ActionInterface if (isset($cached['items']) && isset($cached['extraInfos'])) { foreach ($cached['items'] as $item) { - $items[] = new \FeedItem($item); + $items[] = new FeedItem($item); } $infos = $cached['extraInfos']; } - } else { // Collect new data + } else { + // Collect new data try { $bridge->setDatas($bridge_params); $bridge->collectData(); $items = $bridge->getItems(); - // Transform "legacy" items to FeedItems if necessary. - // Remove this code when support for "legacy" items ends! if (isset($items[0]) && is_array($items[0])) { $feedItems = []; - foreach ($items as $item) { - $feedItems[] = new \FeedItem($item); + $feedItems[] = new FeedItem($item); } - $items = $feedItems; } @@ -158,18 +152,16 @@ class DisplayAction implements ActionInterface } catch (\Throwable $e) { error_log($e); - if (logBridgeError($bridge::NAME, $e->getCode()) >= Configuration::getConfig('error', 'report_limit')) { + $errorCount = logBridgeError($bridge::NAME, $e->getCode()); + + if ($errorCount >= Configuration::getConfig('error', 'report_limit')) { if (Configuration::getConfig('error', 'output') === 'feed') { - $item = new \FeedItem(); + $item = new FeedItem(); // Create "new" error message every 24 hours $request['_error_time'] = urlencode((int)(time() / 86400)); - $message = sprintf( - 'Bridge returned error %s! (%s)', - $e->getCode(), - $request['_error_time'] - ); + $message = sprintf('Bridge returned error %s! (%s)', $e->getCode(), $request['_error_time']); $item->setTitle($message); $item->setURI( @@ -205,8 +197,8 @@ class DisplayAction implements ActionInterface } $cache->saveData([ - 'items' => array_map(function ($i) { - return $i->toArray(); + 'items' => array_map(function (FeedItem $item) { + return $item->toArray(); }, $items), 'extraInfos' => $infos ]); diff --git a/actions/ListAction.php b/actions/ListAction.php index 5076b6dc..e2b0ccb9 100644 --- a/actions/ListAction.php +++ b/actions/ListAction.php @@ -16,27 +16,17 @@ class ListAction implements ActionInterface { public function execute(array $request) { - $list = new StdClass(); + $list = new \stdClass(); $list->bridges = []; $list->total = 0; - $bridgeFactory = new \BridgeFactory(); + $bridgeFactory = new BridgeFactory(); foreach ($bridgeFactory->getBridgeClassNames() as $bridgeClassName) { $bridge = $bridgeFactory->create($bridgeClassName); - if ($bridge === false) { // Broken bridge, show as inactive - $list->bridges[$bridgeClassName] = [ - 'status' => 'inactive' - ]; - - continue; - } - - $status = $bridgeFactory->isWhitelisted($bridgeClassName) ? 'active' : 'inactive'; - $list->bridges[$bridgeClassName] = [ - 'status' => $status, + 'status' => $bridgeFactory->isWhitelisted($bridgeClassName) ? 'active' : 'inactive', 'uri' => $bridge->getURI(), 'donationUri' => $bridge->getDonationURI(), 'name' => $bridge->getName(), @@ -50,6 +40,6 @@ class ListAction implements ActionInterface $list->total = count($list->bridges); header('Content-Type: application/json'); - echo json_encode($list, JSON_PRETTY_PRINT); + print Json::encode($list); } } diff --git a/bridges/FDroidRepoBridge.php b/bridges/FDroidRepoBridge.php index 74147310..7ce41baf 100644 --- a/bridges/FDroidRepoBridge.php +++ b/bridges/FDroidRepoBridge.php @@ -43,6 +43,25 @@ class FDroidRepoBridge extends BridgeAbstract // Stores repo information private $repo; + public function collectData() + { + if (!extension_loaded('zip')) { + throw new \Exception('FDroidRepoBridge requires the php-zip extension'); + } + + $this->repo = $this->getRepo(); + switch ($this->queriedContext) { + case 'Latest Updates': + $this->getAllUpdates(); + break; + case 'Follow Package': + $this->getPackage($this->getInput('package')); + break; + default: + returnServerError('Unimplemented Context (collectData)'); + } + } + public function getURI() { if (empty($this->queriedContext)) { @@ -70,21 +89,6 @@ class FDroidRepoBridge extends BridgeAbstract } } - public function collectData() - { - $this->repo = $this->getRepo(); - switch ($this->queriedContext) { - case 'Latest Updates': - $this->getAllUpdates(); - break; - case 'Follow Package': - $this->getPackage($this->getInput('package')); - break; - default: - returnServerError('Unimplemented Context (collectData)'); - } - } - private function getRepo() { $url = $this->getURI(); @@ -95,9 +99,10 @@ class FDroidRepoBridge extends BridgeAbstract file_put_contents($jar_loc, $jar); // JAR files are specially formatted ZIP files - $jar = new ZipArchive(); + $jar = new \ZipArchive(); if ($jar->open($jar_loc) !== true) { - returnServerError('Failed to extract archive'); + unlink($jar_loc); + throw new \Exception('Failed to extract archive'); } // Get file pointer to the relevant JSON inside @@ -109,6 +114,7 @@ class FDroidRepoBridge extends BridgeAbstract $data = json_decode(stream_get_contents($fp), true); fclose($fp); $jar->close(); + unlink($jar_loc); return $data; } diff --git a/bridges/PirateCommunityBridge.php b/bridges/PirateCommunityBridge.php index a1a9d8f5..5a617b04 100644 --- a/bridges/PirateCommunityBridge.php +++ b/bridges/PirateCommunityBridge.php @@ -22,7 +22,9 @@ class PirateCommunityBridge extends BridgeAbstract { $parsed_url = parse_url($url); - if ($parsed_url['host'] !== 'raymanpc.com') { + $host = $parsed_url['host'] ?? null; + + if ($host !== 'raymanpc.com') { return null; } diff --git a/bridges/RedditBridge.php b/bridges/RedditBridge.php index e2303987..f02f46a2 100644 --- a/bridges/RedditBridge.php +++ b/bridges/RedditBridge.php @@ -71,7 +71,9 @@ class RedditBridge extends BridgeAbstract { $parsed_url = parse_url($url); - if ($parsed_url['host'] != 'www.reddit.com' && $parsed_url['host'] != 'old.reddit.com') { + $host = $parsed_url['host'] ?? null; + + if ($host != 'www.reddit.com' && $host != 'old.reddit.com') { return null; } diff --git a/bridges/WordPressBridge.php b/bridges/WordPressBridge.php index 5a80c398..ca004547 100644 --- a/bridges/WordPressBridge.php +++ b/bridges/WordPressBridge.php @@ -72,7 +72,7 @@ class WordPressBridge extends FeedExpander } else { $article_image = $article_image->getAttribute('data-lazy-src'); } - $mime_type = getMimeType($article_image); + $mime_type = parse_mime_type($article_image); if (strpos($mime_type, 'image') === false) { $article_image .= '#.image'; // force image } diff --git a/caches/FileCache.php b/caches/FileCache.php index 29f4d78b..fde7d18d 100644 --- a/caches/FileCache.php +++ b/caches/FileCache.php @@ -1,8 +1,5 @@ <?php -/** -* Cache with file system -*/ class FileCache implements CacheInterface { protected $path; @@ -11,10 +8,7 @@ class FileCache implements CacheInterface public function __construct() { if (!is_writable(PATH_CACHE)) { - returnServerError( - 'RSS-Bridge does not have write permissions for ' - . PATH_CACHE . '!' - ); + throw new \Exception('The cache folder is not writeable'); } } @@ -23,20 +17,15 @@ class FileCache implements CacheInterface if (file_exists($this->getCacheFile())) { return unserialize(file_get_contents($this->getCacheFile())); } - return null; } public function saveData($data) { - // Notice: We use plain serialize() here to reduce memory footprint on - // large input data. $writeStream = file_put_contents($this->getCacheFile(), serialize($data)); - if ($writeStream === false) { throw new \Exception('Cannot write the cache... Do you have the right permissions ?'); } - return $this; } @@ -46,7 +35,10 @@ class FileCache implements CacheInterface clearstatcache(false, $cacheFile); if (file_exists($cacheFile)) { $time = filemtime($cacheFile); - return ($time !== false) ? $time : null; + if ($time !== false) { + return $time; + } + return null; } return null; @@ -55,28 +47,25 @@ class FileCache implements CacheInterface public function purgeCache($seconds) { $cachePath = $this->getPath(); - if (file_exists($cachePath)) { - $cacheIterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($cachePath), - RecursiveIteratorIterator::CHILD_FIRST - ); - - foreach ($cacheIterator as $cacheFile) { - if (in_array($cacheFile->getBasename(), ['.', '..', '.gitkeep'])) { - continue; - } elseif ($cacheFile->isFile()) { - if (filemtime($cacheFile->getPathname()) < time() - $seconds) { - unlink($cacheFile->getPathname()); - } + if (!file_exists($cachePath)) { + return; + } + $cacheIterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($cachePath), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($cacheIterator as $cacheFile) { + if (in_array($cacheFile->getBasename(), ['.', '..', '.gitkeep'])) { + continue; + } elseif ($cacheFile->isFile()) { + if (filemtime($cacheFile->getPathname()) < time() - $seconds) { + unlink($cacheFile->getPathname()); } } } } - /** - * Set scope - * @return self - */ public function setScope($scope) { if (is_null($scope) || !is_string($scope)) { @@ -88,10 +77,6 @@ class FileCache implements CacheInterface return $this; } - /** - * Set key - * @return self - */ public function setKey($key) { if (!empty($key) && is_array($key)) { @@ -107,10 +92,6 @@ class FileCache implements CacheInterface return $this; } - /** - * Return cache path (and create if not exist) - * @return string Cache path - */ private function getPath() { if (is_null($this->path)) { @@ -119,26 +100,18 @@ class FileCache implements CacheInterface if (!is_dir($this->path)) { if (mkdir($this->path, 0755, true) !== true) { - throw new \Exception('Unable to create ' . $this->path); + throw new \Exception('mkdir: Unable to create file cache folder'); } } return $this->path; } - /** - * Get the file name use for cache store - * @return string Path to the file cache - */ private function getCacheFile() { return $this->getPath() . $this->getCacheName(); } - /** - * Determines file name for store the cache - * return string - */ private function getCacheName() { if (is_null($this->key)) { diff --git a/caches/MemcachedCache.php b/caches/MemcachedCache.php index 42a8ddad..c593ac79 100644 --- a/caches/MemcachedCache.php +++ b/caches/MemcachedCache.php @@ -12,29 +12,33 @@ class MemcachedCache implements CacheInterface public function __construct() { if (!extension_loaded('memcached')) { - returnServerError('"memcached" extension not loaded. Please check "php.ini"'); + throw new \Exception('"memcached" extension not loaded. Please check "php.ini"'); } $section = 'MemcachedCache'; $host = Configuration::getConfig($section, 'host'); $port = Configuration::getConfig($section, 'port'); + if (empty($host) && empty($port)) { - returnServerError('Configuration for ' . $section . ' missing. Please check your ' . FILE_CONFIG); - } elseif (empty($host)) { - returnServerError('"host" param is not set for ' . $section . '. Please check your ' . FILE_CONFIG); - } elseif (empty($port)) { - returnServerError('"port" param is not set for ' . $section . '. Please check your ' . FILE_CONFIG); - } elseif (!ctype_digit($port)) { - returnServerError('"port" param is invalid for ' . $section . '. Please check your ' . FILE_CONFIG); + throw new \Exception('Configuration for ' . $section . ' missing. Please check your ' . FILE_CONFIG); + } + if (empty($host)) { + throw new \Exception('"host" param is not set for ' . $section . '. Please check your ' . FILE_CONFIG); + } + if (empty($port)) { + throw new \Exception('"port" param is not set for ' . $section . '. Please check your ' . FILE_CONFIG); + } + if (!ctype_digit($port)) { + throw new \Exception('"port" param is invalid for ' . $section . '. Please check your ' . FILE_CONFIG); } $port = intval($port); if ($port < 1 || $port > 65535) { - returnServerError('"port" param is invalid for ' . $section . '. Please check your ' . FILE_CONFIG); + throw new \Exception('"port" param is invalid for ' . $section . '. Please check your ' . FILE_CONFIG); } - $conn = new Memcached(); + $conn = new \Memcached(); $conn->addServer($host, $port) or returnServerError('Could not connect to memcached server'); $this->conn = $conn; } @@ -64,7 +68,7 @@ class MemcachedCache implements CacheInterface $result = $this->conn->set($this->getCacheKey(), $object_to_save, $this->expiration); if ($result === false) { - returnServerError('Cannot write the cache to memcached server'); + throw new \Exception('Cannot write the cache to memcached server'); } $this->time = $time; @@ -87,20 +91,12 @@ class MemcachedCache implements CacheInterface $this->expiration = $duration; } - /** - * Set scope - * @return self - */ public function setScope($scope) { $this->scope = $scope; return $this; } - /** - * Set key - * @return self - */ public function setKey($key) { if (!empty($key) && is_array($key)) { @@ -119,7 +115,7 @@ class MemcachedCache implements CacheInterface private function getCacheKey() { if (is_null($this->key)) { - returnServerError('Call "setKey" first!'); + throw new \Exception('Call "setKey" first!'); } return 'rss_bridge_cache_' . hash('md5', $this->scope . $this->key . 'A'); diff --git a/caches/SQLiteCache.php b/caches/SQLiteCache.php index 2f798714..339e4119 100644 --- a/caches/SQLiteCache.php +++ b/caches/SQLiteCache.php @@ -13,38 +13,32 @@ class SQLiteCache implements CacheInterface public function __construct() { if (!extension_loaded('sqlite3')) { - print render('error.html.php', ['message' => '"sqlite3" extension not loaded. Please check "php.ini"']); - exit; + throw new \Exception('"sqlite3" extension not loaded. Please check "php.ini"'); } if (!is_writable(PATH_CACHE)) { - returnServerError( - 'RSS-Bridge does not have write permissions for ' - . PATH_CACHE . '!' - ); + throw new \Exception('The cache folder is not writable'); } $section = 'SQLiteCache'; $file = Configuration::getConfig($section, 'file'); if (empty($file)) { - $message = sprintf('Configuration for %s missing. Please check your %s', $section, FILE_CONFIG); - print render('error.html.php', ['message' => $message]); - exit; + throw new \Exception(sprintf('Configuration for %s missing.', $section)); } + if (dirname($file) == '.') { $file = PATH_CACHE . $file; } elseif (!is_dir(dirname($file))) { - $message = sprintf('Invalid configuration for %s. Please check your %s', $section, FILE_CONFIG); - print render('error.html.php', ['message' => $message]); - exit; + throw new \Exception(sprintf('Invalid configuration for %s', $section)); } if (!is_file($file)) { - $this->db = new SQLite3($file); + // The instantiation creates the file + $this->db = new \SQLite3($file); $this->db->enableExceptions(true); $this->db->exec("CREATE TABLE storage ('key' BLOB PRIMARY KEY, 'value' BLOB, 'updated' INTEGER)"); } else { - $this->db = new SQLite3($file); + $this->db = new \SQLite3($file); $this->db->enableExceptions(true); } $this->db->busyTimeout(5000); @@ -55,8 +49,8 @@ class SQLiteCache implements CacheInterface $Qselect = $this->db->prepare('SELECT value FROM storage WHERE key = :key'); $Qselect->bindValue(':key', $this->getCacheKey()); $result = $Qselect->execute(); - if ($result instanceof SQLite3Result) { - $data = $result->fetchArray(SQLITE3_ASSOC); + if ($result instanceof \SQLite3Result) { + $data = $result->fetchArray(\SQLITE3_ASSOC); if (isset($data['value'])) { return unserialize($data['value']); } @@ -81,7 +75,7 @@ class SQLiteCache implements CacheInterface $Qselect = $this->db->prepare('SELECT updated FROM storage WHERE key = :key'); $Qselect->bindValue(':key', $this->getCacheKey()); $result = $Qselect->execute(); - if ($result instanceof SQLite3Result) { + if ($result instanceof \SQLite3Result) { $data = $result->fetchArray(SQLITE3_ASSOC); if (isset($data['updated'])) { return $data['updated']; @@ -98,10 +92,6 @@ class SQLiteCache implements CacheInterface $Qdelete->execute(); } - /** - * Set scope - * @return self - */ public function setScope($scope) { if (is_null($scope) || !is_string($scope)) { @@ -112,10 +102,6 @@ class SQLiteCache implements CacheInterface return $this; } - /** - * Set key - * @return self - */ public function setKey($key) { if (!empty($key) && is_array($key)) { @@ -131,8 +117,6 @@ class SQLiteCache implements CacheInterface return $this; } - //////////////////////////////////////////////////////////////////////////// - private function getCacheKey() { if (is_null($this->key)) { diff --git a/composer.json b/composer.json index 4a63e72e..2c0c5038 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "suggest": { "ext-memcached": "Allows to use memcached as cache type", "ext-sqlite3": "Allows to use an SQLite database for caching", + "ext-zip": "Required for FDroidRepoBridge", "ext-dom": "Allows to use some bridges based on XPath expressions" }, "autoload-dev": { diff --git a/config.default.ini.php b/config.default.ini.php index b7f2677b..7ea40bc2 100644 --- a/config.default.ini.php +++ b/config.default.ini.php @@ -65,12 +65,10 @@ by_bridge = false ; false = disabled (default) enable = false -; The username for authentication. Insert this name when prompted for login. -username = "" +username = "admin" -; The password for authentication. Insert this password when prompted for login. -; Use a strong password to prevent others from guessing your login! -password = "" +; This default password is public knowledge. Replace it. +password = "7afbf648a369b261" [error] diff --git a/docs/08_Format_API/02_FormatInterface.md b/docs/08_Format_API/02_FormatInterface.md index 88448c38..f216092c 100644 --- a/docs/08_Format_API/02_FormatInterface.md +++ b/docs/08_Format_API/02_FormatInterface.md @@ -82,7 +82,7 @@ getExtraInfos(): array The `getMimeType` function returns the expected [MIME type](https://en.wikipedia.org/wiki/Media_type#Common_examples) of the format's output. ```PHP -getMimeType(): string +parse_mime_type(): string ``` # Template diff --git a/docs/08_Format_API/03_FormatAbstract.md b/docs/08_Format_API/03_FormatAbstract.md index 8cf0418f..cb206b85 100644 --- a/docs/08_Format_API/03_FormatAbstract.md +++ b/docs/08_Format_API/03_FormatAbstract.md @@ -9,5 +9,5 @@ The `FormatAbstract` class implements the [`FormatInterface`](../08_Format_API/0 The `sanitizeHtml` function receives an HTML formatted string and returns the string with disabled `<script>`, `<iframe>` and `<link>` tags. ```PHP -sanitizeHtml(string $html): string +sanitize_html(string $html): string ``` diff --git a/formats/AtomFormat.php b/formats/AtomFormat.php index 5f564266..c611226f 100644 --- a/formats/AtomFormat.php +++ b/formats/AtomFormat.php @@ -18,17 +18,21 @@ class AtomFormat extends FormatAbstract public function stringify() { - $urlPrefix = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') ? 'https://' : 'http://'; - $urlHost = (isset($_SERVER['HTTP_HOST'])) ? $_SERVER['HTTP_HOST'] : ''; - $urlPath = (isset($_SERVER['PATH_INFO'])) ? $_SERVER['PATH_INFO'] : ''; - $urlRequest = (isset($_SERVER['REQUEST_URI'])) ? $_SERVER['REQUEST_URI'] : ''; + $https = $_SERVER['HTTPS'] ?? null; + $urlPrefix = $https === 'on' ? 'https://' : 'http://'; + $urlHost = $_SERVER['HTTP_HOST'] ?? ''; + $urlRequest = $_SERVER['REQUEST_URI'] ?? ''; $feedUrl = $urlPrefix . $urlHost . $urlRequest; $extraInfos = $this->getExtraInfos(); - $uri = !empty($extraInfos['uri']) ? $extraInfos['uri'] : REPOSITORY; + if (empty($extraInfos['uri'])) { + $uri = REPOSITORY; + } else { + $uri = $extraInfos['uri']; + } - $document = new DomDocument('1.0', $this->getCharset()); + $document = new \DomDocument('1.0', $this->getCharset()); $document->formatOutput = true; $feed = $document->createElementNS(self::ATOM_NS, 'feed'); $document->appendChild($feed); @@ -44,10 +48,10 @@ class AtomFormat extends FormatAbstract $id->appendChild($document->createTextNode($feedUrl)); $uriparts = parse_url($uri); - if (!empty($extraInfos['icon'])) { - $iconUrl = $extraInfos['icon']; - } else { + if (empty($extraInfos['icon'])) { $iconUrl = $uriparts['scheme'] . '://' . $uriparts['host'] . '/favicon.ico'; + } else { + $iconUrl = $extraInfos['icon']; } $icon = $document->createElement('icon'); $feed->appendChild($icon); @@ -94,11 +98,13 @@ class AtomFormat extends FormatAbstract $entryID = 'urn:sha1:' . $item->getUid(); } - if (empty($entryID)) { // Fallback to provided URI + if (empty($entryID)) { + // Fallback to provided URI $entryID = $entryUri; } - if (empty($entryID)) { // Fallback to title and content + if (empty($entryID)) { + // Fallback to title and content $entryID = 'urn:sha1:' . hash('sha1', $entryTitle . $entryContent); } @@ -126,7 +132,7 @@ class AtomFormat extends FormatAbstract $title->setAttribute('type', 'html'); $title->appendChild($document->createTextNode($entryTitle)); - $entryTimestamp = gmdate(DATE_ATOM, $entryTimestamp); + $entryTimestamp = gmdate(\DATE_ATOM, $entryTimestamp); $published = $document->createElement('published'); $entry->appendChild($published); $published->appendChild($document->createTextNode($entryTimestamp)); @@ -157,14 +163,14 @@ class AtomFormat extends FormatAbstract $content = $document->createElement('content'); $content->setAttribute('type', 'html'); - $content->appendChild($document->createTextNode($this->sanitizeHtml($entryContent))); + $content->appendChild($document->createTextNode(sanitize_html($entryContent))); $entry->appendChild($content); foreach ($item->getEnclosures() as $enclosure) { $entryEnclosure = $document->createElement('link'); $entry->appendChild($entryEnclosure); $entryEnclosure->setAttribute('rel', 'enclosure'); - $entryEnclosure->setAttribute('type', getMimeType($enclosure)); + $entryEnclosure->setAttribute('type', parse_mime_type($enclosure)); $entryEnclosure->setAttribute('href', $enclosure); } diff --git a/formats/HtmlFormat.php b/formats/HtmlFormat.php index d9ab65ef..6c916de6 100644 --- a/formats/HtmlFormat.php +++ b/formats/HtmlFormat.php @@ -7,9 +7,9 @@ class HtmlFormat extends FormatAbstract public function stringify() { $extraInfos = $this->getExtraInfos(); - $title = htmlspecialchars($extraInfos['name']); - $uri = htmlspecialchars($extraInfos['uri']); - $donationUri = htmlspecialchars($extraInfos['donationUri']); + $title = e($extraInfos['name']); + $uri = e($extraInfos['uri']); + $donationUri = e($extraInfos['donationUri']); $donationsAllowed = Configuration::getConfig('admin', 'donations'); // Dynamically build buttons for all formats (except HTML) @@ -19,32 +19,39 @@ class HtmlFormat extends FormatAbstract $links = ''; foreach ($formatFactory->getFormatNames() as $format) { - if (strcasecmp($format, 'HTML') === 0) { + if ($format === 'Html') { continue; } - $query = str_ireplace('format=Html', 'format=' . $format, htmlentities($_SERVER['QUERY_STRING'])); - $buttons .= $this->buildButton($format, $query) . PHP_EOL; + $queryString = $_SERVER['QUERY_STRING']; + $query = str_ireplace('format=Html', 'format=' . $format, htmlentities($queryString)); + $buttons .= sprintf('<a href="./?%s"><button class="rss-feed">%s</button></a>', $query, $format) . "\n"; $mime = $formatFactory->create($format)->getMimeType(); - $links .= $this->buildLink($format, $query, $mime) . PHP_EOL; + $links .= sprintf('<link href="./?%s" title="%s" rel="alternate" type="%s">', $query, $format, $mime) . "\n"; } if ($donationUri !== '' && $donationsAllowed) { - $buttons .= '<a href="' - . $donationUri - . '" target="_blank"><button class="highlight">Donate to maintainer</button></a>' - . PHP_EOL; - $links .= '<link href="' - . $donationUri - . ' target="_blank"" title="Donate to Maintainer" rel="alternate">' - . PHP_EOL; + $str = sprintf( + '<a href="%s" target="_blank"><button class="highlight">Donate to maintainer</button></a>', + $donationUri + ); + $buttons .= $str; + $str1 = sprintf( + '<link href="%s target="_blank"" title="Donate to Maintainer" rel="alternate">', + $donationUri + ); + $links .= $str1; } $entries = ''; foreach ($this->getItems() as $item) { - $entryAuthor = $item->getAuthor() ? '<br /><p class="author">by: ' . $item->getAuthor() . '</p>' : ''; - $entryTitle = $this->sanitizeHtml(strip_tags($item->getTitle())); + if ($item->getAuthor()) { + $entryAuthor = sprintf('<br /><p class="author">by: %s</p>', $item->getAuthor()); + } else { + $entryAuthor = ''; + } + $entryTitle = sanitize_html(strip_tags($item->getTitle())); $entryUri = $item->getURI() ?: $uri; $entryDate = ''; @@ -58,9 +65,8 @@ class HtmlFormat extends FormatAbstract $entryContent = ''; if ($item->getContent()) { - $entryContent = '<div class="content">' - . $this->sanitizeHtml($item->getContent()) - . '</div>'; + $str2 = sprintf('<div class="content">%s</div>', sanitize_html($item->getContent())); + $entryContent = $str2; } $entryEnclosures = ''; @@ -69,7 +75,7 @@ class HtmlFormat extends FormatAbstract foreach ($item->getEnclosures() as $enclosure) { $template = '<li class="enclosure"><a href="%s" rel="noopener noreferrer nofollow">%s</a></li>'; - $url = $this->sanitizeHtml($enclosure); + $url = sanitize_html($enclosure); $anchorText = substr($url, strrpos($url, '/') + 1); $entryEnclosures .= sprintf($template, $url, $anchorText); @@ -84,7 +90,7 @@ class HtmlFormat extends FormatAbstract foreach ($item->getCategories() as $category) { $entryCategories .= '<li class="category">' - . $this->sanitizeHtml($category) + . sanitize_html($category) . '</li>'; } @@ -106,8 +112,6 @@ EOD; } $charset = $this->getCharset(); - - /* Data are prepared, now let's begin the "MAGIE !!!" */ $toReturn = <<<EOD <!DOCTYPE html> <html> @@ -136,19 +140,4 @@ EOD; $toReturn = mb_convert_encoding($toReturn, $this->getCharset(), 'UTF-8'); return $toReturn; } - - private function buildButton($format, $query) - { - return <<<EOD -<a href="./?{$query}"><button class="rss-feed">{$format}</button></a> -EOD; - } - - private function buildLink($format, $query, $mime) - { - return <<<EOD -<link href="./?{$query}" title="{$format}" rel="alternate" type="{$mime}"> - -EOD; - } } diff --git a/formats/JsonFormat.php b/formats/JsonFormat.php index 3b2a29ab..bb9e81a2 100644 --- a/formats/JsonFormat.php +++ b/formats/JsonFormat.php @@ -25,10 +25,10 @@ class JsonFormat extends FormatAbstract public function stringify() { - $urlPrefix = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') ? 'https://' : 'http://'; - $urlHost = (isset($_SERVER['HTTP_HOST'])) ? $_SERVER['HTTP_HOST'] : ''; - $urlPath = (isset($_SERVER['PATH_INFO'])) ? $_SERVER['PATH_INFO'] : ''; - $urlRequest = (isset($_SERVER['REQUEST_URI'])) ? $_SERVER['REQUEST_URI'] : ''; + $https = $_SERVER['HTTPS'] ?? null; + $urlPrefix = $https === 'on' ? 'https://' : 'http://'; + $urlHost = $_SERVER['HTTP_HOST'] ?? ''; + $urlRequest = $_SERVER['REQUEST_URI'] ?? ''; $extraInfos = $this->getExtraInfos(); @@ -52,7 +52,7 @@ class JsonFormat extends FormatAbstract $entryTitle = $item->getTitle(); $entryUri = $item->getURI(); $entryTimestamp = $item->getTimestamp(); - $entryContent = $item->getContent() ? $this->sanitizeHtml($item->getContent()) : ''; + $entryContent = $item->getContent() ? sanitize_html($item->getContent()) : ''; $entryEnclosures = $item->getEnclosures(); $entryCategories = $item->getCategories(); @@ -76,13 +76,13 @@ class JsonFormat extends FormatAbstract ]; } if (!empty($entryTimestamp)) { - $entry['date_modified'] = gmdate(DATE_ATOM, $entryTimestamp); + $entry['date_modified'] = gmdate(\DATE_ATOM, $entryTimestamp); } if (!empty($entryUri)) { $entry['url'] = $entryUri; } if (!empty($entryContent)) { - if ($this->isHTML($entryContent)) { + if (is_html($entryContent)) { $entry['content_html'] = $entryContent; } else { $entry['content_text'] = $entryContent; @@ -93,7 +93,7 @@ class JsonFormat extends FormatAbstract foreach ($entryEnclosures as $enclosure) { $entry['attachments'][] = [ 'url' => $enclosure, - 'mime_type' => getMimeType($enclosure) + 'mime_type' => parse_mime_type($enclosure) ]; } } @@ -121,13 +121,8 @@ class JsonFormat extends FormatAbstract * So consider this a hack. * Switch to JSON_INVALID_UTF8_IGNORE when PHP 7.2 is the latest platform requirement. */ - $json = json_encode($data, JSON_PRETTY_PRINT | JSON_PARTIAL_OUTPUT_ON_ERROR); + $json = json_encode($data, \JSON_PRETTY_PRINT | \JSON_PARTIAL_OUTPUT_ON_ERROR); return $json; } - - private function isHTML($text) - { - return (strlen(strip_tags($text)) != strlen($text)); - } } diff --git a/formats/MrssFormat.php b/formats/MrssFormat.php index 45c2181f..f4067b73 100644 --- a/formats/MrssFormat.php +++ b/formats/MrssFormat.php @@ -33,22 +33,28 @@ class MrssFormat extends FormatAbstract protected const MRSS_NS = 'http://search.yahoo.com/mrss/'; const ALLOWED_IMAGE_EXT = [ - '.gif', '.jpg', '.png' + '.gif', + '.jpg', + '.png', ]; public function stringify() { - $urlPrefix = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') ? 'https://' : 'http://'; - $urlHost = (isset($_SERVER['HTTP_HOST'])) ? $_SERVER['HTTP_HOST'] : ''; - $urlPath = (isset($_SERVER['PATH_INFO'])) ? $_SERVER['PATH_INFO'] : ''; - $urlRequest = (isset($_SERVER['REQUEST_URI'])) ? $_SERVER['REQUEST_URI'] : ''; + $https = $_SERVER['HTTPS'] ?? null; + $urlPrefix = $https == 'on' ? 'https://' : 'http://'; + $urlHost = $_SERVER['HTTP_HOST'] ?? ''; + $urlRequest = $_SERVER['REQUEST_URI'] ?? ''; $feedUrl = $urlPrefix . $urlHost . $urlRequest; $extraInfos = $this->getExtraInfos(); - $uri = !empty($extraInfos['uri']) ? $extraInfos['uri'] : REPOSITORY; + if (empty($extraInfos['uri'])) { + $uri = REPOSITORY; + } else { + $uri = $extraInfos['uri']; + } - $document = new DomDocument('1.0', $this->getCharset()); + $document = new \DomDocument('1.0', $this->getCharset()); $document->formatOutput = true; $feed = $document->createElement('rss'); $document->appendChild($feed); @@ -103,16 +109,18 @@ class MrssFormat extends FormatAbstract $itemTimestamp = $item->getTimestamp(); $itemTitle = $item->getTitle(); $itemUri = $item->getURI(); - $itemContent = $item->getContent() ? $this->sanitizeHtml($item->getContent()) : ''; + $itemContent = $item->getContent() ? sanitize_html($item->getContent()) : ''; $entryID = $item->getUid(); $isPermaLink = 'false'; - if (empty($entryID) && !empty($itemUri)) { // Fallback to provided URI + if (empty($entryID) && !empty($itemUri)) { + // Fallback to provided URI $entryID = $itemUri; $isPermaLink = 'true'; } - if (empty($entryID)) { // Fallback to title and content + if (empty($entryID)) { + // Fallback to title and content $entryID = hash('sha1', $itemTitle . $itemContent); } @@ -139,7 +147,7 @@ class MrssFormat extends FormatAbstract if (!empty($itemTimestamp)) { $entryPublished = $document->createElement('pubDate'); $entry->appendChild($entryPublished); - $entryPublished->appendChild($document->createTextNode(gmdate(DATE_RFC2822, $itemTimestamp))); + $entryPublished->appendChild($document->createTextNode(gmdate(\DATE_RFC2822, $itemTimestamp))); } if (!empty($itemContent)) { @@ -152,10 +160,9 @@ class MrssFormat extends FormatAbstract $entryEnclosure = $document->createElementNS(self::MRSS_NS, 'content'); $entry->appendChild($entryEnclosure); $entryEnclosure->setAttribute('url', $enclosure); - $entryEnclosure->setAttribute('type', getMimeType($enclosure)); + $entryEnclosure->setAttribute('type', parse_mime_type($enclosure)); } - $entryCategories = ''; foreach ($item->getCategories() as $category) { $entryCategory = $document->createElement('category'); $entry->appendChild($entryCategory); diff --git a/formats/PlaintextFormat.php b/formats/PlaintextFormat.php index a1e125c7..c8c4e9d6 100644 --- a/formats/PlaintextFormat.php +++ b/formats/PlaintextFormat.php @@ -1,9 +1,5 @@ <?php -/** -* Plaintext -* Returns $this->items as raw php data. -*/ class PlaintextFormat extends FormatAbstract { const MIME_TYPE = 'text/plain'; @@ -2,16 +2,19 @@ require_once __DIR__ . '/lib/rssbridge.php'; -Configuration::verifyInstallation(); -Configuration::loadConfiguration(); +try { + Configuration::verifyInstallation(); + Configuration::loadConfiguration(); -date_default_timezone_set(Configuration::getConfig('system', 'timezone')); + date_default_timezone_set(Configuration::getConfig('system', 'timezone')); -define('CUSTOM_CACHE_TIMEOUT', Configuration::getConfig('cache', 'custom_timeout')); + define('CUSTOM_CACHE_TIMEOUT', Configuration::getConfig('cache', 'custom_timeout')); -Authentication::showPromptIfNeeded(); + $authenticationMiddleware = new AuthenticationMiddleware(); + if (Configuration::getConfig('authentication', 'enable')) { + $authenticationMiddleware(); + } -try { if (isset($argv)) { parse_str(implode('&', array_slice($argv, 1)), $cliArgs); $request = $cliArgs; @@ -20,12 +23,7 @@ try { } foreach ($request as $key => $value) { if (! is_string($value)) { - http_response_code(400); - print render('error.html.php', [ - 'title' => '400 Bad Request', - 'message' => "Query parameter \"$key\" is not a string.", - ]); - exit(1); + throw new \Exception("Query parameter \"$key\" is not a string."); } } 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'; +} diff --git a/phpcompatibility.xml b/phpcompatibility.xml index 2726bfcb..f2268d7f 100644 --- a/phpcompatibility.xml +++ b/phpcompatibility.xml @@ -10,6 +10,8 @@ --> <config name="testVersion" value="7.4-"/> <rule ref="PHPCompatibility"> + <!-- This sniff is very overzealous and inaccurate, so we'll disable it --> + <exclude name="PHPCompatibility.Extensions.RemovedExtensions"/> </rule> </ruleset> @@ -9,6 +9,7 @@ <rule ref="PSR12"> <exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace"/> + <exclude name="PSR1.Classes.ClassDeclaration.MultipleClasses"/> <exclude name="PSR1.Files.SideEffects.FoundWithSymbols"/> <exclude name="PSR12.Properties.ConstantVisibility.NotFound"/> </rule> diff --git a/templates/connectivity.html.php b/templates/connectivity.html.php new file mode 100644 index 00000000..c00e8177 --- /dev/null +++ b/templates/connectivity.html.php @@ -0,0 +1,29 @@ +<!DOCTYPE html> + +<html> +<head> + <link rel="stylesheet" href="static/bootstrap.min.css"> + <link + rel="stylesheet" + href="https://use.fontawesome.com/releases/v5.6.3/css/all.css" + integrity="sha384-UHRtZLI+pbxtHCWp1t77Bi1L4ZtiqrqD80Kn4Z8NTSRyMA2Fd33n5dQ8lWUE00s/" + crossorigin="anonymous"> + <link rel="stylesheet" href="static/connectivity.css"> + <script src="static/connectivity.js" type="text/javascript"></script> +</head> +<body> +<div id="main-content" class="container"> + <div class="progress"> + <div class="progress-bar" role="progressbar" aria-valuenow="75" aria-valuemin="0" aria-valuemax="100"></div> + </div> + <div id="status-message" class="sticky-top alert alert-primary alert-dismissible fade show" role="alert"> + <i id="status-icon" class="fas fa-sync"></i> + <span>...</span> + <button type="button" class="close" data-dismiss="alert" aria-label="Close" onclick="stopConnectivityChecks()"> + <span aria-hidden="true">×</span> + </button> + </div> + <input type="text" class="form-control" id="search" onkeyup="search()" placeholder="Search for bridge.."> +</div> +</body> +</html>
\ No newline at end of file diff --git a/templates/error.html.php b/templates/error.html.php index 12f77b0b..2473e5f2 100644 --- a/templates/error.html.php +++ b/templates/error.html.php @@ -10,6 +10,9 @@ <br> <?php if (isset($stacktrace)): ?> + <h2>Stacktrace</h2> + <br> + <?php foreach ($stacktrace as $frame) : ?> <code> <?= e($frame) ?> diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php index 996886a7..3dd389c6 100644 --- a/tests/UtilsTest.php +++ b/tests/UtilsTest.php @@ -16,4 +16,19 @@ final class UtilsTest extends TestCase $this->assertSame('foo', truncate('foo', 4)); $this->assertSame('fo[...]', truncate('foo', 2, '[...]')); } + + public function testFileCache() + { + $sut = new \FileCache(); + $sut->setScope('scope'); + $sut->purgeCache(-1); + $sut->setKey(['key']); + + $this->assertNull($sut->loadData()); + + $sut->saveData('data'); + $this->assertSame('data', $sut->loadData()); + $this->assertIsNumeric($sut->getTime()); + $sut->purgeCache(-1); + } } |