From a18c35046daee15e7ac5f85db290d54541a03e3c Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Tue, 11 Nov 2025 08:17:12 +0100 Subject: Housekeeping lib_rss.php (#8193) * Housekeeping lib_rss.php `lib_rss.php` had become much too large, especially after https://github.com/FreshRSS/FreshRSS/pull/7924 Moved most functions to other places. Mostly no change of code otherwise (see comments). * Extension: composer run-script phpstan-third-party --- app/Controllers/authController.php | 6 +- app/Controllers/categoryController.php | 8 +- app/Controllers/configureController.php | 26 +- app/Controllers/extensionController.php | 2 +- app/Controllers/feedController.php | 10 +- app/Controllers/importExportController.php | 2 +- app/Controllers/indexController.php | 2 +- app/Controllers/javascriptController.php | 2 +- app/Controllers/subscriptionController.php | 6 +- app/Controllers/updateController.php | 77 ++- app/Controllers/userController.php | 63 +- app/Models/Auth.php | 10 +- app/Models/Category.php | 4 +- app/Models/Entry.php | 4 +- app/Models/Feed.php | 32 +- app/Models/FeedDAO.php | 2 +- app/Models/SimplePieCustom.php | 295 ++++++++ app/Models/SimplePieResponse.php | 4 +- app/Models/UserConfiguration.php | 28 + app/Models/UserQuery.php | 18 + app/Services/ImportService.php | 2 +- app/Utils/feverUtil.php | 2 +- app/Utils/httpUtil.php | 417 ++++++++++++ app/Utils/passwordUtil.php | 5 + app/actualize_script.php | 2 +- app/install.php | 16 +- app/views/auth/formLogin.phtml | 2 +- app/views/auth/index.phtml | 4 +- app/views/configure/shortcut.phtml | 2 +- app/views/configure/system.phtml | 2 +- app/views/user/details.phtml | 2 +- app/views/user/profile.phtml | 2 +- cli/create-user.php | 2 +- cli/db-backup.php | 2 +- cli/db-restore.php | 2 +- cli/list-users.php | 2 +- cli/user-info.php | 2 +- docs/en/developers/03_Backend/05_Extensions.md | 4 +- docs/fr/developers/03_Backend/05_Extensions.md | 4 +- lib/Minz/HookType.php | 4 +- lib/Minz/Request.php | 14 + lib/favicons.php | 7 +- lib/lib_rss.php | 899 +------------------------ p/api/pshb.php | 4 +- 44 files changed, 1016 insertions(+), 989 deletions(-) create mode 100644 app/Models/SimplePieCustom.php diff --git a/app/Controllers/authController.php b/app/Controllers/authController.php index 64526a884..61f4f5aaf 100644 --- a/app/Controllers/authController.php +++ b/app/Controllers/authController.php @@ -85,8 +85,8 @@ class FreshRSS_auth_Controller extends FreshRSS_ActionController { 'http_auth' => Minz_Error::error(403, [ 'error' => [ _t('feedback.access.denied'), - ' [HTTP Remote-User=' . htmlspecialchars(httpAuthUser(false), ENT_NOQUOTES, 'UTF-8') . - ' ; Remote IP address=' . connectionRemoteAddress() . ']' + ' [HTTP Remote-User=' . htmlspecialchars(FreshRSS_http_Util::httpAuthUser(onlyTrusted: false), ENT_NOQUOTES, 'UTF-8') . + ' ; Remote IP address=' . Minz_Request::connectionRemoteAddress() . ']' ] ], false), 'none' => Minz_Error::error(404), // It should not happen! @@ -297,7 +297,7 @@ class FreshRSS_auth_Controller extends FreshRSS_ActionController { Minz_Request::forward(['c' => 'index', 'a' => 'index'], true); } - if (max_registrations_reached()) { + if (FreshRSS_user_Controller::max_registrations_reached()) { Minz_Error::error(403); } diff --git a/app/Controllers/categoryController.php b/app/Controllers/categoryController.php index 2212a158b..5b1dd9d17 100644 --- a/app/Controllers/categoryController.php +++ b/app/Controllers/categoryController.php @@ -59,7 +59,7 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController { Minz_Request::bad(_t('feedback.tag.name_exists', $cat->name()), $url_redirect); } - $opml_url = checkUrl(Minz_Request::paramString('opml_url', plaintext: true)); + $opml_url = FreshRSS_http_Util::checkUrl(Minz_Request::paramString('opml_url', plaintext: true)); if ($opml_url != '') { $cat->_kind(FreshRSS_Category::KIND_DYNAMIC_OPML); $cat->_attribute('opml_url', $opml_url); @@ -141,7 +141,7 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController { $position = Minz_Request::paramInt('position') ?: null; $category->_attribute('position', $position); - $opml_url = checkUrl(Minz_Request::paramString('opml_url', plaintext: true)); + $opml_url = FreshRSS_http_Util::checkUrl(Minz_Request::paramString('opml_url', plaintext: true)); if ($opml_url != '') { $category->_kind(FreshRSS_Category::KIND_DYNAMIC_OPML); $category->_attribute('opml_url', $opml_url); @@ -229,7 +229,7 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController { } // Remove related queries. - $queries = remove_query_by_get('c_' . $id, FreshRSS_Context::userConf()->queries); + $queries = FreshRSS_UserQuery::remove_query_by_get('c_' . $id, FreshRSS_Context::userConf()->queries); FreshRSS_Context::userConf()->queries = $queries; FreshRSS_Context::userConf()->save(); @@ -274,7 +274,7 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController { // Remove related queries foreach ($feeds as $feed) { - $queries = remove_query_by_get('f_' . $feed->id(), FreshRSS_Context::userConf()->queries); + $queries = FreshRSS_UserQuery::remove_query_by_get('f_' . $feed->id(), FreshRSS_Context::userConf()->queries); FreshRSS_Context::userConf()->queries = $queries; } FreshRSS_Context::userConf()->save(); diff --git a/app/Controllers/configureController.php b/app/Controllers/configureController.php index 17c6c20bd..451e98a8b 100644 --- a/app/Controllers/configureController.php +++ b/app/Controllers/configureController.php @@ -236,6 +236,30 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController { FreshRSS_View::prependTitle(_t('conf.sharing.title') . ' · '); } + private const SHORTCUT_KEYS = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', + 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12', + 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'Backspace', 'Delete', + 'End', 'Enter', 'Escape', 'Home', 'Insert', 'PageDown', 'PageUp', 'Space', 'Tab', + ]; + + /** + * @param array $shortcuts + * @return list + */ + public static function getNonStandardShortcuts(array $shortcuts): array { + $standard = strtolower(implode(' ', self::SHORTCUT_KEYS)); + + $nonStandard = array_filter($shortcuts, static function (string $shortcut) use ($standard) { + $shortcut = trim($shortcut); + return $shortcut !== '' && stripos($standard, $shortcut) === false; + }); + + return array_values($nonStandard); + } + /** * This action handles the shortcut configuration page. * @@ -249,7 +273,7 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController { * tab and up. */ public function shortcutAction(): void { - $this->view->list_keys = SHORTCUT_KEYS; + $this->view->list_keys = self::SHORTCUT_KEYS; if (Minz_Request::isPost()) { $shortcuts = Minz_Request::paramArray('shortcuts', plaintext: true); diff --git a/app/Controllers/extensionController.php b/app/Controllers/extensionController.php index 315d37101..9b52cd168 100644 --- a/app/Controllers/extensionController.php +++ b/app/Controllers/extensionController.php @@ -50,7 +50,7 @@ class FreshRSS_extension_Controller extends FreshRSS_ActionController { $cacheFile = CACHE_PATH . '/extension_list.json'; if (FreshRSS_Context::userConf()->retrieve_extension_list === true) { if (!file_exists($cacheFile) || (time() - (filemtime($cacheFile) ?: 0) > 86400)) { - $json = httpGet($extensionListUrl, $cacheFile, 'json')['body']; + $json = FreshRSS_http_Util::httpGet($extensionListUrl, $cacheFile, 'json')['body']; } else { $json = @file_get_contents($cacheFile) ?: ''; } diff --git a/app/Controllers/feedController.php b/app/Controllers/feedController.php index 678388cbb..b6ecbeec2 100644 --- a/app/Controllers/feedController.php +++ b/app/Controllers/feedController.php @@ -417,14 +417,14 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { } /** - * @param \SimplePie\SimplePie|null $simplePiePush Used by WebSub (PubSubHubbub) to push updates + * @param FreshRSS_SimplePieCustom|null $simplePiePush Used by WebSub (PubSubHubbub) to push updates * @param string $selfUrl Used by WebSub (PubSubHubbub) to override the feed URL * @return array{0:int,1:FreshRSS_Feed|null,2:int,3:array} Number of updated feeds, first feed or null, number of new articles, * list of feeds for which a cache refresh is needed * @throws FreshRSS_BadUrl_Exception */ public static function actualizeFeeds(?int $feed_id = null, ?string $feed_url = null, ?int $maxFeeds = null, - ?\SimplePie\SimplePie $simplePiePush = null, string $selfUrl = ''): array { + ?FreshRSS_SimplePieCustom $simplePiePush = null, string $selfUrl = ''): array { if (function_exists('set_time_limit')) { @set_time_limit(300); } @@ -866,14 +866,14 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { } /** - * @param \SimplePie\SimplePie|null $simplePiePush Used by WebSub (PubSubHubbub) to push updates + * @param FreshRSS_SimplePieCustom|null $simplePiePush Used by WebSub (PubSubHubbub) to push updates * @param string $selfUrl Used by WebSub (PubSubHubbub) to override the feed URL * @return array{0:int,1:FreshRSS_Feed|null,2:int,3:array} Number of updated feeds, first feed or null, number of new articles, * list of feeds for which a cache refresh is needed * @throws FreshRSS_BadUrl_Exception */ public static function actualizeFeedsAndCommit(?int $feed_id = null, ?string $feed_url = null, ?int $maxFeeds = null, - ?SimplePie\SimplePie $simplePiePush = null, string $selfUrl = ''): array { + ?FreshRSS_SimplePieCustom $simplePiePush = null, string $selfUrl = ''): array { $entryDAO = FreshRSS_Factory::createEntryDao(); [$nbUpdatedFeeds, $feed, $nbNewArticles, $feedsCacheToRefresh] = FreshRSS_feed_Controller::actualizeFeeds($feed_id, $feed_url, $maxFeeds, $simplePiePush, $selfUrl); @@ -1066,7 +1066,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { } // Remove related queries - $queries = remove_query_by_get('f_' . $feed_id, FreshRSS_Context::userConf()->queries); + $queries = FreshRSS_UserQuery::remove_query_by_get('f_' . $feed_id, FreshRSS_Context::userConf()->queries); FreshRSS_Context::userConf()->queries = $queries; FreshRSS_Context::userConf()->save(); return true; diff --git a/app/Controllers/importExportController.php b/app/Controllers/importExportController.php index 7dc825b9e..d1635a046 100644 --- a/app/Controllers/importExportController.php +++ b/app/Controllers/importExportController.php @@ -448,7 +448,7 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController { } else { $content = ''; } - $content = sanitizeHTML($content, $url); + $content = FreshRSS_SimplePieCustom::sanitizeHTML($content, $url); if (is_int($item['published'] ?? null) || is_string($item['published'] ?? null)) { $published = (string)$item['published']; diff --git a/app/Controllers/indexController.php b/app/Controllers/indexController.php index 9005dff93..59302d3f1 100644 --- a/app/Controllers/indexController.php +++ b/app/Controllers/indexController.php @@ -383,7 +383,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController { } $this->view->terms_of_service = $terms_of_service; - $this->view->can_register = !max_registrations_reached(); + $this->view->can_register = !FreshRSS_user_Controller::max_registrations_reached(); FreshRSS_View::prependTitle(_t('index.tos.title') . ' · '); } diff --git a/app/Controllers/javascriptController.php b/app/Controllers/javascriptController.php index eda468dff..32bdee305 100644 --- a/app/Controllers/javascriptController.php +++ b/app/Controllers/javascriptController.php @@ -71,7 +71,7 @@ class FreshRSS_javascript_Controller extends FreshRSS_ActionController { Minz_Error::error(400); return; } - $user_conf = get_user_configuration($user); + $user_conf = FreshRSS_UserConfiguration::getForUser($user); if ($user_conf !== null) { try { $s = $user_conf->passwordHash; diff --git a/app/Controllers/subscriptionController.php b/app/Controllers/subscriptionController.php index 244a16671..a2d1c1d07 100644 --- a/app/Controllers/subscriptionController.php +++ b/app/Controllers/subscriptionController.php @@ -337,9 +337,9 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController { $values = [ 'name' => Minz_Request::paramString('name'), 'kind' => $feed->kind(), - 'description' => sanitizeHTML(Minz_Request::paramString('description', true)), - 'website' => checkUrl(Minz_Request::paramString('website')) ?: '', - 'url' => checkUrl(Minz_Request::paramString('url')) ?: '', + 'description' => FreshRSS_SimplePieCustom::sanitizeHTML(Minz_Request::paramString('description', true)), + 'website' => FreshRSS_http_Util::checkUrl(Minz_Request::paramString('website')) ?: '', + 'url' => FreshRSS_http_Util::checkUrl(Minz_Request::paramString('url')) ?: '', 'category' => Minz_Request::paramInt('category'), 'pathEntries' => Minz_Request::paramString('path_entries'), 'priority' => Minz_Request::paramTernary('priority') === null ? FreshRSS_Feed::PRIORITY_MAIN_STREAM : Minz_Request::paramInt('priority'), diff --git a/app/Controllers/updateController.php b/app/Controllers/updateController.php index 7c204de8c..98d552688 100644 --- a/app/Controllers/updateController.php +++ b/app/Controllers/updateController.php @@ -334,14 +334,85 @@ class FreshRSS_update_Controller extends FreshRSS_ActionController { } } + /** + * Check PHP and its extensions are well-installed. + * + * @return array of tested values. + */ + private static function check_install_php(): array { + $pdo_mysql = extension_loaded('pdo_mysql'); + $pdo_pgsql = extension_loaded('pdo_pgsql'); + $pdo_sqlite = extension_loaded('pdo_sqlite'); + return [ + 'php' => version_compare(PHP_VERSION, FRESHRSS_MIN_PHP_VERSION) >= 0, + 'curl' => extension_loaded('curl'), + 'pdo' => $pdo_mysql || $pdo_sqlite || $pdo_pgsql, + 'pcre' => extension_loaded('pcre'), + 'ctype' => extension_loaded('ctype'), + 'fileinfo' => extension_loaded('fileinfo'), + 'dom' => class_exists('DOMDocument'), + 'json' => extension_loaded('json'), + 'mbstring' => extension_loaded('mbstring'), + 'zip' => extension_loaded('zip'), + ]; + } + + /** + * Check different data files and directories exist. + * @return array of tested values. + */ + private static function check_install_files(): array { + return [ + 'data' => is_dir(DATA_PATH) && touch(DATA_PATH . '/index.html'), // is_writable() is not reliable for a folder on NFS + 'cache' => is_dir(CACHE_PATH) && touch(CACHE_PATH . '/index.html'), + 'users' => is_dir(USERS_PATH) && touch(USERS_PATH . '/index.html'), + 'favicons' => is_dir(DATA_PATH) && touch(DATA_PATH . '/favicons/index.html'), + 'tokens' => is_dir(DATA_PATH) && touch(DATA_PATH . '/tokens/index.html'), + ]; + } + + /** + * Check database is well-installed. + * + * @return array of tested values. + */ + private static function check_install_database(): array { + $status = [ + 'connection' => true, + 'tables' => false, + 'categories' => false, + 'feeds' => false, + 'entries' => false, + 'entrytmp' => false, + 'tag' => false, + 'entrytag' => false, + ]; + + try { + $dbDAO = FreshRSS_Factory::createDatabaseDAO(); + + $status['tables'] = $dbDAO->tablesAreCorrect(); + $status['categories'] = $dbDAO->categoryIsCorrect(); + $status['feeds'] = $dbDAO->feedIsCorrect(); + $status['entries'] = $dbDAO->entryIsCorrect(); + $status['entrytmp'] = $dbDAO->entrytmpIsCorrect(); + $status['tag'] = $dbDAO->tagIsCorrect(); + $status['entrytag'] = $dbDAO->entrytagIsCorrect(); + } catch (Minz_PDOConnectionException $e) { + $status['connection'] = false; + } + + return $status; + } + /** * This action displays information about installation. */ public function checkInstallAction(): void { FreshRSS_View::prependTitle(_t('admin.check_install.title') . ' · '); - $this->view->status_php = check_install_php(); - $this->view->status_files = check_install_files(); - $this->view->status_database = check_install_database(); + $this->view->status_php = self::check_install_php(); + $this->view->status_files = self::check_install_files(); + $this->view->status_database = self::check_install_database(); } } diff --git a/app/Controllers/userController.php b/app/Controllers/userController.php index a7a79b067..50a89eb3c 100644 --- a/app/Controllers/userController.php +++ b/app/Controllers/userController.php @@ -15,6 +15,37 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController { return preg_match('/^' . self::USERNAME_PATTERN . '$/', $username) === 1; } + /** + * Validate an email address, supports internationalized addresses. + * + * @param string $email The address to validate + * @return bool true if email is valid, else false + */ + private static function validateEmailAddress(string $email): bool { + $mailer = new PHPMailer\PHPMailer\PHPMailer(); + $mailer->CharSet = 'utf-8'; + $punyemail = $mailer->punyencodeAddress($email); + return PHPMailer\PHPMailer\PHPMailer::validateAddress($punyemail, 'html5'); + } + + /** + * @return list + */ + public static function listUsers(): array { + $final_list = []; + $base_path = join_path(DATA_PATH, 'users'); + $dir_list = array_values(array_diff( + scandir($base_path) ?: [], + ['..', '.', Minz_User::INTERNAL_USER] + )); + foreach ($dir_list as $file) { + if ($file[0] !== '.' && is_dir(join_path($base_path, $file)) && file_exists(join_path($base_path, $file, 'config.php'))) { + $final_list[] = $file; + } + } + return $final_list; + } + public static function userExists(string $username): bool { $config_path = USERS_PATH . '/' . $username . '/config.php'; if (@file_exists($config_path)) { @@ -30,9 +61,21 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController { return false; } + /** + * Return if the maximum number of registrations has been reached. + * Note a max_registrations of 0 means there is no limit. + * + * @return bool true if number of users >= max registrations, false else. + */ + public static function max_registrations_reached(): bool { + $limit_registrations = FreshRSS_Context::systemConf()->limits['max_registrations']; + $number_accounts = count(self::listUsers()); + return $limit_registrations > 0 && $number_accounts >= $limit_registrations; + } + /** @param array $userConfigUpdated */ public static function updateUser(string $user, ?string $email, string $passwordPlain, array $userConfigUpdated = []): bool { - $userConfig = get_user_configuration($user); + $userConfig = FreshRSS_UserConfiguration::getForUser($user); if ($userConfig === null) { return false; } @@ -166,7 +209,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController { ); } - if (!empty($email) && !validateEmailAddress($email)) { + if (!empty($email) && !self::validateEmailAddress($email)) { Minz_Request::bad( _t('user.email.feedback.invalid'), ['c' => 'user', 'a' => 'profile'] @@ -285,7 +328,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController { $this->view->show_email_field = FreshRSS_Context::systemConf()->force_email_validation; $this->view->current_user = Minz_Request::paramString('u'); - foreach (listUsers() as $user) { + foreach (self::listUsers() as $user) { $this->view->users[$user] = $this->retrieveUserDetails($user); } } @@ -322,7 +365,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController { $userConfig['language'] = Minz_Translate::DEFAULT_LANGUAGE; } - $ok &= !in_array(strtoupper($new_user_name), array_map('strtoupper', listUsers()), true); //Not an existing user, case-insensitive + $ok &= !in_array(strtoupper($new_user_name), array_map('strtoupper', self::listUsers()), true); //Not an existing user, case-insensitive $configPath = join_path($homeDir, 'config.php'); $ok &= !file_exists($configPath); @@ -370,7 +413,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController { * @todo clean up this method. Idea: write a method to init a user with basic information. */ public function createAction(): void { - if (!FreshRSS_Auth::hasAccess('admin') && max_registrations_reached()) { + if (!FreshRSS_Auth::hasAccess('admin') && self::max_registrations_reached()) { Minz_Error::error(403); } @@ -424,7 +467,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController { ); } - if (!empty($email) && !validateEmailAddress($email)) { + if (!empty($email) && !self::validateEmailAddress($email)) { Minz_Request::bad( _t('user.email.feedback.invalid'), $badRedirectUrl @@ -451,7 +494,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController { // user just created its account himself so he probably wants to // get started immediately. if ($ok && !FreshRSS_Auth::hasAccess('admin')) { - $user_conf = get_user_configuration($new_user_name); + $user_conf = FreshRSS_UserConfiguration::getForUser($new_user_name); if ($user_conf !== null) { Minz_Session::_params([ Minz_User::CURRENT_USER => $new_user_name, @@ -531,7 +574,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController { $token = Minz_Request::paramString('token'); if ($username !== '') { - $user_config = get_user_configuration($username); + $user_config = FreshRSS_UserConfiguration::getForUser($username); } elseif (FreshRSS_Auth::hasAccess()) { $user_config = FreshRSS_Context::userConf(); } else { @@ -711,7 +754,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController { Minz_Error::error(404); } - if (null === $userConfig = get_user_configuration($username)) { + if (null === $userConfig = FreshRSS_UserConfiguration::getForUser($username)) { Minz_Error::error(500); return; } @@ -769,7 +812,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController { $entryDAO = FreshRSS_Factory::createEntryDao($username); $databaseDAO = FreshRSS_Factory::createDatabaseDAO($username); - $userConfiguration = get_user_configuration($username); + $userConfiguration = FreshRSS_UserConfiguration::getForUser($username); if ($userConfiguration === null) { throw new Exception('Error loading user configuration!'); } diff --git a/app/Models/Auth.php b/app/Models/Auth.php index 888215730..6bf4a2b3f 100644 --- a/app/Models/Auth.php +++ b/app/Models/Auth.php @@ -16,7 +16,7 @@ class FreshRSS_Auth { * This method initializes authentication system. */ public static function init(): bool { - if (isset($_SESSION['REMOTE_USER']) && $_SESSION['REMOTE_USER'] !== httpAuthUser()) { + if (isset($_SESSION['REMOTE_USER']) && $_SESSION['REMOTE_USER'] !== FreshRSS_http_Util::httpAuthUser()) { //HTTP REMOTE_USER has changed self::removeAccess(); } @@ -67,7 +67,7 @@ class FreshRSS_Auth { } return $current_user != ''; case 'http_auth': - $current_user = httpAuthUser(); + $current_user = FreshRSS_http_Util::httpAuthUser(); if ($current_user == '') { return false; } @@ -115,7 +115,7 @@ class FreshRSS_Auth { break; case 'http_auth': $current_user = Minz_User::name() ?? ''; - self::$login_ok = strcasecmp($current_user, httpAuthUser()) === 0; + self::$login_ok = strcasecmp($current_user, FreshRSS_http_Util::httpAuthUser()) === 0; break; case 'none': self::$login_ok = true; @@ -127,7 +127,7 @@ class FreshRSS_Auth { Minz_Session::_params([ 'loginOk' => self::$login_ok, - 'REMOTE_USER' => httpAuthUser(), + 'REMOTE_USER' => FreshRSS_http_Util::httpAuthUser(), ]); return self::$login_ok; } @@ -175,7 +175,7 @@ class FreshRSS_Auth { if ($token_param != '') { $username = Minz_Request::paramString('user'); if ($username != '') { - $conf = get_user_configuration($username); + $conf = FreshRSS_UserConfiguration::getForUser($username); if ($conf == null) { $username = ''; } diff --git a/app/Models/Category.php b/app/Models/Category.php index 554e002fb..4d7740ed1 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -176,7 +176,7 @@ class FreshRSS_Category extends Minz_Model { * @throws FreshRSS_Context_Exception */ public function cacheFilename(string $url): string { - $simplePie = customSimplePie($this->attributes(), $this->curlOptions()); + $simplePie = new FreshRSS_SimplePieCustom($this->attributes(), $this->curlOptions()); $filename = $simplePie->get_cache_filename($url); return CACHE_PATH . '/' . $filename . '.opml.xml'; } @@ -188,7 +188,7 @@ class FreshRSS_Category extends Minz_Model { } $ok = true; $cachePath = $this->cacheFilename($url); - $opml = httpGet($url, $cachePath, 'opml', $this->attributes(), $this->curlOptions())['body']; + $opml = FreshRSS_http_Util::httpGet($url, $cachePath, 'opml', $this->attributes(), $this->curlOptions())['body']; if ($opml == '') { Minz_Log::warning('Error getting dynamic OPML for category ' . $this->id() . '! ' . \SimplePie\Misc::url_remove_credentials($url)); diff --git a/app/Models/Entry.php b/app/Models/Entry.php index 3cf6382dd..fa12ceb66 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -904,7 +904,7 @@ HTML; } $cachePath = $feed->cacheFilename($url . '#' . $feed->pathEntries()); - $response = httpGet($url, $cachePath, 'html', $feed->attributes(), $feed->curlOptions()); + $response = FreshRSS_http_Util::httpGet($url, $cachePath, 'html', $feed->attributes(), $feed->curlOptions()); $html = $response['body']; if ($html !== '') { $doc = new DOMDocument(); @@ -979,7 +979,7 @@ HTML; } unset($xpath, $doc); - $html = sanitizeHTML($html, $base); + $html = FreshRSS_SimplePieCustom::sanitizeHTML($html, $base); if ($path_entries_filter !== '') { // Remove unwanted elements again after sanitizing, for CSS selectors to also match sanitized content diff --git a/app/Models/Feed.php b/app/Models/Feed.php index 2a1ec3f63..cd5fa508d 100644 --- a/app/Models/Feed.php +++ b/app/Models/Feed.php @@ -455,7 +455,7 @@ class FreshRSS_Feed extends Minz_Model { $this->hashFavicon = ''; $url = $value; if ($validate) { - $url = checkUrl($url); + $url = FreshRSS_http_Util::checkUrl($url); } if ($url == false) { throw new FreshRSS_BadUrl_Exception($value); @@ -488,7 +488,7 @@ class FreshRSS_Feed extends Minz_Model { public function _website(string $value, bool $validate = true): void { $this->hashFavicon = ''; if ($validate) { - $value = checkUrl($value); + $value = FreshRSS_http_Util::checkUrl($value); } if ($value == false) { $value = ''; @@ -541,7 +541,7 @@ class FreshRSS_Feed extends Minz_Model { * @throws Minz_FileNotExistException * @throws FreshRSS_Feed_Exception */ - public function load(bool $loadDetails = false, bool $noCache = false): ?\SimplePie\SimplePie { + public function load(bool $loadDetails = false, bool $noCache = false): ?FreshRSS_SimplePieCustom { if ($this->url != '') { /** * @throws Minz_FileNotExistException @@ -556,7 +556,7 @@ class FreshRSS_Feed extends Minz_Model { throw new FreshRSS_Feed_Exception('For that domain, will first retry after ' . date('c', $retryAfter) . '. ' . $this->url(includeCredentials: false), code: 503); } - $simplePie = customSimplePie($this->attributes(), $this->curlOptions()); + $simplePie = new FreshRSS_SimplePieCustom($this->attributes(), $this->curlOptions()); $url = htmlspecialchars_decode($this->url, ENT_QUOTES); if (str_ends_with($url, '#force_feed')) { $simplePie->force_feed(true); @@ -595,9 +595,9 @@ class FreshRSS_Feed extends Minz_Model { } $links = $simplePie->get_links('self'); - $this->selfUrl = empty($links[0]) ? '' : (checkUrl($links[0]) ?: ''); + $this->selfUrl = empty($links[0]) ? '' : (FreshRSS_http_Util::checkUrl($links[0]) ?: ''); $links = $simplePie->get_links('hub'); - $this->hubUrl = empty($links[0]) ? '' : (checkUrl($links[0]) ?: ''); + $this->hubUrl = empty($links[0]) ? '' : (FreshRSS_http_Util::checkUrl($links[0]) ?: ''); if ($loadDetails) { // si on a utilisé l’auto-discover, notre url va avoir changé @@ -693,7 +693,7 @@ class FreshRSS_Feed extends Minz_Model { * The default value of 5% rounded was chosen to allow 1 invalid GUID for feeds of 10 articles, which is a frequently observed amount of articles. * @return list */ - public function loadGuids(\SimplePie\SimplePie $simplePie, float $invalidGuidsTolerance = 0.05): array { + public function loadGuids(FreshRSS_SimplePieCustom $simplePie, float $invalidGuidsTolerance = 0.05): array { $invalidGuids = 0; $testGuids = []; $guids = []; @@ -747,7 +747,7 @@ class FreshRSS_Feed extends Minz_Model { } /** @return Traversable */ - public function loadEntries(\SimplePie\SimplePie $simplePie): Traversable { + public function loadEntries(FreshRSS_SimplePieCustom $simplePie): Traversable { $items = $simplePie->get_items(); if (empty($items)) { return; @@ -889,8 +889,8 @@ class FreshRSS_Feed extends Minz_Model { * returns a SimplePie initialized already with that content * @param string $feedContent the content of the feed, typically generated via FreshRSS_View::renderToString() */ - private function simplePieFromContent(string $feedContent): \SimplePie\SimplePie { - $simplePie = customSimplePie(); + private function simplePieFromContent(string $feedContent): FreshRSS_SimplePieCustom { + $simplePie = new FreshRSS_SimplePieCustom(); $simplePie->enable_cache(false); $simplePie->set_raw_data($feedContent); $simplePie->init(); @@ -956,7 +956,7 @@ class FreshRSS_Feed extends Minz_Model { return null; } - public function loadJson(): ?\SimplePie\SimplePie { + public function loadJson(): ?FreshRSS_SimplePieCustom { if ($this->url == '') { return null; } @@ -966,7 +966,7 @@ class FreshRSS_Feed extends Minz_Model { } $httpAccept = $this->kind() === FreshRSS_Feed::KIND_HTML_XPATH_JSON_DOTNOTATION ? 'html' : 'json'; - $content = httpGet($feedSourceUrl, $this->cacheFilename(), $httpAccept, $this->attributes(), $this->curlOptions())['body']; + $content = FreshRSS_http_Util::httpGet($feedSourceUrl, $this->cacheFilename(), $httpAccept, $this->attributes(), $this->curlOptions())['body']; if (strlen($content) <= 0) { return null; } @@ -995,7 +995,7 @@ class FreshRSS_Feed extends Minz_Model { return $this->simplePieFromContent($feedContent); } - public function loadHtmlXpath(): ?\SimplePie\SimplePie { + public function loadHtmlXpath(): ?FreshRSS_SimplePieCustom { if ($this->url == '') { return null; } @@ -1024,7 +1024,7 @@ class FreshRSS_Feed extends Minz_Model { } $httpAccept = $this->kind() === FreshRSS_Feed::KIND_XML_XPATH ? 'xml' : 'html'; - $html = httpGet($feedSourceUrl, $this->cacheFilename(), $httpAccept, $this->attributes(), $this->curlOptions())['body']; + $html = FreshRSS_http_Util::httpGet($feedSourceUrl, $this->cacheFilename(), $httpAccept, $this->attributes(), $this->curlOptions())['body']; if (strlen($html) <= 0) { return null; } @@ -1230,7 +1230,7 @@ class FreshRSS_Feed extends Minz_Model { * @throws FreshRSS_Context_Exception */ public function cacheFilename(string $url = ''): string { - $simplePie = customSimplePie($this->attributes(), $this->curlOptions()); + $simplePie = new FreshRSS_SimplePieCustom($this->attributes(), $this->curlOptions()); if ($url !== '') { $filename = $simplePie->get_cache_filename($url); return CACHE_PATH . '/' . $filename . '.html'; @@ -1385,7 +1385,7 @@ class FreshRSS_Feed extends Minz_Model { Minz_Log::warning('Invalid JSON for WebSub: ' . $this->url); return false; } - $callbackUrl = checkUrl(Minz_Request::getBaseUrl() . '/api/pshb.php?k=' . $hubJson['key']); + $callbackUrl = FreshRSS_http_Util::checkUrl(Minz_Request::getBaseUrl() . '/api/pshb.php?k=' . $hubJson['key']); if ($callbackUrl == '') { Minz_Log::warning('Invalid callback for WebSub: ' . $this->url); return false; diff --git a/app/Models/FeedDAO.php b/app/Models/FeedDAO.php index 346aa1924..2e0d5781a 100644 --- a/app/Models/FeedDAO.php +++ b/app/Models/FeedDAO.php @@ -71,7 +71,7 @@ SQL; $valuesTmp['category'], mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'), $valuesTmp['website'], - sanitizeHTML($valuesTmp['description'], ''), + FreshRSS_SimplePieCustom::sanitizeHTML($valuesTmp['description'], ''), $valuesTmp['lastUpdate'], isset($valuesTmp['priority']) ? (int)$valuesTmp['priority'] : FreshRSS_Feed::PRIORITY_MAIN_STREAM, mb_strcut($valuesTmp['pathEntries'], 0, 4096, 'UTF-8'), diff --git a/app/Models/SimplePieCustom.php b/app/Models/SimplePieCustom.php new file mode 100644 index 000000000..372ce6d3d --- /dev/null +++ b/app/Models/SimplePieCustom.php @@ -0,0 +1,295 @@ + $attributes + * @param array $curl_options + * @throws FreshRSS_Context_Exception + */ + public function __construct(array $attributes = [], array $curl_options = []) { + parent::__construct(); + $limits = FreshRSS_Context::systemConf()->limits; + $this->get_registry()->register(\SimplePie\File::class, FreshRSS_SimplePieResponse::class); + $this->set_useragent(FRESHRSS_USERAGENT); + $this->set_cache_name_function('sha1'); + $this->set_cache_location(CACHE_PATH); + $this->set_cache_duration($limits['cache_duration'], $limits['cache_duration_min'], $limits['cache_duration_max']); + $this->enable_order_by_date(false); + + $feed_timeout = empty($attributes['timeout']) || !is_numeric($attributes['timeout']) ? 0 : (int)$attributes['timeout']; + $this->set_timeout($feed_timeout > 0 ? $feed_timeout : $limits['timeout']); + + $curl_options = array_replace(FreshRSS_Context::systemConf()->curl_options, $curl_options); + if (isset($attributes['ssl_verify'])) { + $curl_options[CURLOPT_SSL_VERIFYHOST] = empty($attributes['ssl_verify']) ? 0 : 2; + $curl_options[CURLOPT_SSL_VERIFYPEER] = (bool)$attributes['ssl_verify']; + if (empty($attributes['ssl_verify'])) { + $curl_options[CURLOPT_SSL_CIPHER_LIST] = 'DEFAULT@SECLEVEL=1'; + } + } + $attributes['curl_params'] = FreshRSS_http_Util::sanitizeCurlParams(is_array($attributes['curl_params'] ?? null) ? $attributes['curl_params'] : []); + if (!empty($attributes['curl_params']) && is_array($attributes['curl_params'])) { + foreach ($attributes['curl_params'] as $co => $v) { + if (is_int($co)) { + $curl_options[$co] = $v; + } + } + } + if (!empty($curl_options[CURLOPT_PROXYTYPE]) && ($curl_options[CURLOPT_PROXYTYPE] < 0 || $curl_options[CURLOPT_PROXYTYPE] === 3)) { + // 3 is legacy for NONE + unset($curl_options[CURLOPT_PROXYTYPE]); + if (isset($curl_options[CURLOPT_PROXY])) { + unset($curl_options[CURLOPT_PROXY]); + } + } + $this->set_curl_options($curl_options); + + $this->strip_comments(true); + $this->rename_attributes(['id', 'class']); + $this->allow_aria_attr(true); + $this->allow_data_attr(true); + $this->allowed_html_attributes([ + // HTML + 'dir', + 'draggable', + 'hidden', + 'lang', + 'role', + 'title', + // MathML + 'displaystyle', + 'mathsize', + 'scriptlevel', + ]); + $this->allowed_html_elements_with_attributes([ + // HTML + 'a' => ['href', 'hreflang', 'type'], + 'abbr' => [], + 'acronym' => [], + 'address' => [], + // 'area' => [], // TODO: support after rewriting ids with a format like #ugc- (maybe) + 'article' => [], + 'aside' => [], + 'audio' => ['controlslist', 'loop', 'muted', 'src'], + 'b' => [], + 'bdi' => [], + 'bdo' => [], + 'big' => [], + 'blink' => [], + 'blockquote' => ['cite'], + 'br' => ['clear'], + 'button' => ['disabled'], + 'canvas' => ['width', 'height'], + 'caption' => ['align'], + 'center' => [], + 'cite' => [], + 'code' => [], + 'col' => ['span', 'align', 'valign', 'width'], + 'colgroup' => ['span', 'align', 'valign', 'width'], + 'data' => ['value'], + 'datalist' => [], + 'dd' => [], + 'del' => ['cite', 'datetime'], + 'details' => ['open'], + 'dfn' => [], + 'dialog' => [], + 'dir' => [], + 'div' => ['align'], + 'dl' => [], + 'dt' => [], + 'em' => [], + 'fieldset' => ['disabled'], + 'figcaption' => [], + 'figure' => [], + 'footer' => [], + 'h1' => [], + 'h2' => [], + 'h3' => [], + 'h4' => [], + 'h5' => [], + 'h6' => [], + 'header' => [], + 'hgroup' => [], + 'hr' => ['align', 'noshade', 'size', 'width'], + 'i' => [], + 'iframe' => ['src', 'align', 'frameborder', 'longdesc', 'marginheight', 'marginwidth', 'scrolling'], + 'image' => ['src', 'alt', 'width', 'height', 'align', 'border', 'hspace', 'longdesc', 'vspace'], + 'img' => ['src', 'alt', 'width', 'height', 'align', 'border', 'hspace', 'longdesc', 'vspace'], + 'ins' => ['cite', 'datetime'], + 'kbd' => [], + 'label' => [], + 'legend' => [], + 'li' => ['value', 'type'], + 'main' => [], + // 'map' => [], // TODO: support after rewriting ids with a format like #ugc- (maybe) + 'mark' => [], + 'marquee' => ['behavior', 'direction', 'height', 'hspace', 'loop', 'scrollamount', 'scrolldelay', 'truespeed', 'vspace', 'width'], + 'menu' => [], + 'meter' => ['value', 'min', 'max', 'low', 'high', 'optimum'], + 'nav' => [], + 'nobr' => [], + // 'noembed' => [], // is not allowed, so we want to display the contents of + 'noframes' => [], + // 'noscript' => [], // From the perspective of the feed content, JS isn't allowed so we want to display the contents of <noscript> + 'ol' => ['reversed', 'start', 'type'], + 'optgroup' => ['disabled', 'label'], + 'option' => ['disabled', 'label', 'selected', 'value'], + 'output' => [], + 'p' => ['align'], + 'picture' => [], + // 'plaintext' => [], // Can't be closed. See: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/plaintext + 'pre' => ['width', 'wrap'], + 'progress' => ['max', 'value'], + 'q' => ['cite'], + 'rb' => [], + 'rp' => [], + 'rt' => [], + 'rtc' => [], + 'ruby' => [], + 's' => [], + 'samp' => [], + 'search' => [], + 'section' => [], + 'select' => ['disabled', 'multiple', 'size'], + 'small' => [], + 'source' => ['type', 'src', 'media', 'height', 'width'], + 'span' => [], + 'strike' => [], + 'strong' => [], + 'sub' => [], + 'summary' => [], + 'sup' => [], + 'table' => ['align', 'border', 'cellpadding', 'cellspacing', 'rules', 'summary', 'width'], + 'tbody' => ['align', 'char', 'charoff', 'valign'], + 'td' => ['colspan', 'headers', 'rowspan', 'abbr', 'align', 'height', 'scope', 'valign', 'width'], + 'textarea' => ['cols', 'disabled', 'maxlength', 'minlength', 'placeholder', 'readonly', 'rows', 'wrap'], + 'tfoot' => ['align', 'valign'], + 'th' => ['abbr', 'colspan', 'rowspan', 'scope', 'align', 'height', 'valign', 'width'], + 'thead' => ['align', 'valign'], + 'time' => ['datetime'], + 'tr' => ['align', 'valign'], + 'track' => ['default', 'kind', 'srclang', 'label', 'src'], + 'tt' => [], + 'u' => [], + 'ul' => ['type'], + 'var' => [], + 'video' => ['src', 'poster', 'controlslist', 'height', 'loop', 'muted', 'playsinline', 'width'], + 'wbr' => [], + 'xmp' => [], + // MathML + 'maction' => ['actiontype', 'selection'], + 'math' => ['display'], + 'menclose' => ['notation'], + 'merror' => [], + 'mfenced' => ['close', 'open', 'separators'], + 'mfrac' => ['denomalign', 'linethickness', 'numalign'], + 'mi' => ['mathvariant'], + 'mmultiscripts' => ['subscriptshift', 'superscriptshift'], + 'mn' => [], + 'mo' => ['accent', 'fence', 'form', 'largeop', 'lspace', 'maxsize', 'minsize', 'movablelimits', 'rspace', 'separator', 'stretchy', 'symmetric'], + 'mover' => ['accent'], + 'mpadded' => ['depth', 'height', 'lspace', 'voffset', 'width'], + 'mphantom' => [], + 'mprescripts' => [], + 'mroot' => [], + 'mrow' => [], + 'ms' => [], + 'mspace' => ['depth', 'height', 'width'], + 'msqrt' => [], + 'msub' => [], + 'msubsup' => ['subscriptshift', 'superscriptshift'], + 'msup' => ['superscriptshift'], + 'mtable' => ['align', 'columnalign', 'columnlines', 'columnspacing', 'frame', 'framespacing', 'rowalign', 'rowlines', 'rowspacing', 'width'], + 'mtd' => ['columnspan', 'rowspan', 'columnalign', 'rowalign'], + 'mtext' => [], + 'mtr' => ['columnalign', 'rowalign'], + 'munder' => ['accentunder'], + 'munderover' => ['accent', 'accentunder'], + // TODO: Support SVG after sanitizing and URL rewriting of xlink:href + ]); + $this->strip_attributes([ + 'data-auto-leave-validation', + 'data-leave-validation', + 'data-no-leave-validation', + 'data-original', + ]); + $this->add_attributes([ + 'audio' => ['controls' => 'controls', 'preload' => 'none'], + 'iframe' => [ + 'allow' => 'accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share', + 'sandbox' => 'allow-scripts allow-same-origin', + ], + 'video' => ['controls' => 'controls', 'preload' => 'none'], + ]); + $this->set_url_replacements([ + 'a' => 'href', + 'area' => 'href', + 'audio' => 'src', + 'blockquote' => 'cite', + 'del' => 'cite', + 'form' => 'action', + 'iframe' => 'src', + 'img' => [ + 'longdesc', + 'src', + ], + 'image' => [ + 'longdesc', + 'src', + ], + 'input' => 'src', + 'ins' => 'cite', + 'q' => 'cite', + 'source' => 'src', + 'track' => 'src', + 'video' => [ + 'poster', + 'src', + ], + ]); + $https_domains = []; + $force = @file(FRESHRSS_PATH . '/force-https.default.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if (is_array($force)) { + $https_domains = array_merge($https_domains, $force); + } + $force = @file(DATA_PATH . '/force-https.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if (is_array($force)) { + $https_domains = array_merge($https_domains, $force); + } + + // Remove whitespace and comments starting with # / ; + $https_domains = preg_replace('%\\s+|[\/#;].*$%', '', $https_domains) ?? $https_domains; + $https_domains = array_filter($https_domains, fn(string $v) => $v !== ''); + + $this->set_https_domains($https_domains); + } + + public static function sanitizeHTML(string $data, string $base = '', ?int $maxLength = null): string { + if ($data === '' || ($maxLength !== null && $maxLength <= 0)) { + return ''; + } + if ($maxLength !== null) { + $data = mb_strcut($data, 0, $maxLength, 'UTF-8'); + } + /** @var FreshRSS_SimplePieCustom|null $simplePie */ + static $simplePie = null; + if ($simplePie === null) { + $simplePie = new static(); + $simplePie->enable_cache(false); + $simplePie->init(); + } + $sanitized = $simplePie->sanitize->sanitize($data, \SimplePie\SimplePie::CONSTRUCT_HTML, $base); + if (!is_string($sanitized)) { + return ''; + } + $result = html_only_entity_decode($sanitized); + if ($maxLength !== null && strlen($result) > $maxLength) { + //Sanitizing has made the result too long so try again shorter + $data = mb_strcut($result, 0, (2 * $maxLength) - strlen($result) - 2, 'UTF-8'); + return self::sanitizeHTML($data, $base, $maxLength); + } + return $result; + } +} diff --git a/app/Models/SimplePieResponse.php b/app/Models/SimplePieResponse.php index 27dc41b74..42625ccf3 100644 --- a/app/Models/SimplePieResponse.php +++ b/app/Models/SimplePieResponse.php @@ -5,7 +5,9 @@ final class FreshRSS_SimplePieResponse extends \SimplePie\File { #[\Override] protected function on_http_response($response, array $curl_options = []): void { - syslog(LOG_INFO, 'FreshRSS SimplePie GET ' . $this->get_status_code() . ' ' . \SimplePie\Misc::url_remove_credentials($this->get_final_requested_uri())); + if (FreshRSS_Context::systemConf()->simplepie_syslog_enabled) { + syslog(LOG_INFO, 'FreshRSS SimplePie GET ' . $this->get_status_code() . ' ' . \SimplePie\Misc::url_remove_credentials($this->get_final_requested_uri())); + } if (in_array($this->get_status_code(), [429, 503], true)) { $parser = new \SimplePie\HTTP\Parser(is_string($response) ? $response : ''); diff --git a/app/Models/UserConfiguration.php b/app/Models/UserConfiguration.php index eaa08d92d..d98d85fe3 100644 --- a/app/Models/UserConfiguration.php +++ b/app/Models/UserConfiguration.php @@ -108,4 +108,32 @@ final class FreshRSS_UserConfiguration extends Minz_Configuration { } return $default_user_conf; } + + /** + * Register and return the configuration for a given user. + * + * Note this function has been created to generate temporary configuration + * objects. If you need a long-time configuration, please don't use this function. + * + * @param string $username the name of the user of which we want the configuration. + * @return FreshRSS_UserConfiguration|null object, or null if the configuration cannot be loaded. + * @throws Minz_ConfigurationNamespaceException + */ + public static function getForUser(string $username): ?FreshRSS_UserConfiguration { + if (!FreshRSS_user_Controller::checkUsername($username)) { + return null; + } + $namespace = 'user_' . $username; + try { + FreshRSS_UserConfiguration::register($namespace, + USERS_PATH . '/' . $username . '/config.php', + FRESHRSS_PATH . '/config-user.default.php'); + } catch (Minz_FileNotExistException $e) { + Minz_Log::warning($e->getMessage(), ADMIN_LOG); + return null; + } + + $user_conf = FreshRSS_UserConfiguration::get($namespace); + return $user_conf; + } } diff --git a/app/Models/UserQuery.php b/app/Models/UserQuery.php index 26264fa24..6050436f4 100644 --- a/app/Models/UserQuery.php +++ b/app/Models/UserQuery.php @@ -350,4 +350,22 @@ class FreshRSS_UserQuery { public function setImageUrl(string $imageUrl): void { $this->imageUrl = $imageUrl; } + + /** + * Remove queries where $get is appearing. + * @param string $get the get attribute which should be removed. + * @param array<int,array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string, + * shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string}> $queries an array of queries. + * @return array<int,array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string, + * shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string}> without queries where $get is appearing. + */ + public static function remove_query_by_get(string $get, array $queries): array { + $final_queries = []; + foreach ($queries as $query) { + if (empty($query['get']) || $query['get'] !== $get) { + $final_queries[] = $query; + } + } + return $final_queries; + } } diff --git a/app/Services/ImportService.php b/app/Services/ImportService.php index 1ab2b8d69..26d07faae 100644 --- a/app/Services/ImportService.php +++ b/app/Services/ImportService.php @@ -363,7 +363,7 @@ class FreshRSS_Import_Service { $category = new FreshRSS_Category($name); if (isset($category_element['frss:opmlUrl'])) { - $opml_url = checkUrl($category_element['frss:opmlUrl']); + $opml_url = FreshRSS_http_Util::checkUrl($category_element['frss:opmlUrl']); if ($opml_url != '') { $category->_kind(FreshRSS_Category::KIND_DYNAMIC_OPML); $category->_attribute('opml_url', $opml_url); diff --git a/app/Utils/feverUtil.php b/app/Utils/feverUtil.php index 8b06568dd..ff6c4f14f 100644 --- a/app/Utils/feverUtil.php +++ b/app/Utils/feverUtil.php @@ -62,7 +62,7 @@ class FreshRSS_fever_Util { * @throws FreshRSS_Context_Exception */ public static function deleteKey(string $username): bool { - $userConfig = get_user_configuration($username); + $userConfig = FreshRSS_UserConfiguration::getForUser($username); if ($userConfig === null) { return false; } diff --git a/app/Utils/httpUtil.php b/app/Utils/httpUtil.php index 6e176a0d3..ffba3712a 100644 --- a/app/Utils/httpUtil.php +++ b/app/Utils/httpUtil.php @@ -83,4 +83,421 @@ final class FreshRSS_http_Util { } return $retryAfter; } + + /** + * @param array<mixed> $curl_params + * @return array<mixed> + */ + public static function sanitizeCurlParams(array $curl_params): array { + $safe_params = [ + CURLOPT_COOKIE, + CURLOPT_COOKIEFILE, + CURLOPT_FOLLOWLOCATION, + CURLOPT_HTTPHEADER, + CURLOPT_MAXREDIRS, + CURLOPT_POST, + CURLOPT_POSTFIELDS, + CURLOPT_PROXY, + CURLOPT_PROXYTYPE, + CURLOPT_USERAGENT, + ]; + foreach ($curl_params as $k => $_) { + if (!in_array($k, $safe_params, true)) { + unset($curl_params[$k]); + continue; + } + // Allow only an empty value just to enable the libcurl cookie engine + if ($k === CURLOPT_COOKIEFILE) { + $curl_params[$k] = ''; + } + } + return $curl_params; + } + + private static function idn_to_puny(string $url): string { + if (function_exists('idn_to_ascii')) { + $idn = parse_url($url, PHP_URL_HOST); + if (is_string($idn) && $idn != '') { + $puny = idn_to_ascii($idn); + $pos = strpos($url, $idn); + if ($puny != false && $pos !== false) { + $url = substr_replace($url, $puny, $pos, strlen($idn)); + } + } + } + return $url; + } + + public static function checkUrl(string $url, bool $fixScheme = true): string|false { + $url = trim($url); + if ($url == '') { + return ''; + } + if ($fixScheme && preg_match('#^https?://#i', $url) !== 1) { + $url = 'https://' . ltrim($url, '/'); + } + + $url = self::idn_to_puny($url); // https://bugs.php.net/bug.php?id=53474 + $urlRelaxed = str_replace('_', 'z', $url); //PHP discussion #64948 Underscore + + if (is_string(filter_var($urlRelaxed, FILTER_VALIDATE_URL))) { + return $url; + } else { + return false; + } + } + + /** + * Remove the charset meta information of an HTML document, e.g.: + * `<meta charset="..." />` + * `<meta http-equiv="Content-Type" content="text/html; charset=...">` + */ + private static function stripHtmlMetaCharset(string $html): string { + return preg_replace('/<meta\s[^>]*charset\s*=\s*[^>]+>/i', '', $html, 1) ?? ''; + } + + /** + * Set an XML preamble to enforce the HTML content type charset received by HTTP. + * @param string $html the raw downloaded HTML content + * @param string $contentType an HTTP Content-Type such as 'text/html; charset=utf-8' + * @return string an HTML string with XML encoding information for DOMDocument::loadHTML() + */ + private static function enforceHttpEncoding(string $html, string $contentType = ''): string { + $httpCharset = preg_match('/\bcharset=([0-9a-z_-]{2,12})$/i', $contentType, $matches) === 1 ? $matches[1] : ''; + if ($httpCharset == '') { + // No charset defined by HTTP + if (preg_match('/<meta\s[^>]*charset\s*=[\s\'"]*UTF-?8\b/i', substr($html, 0, 2048))) { + // Detect UTF-8 even if declared too deep in HTML for DOMDocument + $httpCharset = 'UTF-8'; + } else { + // Do nothing + return $html; + } + } + $httpCharsetNormalized = \SimplePie\Misc::encoding($httpCharset); + if (in_array($httpCharsetNormalized, ['windows-1252', 'US-ASCII'], true)) { + // Default charset for HTTP, do nothing + return $html; + } + if (substr($html, 0, 3) === "\xEF\xBB\xBF" || // UTF-8 BOM + substr($html, 0, 2) === "\xFF\xFE" || // UTF-16 Little Endian BOM + substr($html, 0, 2) === "\xFE\xFF" || // UTF-16 Big Endian BOM + substr($html, 0, 4) === "\xFF\xFE\x00\x00" || // UTF-32 Little Endian BOM + substr($html, 0, 4) === "\x00\x00\xFE\xFF") { // UTF-32 Big Endian BOM + // Existing byte order mark, do nothing + return $html; + } + if (preg_match('/^<[?]xml[^>]+encoding\b/', substr($html, 0, 64))) { + // Existing XML declaration, do nothing + return $html; + } + if ($httpCharsetNormalized !== 'UTF-8') { + // Try to change encoding to UTF-8 using mbstring or iconv or intl + $utf8 = \SimplePie\Misc::change_encoding($html, $httpCharsetNormalized, 'UTF-8'); + if (is_string($utf8)) { + $html = self::stripHtmlMetaCharset($utf8); + $httpCharsetNormalized = 'UTF-8'; + } + } + if ($httpCharsetNormalized === 'UTF-8') { + // Save encoding information as XML declaration + return '<' . '?xml version="1.0" encoding="' . $httpCharsetNormalized . '" ?' . ">\n" . $html; + } + // Give up + return $html; + } + + /** + * Set an HTML base URL to the HTML content if there is none. + * @param string $html the raw downloaded HTML content + * @param string $href the HTML base URL + * @return string an HTML string + */ + private static function enforceHtmlBase(string $html, string $href): string { + $doc = new DOMDocument(); + $doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); + if ($doc->documentElement === null) { + return ''; + } + $xpath = new DOMXPath($doc); + $bases = $xpath->evaluate('//base'); + if (!($bases instanceof DOMNodeList) || $bases->length === 0) { + $base = $doc->createElement('base'); + if ($base === false) { + return $html; + } + $base->setAttribute('href', $href); + $head = null; + $heads = $xpath->evaluate('//head'); + if ($heads instanceof DOMNodeList && $heads->length > 0) { + $head = $heads->item(0); + } + if ($head instanceof DOMElement) { + $head->insertBefore($base, $head->firstChild); + } else { + $doc->documentElement->insertBefore($base, $doc->documentElement->firstChild); + } + } + return $doc->saveHTML() ?: $html; + } + + /** + * @param non-empty-string $url + * @param string $type {html,ico,json,opml,xml} + * @param array<string,mixed> $attributes + * @param array<int,mixed> $curl_options + * @return array{body:string,effective_url:string,redirect_count:int,fail:bool} + */ + public static function httpGet(string $url, string $cachePath, string $type = 'html', array $attributes = [], array $curl_options = []): array { + $limits = FreshRSS_Context::systemConf()->limits; + $feed_timeout = empty($attributes['timeout']) || !is_numeric($attributes['timeout']) ? 0 : intval($attributes['timeout']); + + $cacheMtime = @filemtime($cachePath); + if ($cacheMtime !== false && $cacheMtime > time() - intval($limits['cache_duration'])) { + $body = @file_get_contents($cachePath); + if ($body != false) { + syslog(LOG_DEBUG, 'FreshRSS uses cache for ' . \SimplePie\Misc::url_remove_credentials($url)); + return ['body' => $body, 'effective_url' => $url, 'redirect_count' => 0, 'fail' => false]; + } + } + + if (rand(0, 30) === 1) { // Remove old cache once in a while + cleanCache(CLEANCACHE_HOURS); + } + + $options = []; + $accept = ''; + $proxy = is_string(FreshRSS_Context::systemConf()->curl_options[CURLOPT_PROXY] ?? null) ? FreshRSS_Context::systemConf()->curl_options[CURLOPT_PROXY] : ''; + if (is_array($attributes['curl_params'] ?? null)) { + $options = self::sanitizeCurlParams($attributes['curl_params']); + $proxy = is_string($options[CURLOPT_PROXY]) ? $options[CURLOPT_PROXY] : ''; + if (is_array($options[CURLOPT_HTTPHEADER] ?? null)) { + // Remove headers problematic for security + $options[CURLOPT_HTTPHEADER] = array_filter($options[CURLOPT_HTTPHEADER], + fn($header) => is_string($header) && !preg_match('/^(Remote-User|X-WebAuth-User)\\s*:/i', $header)); + // Add Accept header if it is not set + if (preg_grep('/^Accept\\s*:/i', $options[CURLOPT_HTTPHEADER]) === false) { + $options[CURLOPT_HTTPHEADER][] = 'Accept: ' . $accept; + } + } + } + + if (($retryAfter = FreshRSS_http_Util::getRetryAfter($url, $proxy)) > 0) { + Minz_Log::warning('For that domain, will first retry after ' . date('c', $retryAfter) . '. ' . \SimplePie\Misc::url_remove_credentials($url)); + return ['body' => '', 'effective_url' => $url, 'redirect_count' => 0, 'fail' => true]; + } + + if (FreshRSS_Context::systemConf()->simplepie_syslog_enabled) { + syslog(LOG_INFO, 'FreshRSS GET ' . $type . ' ' . \SimplePie\Misc::url_remove_credentials($url)); + } + + switch ($type) { + case 'json': + $accept = 'application/json,application/feed+json,application/javascript;q=0.9,text/javascript;q=0.8,*/*;q=0.7'; + break; + case 'opml': + $accept = 'text/x-opml,text/xml;q=0.9,application/xml;q=0.9,*/*;q=0.8'; + break; + case 'xml': + $accept = 'application/xml,application/xhtml+xml,text/xml;q=0.9,*/*;q=0.8'; + break; + case 'ico': + $accept = 'image/x-icon,image/vnd.microsoft.icon,image/ico,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.1'; + break; + case 'html': + default: + $accept = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'; + break; + } + + // TODO: Implement HTTP 1.1 conditional GET If-Modified-Since + $ch = curl_init(); + if ($ch === false) { + return ['body' => '', 'effective_url' => '', 'redirect_count' => 0, 'fail' => true]; + } + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_HTTPHEADER => ['Accept: ' . $accept], + CURLOPT_USERAGENT => FRESHRSS_USERAGENT, + CURLOPT_CONNECTTIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'], + CURLOPT_TIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'], + CURLOPT_MAXREDIRS => 4, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_ENCODING => '', //Enable all encodings + //CURLOPT_VERBOSE => 1, // To debug sent HTTP headers + ]); + + curl_setopt_array($ch, $options); + curl_setopt_array($ch, FreshRSS_Context::systemConf()->curl_options); + + $responseHeaders = ''; + curl_setopt($ch, CURLOPT_HEADERFUNCTION, function (\CurlHandle $ch, string $header) use (&$responseHeaders) { + if (trim($header) !== '') { // Skip e.g. separation with trailer headers + $responseHeaders .= $header; + } + return strlen($header); + }); + + if (isset($attributes['ssl_verify'])) { + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, empty($attributes['ssl_verify']) ? 0 : 2); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, (bool)$attributes['ssl_verify']); + if (empty($attributes['ssl_verify'])) { + curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, 'DEFAULT@SECLEVEL=1'); + } + } + + curl_setopt_array($ch, $curl_options); + + $body = curl_exec($ch); + $c_status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $c_content_type = '' . curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + $c_effective_url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); + $c_redirect_count = curl_getinfo($ch, CURLINFO_REDIRECT_COUNT); + $c_error = curl_error($ch); + + $headers = []; + if ($body !== false) { + assert($c_redirect_count >= 0); + $responseHeaders = \SimplePie\HTTP\Parser::prepareHeaders($responseHeaders, $c_redirect_count + 1); + $parser = new \SimplePie\HTTP\Parser($responseHeaders); + if ($parser->parse()) { + $headers = $parser->headers; + } + } + + $fail = $c_status != 200 || $c_error != '' || $body === false; + if ($fail) { + $body = ''; + Minz_Log::warning('Error fetching content: HTTP code ' . $c_status . ': ' . $c_error . ' ' . $url); + if (in_array($c_status, [429, 503], true)) { + $retryAfter = FreshRSS_http_Util::setRetryAfter($url, $proxy, $headers['retry-after'] ?? ''); + if ($c_status === 429) { + $errorMessage = 'HTTP 429 Too Many Requests! [' . \SimplePie\Misc::url_remove_credentials($url) . ']'; + } elseif ($c_status === 503) { + $errorMessage = 'HTTP 503 Service Unavailable! [' . \SimplePie\Misc::url_remove_credentials($url) . ']'; + } + if ($retryAfter > 0) { + $errorMessage .= ' We may retry after ' . date('c', $retryAfter); + } + } + // TODO: Implement HTTP 410 Gone + } elseif (!is_string($body) || strlen($body) === 0) { + $body = ''; + } else { + if (in_array($type, ['html', 'json', 'opml', 'xml'], true)) { + $body = trim($body, " \n\r\t\v"); // Do not trim \x00 to avoid breaking a BOM + } + if (in_array($type, ['html', 'xml', 'opml'], true)) { + $body = self::enforceHttpEncoding($body, $c_content_type); + } + if (in_array($type, ['html'], true)) { + $body = self::enforceHtmlBase($body, $c_effective_url); + } + } + + if (file_put_contents($cachePath, $body) === false) { + Minz_Log::warning("Error saving cache $cachePath for $url"); + } + + return ['body' => $body, 'effective_url' => $c_effective_url, 'redirect_count' => $c_redirect_count, 'fail' => $fail]; + } + + /** + * Converts an IP (v4 or v6) to a binary representation using inet_pton + * + * @param string $ip the IP to convert + * @return string a binary representation of the specified IP + */ + private static function ipToBits(string $ip): string { + $binaryip = ''; + foreach (str_split(inet_pton($ip) ?: '') as $char) { + $binaryip .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT); + } + return $binaryip; + } + + /** + * Check if an ip belongs to the provided range (in CIDR format) + * + * @param string $ip the IP that we want to verify (ex: 192.168.16.1) + * @param string $range the range to check against (ex: 192.168.16.0/24) + * @return bool true if the IP is in the range, otherwise false + */ + private static function checkCIDR(string $ip, string $range): bool { + $binary_ip = self::ipToBits($ip); + $split = explode('/', $range); + + $subnet = $split[0] ?? ''; + if ($subnet == '') { + return false; + } + $binary_subnet = self::ipToBits($subnet); + + $mask_bits = $split[1] ?? ''; + $mask_bits = (int)$mask_bits; + if ($mask_bits === 0) { + $mask_bits = null; + } + + $ip_net_bits = substr($binary_ip, 0, $mask_bits); + $subnet_bits = substr($binary_subnet, 0, $mask_bits); + return $ip_net_bits === $subnet_bits; + } + + /** + * Check if the client (e.g. last proxy) is allowed to send unsafe headers. + * This uses the `TRUSTED_PROXY` environment variable or the `trusted_sources` configuration option to get an array of the authorized ranges, + * The connection IP is obtained from the `CONN_REMOTE_ADDR` + * (if available, to be robust even when using Apache mod_remoteip) or `REMOTE_ADDR` environment variables. + * @return bool true if the sender’s IP is in one of the ranges defined in the configuration, else false + */ + public static function checkTrustedIP(): bool { + if (!FreshRSS_Context::hasSystemConf()) { + return false; + } + $remoteIp = Minz_Request::connectionRemoteAddress(); + if ($remoteIp === '') { + return false; + } + $trusted = getenv('TRUSTED_PROXY'); + if ($trusted != 0 && is_string($trusted)) { + $trusted = preg_split('/\s+/', $trusted, -1, PREG_SPLIT_NO_EMPTY); + } + if (!is_array($trusted) || empty($trusted)) { + $trusted = FreshRSS_Context::systemConf()->trusted_sources; + } + foreach ($trusted as $cidr) { + if (self::checkCIDR($remoteIp, $cidr)) { + return true; + } + } + return false; + } + + public static function httpAuthUser(bool $onlyTrusted = true): string { + $auths = array_unique( + array_intersect_key($_SERVER, ['REMOTE_USER' => '', 'REDIRECT_REMOTE_USER' => '', 'HTTP_REMOTE_USER' => '', 'HTTP_X_WEBAUTH_USER' => '']) + ); + if (count($auths) > 1) { + Minz_Log::warning('Multiple HTTP authentication headers!'); + return ''; + } + + if (!empty($_SERVER['REMOTE_USER']) && is_string($_SERVER['REMOTE_USER'])) { + return $_SERVER['REMOTE_USER']; + } + if (!empty($_SERVER['REDIRECT_REMOTE_USER']) && is_string($_SERVER['REDIRECT_REMOTE_USER'])) { + return $_SERVER['REDIRECT_REMOTE_USER']; + } + if (!$onlyTrusted || self::checkTrustedIP()) { + if (!empty($_SERVER['HTTP_REMOTE_USER']) && is_string($_SERVER['HTTP_REMOTE_USER'])) { + return $_SERVER['HTTP_REMOTE_USER']; + } + if (!empty($_SERVER['HTTP_X_WEBAUTH_USER']) && is_string($_SERVER['HTTP_X_WEBAUTH_USER'])) { + return $_SERVER['HTTP_X_WEBAUTH_USER']; + } + } + return ''; + } } diff --git a/app/Utils/passwordUtil.php b/app/Utils/passwordUtil.php index 76c5455c5..d522abac0 100644 --- a/app/Utils/passwordUtil.php +++ b/app/Utils/passwordUtil.php @@ -28,4 +28,9 @@ class FreshRSS_password_Util { public static function check(string $password): bool { return strlen($password) >= 7; } + + public static function cryptAvailable(): bool { + $hash = '$2y$04$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG'; + return $hash === @crypt('password', $hash); + } } diff --git a/app/actualize_script.php b/app/actualize_script.php index e64d0b707..3935aaac4 100755 --- a/app/actualize_script.php +++ b/app/actualize_script.php @@ -66,7 +66,7 @@ echo 'Results: ', "\n"; //Buffered // Create the list of users to actualize. // Users are processed in a random order but always start with default user -$users = listUsers(); +$users = FreshRSS_user_Controller::listUsers(); shuffle($users); if (FreshRSS_Context::systemConf()->default_user !== '') { array_unshift($users, FreshRSS_Context::systemConf()->default_user); diff --git a/app/install.php b/app/install.php index f7b15d468..ae898d814 100644 --- a/app/install.php +++ b/app/install.php @@ -224,12 +224,12 @@ function saveStep3(): bool { } if (FreshRSS_Context::systemConf()->auth_type === 'http_auth' && - connectionRemoteAddress() !== '' && + Minz_Request::connectionRemoteAddress() !== '' && empty($_SERVER['REMOTE_USER']) && empty($_SERVER['REDIRECT_REMOTE_USER']) && // No safe authentication HTTP headers (!empty($_SERVER['HTTP_REMOTE_USER']) || !empty($_SERVER['HTTP_X_WEBAUTH_USER'])) // but has unsafe authentication HTTP headers ) { // Trust by default the remote IP address (e.g. last proxy) used during install to provide remote user name via unsafe HTTP header - FreshRSS_Context::systemConf()->trusted_sources[] = connectionRemoteAddress(); + FreshRSS_Context::systemConf()->trusted_sources[] = Minz_Request::connectionRemoteAddress(); FreshRSS_Context::systemConf()->trusted_sources = array_unique(FreshRSS_Context::systemConf()->trusted_sources); } @@ -661,7 +661,7 @@ function printStep3(): void { <div class="group-controls"> <input type="text" id="default_user" name="default_user" autocomplete="username" required="required" size="16" pattern="<?= FreshRSS_user_Controller::USERNAME_PATTERN ?>" value="<?= $default_user ?>" - placeholder="<?= httpAuthUser(false) == '' ? 'alice' : httpAuthUser(false) ?>" tabindex="1" /> + placeholder="<?= FreshRSS_http_Util::httpAuthUser(false) == '' ? 'alice' : FreshRSS_http_Util::httpAuthUser(false) ?>" tabindex="1" /> <p class="help"><?= _i('help') ?> <?= _t('install.default_user.max_char') ?></p> </div> </div> @@ -670,12 +670,12 @@ function printStep3(): void { <label class="group-name" for="auth_type"><?= _t('admin.auth.type') ?></label> <div class="group-controls"> <select id="auth_type" name="auth_type" required="required" tabindex="2"> - <option value="form"<?= $auth_type === 'form' || (no_auth($auth_type) && cryptAvailable()) ? ' selected="selected"' : '', - cryptAvailable() ? '' : ' disabled="disabled"' ?>><?= _t('admin.auth.form') ?></option> + <option value="form"<?= $auth_type === 'form' || (no_auth($auth_type) && FreshRSS_password_Util::cryptAvailable()) ? ' selected="selected"' : '', + FreshRSS_password_Util::cryptAvailable() ? '' : ' disabled="disabled"' ?>><?= _t('admin.auth.form') ?></option> <option value="http_auth"<?= $auth_type === 'http_auth' ? ' selected="selected"' : '', - httpAuthUser(false) == '' ? ' disabled="disabled"' : '' ?>> - <?= _t('admin.auth.http') ?> (REMOTE_USER = '<?= httpAuthUser(false) ?>')</option> - <option value="none"<?= $auth_type === 'none' || (no_auth($auth_type) && !cryptAvailable()) ? ' selected="selected"' : '' + FreshRSS_http_Util::httpAuthUser(false) == '' ? ' disabled="disabled"' : '' ?>> + <?= _t('admin.auth.http') ?> (REMOTE_USER = '<?= FreshRSS_http_Util::httpAuthUser(false) ?>')</option> + <option value="none"<?= $auth_type === 'none' || (no_auth($auth_type) && !FreshRSS_password_Util::cryptAvailable()) ? ' selected="selected"' : '' ?>><?= _t('admin.auth.none') ?></option> </select> </div> diff --git a/app/views/auth/formLogin.phtml b/app/views/auth/formLogin.phtml index 24427193d..658adc912 100644 --- a/app/views/auth/formLogin.phtml +++ b/app/views/auth/formLogin.phtml @@ -5,7 +5,7 @@ <main class="prompt"> <h1><?= _t('gen.auth.login') ?></h1> - <?php if (!max_registrations_reached()) { ?> + <?php if (!FreshRSS_user_Controller::max_registrations_reached()) { ?> <div class="link-registration"> <a href="<?= _url('auth', 'register') ?>"><?= _t('gen.auth.registration.ask') ?></a> </div> diff --git a/app/views/auth/index.phtml b/app/views/auth/index.phtml index a0aa28256..f11d29ae4 100644 --- a/app/views/auth/index.phtml +++ b/app/views/auth/index.phtml @@ -16,9 +16,9 @@ <option selected="selected"></option> <?php } ?> <option value="form"<?= FreshRSS_Context::systemConf()->auth_type === 'form' ? ' selected="selected"' : '', - cryptAvailable() ? '' : ' disabled="disabled"' ?>><?= _t('admin.auth.form') ?></option> + FreshRSS_password_Util::cryptAvailable() ? '' : ' disabled="disabled"' ?>><?= _t('admin.auth.form') ?></option> <option value="http_auth"<?= FreshRSS_Context::systemConf()->auth_type === 'http_auth' ? ' selected="selected"' : '' ?>> - <?= _t('admin.auth.http') ?> (REMOTE_USER = '<?= httpAuthUser() ?>')</option> + <?= _t('admin.auth.http') ?> (REMOTE_USER = '<?= FreshRSS_http_Util::httpAuthUser() ?>')</option> <option value="none"<?= FreshRSS_Context::systemConf()->auth_type === 'none' ? ' selected="selected"' : '' ?>><?= _t('admin.auth.none') ?></option> </select> </div> diff --git a/app/views/configure/shortcut.phtml b/app/views/configure/shortcut.phtml index 0b9b6dd1c..3f7f84759 100644 --- a/app/views/configure/shortcut.phtml +++ b/app/views/configure/shortcut.phtml @@ -16,7 +16,7 @@ $s = array_map(static fn(string $string) => htmlspecialchars($string, ENT_COMPAT, 'UTF-8'), FreshRSS_Context::userConf()->shortcuts); ?> - <?php if ([] !== $nonStandard = getNonStandardShortcuts($s)): ?> + <?php if ([] !== $nonStandard = FreshRSS_configure_Controller::getNonStandardShortcuts($s)): ?> <p class="alert alert-error"> <?= _t('conf.shortcut.non_standard', implode('</kbd>, <kbd>', $nonStandard)) ?> </p> diff --git a/app/views/configure/system.phtml b/app/views/configure/system.phtml index 6269b5c95..2bee35227 100644 --- a/app/views/configure/system.phtml +++ b/app/views/configure/system.phtml @@ -80,7 +80,7 @@ <div class="form-group" id="max-registrations-block"> <label class="group-name" for="max-registrations-input"><?= _t('admin.system.registration.number') ?></label> <div class="group-controls"> - <?php $number = count(listUsers()); ?> + <?php $number = count(FreshRSS_user_Controller::listUsers()); ?> <input type="number" id="max-registrations-input" name="" value="<?= FreshRSS_Context::systemConf()->limits['max_registrations'] > 1 ? FreshRSS_Context::systemConf()->limits['max_registrations'] : $number + 1; ?>" min="2" data-number="<?= $number ?>"/> <span id="max-registrations-status-disabled">(= <?= _t('admin.system.registration.status.disabled') ?>)</span><span id="max-registrations-status-enabled">(= <?= _t('admin.system.registration.status.enabled') ?>)</span> </div> diff --git a/app/views/user/details.phtml b/app/views/user/details.phtml index 648bbfd27..8bad08a81 100644 --- a/app/views/user/details.phtml +++ b/app/views/user/details.phtml @@ -60,7 +60,7 @@ <div class="group-controls"> <div class="stick"> <input type="password" id="newPasswordPlain" name="newPasswordPlain" autocomplete="new-password" - pattern=".{7,}" <?= cryptAvailable() && Minz_User::name() !== $this->username ? '' : 'disabled="disabled" ' ?>/> + pattern=".{7,}" <?= FreshRSS_password_Util::cryptAvailable() && Minz_User::name() !== $this->username ? '' : 'disabled="disabled" ' ?>/> <button type="button" class="btn toggle-password"><?= _i('key') ?></button> </div> <p class="help"><?= _i('help'); ?> <?= _t('admin.user.password_format') ?></p> diff --git a/app/views/user/profile.phtml b/app/views/user/profile.phtml index dbbe07b5f..9f89306bf 100644 --- a/app/views/user/profile.phtml +++ b/app/views/user/profile.phtml @@ -121,7 +121,7 @@ <?php } else {?> placeholder="<?= _t('conf.profile.api.api_not_set') ?>" <?php } ?> - pattern=".{7,}" <?= cryptAvailable() ? '' : 'disabled="disabled" ' ?>/> + pattern=".{7,}" <?= FreshRSS_password_Util::cryptAvailable() ? '' : 'disabled="disabled" ' ?>/> <button type="button" class="btn toggle-password"><?= _i('key') ?></button> </div> <p class="help"><?= _i('help') ?> <?= _t('conf.profile.api.check_link', Minz_Url::display('/api/', 'html', true)) ?></p> diff --git a/cli/create-user.php b/cli/create-user.php index 4de0ced1d..073a40b15 100755 --- a/cli/create-user.php +++ b/cli/create-user.php @@ -58,7 +58,7 @@ if (!empty($cliOptions->errors)) { $username = $cliOptions->user; -if (!empty(preg_grep("/^$username$/i", listUsers()))) { +if (!empty(preg_grep("/^$username$/i", FreshRSS_user_Controller::listUsers()))) { fail('FreshRSS warning: username already exists “' . $username . '”', EXIT_CODE_ALREADY_EXISTS); } diff --git a/cli/db-backup.php b/cli/db-backup.php index f391c6b41..959a7872d 100755 --- a/cli/db-backup.php +++ b/cli/db-backup.php @@ -19,7 +19,7 @@ if (!empty($cliOptions->errors)) { fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } -foreach (listUsers() as $username) { +foreach (FreshRSS_user_Controller::listUsers() as $username) { $username = cliInitUser($username); $filename = DATA_PATH . '/users/' . $username . '/backup.sqlite'; @unlink($filename); diff --git a/cli/db-restore.php b/cli/db-restore.php index 0de624519..dc4550b08 100755 --- a/cli/db-restore.php +++ b/cli/db-restore.php @@ -37,7 +37,7 @@ if (!$ok) { fail('FreshRSS database error: ' . (is_string($_SESSION['bd_error'] ?? null) ? $_SESSION['bd_error'] : 'Unknown error')); } -foreach (listUsers() as $username) { +foreach (FreshRSS_user_Controller::listUsers() as $username) { $username = cliInitUser($username); $filename = DATA_PATH . "/users/{$username}/backup.sqlite"; if (!file_exists($filename)) { diff --git a/cli/list-users.php b/cli/list-users.php index b4c185e03..46a3f2a7c 100755 --- a/cli/list-users.php +++ b/cli/list-users.php @@ -3,7 +3,7 @@ declare(strict_types=1); require __DIR__ . '/_cli.php'; -$users = listUsers(); +$users = FreshRSS_user_Controller::listUsers(); sort($users); if (FreshRSS_Context::systemConf()->default_user !== '' && in_array(FreshRSS_Context::systemConf()->default_user, $users, true)) { diff --git a/cli/user-info.php b/cli/user-info.php index dca3caefd..2771605ac 100755 --- a/cli/user-info.php +++ b/cli/user-info.php @@ -25,7 +25,7 @@ if (!empty($cliOptions->errors)) { fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage); } -$users = $cliOptions->user ?? listUsers(); +$users = $cliOptions->user ?? FreshRSS_user_Controller::listUsers(); sort($users); diff --git a/docs/en/developers/03_Backend/05_Extensions.md b/docs/en/developers/03_Backend/05_Extensions.md index c24ee0569..17e56b83f 100644 --- a/docs/en/developers/03_Backend/05_Extensions.md +++ b/docs/en/developers/03_Backend/05_Extensions.md @@ -195,8 +195,8 @@ Example response for a `query_icon_info` request: * `nav_menu` (`function() -> string`): will be executed if the navigation was built. * `nav_reading_modes` (`function($reading_modes) -> array | null`): **TODO** add documentation. * `post_update` (`function(none) -> none`): **TODO** add documentation. -* `simplepie_after_init` (`function(\SimplePie\SimplePie $simplePie, FreshRSS_Feed $feed, bool $result): void`): Triggered after fetching an RSS/Atom feed with SimplePie. Useful for instance to get the HTTP response headers (e.g. `$simplePie->data['headers']`). -* `simplepie_before_init` (`function(\SimplePie\SimplePie $simplePie, FreshRSS_Feed $feed): void`): Triggered before fetching an RSS/Atom feed with SimplePie. +* `simplepie_after_init` (`function(FreshRSS_SimplePieCustom $simplePie, FreshRSS_Feed $feed, bool $result): void`): Triggered after fetching an RSS/Atom feed with SimplePie. Useful for instance to get the HTTP response headers (e.g. `$simplePie->data['headers']`). +* `simplepie_before_init` (`function(FreshRSS_SimplePieCustom $simplePie, FreshRSS_Feed $feed): void`): Triggered before fetching an RSS/Atom feed with SimplePie. * `view_modes` (`function(array<FreshRSS_ViewMode> $viewModes): array|null`): Allow extensions to register additional view modes than *normal*, *reader*, *global*. > ℹ️ Note: the `simplepie_*` hooks are only fired for feeds using SimplePie via pull, i.e. normal RSS/Atom feeds. This excludes WebSub (push), and the various HTML or JSON Web scraping methods. diff --git a/docs/fr/developers/03_Backend/05_Extensions.md b/docs/fr/developers/03_Backend/05_Extensions.md index a71919def..946ab8dd4 100644 --- a/docs/fr/developers/03_Backend/05_Extensions.md +++ b/docs/fr/developers/03_Backend/05_Extensions.md @@ -272,8 +272,8 @@ The following events are available: * `nav_reading_modes` (`function($reading_modes) -> array | null`): **TODO** add documentation * `post_update` (`function(none) -> none`): **TODO** add documentation -* `simplepie_after_init` (`function(\SimplePie\SimplePie $simplePie, FreshRSS_Feed $feed, bool $result): void`): Triggered after fetching an RSS/Atom feed with SimplePie. Useful for instance to get the HTTP response headers (e.g. `$simplePie->data['headers']`). -* `simplepie_before_init` (`function(\SimplePie\SimplePie $simplePie, FreshRSS_Feed $feed): void`): Triggered before fetching an RSS/Atom feed with SimplePie. +* `simplepie_after_init` (`function(FreshRSS_SimplePieCustom $simplePie, FreshRSS_Feed $feed, bool $result): void`): Triggered after fetching an RSS/Atom feed with SimplePie. Useful for instance to get the HTTP response headers (e.g. `$simplePie->data['headers']`). +* `simplepie_before_init` (`function(FreshRSS_SimplePieCustom $simplePie, FreshRSS_Feed $feed): void`): Triggered before fetching an RSS/Atom feed with SimplePie. * `view_modes` (`function(array<FreshRSS_ViewMode> $viewModes): array|null`): permet aux extensions de déclarer d’autres modes de vue que *normale*, *lecture*, *globale*. > ℹ️ Note: the `simplepie_*` hooks are only fired for feeds using SimplePie via pull, i.e. normal RSS/Atom feeds. This excludes WebSub (push), and the various HTML or JSON Web scraping methods. diff --git a/lib/Minz/HookType.php b/lib/Minz/HookType.php index 0d96b8832..07db599c3 100644 --- a/lib/Minz/HookType.php +++ b/lib/Minz/HookType.php @@ -26,8 +26,8 @@ enum Minz_HookType: string { case NavMenu = 'nav_menu'; // function() -> string case NavReadingModes = 'nav_reading_modes'; // function($readingModes = array) -> array | null case PostUpdate = 'post_update'; // function(none) -> none - case SimplepieAfterInit = 'simplepie_after_init'; // function(\SimplePie\SimplePie $simplePie, FreshRSS_Feed $feed, bool $result): void - case SimplepieBeforeInit = 'simplepie_before_init'; // function(\SimplePie\SimplePie $simplePie, FreshRSS_Feed $feed): void + case SimplepieAfterInit = 'simplepie_after_init'; // function(FreshRSS_SimplePieCustom $simplePie, FreshRSS_Feed $feed, bool $result): void + case SimplepieBeforeInit = 'simplepie_before_init'; // function(FreshRSS_SimplePieCustom $simplePie, FreshRSS_Feed $feed): void case ViewModes = 'view_modes'; // function($viewModes = array) -> array | null public function signature(): Minz_HookSignature { diff --git a/lib/Minz/Request.php b/lib/Minz/Request.php index c938afd7c..973e46f2c 100644 --- a/lib/Minz/Request.php +++ b/lib/Minz/Request.php @@ -254,6 +254,20 @@ class Minz_Request { self::$action_name === $action_name; } + /** + * Use CONN_REMOTE_ADDR (if available, to be robust even when using Apache mod_remoteip) or REMOTE_ADDR environment variable to determine the connection IP. + */ + public static function connectionRemoteAddress(): string { + $remoteIp = is_string($_SERVER['CONN_REMOTE_ADDR'] ?? null) ? $_SERVER['CONN_REMOTE_ADDR'] : ''; + if ($remoteIp == '') { + $remoteIp = is_string($_SERVER['REMOTE_ADDR'] ?? null) ? $_SERVER['REMOTE_ADDR'] : ''; + } + if ($remoteIp == 0) { + $remoteIp = ''; + } + return $remoteIp; + } + /** * Return true if the request is over HTTPS, false otherwise (HTTP) */ diff --git a/lib/favicons.php b/lib/favicons.php index 786f832fc..7c12a842e 100644 --- a/lib/favicons.php +++ b/lib/favicons.php @@ -31,7 +31,8 @@ function searchFavicon(string $url): string { return ''; } $dom = new DOMDocument(); - ['body' => $html, 'effective_url' => $effective_url, 'fail' => $fail] = httpGet($url, cachePath: CACHE_PATH . '/' . sha1($url) . '.html', type: 'html'); + ['body' => $html, 'effective_url' => $effective_url, 'fail' => $fail] = + FreshRSS_http_Util::httpGet($url, cachePath: CACHE_PATH . '/' . sha1($url) . '.html', type: 'html'); if ($fail || $html === '' || !@$dom->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) { return ''; } @@ -69,7 +70,7 @@ function searchFavicon(string $url): string { if ($iri == false) { return ''; } - $favicon = httpGet($iri, faviconCachePath($iri), 'ico', curl_options: [ + $favicon = FreshRSS_http_Util::httpGet($iri, faviconCachePath($iri), 'ico', curl_options: [ CURLOPT_REFERER => $effective_url, ])['body']; if (isImgMime($favicon)) { @@ -90,7 +91,7 @@ function download_favicon(string $url, string $dest): bool { } if ($favicon == '') { $link = $rootUrl . 'favicon.ico'; - $favicon = httpGet($link, faviconCachePath($link), 'ico', curl_options: [ + $favicon = FreshRSS_http_Util::httpGet($link, faviconCachePath($link), 'ico', curl_options: [ CURLOPT_REFERER => $url, ])['body']; if (!isImgMime($favicon)) { diff --git a/lib/lib_rss.php b/lib/lib_rss.php index e7503ffe4..2fca6896f 100644 --- a/lib/lib_rss.php +++ b/lib/lib_rss.php @@ -145,39 +145,6 @@ function echoJson($json, int $optimisationDepth = -1): void { } } -function idn_to_puny(string $url): string { - if (function_exists('idn_to_ascii')) { - $idn = parse_url($url, PHP_URL_HOST); - if (is_string($idn) && $idn != '') { - $puny = idn_to_ascii($idn); - $pos = strpos($url, $idn); - if ($puny != false && $pos !== false) { - $url = substr_replace($url, $puny, $pos, strlen($idn)); - } - } - } - return $url; -} - -function checkUrl(string $url, bool $fixScheme = true): string|false { - $url = trim($url); - if ($url == '') { - return ''; - } - if ($fixScheme && preg_match('#^https?://#i', $url) !== 1) { - $url = 'https://' . ltrim($url, '/'); - } - - $url = idn_to_puny($url); // https://bugs.php.net/bug.php?id=53474 - $urlRelaxed = str_replace('_', 'z', $url); //PHP discussion #64948 Underscore - - if (is_string(filter_var($urlRelaxed, FILTER_VALIDATE_URL))) { - return $url; - } else { - return false; - } -} - function safe_ascii(?string $text): string { return $text === null ? '' : (filter_var($text, FILTER_DEFAULT, FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH) ?: ''); } @@ -290,319 +257,6 @@ function sensitive_log(array|string $log): array|string { return $log; } -/** - * @param array<mixed> $curl_params - * @return array<mixed> - */ -function sanitizeCurlParams(array $curl_params): array { - $safe_params = [ - CURLOPT_COOKIE, - CURLOPT_COOKIEFILE, - CURLOPT_FOLLOWLOCATION, - CURLOPT_HTTPHEADER, - CURLOPT_MAXREDIRS, - CURLOPT_POST, - CURLOPT_POSTFIELDS, - CURLOPT_PROXY, - CURLOPT_PROXYTYPE, - CURLOPT_USERAGENT, - ]; - foreach ($curl_params as $k => $_) { - if (!in_array($k, $safe_params, true)) { - unset($curl_params[$k]); - continue; - } - // Allow only an empty value just to enable the libcurl cookie engine - if ($k === CURLOPT_COOKIEFILE) { - $curl_params[$k] = ''; - } - } - return $curl_params; -} - -/** - * @param array<string,mixed> $attributes - * @param array<int,mixed> $curl_options - * @throws FreshRSS_Context_Exception - */ -function customSimplePie(array $attributes = [], array $curl_options = []): \SimplePie\SimplePie { - $limits = FreshRSS_Context::systemConf()->limits; - $simplePie = new \SimplePie\SimplePie(); - if (FreshRSS_Context::systemConf()->simplepie_syslog_enabled) { - $simplePie->get_registry()->register(\SimplePie\File::class, FreshRSS_SimplePieResponse::class); - } - $simplePie->set_useragent(FRESHRSS_USERAGENT); - $simplePie->set_cache_name_function('sha1'); - $simplePie->set_cache_location(CACHE_PATH); - $simplePie->set_cache_duration($limits['cache_duration'], $limits['cache_duration_min'], $limits['cache_duration_max']); - $simplePie->enable_order_by_date(false); - - $feed_timeout = empty($attributes['timeout']) || !is_numeric($attributes['timeout']) ? 0 : (int)$attributes['timeout']; - $simplePie->set_timeout($feed_timeout > 0 ? $feed_timeout : $limits['timeout']); - - $curl_options = array_replace(FreshRSS_Context::systemConf()->curl_options, $curl_options); - if (isset($attributes['ssl_verify'])) { - $curl_options[CURLOPT_SSL_VERIFYHOST] = empty($attributes['ssl_verify']) ? 0 : 2; - $curl_options[CURLOPT_SSL_VERIFYPEER] = (bool)$attributes['ssl_verify']; - if (empty($attributes['ssl_verify'])) { - $curl_options[CURLOPT_SSL_CIPHER_LIST] = 'DEFAULT@SECLEVEL=1'; - } - } - $attributes['curl_params'] = sanitizeCurlParams(is_array($attributes['curl_params'] ?? null) ? $attributes['curl_params'] : []); - if (!empty($attributes['curl_params']) && is_array($attributes['curl_params'])) { - foreach ($attributes['curl_params'] as $co => $v) { - if (is_int($co)) { - $curl_options[$co] = $v; - } - } - } - if (!empty($curl_options[CURLOPT_PROXYTYPE]) && ($curl_options[CURLOPT_PROXYTYPE] < 0 || $curl_options[CURLOPT_PROXYTYPE] === 3)) { - // 3 is legacy for NONE - unset($curl_options[CURLOPT_PROXYTYPE]); - if (isset($curl_options[CURLOPT_PROXY])) { - unset($curl_options[CURLOPT_PROXY]); - } - } - $simplePie->set_curl_options($curl_options); - - $simplePie->strip_comments(true); - $simplePie->rename_attributes(['id', 'class']); - $simplePie->allow_aria_attr(true); - $simplePie->allow_data_attr(true); - $simplePie->allowed_html_attributes([ - // HTML - 'dir', 'draggable', 'hidden', 'lang', 'role', 'title', - // MathML - 'displaystyle', 'mathsize', 'scriptlevel', - ]); - $simplePie->allowed_html_elements_with_attributes([ - // HTML - 'a' => ['href', 'hreflang', 'type'], - 'abbr' => [], - 'acronym' => [], - 'address' => [], - // 'area' => [], // TODO: support <area> after rewriting ids with a format like #ugc-<insert original id here> (maybe) - 'article' => [], - 'aside' => [], - 'audio' => ['controlslist', 'loop', 'muted', 'src'], - 'b' => [], - 'bdi' => [], - 'bdo' => [], - 'big' => [], - 'blink' => [], - 'blockquote' => ['cite'], - 'br' => ['clear'], - 'button' => ['disabled'], - 'canvas' => ['width', 'height'], - 'caption' => ['align'], - 'center' => [], - 'cite' => [], - 'code' => [], - 'col' => ['span', 'align', 'valign', 'width'], - 'colgroup' => ['span', 'align', 'valign', 'width'], - 'data' => ['value'], - 'datalist' => [], - 'dd' => [], - 'del' => ['cite', 'datetime'], - 'details' => ['open'], - 'dfn' => [], - 'dialog' => [], - 'dir' => [], - 'div' => ['align'], - 'dl' => [], - 'dt' => [], - 'em' => [], - 'fieldset' => ['disabled'], - 'figcaption' => [], - 'figure' => [], - 'footer' => [], - 'h1' => [], - 'h2' => [], - 'h3' => [], - 'h4' => [], - 'h5' => [], - 'h6' => [], - 'header' => [], - 'hgroup' => [], - 'hr' => ['align', 'noshade', 'size', 'width'], - 'i' => [], - 'iframe' => ['src', 'align', 'frameborder', 'longdesc', 'marginheight', 'marginwidth', 'scrolling'], - 'image' => ['src', 'alt', 'width', 'height', 'align', 'border', 'hspace', 'longdesc', 'vspace'], - 'img' => ['src', 'alt', 'width', 'height', 'align', 'border', 'hspace', 'longdesc', 'vspace'], - 'ins' => ['cite', 'datetime'], - 'kbd' => [], - 'label' => [], - 'legend' => [], - 'li' => ['value', 'type'], - 'main' => [], - // 'map' => [], // TODO: support <map> after rewriting ids with a format like #ugc-<insert original id here> (maybe) - 'mark' => [], - 'marquee' => ['behavior', 'direction', 'height', 'hspace', 'loop', 'scrollamount', 'scrolldelay', 'truespeed', 'vspace', 'width'], - 'menu' => [], - 'meter' => ['value', 'min', 'max', 'low', 'high', 'optimum'], - 'nav' => [], - 'nobr' => [], - // 'noembed' => [], // <embed> is not allowed, so we want to display the contents of <noembed> - 'noframes' => [], - // 'noscript' => [], // From the perspective of the feed content, JS isn't allowed so we want to display the contents of <noscript> - 'ol' => ['reversed', 'start', 'type'], - 'optgroup' => ['disabled', 'label'], - 'option' => ['disabled', 'label', 'selected', 'value'], - 'output' => [], - 'p' => ['align'], - 'picture' => [], - // 'plaintext' => [], // Can't be closed. See: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/plaintext - 'pre' => ['width', 'wrap'], - 'progress' => ['max', 'value'], - 'q' => ['cite'], - 'rb' => [], - 'rp' => [], - 'rt' => [], - 'rtc' => [], - 'ruby' => [], - 's' => [], - 'samp' => [], - 'search' => [], - 'section' => [], - 'select' => ['disabled', 'multiple', 'size'], - 'small' => [], - 'source' => ['type', 'src', 'media', 'height', 'width'], - 'span' => [], - 'strike' => [], - 'strong' => [], - 'sub' => [], - 'summary' => [], - 'sup' => [], - 'table' => ['align', 'border', 'cellpadding', 'cellspacing', 'rules', 'summary', 'width'], - 'tbody' => ['align', 'char', 'charoff', 'valign'], - 'td' => ['colspan', 'headers', 'rowspan', 'abbr', 'align', 'height', 'scope', 'valign', 'width'], - 'textarea' => ['cols', 'disabled', 'maxlength', 'minlength', 'placeholder', 'readonly', 'rows', 'wrap'], - 'tfoot' => ['align', 'valign'], - 'th' => ['abbr', 'colspan', 'rowspan', 'scope', 'align', 'height', 'valign', 'width'], - 'thead' => ['align', 'valign'], - 'time' => ['datetime'], - 'tr' => ['align', 'valign'], - 'track' => ['default', 'kind', 'srclang', 'label', 'src'], - 'tt' => [], - 'u' => [], - 'ul' => ['type'], - 'var' => [], - 'video' => ['src', 'poster', 'controlslist', 'height', 'loop', 'muted', 'playsinline', 'width'], - 'wbr' => [], - 'xmp' => [], - // MathML - 'maction' => ['actiontype', 'selection'], - 'math' => ['display'], - 'menclose' => ['notation'], - 'merror' => [], - 'mfenced' => ['close', 'open', 'separators'], - 'mfrac' => ['denomalign', 'linethickness', 'numalign'], - 'mi' => ['mathvariant'], - 'mmultiscripts' => ['subscriptshift', 'superscriptshift'], - 'mn' => [], - 'mo' => ['accent', 'fence', 'form', 'largeop', 'lspace', 'maxsize', 'minsize', 'movablelimits', 'rspace', 'separator', 'stretchy', 'symmetric'], - 'mover' => ['accent'], - 'mpadded' => ['depth', 'height', 'lspace', 'voffset', 'width'], - 'mphantom' => [], - 'mprescripts' => [], - 'mroot' => [], - 'mrow' => [], - 'ms' => [], - 'mspace' => ['depth', 'height', 'width'], - 'msqrt' => [], - 'msub' => [], - 'msubsup' => ['subscriptshift', 'superscriptshift'], - 'msup' => ['superscriptshift'], - 'mtable' => ['align', 'columnalign', 'columnlines', 'columnspacing', 'frame', 'framespacing', 'rowalign', 'rowlines', 'rowspacing', 'width'], - 'mtd' => ['columnspan', 'rowspan', 'columnalign', 'rowalign'], - 'mtext' => [], - 'mtr' => ['columnalign', 'rowalign'], - 'munder' => ['accentunder'], - 'munderover' => ['accent', 'accentunder'], - // TODO: Support SVG after sanitizing and URL rewriting of xlink:href - ]); - $simplePie->strip_attributes([ - 'data-auto-leave-validation', 'data-leave-validation', 'data-no-leave-validation', 'data-original', - ]); - $simplePie->add_attributes([ - 'audio' => ['controls' => 'controls', 'preload' => 'none'], - 'iframe' => [ - 'allow' => 'accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share', - 'sandbox' => 'allow-scripts allow-same-origin', - ], - 'video' => ['controls' => 'controls', 'preload' => 'none'], - ]); - $simplePie->set_url_replacements([ - 'a' => 'href', - 'area' => 'href', - 'audio' => 'src', - 'blockquote' => 'cite', - 'del' => 'cite', - 'form' => 'action', - 'iframe' => 'src', - 'img' => [ - 'longdesc', - 'src', - ], - 'image' => [ - 'longdesc', - 'src', - ], - 'input' => 'src', - 'ins' => 'cite', - 'q' => 'cite', - 'source' => 'src', - 'track' => 'src', - 'video' => [ - 'poster', - 'src', - ], - ]); - $https_domains = []; - $force = @file(FRESHRSS_PATH . '/force-https.default.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - if (is_array($force)) { - $https_domains = array_merge($https_domains, $force); - } - $force = @file(DATA_PATH . '/force-https.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - if (is_array($force)) { - $https_domains = array_merge($https_domains, $force); - } - - // Remove whitespace and comments starting with # / ; - $https_domains = preg_replace('%\\s+|[\/#;].*$%', '', $https_domains) ?? $https_domains; - $https_domains = array_filter($https_domains, fn(string $v) => $v !== ''); - - $simplePie->set_https_domains($https_domains); - return $simplePie; -} - -function sanitizeHTML(string $data, string $base = '', ?int $maxLength = null): string { - if ($data === '' || ($maxLength !== null && $maxLength <= 0)) { - return ''; - } - if ($maxLength !== null) { - $data = mb_strcut($data, 0, $maxLength, 'UTF-8'); - } - /** @var \SimplePie\SimplePie|null $simplePie */ - static $simplePie = null; - if ($simplePie === null) { - $simplePie = customSimplePie(); - $simplePie->enable_cache(false); - $simplePie->init(); - } - $sanitized = $simplePie->sanitize->sanitize($data, \SimplePie\SimplePie::CONSTRUCT_HTML, $base); - if (!is_string($sanitized)) { - return ''; - } - $result = html_only_entity_decode($sanitized); - if ($maxLength !== null && strlen($result) > $maxLength) { - //Sanitizing has made the result too long so try again shorter - $data = mb_strcut($result, 0, (2 * $maxLength) - strlen($result) - 2, 'UTF-8'); - return sanitizeHTML($data, $base, $maxLength); - } - return $result; -} - function cleanCache(int $hours = 720): void { // N.B.: GLOB_BRACE is not available on all platforms $files = glob(CACHE_PATH . '/*.*', GLOB_NOSORT) ?: []; @@ -617,275 +271,6 @@ function cleanCache(int $hours = 720): void { } } -/** - * Remove the charset meta information of an HTML document, e.g.: - * `<meta charset="..." />` - * `<meta http-equiv="Content-Type" content="text/html; charset=...">` - */ -function stripHtmlMetaCharset(string $html): string { - return preg_replace('/<meta\s[^>]*charset\s*=\s*[^>]+>/i', '', $html, 1) ?? ''; -} - -/** - * Set an XML preamble to enforce the HTML content type charset received by HTTP. - * @param string $html the raw downloaded HTML content - * @param string $contentType an HTTP Content-Type such as 'text/html; charset=utf-8' - * @return string an HTML string with XML encoding information for DOMDocument::loadHTML() - */ -function enforceHttpEncoding(string $html, string $contentType = ''): string { - $httpCharset = preg_match('/\bcharset=([0-9a-z_-]{2,12})$/i', $contentType, $matches) === 1 ? $matches[1] : ''; - if ($httpCharset == '') { - // No charset defined by HTTP - if (preg_match('/<meta\s[^>]*charset\s*=[\s\'"]*UTF-?8\b/i', substr($html, 0, 2048))) { - // Detect UTF-8 even if declared too deep in HTML for DOMDocument - $httpCharset = 'UTF-8'; - } else { - // Do nothing - return $html; - } - } - $httpCharsetNormalized = \SimplePie\Misc::encoding($httpCharset); - if (in_array($httpCharsetNormalized, ['windows-1252', 'US-ASCII'], true)) { - // Default charset for HTTP, do nothing - return $html; - } - if (substr($html, 0, 3) === "\xEF\xBB\xBF" || // UTF-8 BOM - substr($html, 0, 2) === "\xFF\xFE" || // UTF-16 Little Endian BOM - substr($html, 0, 2) === "\xFE\xFF" || // UTF-16 Big Endian BOM - substr($html, 0, 4) === "\xFF\xFE\x00\x00" || // UTF-32 Little Endian BOM - substr($html, 0, 4) === "\x00\x00\xFE\xFF") { // UTF-32 Big Endian BOM - // Existing byte order mark, do nothing - return $html; - } - if (preg_match('/^<[?]xml[^>]+encoding\b/', substr($html, 0, 64))) { - // Existing XML declaration, do nothing - return $html; - } - if ($httpCharsetNormalized !== 'UTF-8') { - // Try to change encoding to UTF-8 using mbstring or iconv or intl - $utf8 = \SimplePie\Misc::change_encoding($html, $httpCharsetNormalized, 'UTF-8'); - if (is_string($utf8)) { - $html = stripHtmlMetaCharset($utf8); - $httpCharsetNormalized = 'UTF-8'; - } - } - if ($httpCharsetNormalized === 'UTF-8') { - // Save encoding information as XML declaration - return '<' . '?xml version="1.0" encoding="' . $httpCharsetNormalized . '" ?' . ">\n" . $html; - } - // Give up - return $html; -} - -/** - * Set an HTML base URL to the HTML content if there is none. - * @param string $html the raw downloaded HTML content - * @param string $href the HTML base URL - * @return string an HTML string - */ -function enforceHtmlBase(string $html, string $href): string { - $doc = new DOMDocument(); - $doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); - if ($doc->documentElement === null) { - return ''; - } - $xpath = new DOMXPath($doc); - $bases = $xpath->evaluate('//base'); - if (!($bases instanceof DOMNodeList) || $bases->length === 0) { - $base = $doc->createElement('base'); - if ($base === false) { - return $html; - } - $base->setAttribute('href', $href); - $head = null; - $heads = $xpath->evaluate('//head'); - if ($heads instanceof DOMNodeList && $heads->length > 0) { - $head = $heads->item(0); - } - if ($head instanceof DOMElement) { - $head->insertBefore($base, $head->firstChild); - } else { - $doc->documentElement->insertBefore($base, $doc->documentElement->firstChild); - } - } - return $doc->saveHTML() ?: $html; -} - -/** - * @param non-empty-string $url - * @param string $type {html,ico,json,opml,xml} - * @param array<string,mixed> $attributes - * @param array<int,mixed> $curl_options - * @return array{body:string,effective_url:string,redirect_count:int,fail:bool} - */ -function httpGet(string $url, string $cachePath, string $type = 'html', array $attributes = [], array $curl_options = []): array { - $limits = FreshRSS_Context::systemConf()->limits; - $feed_timeout = empty($attributes['timeout']) || !is_numeric($attributes['timeout']) ? 0 : intval($attributes['timeout']); - - $cacheMtime = @filemtime($cachePath); - if ($cacheMtime !== false && $cacheMtime > time() - intval($limits['cache_duration'])) { - $body = @file_get_contents($cachePath); - if ($body != false) { - syslog(LOG_DEBUG, 'FreshRSS uses cache for ' . \SimplePie\Misc::url_remove_credentials($url)); - return ['body' => $body, 'effective_url' => $url, 'redirect_count' => 0, 'fail' => false]; - } - } - - if (rand(0, 30) === 1) { // Remove old cache once in a while - cleanCache(CLEANCACHE_HOURS); - } - - $options = []; - $accept = ''; - $proxy = is_string(FreshRSS_Context::systemConf()->curl_options[CURLOPT_PROXY] ?? null) ? FreshRSS_Context::systemConf()->curl_options[CURLOPT_PROXY] : ''; - if (is_array($attributes['curl_params'] ?? null)) { - $options = sanitizeCurlParams($attributes['curl_params']); - $proxy = is_string($options[CURLOPT_PROXY]) ? $options[CURLOPT_PROXY] : ''; - if (is_array($options[CURLOPT_HTTPHEADER] ?? null)) { - // Remove headers problematic for security - $options[CURLOPT_HTTPHEADER] = array_filter($options[CURLOPT_HTTPHEADER], - fn($header) => is_string($header) && !preg_match('/^(Remote-User|X-WebAuth-User)\\s*:/i', $header)); - // Add Accept header if it is not set - if (preg_grep('/^Accept\\s*:/i', $options[CURLOPT_HTTPHEADER]) === false) { - $options[CURLOPT_HTTPHEADER][] = 'Accept: ' . $accept; - } - } - } - - if (($retryAfter = FreshRSS_http_Util::getRetryAfter($url, $proxy)) > 0) { - Minz_Log::warning('For that domain, will first retry after ' . date('c', $retryAfter) . '. ' . \SimplePie\Misc::url_remove_credentials($url)); - return ['body' => '', 'effective_url' => $url, 'redirect_count' => 0, 'fail' => true]; - } - - if (FreshRSS_Context::systemConf()->simplepie_syslog_enabled) { - syslog(LOG_INFO, 'FreshRSS GET ' . $type . ' ' . \SimplePie\Misc::url_remove_credentials($url)); - } - - switch ($type) { - case 'json': - $accept = 'application/json,application/feed+json,application/javascript;q=0.9,text/javascript;q=0.8,*/*;q=0.7'; - break; - case 'opml': - $accept = 'text/x-opml,text/xml;q=0.9,application/xml;q=0.9,*/*;q=0.8'; - break; - case 'xml': - $accept = 'application/xml,application/xhtml+xml,text/xml;q=0.9,*/*;q=0.8'; - break; - case 'ico': - $accept = 'image/x-icon,image/vnd.microsoft.icon,image/ico,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.1'; - break; - case 'html': - default: - $accept = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'; - break; - } - - // TODO: Implement HTTP 1.1 conditional GET If-Modified-Since - $ch = curl_init(); - if ($ch === false) { - return ['body' => '', 'effective_url' => '', 'redirect_count' => 0, 'fail' => true]; - } - curl_setopt_array($ch, [ - CURLOPT_URL => $url, - CURLOPT_HTTPHEADER => ['Accept: ' . $accept], - CURLOPT_USERAGENT => FRESHRSS_USERAGENT, - CURLOPT_CONNECTTIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'], - CURLOPT_TIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'], - CURLOPT_MAXREDIRS => 4, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_ENCODING => '', //Enable all encodings - //CURLOPT_VERBOSE => 1, // To debug sent HTTP headers - ]); - - curl_setopt_array($ch, $options); - curl_setopt_array($ch, FreshRSS_Context::systemConf()->curl_options); - - $responseHeaders = ''; - curl_setopt($ch, CURLOPT_HEADERFUNCTION, function (\CurlHandle $ch, string $header) use (&$responseHeaders) { - if (trim($header) !== '') { // Skip e.g. separation with trailer headers - $responseHeaders .= $header; - } - return strlen($header); - }); - - if (isset($attributes['ssl_verify'])) { - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, empty($attributes['ssl_verify']) ? 0 : 2); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, (bool)$attributes['ssl_verify']); - if (empty($attributes['ssl_verify'])) { - curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, 'DEFAULT@SECLEVEL=1'); - } - } - - curl_setopt_array($ch, $curl_options); - - $body = curl_exec($ch); - $c_status = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $c_content_type = '' . curl_getinfo($ch, CURLINFO_CONTENT_TYPE); - $c_effective_url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); - $c_redirect_count = curl_getinfo($ch, CURLINFO_REDIRECT_COUNT); - $c_error = curl_error($ch); - - $headers = []; - if ($body !== false) { - assert($c_redirect_count >= 0); - $responseHeaders = \SimplePie\HTTP\Parser::prepareHeaders($responseHeaders, $c_redirect_count + 1); - $parser = new \SimplePie\HTTP\Parser($responseHeaders); - if ($parser->parse()) { - $headers = $parser->headers; - } - } - - $fail = $c_status != 200 || $c_error != '' || $body === false; - if ($fail) { - $body = ''; - Minz_Log::warning('Error fetching content: HTTP code ' . $c_status . ': ' . $c_error . ' ' . $url); - if (in_array($c_status, [429, 503], true)) { - $retryAfter = FreshRSS_http_Util::setRetryAfter($url, $proxy, $headers['retry-after'] ?? ''); - if ($c_status === 429) { - $errorMessage = 'HTTP 429 Too Many Requests! [' . \SimplePie\Misc::url_remove_credentials($url) . ']'; - } elseif ($c_status === 503) { - $errorMessage = 'HTTP 503 Service Unavailable! [' . \SimplePie\Misc::url_remove_credentials($url) . ']'; - } - if ($retryAfter > 0) { - $errorMessage .= ' We may retry after ' . date('c', $retryAfter); - } - } - // TODO: Implement HTTP 410 Gone - } elseif (!is_string($body) || strlen($body) === 0) { - $body = ''; - } else { - if (in_array($type, ['html', 'json', 'opml', 'xml'], true)) { - $body = trim($body, " \n\r\t\v"); // Do not trim \x00 to avoid breaking a BOM - } - if (in_array($type, ['html', 'xml', 'opml'], true)) { - $body = enforceHttpEncoding($body, $c_content_type); - } - if (in_array($type, ['html'], true)) { - $body = enforceHtmlBase($body, $c_effective_url); - } - } - - if (file_put_contents($cachePath, $body) === false) { - Minz_Log::warning("Error saving cache $cachePath for $url"); - } - - return ['body' => $body, 'effective_url' => $c_effective_url, 'redirect_count' => $c_redirect_count, 'fail' => $fail]; -} - -/** - * Validate an email address, supports internationalized addresses. - * - * @param string $email The address to validate - * @return bool true if email is valid, else false - */ -function validateEmailAddress(string $email): bool { - $mailer = new PHPMailer\PHPMailer\PHPMailer(); - $mailer->CharSet = 'utf-8'; - $punyemail = $mailer->punyencodeAddress($email); - return PHPMailer\PHPMailer\PHPMailer::validateAddress($punyemail, 'html5'); -} - /** * Add support of image lazy loading * Move content from src/poster attribute to data-original @@ -923,250 +308,17 @@ function invalidateHttpCache(string $username = ''): bool { } /** - * @return list<string> - */ -function listUsers(): array { - $final_list = []; - $base_path = join_path(DATA_PATH, 'users'); - $dir_list = array_values(array_diff( - scandir($base_path) ?: [], - ['..', '.', Minz_User::INTERNAL_USER] - )); - foreach ($dir_list as $file) { - if ($file[0] !== '.' && is_dir(join_path($base_path, $file)) && file_exists(join_path($base_path, $file, 'config.php'))) { - $final_list[] = $file; - } - } - return $final_list; -} - - -/** - * Return if the maximum number of registrations has been reached. - * Note a max_registrations of 0 means there is no limit. - * - * @return bool true if number of users >= max registrations, false else. - */ -function max_registrations_reached(): bool { - $limit_registrations = FreshRSS_Context::systemConf()->limits['max_registrations']; - $number_accounts = count(listUsers()); - - return $limit_registrations > 0 && $number_accounts >= $limit_registrations; -} - - -/** - * Register and return the configuration for a given user. - * - * Note this function has been created to generate temporary configuration - * objects. If you need a long-time configuration, please don't use this function. - * - * @param string $username the name of the user of which we want the configuration. - * @return FreshRSS_UserConfiguration|null object, or null if the configuration cannot be loaded. - * @throws Minz_ConfigurationNamespaceException - */ -function get_user_configuration(string $username): ?FreshRSS_UserConfiguration { - if (!FreshRSS_user_Controller::checkUsername($username)) { - return null; - } - $namespace = 'user_' . $username; - try { - FreshRSS_UserConfiguration::register($namespace, - USERS_PATH . '/' . $username . '/config.php', - FRESHRSS_PATH . '/config-user.default.php'); - } catch (Minz_FileNotExistException $e) { - Minz_Log::warning($e->getMessage(), ADMIN_LOG); - return null; - } - - $user_conf = FreshRSS_UserConfiguration::get($namespace); - return $user_conf; -} - -/** - * Converts an IP (v4 or v6) to a binary representation using inet_pton - * - * @param string $ip the IP to convert - * @return string a binary representation of the specified IP - */ -function ipToBits(string $ip): string { - $binaryip = ''; - foreach (str_split(inet_pton($ip) ?: '') as $char) { - $binaryip .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT); - } - return $binaryip; -} - -/** - * Check if an ip belongs to the provided range (in CIDR format) - * - * @param string $ip the IP that we want to verify (ex: 192.168.16.1) - * @param string $range the range to check against (ex: 192.168.16.0/24) - * @return bool true if the IP is in the range, otherwise false - */ -function checkCIDR(string $ip, string $range): bool { - $binary_ip = ipToBits($ip); - $split = explode('/', $range); - - $subnet = $split[0] ?? ''; - if ($subnet == '') { - return false; - } - $binary_subnet = ipToBits($subnet); - - $mask_bits = $split[1] ?? ''; - $mask_bits = (int)$mask_bits; - if ($mask_bits === 0) { - $mask_bits = null; - } - - $ip_net_bits = substr($binary_ip, 0, $mask_bits); - $subnet_bits = substr($binary_subnet, 0, $mask_bits); - return $ip_net_bits === $subnet_bits; -} - -/** - * Use CONN_REMOTE_ADDR (if available, to be robust even when using Apache mod_remoteip) or REMOTE_ADDR environment variable to determine the connection IP. + * @deprecated Use {@see Minz_Request::connectionRemoteAddress()} instead. */ function connectionRemoteAddress(): string { - $remoteIp = is_string($_SERVER['CONN_REMOTE_ADDR'] ?? null) ? $_SERVER['CONN_REMOTE_ADDR'] : ''; - if ($remoteIp == '') { - $remoteIp = is_string($_SERVER['REMOTE_ADDR'] ?? null) ? $_SERVER['REMOTE_ADDR'] : ''; - } - if ($remoteIp == 0) { - $remoteIp = ''; - } - return $remoteIp; + return Minz_Request::connectionRemoteAddress(); } /** - * Check if the client (e.g. last proxy) is allowed to send unsafe headers. - * This uses the `TRUSTED_PROXY` environment variable or the `trusted_sources` configuration option to get an array of the authorized ranges, - * The connection IP is obtained from the `CONN_REMOTE_ADDR` (if available, to be robust even when using Apache mod_remoteip) or `REMOTE_ADDR` environment variables. - * @return bool true if the sender’s IP is in one of the ranges defined in the configuration, else false + * @deprecated Use {@see FreshRSS_http_Util::checkTrustedIP()} instead. */ function checkTrustedIP(): bool { - if (!FreshRSS_Context::hasSystemConf()) { - return false; - } - $remoteIp = connectionRemoteAddress(); - if ($remoteIp === '') { - return false; - } - $trusted = getenv('TRUSTED_PROXY'); - if ($trusted != 0 && is_string($trusted)) { - $trusted = preg_split('/\s+/', $trusted, -1, PREG_SPLIT_NO_EMPTY); - } - if (!is_array($trusted) || empty($trusted)) { - $trusted = FreshRSS_Context::systemConf()->trusted_sources; - } - foreach ($trusted as $cidr) { - if (checkCIDR($remoteIp, $cidr)) { - return true; - } - } - return false; -} - -function httpAuthUser(bool $onlyTrusted = true): string { - $auths = array_unique(array_intersect_key($_SERVER, ['REMOTE_USER' => '', 'REDIRECT_REMOTE_USER' => '', 'HTTP_REMOTE_USER' => '', 'HTTP_X_WEBAUTH_USER' => ''])); - if (count($auths) > 1) { - Minz_Log::warning('Multiple HTTP authentication headers!'); - return ''; - } - - if (!empty($_SERVER['REMOTE_USER']) && is_string($_SERVER['REMOTE_USER'])) { - return $_SERVER['REMOTE_USER']; - } - if (!empty($_SERVER['REDIRECT_REMOTE_USER']) && is_string($_SERVER['REDIRECT_REMOTE_USER'])) { - return $_SERVER['REDIRECT_REMOTE_USER']; - } - if (!$onlyTrusted || checkTrustedIP()) { - if (!empty($_SERVER['HTTP_REMOTE_USER']) && is_string($_SERVER['HTTP_REMOTE_USER'])) { - return $_SERVER['HTTP_REMOTE_USER']; - } - if (!empty($_SERVER['HTTP_X_WEBAUTH_USER']) && is_string($_SERVER['HTTP_X_WEBAUTH_USER'])) { - return $_SERVER['HTTP_X_WEBAUTH_USER']; - } - } - return ''; -} - -function cryptAvailable(): bool { - $hash = '$2y$04$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG'; - return $hash === @crypt('password', $hash); -} - - -/** - * Check PHP and its extensions are well-installed. - * - * @return array<string,bool> of tested values. - */ -function check_install_php(): array { - $pdo_mysql = extension_loaded('pdo_mysql'); - $pdo_pgsql = extension_loaded('pdo_pgsql'); - $pdo_sqlite = extension_loaded('pdo_sqlite'); - return [ - 'php' => version_compare(PHP_VERSION, FRESHRSS_MIN_PHP_VERSION) >= 0, - 'curl' => extension_loaded('curl'), - 'pdo' => $pdo_mysql || $pdo_sqlite || $pdo_pgsql, - 'pcre' => extension_loaded('pcre'), - 'ctype' => extension_loaded('ctype'), - 'fileinfo' => extension_loaded('fileinfo'), - 'dom' => class_exists('DOMDocument'), - 'json' => extension_loaded('json'), - 'mbstring' => extension_loaded('mbstring'), - 'zip' => extension_loaded('zip'), - ]; -} - -/** - * Check different data files and directories exist. - * @return array<string,bool> of tested values. - */ -function check_install_files(): array { - return [ - 'data' => is_dir(DATA_PATH) && touch(DATA_PATH . '/index.html'), // is_writable() is not reliable for a folder on NFS - 'cache' => is_dir(CACHE_PATH) && touch(CACHE_PATH . '/index.html'), - 'users' => is_dir(USERS_PATH) && touch(USERS_PATH . '/index.html'), - 'favicons' => is_dir(DATA_PATH) && touch(DATA_PATH . '/favicons/index.html'), - 'tokens' => is_dir(DATA_PATH) && touch(DATA_PATH . '/tokens/index.html'), - ]; -} - -/** - * Check database is well-installed. - * - * @return array<string,bool> of tested values. - */ -function check_install_database(): array { - $status = [ - 'connection' => true, - 'tables' => false, - 'categories' => false, - 'feeds' => false, - 'entries' => false, - 'entrytmp' => false, - 'tag' => false, - 'entrytag' => false, - ]; - - try { - $dbDAO = FreshRSS_Factory::createDatabaseDAO(); - - $status['tables'] = $dbDAO->tablesAreCorrect(); - $status['categories'] = $dbDAO->categoryIsCorrect(); - $status['feeds'] = $dbDAO->feedIsCorrect(); - $status['entries'] = $dbDAO->entryIsCorrect(); - $status['entrytmp'] = $dbDAO->entrytmpIsCorrect(); - $status['tag'] = $dbDAO->tagIsCorrect(); - $status['entrytag'] = $dbDAO->entrytagIsCorrect(); - } catch (Minz_PDOConnectionException $e) { - $status['connection'] = false; - } - - return $status; + return FreshRSS_http_Util::checkTrustedIP(); } /** @@ -1200,53 +352,10 @@ function recursive_unlink(string $dir): bool { return rmdir($dir); } -/** - * Remove queries where $get is appearing. - * @param string $get the get attribute which should be removed. - * @param array<int,array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string, - * shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string}> $queries an array of queries. - * @return array<int,array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string, - * shareRss?:bool,shareOpml?:bool,description?:string,imageUrl?:string}> without queries where $get is appearing. - */ -function remove_query_by_get(string $get, array $queries): array { - $final_queries = []; - foreach ($queries as $query) { - if (empty($query['get']) || $query['get'] !== $get) { - $final_queries[] = $query; - } - } - return $final_queries; -} - function _i(string $icon, int $type = FreshRSS_Themes::ICON_DEFAULT): string { return FreshRSS_Themes::icon($icon, $type); } - -const SHORTCUT_KEYS = [ - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', - 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', - 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', - 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12', - 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'Backspace', 'Delete', - 'End', 'Enter', 'Escape', 'Home', 'Insert', 'PageDown', 'PageUp', 'Space', 'Tab', - ]; - -/** - * @param array<string> $shortcuts - * @return list<string> - */ -function getNonStandardShortcuts(array $shortcuts): array { - $standard = strtolower(implode(' ', SHORTCUT_KEYS)); - - $nonStandard = array_filter($shortcuts, static function (string $shortcut) use ($standard) { - $shortcut = trim($shortcut); - return $shortcut !== '' && stripos($standard, $shortcut) === false; - }); - - return array_values($nonStandard); -} - function errorMessageInfo(string $errorTitle, string $error = ''): string { $errorTitle = htmlspecialchars($errorTitle, ENT_NOQUOTES, 'UTF-8'); diff --git a/p/api/pshb.php b/p/api/pshb.php index 37b3fe056..5937f579b 100644 --- a/p/api/pshb.php +++ b/p/api/pshb.php @@ -98,7 +98,7 @@ if ($ORIGINAL_INPUT == '') { die('Missing XML payload!'); } -$simplePie = customSimplePie(); +$simplePie = new FreshRSS_SimplePieCustom(); $simplePie->enable_cache(false); $simplePie->set_raw_data($ORIGINAL_INPUT); $simplePie->init(); @@ -120,7 +120,7 @@ if ($httpLink !== '' && preg_match_all('/<([^>]+)>;\\s*rel="([^"]+)"/', $httpLin // // TODO: Support WebSub hub redirection // } if (!empty($links['self'])) { - $httpSelf = checkUrl($links['self']) ?: ''; + $httpSelf = FreshRSS_http_Util::checkUrl($links['self']) ?: ''; if ($self !== '' && $self !== $httpSelf) { Minz_Log::warning('Warning: Self URL mismatch between XML [' . $self . '] and HTTP!: ' . $httpSelf, PSHB_LOG); } -- cgit v1.2.3