diff options
Diffstat (limited to 'classes')
50 files changed, 3441 insertions, 1555 deletions
diff --git a/classes/api.php b/classes/api.php index 5f825e551..b17114693 100755 --- a/classes/api.php +++ b/classes/api.php @@ -1,7 +1,7 @@ <?php class API extends Handler { - const API_LEVEL = 17; + const API_LEVEL = 18; const STATUS_OK = 0; const STATUS_ERR = 1; @@ -13,21 +13,22 @@ class API extends Handler { const E_UNKNOWN_METHOD = "UNKNOWN_METHOD"; const E_OPERATION_FAILED = "E_OPERATION_FAILED"; + /** @var int|null */ private $seq; - private static function _param_to_bool($p) { - return $p && ($p !== "f" && $p !== "false"); - } - - private function _wrap($status, $reply) { + /** + * @param array<int|string, mixed> $reply + */ + private function _wrap(int $status, array $reply): bool { print json_encode([ "seq" => $this->seq, "status" => $status, "content" => $reply ]); + return true; } - function before($method) { + function before(string $method): bool { if (parent::before($method)) { header("Content-Type: text/json"); @@ -48,17 +49,17 @@ class API extends Handler { return false; } - function getVersion() { + function getVersion(): bool { $rv = array("version" => Config::get_version()); - $this->_wrap(self::STATUS_OK, $rv); + return $this->_wrap(self::STATUS_OK, $rv); } - function getApiLevel() { + function getApiLevel(): bool { $rv = array("level" => self::API_LEVEL); - $this->_wrap(self::STATUS_OK, $rv); + return $this->_wrap(self::STATUS_OK, $rv); } - function login() { + function login(): bool { if (session_status() == PHP_SESSION_ACTIVE) { session_destroy(); @@ -78,62 +79,60 @@ class API extends Handler { // needed for _get_config() UserHelper::load_user_plugins($_SESSION['uid']); - $this->_wrap(self::STATUS_OK, array("session_id" => session_id(), + return $this->_wrap(self::STATUS_OK, array("session_id" => session_id(), "config" => $this->_get_config(), "api_level" => self::API_LEVEL)); } else { - $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR)); + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR)); } } else { - $this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED)); + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED)); } - } else { - $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR)); - return; } + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR)); } - function logout() { + function logout(): bool { UserHelper::logout(); - $this->_wrap(self::STATUS_OK, array("status" => "OK")); + return $this->_wrap(self::STATUS_OK, array("status" => "OK")); } - function isLoggedIn() { - $this->_wrap(self::STATUS_OK, array("status" => $_SESSION["uid"] != '')); + function isLoggedIn(): bool { + return $this->_wrap(self::STATUS_OK, array("status" => $_SESSION["uid"] != '')); } - function getUnread() { + function getUnread(): bool { $feed_id = clean($_REQUEST["feed_id"] ?? ""); - $is_cat = clean($_REQUEST["is_cat"] ?? ""); + $is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false); if ($feed_id) { - $this->_wrap(self::STATUS_OK, array("unread" => getFeedUnread($feed_id, $is_cat))); + return $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_counters($feed_id, $is_cat, true))); } else { - $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_global_unread())); + return $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_global_unread())); } } /* Method added for ttrss-reader for Android */ - function getCounters() { - $this->_wrap(self::STATUS_OK, Counters::get_all()); + function getCounters(): bool { + return $this->_wrap(self::STATUS_OK, Counters::get_all()); } - function getFeeds() { - $cat_id = clean($_REQUEST["cat_id"]); - $unread_only = self::_param_to_bool(clean($_REQUEST["unread_only"] ?? 0)); + function getFeeds(): bool { + $cat_id = (int) clean($_REQUEST["cat_id"]); + $unread_only = self::_param_to_bool($_REQUEST["unread_only"] ?? false); $limit = (int) clean($_REQUEST["limit"] ?? 0); $offset = (int) clean($_REQUEST["offset"] ?? 0); - $include_nested = self::_param_to_bool(clean($_REQUEST["include_nested"] ?? false)); + $include_nested = self::_param_to_bool($_REQUEST["include_nested"] ?? false); $feeds = $this->_api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested); - $this->_wrap(self::STATUS_OK, $feeds); + return $this->_wrap(self::STATUS_OK, $feeds); } - function getCategories() { - $unread_only = self::_param_to_bool(clean($_REQUEST["unread_only"] ?? false)); - $enable_nested = self::_param_to_bool(clean($_REQUEST["enable_nested"] ?? false)); - $include_empty = self::_param_to_bool(clean($_REQUEST['include_empty'] ?? false)); + function getCategories(): bool { + $unread_only = self::_param_to_bool($_REQUEST["unread_only"] ?? false); + $enable_nested = self::_param_to_bool($_REQUEST["enable_nested"] ?? false); + $include_empty = self::_param_to_bool($_REQUEST["include_empty"] ?? false); // TODO do not return empty categories, return Uncategorized and standard virtual cats @@ -153,7 +152,7 @@ class API extends Handler { foreach ($categories->find_many() as $category) { if ($include_empty || $category->num_feeds > 0 || $category->num_cats > 0) { - $unread = getFeedUnread($category->id, true); + $unread = Feeds::_get_counters($category->id, true, true); if ($enable_nested) $unread += Feeds::_get_cat_children_unread($category->id); @@ -171,7 +170,7 @@ class API extends Handler { foreach ([-2,-1,0] as $cat_id) { if ($include_empty || !$this->_is_cat_empty($cat_id)) { - $unread = getFeedUnread($cat_id, true); + $unread = Feeds::_get_counters($cat_id, true, true); if ($unread || !$unread_only) { array_push($cats, [ @@ -183,40 +182,37 @@ class API extends Handler { } } - $this->_wrap(self::STATUS_OK, $cats); + return $this->_wrap(self::STATUS_OK, $cats); } - function getHeadlines() { - $feed_id = clean($_REQUEST["feed_id"]); - if ($feed_id !== "") { - - if (is_numeric($feed_id)) $feed_id = (int) $feed_id; + function getHeadlines(): bool { + $feed_id = clean($_REQUEST["feed_id"] ?? ""); + if (!empty($feed_id) || is_numeric($feed_id)) { // is_numeric for feed_id "0" $limit = (int)clean($_REQUEST["limit"] ?? 0 ); if (!$limit || $limit >= 200) $limit = 200; $offset = (int)clean($_REQUEST["skip"] ?? 0); $filter = clean($_REQUEST["filter"] ?? ""); - $is_cat = self::_param_to_bool(clean($_REQUEST["is_cat"] ?? false)); - $show_excerpt = self::_param_to_bool(clean($_REQUEST["show_excerpt"] ?? false)); - $show_content = self::_param_to_bool(clean($_REQUEST["show_content"] ?? false)); + $is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false); + $show_excerpt = self::_param_to_bool($_REQUEST["show_excerpt"] ?? false); + $show_content = self::_param_to_bool($_REQUEST["show_content"] ?? false); /* all_articles, unread, adaptive, marked, updated */ $view_mode = clean($_REQUEST["view_mode"] ?? null); - $include_attachments = self::_param_to_bool(clean($_REQUEST["include_attachments"] ?? false)); + $include_attachments = self::_param_to_bool($_REQUEST["include_attachments"] ?? false); $since_id = (int)clean($_REQUEST["since_id"] ?? 0); - $include_nested = self::_param_to_bool(clean($_REQUEST["include_nested"] ?? false)); - $sanitize_content = !isset($_REQUEST["sanitize"]) || - self::_param_to_bool($_REQUEST["sanitize"]); - $force_update = self::_param_to_bool(clean($_REQUEST["force_update"] ?? false)); - $has_sandbox = self::_param_to_bool(clean($_REQUEST["has_sandbox"] ?? false)); + $include_nested = self::_param_to_bool($_REQUEST["include_nested"] ?? false); + $sanitize_content = self::_param_to_bool($_REQUEST["sanitize"] ?? true); + $force_update = self::_param_to_bool($_REQUEST["force_update"] ?? false); + $has_sandbox = self::_param_to_bool($_REQUEST["has_sandbox"] ?? false); $excerpt_length = (int)clean($_REQUEST["excerpt_length"] ?? 0); $check_first_id = (int)clean($_REQUEST["check_first_id"] ?? 0); - $include_header = self::_param_to_bool(clean($_REQUEST["include_header"] ?? false)); + $include_header = self::_param_to_bool($_REQUEST["include_header"] ?? false); $_SESSION['hasSandbox'] = $has_sandbox; - list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query(clean($_REQUEST["order_by"] ?? null)); + list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query(clean($_REQUEST["order_by"] ?? "")); /* do not rely on params below */ @@ -228,16 +224,16 @@ class API extends Handler { $include_nested, $sanitize_content, $force_update, $excerpt_length, $check_first_id, $skip_first_id_check); if ($include_header) { - $this->_wrap(self::STATUS_OK, array($headlines_header, $headlines)); + return $this->_wrap(self::STATUS_OK, array($headlines_header, $headlines)); } else { - $this->_wrap(self::STATUS_OK, $headlines); + return $this->_wrap(self::STATUS_OK, $headlines); } } else { - $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); } } - function updateArticle() { + function updateArticle(): bool { $article_ids = explode(",", clean($_REQUEST["article_ids"])); $mode = (int) clean($_REQUEST["mode"]); $data = clean($_REQUEST["data"] ?? ""); @@ -294,19 +290,19 @@ class API extends Handler { $num_updated = $sth->rowCount(); - $this->_wrap(self::STATUS_OK, array("status" => "OK", + return $this->_wrap(self::STATUS_OK, array("status" => "OK", "updated" => $num_updated)); } else { - $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); } - } - function getArticle() { + function getArticle(): bool { $article_ids = explode(',', clean($_REQUEST['article_id'] ?? '')); $sanitize_content = self::_param_to_bool($_REQUEST['sanitize'] ?? true); + // @phpstan-ignore-next-line if (count($article_ids)) { $entries = ORM::for_table('ttrss_entries') ->table_alias('e') @@ -350,7 +346,7 @@ class API extends Handler { $article['content'] = Sanitizer::sanitize( $entry->content, self::_param_to_bool($entry->hide_images), - false, $entry->site_url, false, $entry->id); + null, $entry->site_url, null, $entry->id); } else { $article['content'] = $entry->content; } @@ -368,14 +364,16 @@ class API extends Handler { array_push($articles, $article); } - $this->_wrap(self::STATUS_OK, $articles); - // @phpstan-ignore-next-line + return $this->_wrap(self::STATUS_OK, $articles); } else { - $this->_wrap(self::STATUS_ERR, ['error' => self::E_INCORRECT_USAGE]); + return $this->_wrap(self::STATUS_ERR, ['error' => self::E_INCORRECT_USAGE]); } } - private function _get_config() { + /** + * @return array<string, array<string, string>|bool|int|string> + */ + private function _get_config(): array { $config = [ "icons_dir" => Config::get(Config::ICONS_DIR), "icons_url" => Config::get(Config::ICONS_URL) @@ -391,42 +389,42 @@ class API extends Handler { return $config; } - function getConfig() { + function getConfig(): bool { $config = $this->_get_config(); - $this->_wrap(self::STATUS_OK, $config); + return $this->_wrap(self::STATUS_OK, $config); } - function updateFeed() { + function updateFeed(): bool { $feed_id = (int) clean($_REQUEST["feed_id"]); if (!ini_get("open_basedir")) { RSSUtils::update_rss_feed($feed_id); } - $this->_wrap(self::STATUS_OK, array("status" => "OK")); + return $this->_wrap(self::STATUS_OK, array("status" => "OK")); } - function catchupFeed() { + function catchupFeed(): bool { $feed_id = clean($_REQUEST["feed_id"]); - $is_cat = clean($_REQUEST["is_cat"]); - @$mode = clean($_REQUEST["mode"]); + $is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false); + $mode = clean($_REQUEST["mode"] ?? ""); if (!in_array($mode, ["all", "1day", "1week", "2week"])) $mode = "all"; Feeds::_catchup($feed_id, $is_cat, $_SESSION["uid"], $mode); - $this->_wrap(self::STATUS_OK, array("status" => "OK")); + return $this->_wrap(self::STATUS_OK, array("status" => "OK")); } - function getPref() { + function getPref(): bool { $pref_name = clean($_REQUEST["pref_name"]); - $this->_wrap(self::STATUS_OK, array("value" => get_pref($pref_name))); + return $this->_wrap(self::STATUS_OK, array("value" => get_pref($pref_name))); } - function getLabels() { + function getLabels(): bool { $article_id = (int)clean($_REQUEST['article_id'] ?? -1); $rv = []; @@ -459,10 +457,10 @@ class API extends Handler { ]); } - $this->_wrap(self::STATUS_OK, $rv); + return $this->_wrap(self::STATUS_OK, $rv); } - function setArticleLabel() { + function setArticleLabel(): bool { $article_ids = explode(",", clean($_REQUEST["article_ids"])); $label_id = (int) clean($_REQUEST['label_id']); @@ -477,52 +475,51 @@ class API extends Handler { foreach ($article_ids as $id) { if ($assign) - Labels::add_article($id, $label, $_SESSION["uid"]); + Labels::add_article((int)$id, $label, $_SESSION["uid"]); else - Labels::remove_article($id, $label, $_SESSION["uid"]); + Labels::remove_article((int)$id, $label, $_SESSION["uid"]); ++$num_updated; } } - $this->_wrap(self::STATUS_OK, array("status" => "OK", + return $this->_wrap(self::STATUS_OK, array("status" => "OK", "updated" => $num_updated)); } - function index($method) { + function index(string $method): bool { $plugin = PluginHost::getInstance()->get_api_method(strtolower($method)); if ($plugin && method_exists($plugin, $method)) { $reply = $plugin->$method(); - $this->_wrap($reply[0], $reply[1]); + return $this->_wrap($reply[0], $reply[1]); } else { - $this->_wrap(self::STATUS_ERR, array("error" => self::E_UNKNOWN_METHOD, "method" => $method)); + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_UNKNOWN_METHOD, "method" => $method)); } } - function shareToPublished() { + function shareToPublished(): bool { $title = strip_tags(clean($_REQUEST["title"])); $url = strip_tags(clean($_REQUEST["url"])); $content = strip_tags(clean($_REQUEST["content"])); if (Article::_create_published_article($title, $url, $content, "", $_SESSION["uid"])) { - $this->_wrap(self::STATUS_OK, array("status" => 'OK')); + return $this->_wrap(self::STATUS_OK, array("status" => 'OK')); } else { - $this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED)); + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED)); } } - private static function _api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested = false) { + /** + * @return array<int, array{'id': int, 'title': string, 'unread': int, 'cat_id': int}> + */ + private static function _api_get_feeds(int $cat_id, bool $unread_only, int $limit, int $offset, bool $include_nested = false): array { $feeds = []; - $limit = (int) $limit; - $offset = (int) $offset; - $cat_id = (int) $cat_id; - /* Labels */ /* API only: -4 All feeds, including virtual feeds */ @@ -549,7 +546,7 @@ class API extends Handler { if ($cat_id == -4 || $cat_id == -1) { foreach ([-1, -2, -3, -4, -6, 0] as $i) { - $unread = getFeedUnread($i); + $unread = Feeds::_get_counters($i, false, true); if ($unread || !$unread_only) { $title = Feeds::_get_title($i); @@ -576,7 +573,7 @@ class API extends Handler { ->find_many(); foreach ($categories as $category) { - $unread = getFeedUnread($category->id, true) + + $unread = Feeds::_get_counters($category->id, true, true) + Feeds::_get_cat_children_unread($category->id); if ($unread || !$unread_only) { @@ -610,7 +607,7 @@ class API extends Handler { } foreach ($feeds_obj->find_many() as $feed) { - $unread = getFeedUnread($feed->id); + $unread = Feeds::_get_counters($feed->id, false, true); $has_icon = Feeds::_has_icon($feed->id); if ($unread || !$unread_only) { @@ -632,13 +629,17 @@ class API extends Handler { return $feeds; } - private static function _api_get_headlines($feed_id, $limit, $offset, - $filter, $is_cat, $show_excerpt, $show_content, $view_mode, $order, - $include_attachments, $since_id, - $search = "", $include_nested = false, $sanitize_content = true, - $force_update = false, $excerpt_length = 100, $check_first_id = false, $skip_first_id_check = false) { - - if ($force_update && $feed_id > 0 && is_numeric($feed_id)) { + /** + * @param string|int $feed_id + * @return array{0: array<int, array<string, mixed>>, 1: array<string, mixed>} $headlines, $headlines_header + */ + private static function _api_get_headlines($feed_id, int $limit, int $offset, + string $filter, bool $is_cat, bool $show_excerpt, bool $show_content, ?string $view_mode, string $order, + bool $include_attachments, int $since_id, string $search = "", bool $include_nested = false, + bool $sanitize_content = true, bool $force_update = false, int $excerpt_length = 100, ?int $check_first_id = null, + bool $skip_first_id_check = false): array { + + if ($force_update && is_numeric($feed_id) && $feed_id > 0) { // Update the feed if required with some basic flood control $feed = ORM::for_table('ttrss_feeds') @@ -746,7 +747,7 @@ class API extends Handler { $headline_row["content"] = Sanitizer::sanitize( $line["content"], self::_param_to_bool($line['hide_images']), - false, $line["site_url"], false, $line["id"]); + null, $line["site_url"], null, $line["id"]); } else { $headline_row["content"] = $line["content"]; } @@ -803,7 +804,7 @@ class API extends Handler { return array($headlines, $headlines_header); } - function unsubscribeFeed() { + function unsubscribeFeed(): bool { $feed_id = (int) clean($_REQUEST["feed_id"]); $feed_exists = ORM::for_table('ttrss_feeds') @@ -812,28 +813,28 @@ class API extends Handler { if ($feed_exists) { Pref_Feeds::remove_feed($feed_id, $_SESSION['uid']); - $this->_wrap(self::STATUS_OK, ['status' => 'OK']); + return $this->_wrap(self::STATUS_OK, ['status' => 'OK']); } else { - $this->_wrap(self::STATUS_ERR, ['error' => self::E_OPERATION_FAILED]); + return $this->_wrap(self::STATUS_ERR, ['error' => self::E_OPERATION_FAILED]); } } - function subscribeToFeed() { + function subscribeToFeed(): bool { $feed_url = clean($_REQUEST["feed_url"]); $category_id = (int) clean($_REQUEST["category_id"]); - $login = clean($_REQUEST["login"]); - $password = clean($_REQUEST["password"]); + $login = clean($_REQUEST["login"] ?? ""); + $password = clean($_REQUEST["password"] ?? ""); if ($feed_url) { $rc = Feeds::_subscribe($feed_url, $category_id, $login, $password); - $this->_wrap(self::STATUS_OK, array("status" => $rc)); + return $this->_wrap(self::STATUS_OK, array("status" => $rc)); } else { - $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); } } - function getFeedTree() { + function getFeedTree(): bool { $include_empty = self::_param_to_bool(clean($_REQUEST['include_empty'])); $pf = new Pref_Feeds($_REQUEST); @@ -841,12 +842,12 @@ class API extends Handler { $_REQUEST['mode'] = 2; $_REQUEST['force_show_empty'] = $include_empty; - $this->_wrap(self::STATUS_OK, + return $this->_wrap(self::STATUS_OK, array("categories" => $pf->_makefeedtree())); } // only works for labels or uncategorized for the time being - private function _is_cat_empty($id) { + private function _is_cat_empty(int $id): bool { if ($id == -2) { $label_count = ORM::for_table('ttrss_labels2') ->where('owner_uid', $_SESSION['uid']) @@ -865,7 +866,8 @@ class API extends Handler { return false; } - private function _get_custom_sort_types() { + /** @return array<string, string> */ + private function _get_custom_sort_types(): array { $ret = []; PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP, function ($result) use (&$ret) { diff --git a/classes/article.php b/classes/article.php index 04855ac9d..e113ed219 100755 --- a/classes/article.php +++ b/classes/article.php @@ -4,7 +4,11 @@ class Article extends Handler_Protected { const ARTICLE_KIND_VIDEO = 2; const ARTICLE_KIND_YOUTUBE = 3; - function redirect() { + const CATCHUP_MODE_MARK_AS_READ = 0; + const CATCHUP_MODE_MARK_AS_UNREAD = 1; + const CATCHUP_MODE_TOGGLE = 2; + + function redirect(): void { $article = ORM::for_table('ttrss_entries') ->table_alias('e') ->join('ttrss_user_entries', [ 'ref_id', '=', 'e.id'], 'ue') @@ -24,8 +28,7 @@ class Article extends Handler_Protected { print "Article not found or has an empty URL."; } - static function _create_published_article($title, $url, $content, $labels_str, - $owner_uid) { + static function _create_published_article(string $title, string $url, string $content, string $labels_str, int $owner_uid): bool { $guid = 'SHA1:' . sha1("ttshared:" . $url . $owner_uid); // include owner_uid to prevent global GUID clash @@ -158,14 +161,14 @@ class Article extends Handler_Protected { return $rc; } - function printArticleTags() { + function printArticleTags(): void { $id = (int) clean($_REQUEST['id'] ?? 0); print json_encode(["id" => $id, "tags" => self::_get_tags($id)]); } - function setScore() { + function setScore(): void { $ids = array_map("intval", clean($_REQUEST['ids'] ?? [])); $score = (int)clean($_REQUEST['score']); @@ -179,14 +182,14 @@ class Article extends Handler_Protected { print json_encode(["id" => $ids, "score" => $score]); } - function setArticleTags() { + function setArticleTags(): void { $id = clean($_REQUEST["id"]); //$tags_str = clean($_REQUEST["tags_str"]); //$tags = array_unique(array_map('trim', explode(",", $tags_str))); - $tags = FeedItem_Common::normalize_categories(explode(",", clean($_REQUEST["tags_str"]))); + $tags = FeedItem_Common::normalize_categories(explode(",", clean($_REQUEST["tags_str"] ?? ""))); $this->pdo->beginTransaction(); @@ -254,18 +257,18 @@ class Article extends Handler_Protected { print "</ul>"; }*/ - function assigntolabel() { - return $this->_label_ops(true); + function assigntolabel(): void { + $this->_label_ops(true); } - function removefromlabel() { - return $this->_label_ops(false); + function removefromlabel(): void { + $this->_label_ops(false); } - private function _label_ops($assign) { + private function _label_ops(bool $assign): void { $reply = array(); - $ids = explode(",", clean($_REQUEST["ids"])); + $ids = array_map("intval", array_filter(explode(",", clean($_REQUEST["ids"] ?? "")), "strlen")); $label_id = clean($_REQUEST["lid"]); $label = Labels::find_caption($label_id, $_SESSION["uid"]); @@ -289,11 +292,10 @@ class Article extends Handler_Protected { print json_encode($reply); } - static function _format_enclosures($id, - $always_display_enclosures, - $article_content, - $hide_images = false) { - + /** + * @return array{'formatted': string, 'entries': array<int, array<string, mixed>>} + */ + static function _format_enclosures(int $id, bool $always_display_enclosures, string $article_content, bool $hide_images = false): array { $enclosures = self::_get_enclosures($id); $enclosures_formatted = ""; @@ -366,7 +368,10 @@ class Article extends Handler_Protected { return $rv; } - static function _get_tags($id, $owner_uid = 0, $tag_cache = false) { + /** + * @return array<int, string> + */ + static function _get_tags(int $id, int $owner_uid = 0, ?string $tag_cache = null): array { $a_id = $id; @@ -383,12 +388,14 @@ class Article extends Handler_Protected { /* check cache first */ - if ($tag_cache === false) { + if (!$tag_cache) { $csth = $pdo->prepare("SELECT tag_cache FROM ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?"); $csth->execute([$id, $owner_uid]); - if ($row = $csth->fetch()) $tag_cache = $row["tag_cache"]; + if ($row = $csth->fetch()) { + $tag_cache = $row["tag_cache"]; + } } if ($tag_cache) { @@ -416,7 +423,7 @@ class Article extends Handler_Protected { return $tags; } - function getmetadatabyid() { + function getmetadatabyid(): void { $article = ORM::for_table('ttrss_entries') ->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue') ->where('ue.owner_uid', $_SESSION['uid']) @@ -429,7 +436,10 @@ class Article extends Handler_Protected { } } - static function _get_enclosures($id) { + /** + * @return array<int, array<string, mixed>> + */ + static function _get_enclosures(int $id): array { $encs = ORM::for_table('ttrss_enclosures') ->where('post_id', $id) ->find_many(); @@ -452,7 +462,7 @@ class Article extends Handler_Protected { } - static function _purge_orphans() { + static function _purge_orphans(): void { // purge orphaned posts in main content table @@ -471,7 +481,11 @@ class Article extends Handler_Protected { } } - static function _catchup_by_id($ids, $cmode, $owner_uid = false) { + /** + * @param array<int, int> $ids + * @param int $cmode Article::CATCHUP_MODE_* + */ + static function _catchup_by_id($ids, int $cmode, ?int $owner_uid = null): void { if (!$owner_uid) $owner_uid = $_SESSION["uid"]; @@ -479,11 +493,11 @@ class Article extends Handler_Protected { $ids_qmarks = arr_qmarks($ids); - if ($cmode == 1) { + if ($cmode == Article::CATCHUP_MODE_MARK_AS_UNREAD) { $sth = $pdo->prepare("UPDATE ttrss_user_entries SET unread = true WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); - } else if ($cmode == 2) { + } else if ($cmode == Article::CATCHUP_MODE_TOGGLE) { $sth = $pdo->prepare("UPDATE ttrss_user_entries SET unread = NOT unread,last_read = NOW() WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); @@ -496,7 +510,10 @@ class Article extends Handler_Protected { $sth->execute(array_merge($ids, [$owner_uid])); } - static function _get_labels($id, $owner_uid = false) { + /** + * @return array<int, array<int, int|string>> + */ + static function _get_labels(int $id, ?int $owner_uid = null): array { $rv = array(); if (!$owner_uid) $owner_uid = $_SESSION["uid"]; @@ -536,6 +553,8 @@ class Article extends Handler_Protected { } if (count($rv) > 0) + // PHPStan has issues with the shape of $rv for some reason (array vs non-empty-array). + // @phpstan-ignore-next-line Labels::update_cache($owner_uid, $id, $rv); else Labels::update_cache($owner_uid, $id, array("no-labels" => 1)); @@ -543,6 +562,12 @@ class Article extends Handler_Protected { return $rv; } + /** + * @param array<int, array<string, mixed>> $enclosures + * @param array<string, mixed> $headline + * + * @return array<int, Article::ARTICLE_KIND_*|string> + */ static function _get_image(array $enclosures, string $content, string $site_url, array $headline) { $article_image = ""; @@ -577,6 +602,7 @@ class Article extends Handler_Protected { } else if ($e->nodeName == "video") { $article_image = $e->getAttribute("poster"); + /** @var DOMElement|null $src */ $src = $tmpxpath->query("//source[@src]", $e)->item(0); if ($src) { @@ -603,14 +629,14 @@ class Article extends Handler_Protected { } if ($article_image) { - $article_image = rewrite_relative_url($site_url, $article_image); + $article_image = UrlHelper::rewrite_relative($site_url, $article_image); if (!$article_kind && (count($enclosures) > 1 || (isset($elems) && $elems->length > 1))) $article_kind = Article::ARTICLE_KIND_ALBUM; } if ($article_stream) - $article_stream = rewrite_relative_url($site_url, $article_stream); + $article_stream = UrlHelper::rewrite_relative($site_url, $article_stream); } $cache = new DiskCache("images"); @@ -624,7 +650,12 @@ class Article extends Handler_Protected { return [$article_image, $article_stream, $article_kind]; } - // only cached, returns label ids (not label feed ids) + /** + * only cached, returns label ids (not label feed ids) + * + * @param array<int, int> $article_ids + * @return array<int, int> + */ static function _labels_of(array $article_ids) { if (count($article_ids) == 0) return []; @@ -651,6 +682,10 @@ class Article extends Handler_Protected { return array_unique($rv); } + /** + * @param array<int, int> $article_ids + * @return array<int, int> + */ static function _feeds_of(array $article_ids) { if (count($article_ids) == 0) return []; diff --git a/classes/auth/base.php b/classes/auth/base.php index 82ea06e1b..d8128400d 100644 --- a/classes/auth/base.php +++ b/classes/auth/base.php @@ -8,13 +8,18 @@ abstract class Auth_Base extends Plugin implements IAuthModule { $this->pdo = Db::pdo(); } - // compatibility wrapper, because of how pluginhost works (hook name == method name) - function hook_auth_user(...$args) { - return $this->authenticate(...$args); + function hook_auth_user($login, $password, $service = '') { + return $this->authenticate($login, $password, $service); } - // Auto-creates specified user if allowed by system configuration - // Can be used instead of find_user_by_login() by external auth modules + /** Auto-creates specified user if allowed by system configuration. + * Can be used instead of find_user_by_login() by external auth modules + * @param string $login + * @param string|false $password + * @return null|int + * @throws Exception + * @throws PDOException + */ function auto_create_user(string $login, $password = false) { if ($login && Config::get(Config::AUTH_AUTO_CREATE)) { $user_id = UserHelper::find_user_by_login($login); @@ -42,7 +47,12 @@ abstract class Auth_Base extends Plugin implements IAuthModule { return UserHelper::find_user_by_login($login); } - // @deprecated + + /** replaced with UserHelper::find_user_by_login() + * @param string $login + * @return null|int + * @deprecated + */ function find_user_by_login(string $login) { return UserHelper::find_user_by_login($login); } diff --git a/classes/config.php b/classes/config.php index 4ae4a2407..39f243d32 100644 --- a/classes/config.php +++ b/classes/config.php @@ -6,171 +6,190 @@ class Config { const T_STRING = 2; const T_INT = 3; - const SCHEMA_VERSION = 145; - - /* override defaults, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX: - - DB_TYPE becomes: - - .env: - - TTRSS_DB_TYPE=pgsql - - or config.php: - - putenv('TTRSS_DB_TYPE=pgsql'); - - etc, etc. + const SCHEMA_VERSION = 146; + + /** override default values, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX: + * + * DB_TYPE becomes: + * + * .env (docker environment): + * + * TTRSS_DB_TYPE=pgsql + * + * or config.php: + * + * putenv('TTRSS_DB_TYPE=pgsql'); + * + * note lack of quotes and spaces before and after "=". + * */ + /** database type: pgsql or mysql */ const DB_TYPE = "DB_TYPE"; + + /** database server hostname */ const DB_HOST = "DB_HOST"; + + /** database user */ const DB_USER = "DB_USER"; + + /** database name */ const DB_NAME = "DB_NAME"; + + /** database password */ const DB_PASS = "DB_PASS"; + + /** database server port */ const DB_PORT = "DB_PORT"; - // database credentials + /** connection charset for MySQL. if you have a legacy database and/or experience + * garbage unicode characters with this option, try setting it to a blank string. */ const MYSQL_CHARSET = "MYSQL_CHARSET"; - // connection charset for MySQL. if you have a legacy database and/or experience - // garbage unicode characters with this option, try setting it to a blank string. + /** this should be set to a fully qualified URL used to access + * your tt-rss instance over the net, such as: https://example.com/tt-rss/ + * if your tt-rss instance is behind a reverse proxy, use external URL. + * tt-rss will likely help you pick correct value for this on startup */ const SELF_URL_PATH = "SELF_URL_PATH"; - // this should be set to a fully qualified URL used to access - // your tt-rss instance over the net, such as: https://example.com/tt-rss/ - // if your tt-rss instance is behind a reverse proxy, use external URL. - // tt-rss will likely help you pick correct value for this on startup + /** operate in single user mode, disables all functionality related to + * multiple users and authentication. enabling this assumes you have + * your tt-rss directory protected by other means (e.g. http auth). */ const SINGLE_USER_MODE = "SINGLE_USER_MODE"; - // operate in single user mode, disables all functionality related to - // multiple users and authentication. enabling this assumes you have - // your tt-rss directory protected by other means (e.g. http auth). + /** enables fallback update mode where tt-rss tries to update feeds in + * background while tt-rss is open in your browser. + * if you don't have a lot of feeds and don't want to or can't run + * background processes while not running tt-rss, this method is generally + * viable to keep your feeds up to date. */ const SIMPLE_UPDATE_MODE = "SIMPLE_UPDATE_MODE"; - // enables fallback update mode where tt-rss tries to update feeds in - // background while tt-rss is open in your browser. - // if you don't have a lot of feeds and don't want to or can't run - // background processes while not running tt-rss, this method is generally - // viable to keep your feeds up to date. + /** use this PHP CLI executable to start various tasks */ const PHP_EXECUTABLE = "PHP_EXECUTABLE"; - // use this PHP CLI executable to start various tasks + /** base directory for lockfiles (must be writable) */ const LOCK_DIRECTORY = "LOCK_DIRECTORY"; - // base directory for lockfiles (must be writable) + /** base directory for local cache (must be writable) */ const CACHE_DIR = "CACHE_DIR"; - // base directory for local cache (must be writable) + /** directory for feed favicons (directory must be writable) */ const ICONS_DIR = "ICONS_DIR"; + + /** URL for feed favicons */ const ICONS_URL = "ICONS_URL"; - // directory and URL for feed favicons (directory must be writable) + /** auto create users authenticated via external modules */ const AUTH_AUTO_CREATE = "AUTH_AUTO_CREATE"; - // auto create users authenticated via external modules + /** auto log in users authenticated via external modules i.e. auth_remote */ const AUTH_AUTO_LOGIN = "AUTH_AUTO_LOGIN"; - // auto log in users authenticated via external modules i.e. auth_remote + /** unconditinally purge all articles older than this amount, in days + * overrides user-controlled purge interval */ const FORCE_ARTICLE_PURGE = "FORCE_ARTICLE_PURGE"; - // unconditinally purge all articles older than this amount, in days - // overrides user-controlled purge interval + /** default lifetime of a session (e.g. login) cookie. In seconds, + * 0 means cookie will be deleted when browser closes. */ const SESSION_COOKIE_LIFETIME = "SESSION_COOKIE_LIFETIME"; - // default lifetime of a session (e.g. login) cookie. In seconds, - // 0 means cookie will be deleted when browser closes. + /** send email using this name */ const SMTP_FROM_NAME = "SMTP_FROM_NAME"; + + /** send email using this address */ const SMTP_FROM_ADDRESS = "SMTP_FROM_ADDRESS"; - // send email using this name and address + /** default subject for email digest */ const DIGEST_SUBJECT = "DIGEST_SUBJECT"; - // default subject for email digest + /** enable built-in update checker, both for core code and plugins (using git) */ const CHECK_FOR_UPDATES = "CHECK_FOR_UPDATES"; - // enable built-in update checker, both for core code and plugins (using git) + /** system plugins enabled for all users, comma separated list, no quotes + * keep at least one auth module in there (i.e. auth_internal) */ const PLUGINS = "PLUGINS"; - // system plugins enabled for all users, comma separated list, no quotes - // keep at least one auth module in there (i.e. auth_internal) + /** available options: sql (default, event log), syslog, stdout (for debugging) */ const LOG_DESTINATION = "LOG_DESTINATION"; - // available options: sql (default, event log), syslog, stdout (for debugging) + /** link this stylesheet on all pages (if it exists), should be placed in themes.local */ const LOCAL_OVERRIDE_STYLESHEET = "LOCAL_OVERRIDE_STYLESHEET"; - // link this stylesheet on all pages (if it exists), should be placed in themes.local + /** same but this javascript file (you can use that for polyfills), should be placed in themes.local */ const LOCAL_OVERRIDE_JS = "LOCAL_OVERRIDE_JS"; - // same but this javascript file (you can use that for polyfills), should be placed in themes.local + /** in seconds, terminate update tasks that ran longer than this interval */ const DAEMON_MAX_CHILD_RUNTIME = "DAEMON_MAX_CHILD_RUNTIME"; - // in seconds, terminate update tasks that ran longer than this interval + /** max concurrent update jobs forking update daemon starts */ const DAEMON_MAX_JOBS = "DAEMON_MAX_JOBS"; - // max concurrent update jobs forking update daemon starts + /** How long to wait for response when requesting feed from a site (seconds) */ const FEED_FETCH_TIMEOUT = "FEED_FETCH_TIMEOUT"; - // How long to wait for response when requesting feed from a site (seconds) + /** How long to wait for response when requesting uncached feed from a site (seconds) */ const FEED_FETCH_NO_CACHE_TIMEOUT = "FEED_FETCH_NO_CACHE_TIMEOUT"; - // Same but not cached + /** Default timeout when fetching files from remote sites */ const FILE_FETCH_TIMEOUT = "FILE_FETCH_TIMEOUT"; - // Default timeout when fetching files from remote sites + /** How long to wait for initial response from website when fetching remote files */ const FILE_FETCH_CONNECT_TIMEOUT = "FILE_FETCH_CONNECT_TIMEOUT"; - // How long to wait for initial response from website when fetching files from remote sites + /** stop updating feeds if user haven't logged in for X days */ const DAEMON_UPDATE_LOGIN_LIMIT = "DAEMON_UPDATE_LOGIN_LIMIT"; - // stop updating feeds if user haven't logged in for X days + /** how many feeds to update in one batch */ const DAEMON_FEED_LIMIT = "DAEMON_FEED_LIMIT"; - // how many feeds to update in one batch + /** default sleep interval between feed updates (sec) */ const DAEMON_SLEEP_INTERVAL = "DAEMON_SLEEP_INTERVAL"; - // default sleep interval between feed updates (sec) + /** do not cache files larger than that (bytes) */ const MAX_CACHE_FILE_SIZE = "MAX_CACHE_FILE_SIZE"; - // do not cache files larger than that (bytes) + /** do not download files larger than that (bytes) */ const MAX_DOWNLOAD_FILE_SIZE = "MAX_DOWNLOAD_FILE_SIZE"; - // do not download files larger than that (bytes) + /** max file size for downloaded favicons (bytes) */ const MAX_FAVICON_FILE_SIZE = "MAX_FAVICON_FILE_SIZE"; - // max file size for downloaded favicons (bytes) + /** max age in days for various automatically cached (temporary) files */ const CACHE_MAX_DAYS = "CACHE_MAX_DAYS"; - // max age in days for various automatically cached (temporary) files + /** max interval between forced unconditional updates for servers + * not complying with http if-modified-since (seconds) */ const MAX_CONDITIONAL_INTERVAL = "MAX_CONDITIONAL_INTERVAL"; - // max interval between forced unconditional updates for servers not complying with http if-modified-since (seconds) + /** automatically disable updates for feeds which failed to + * update for this amount of days; 0 disables */ const DAEMON_UNSUCCESSFUL_DAYS_LIMIT = "DAEMON_UNSUCCESSFUL_DAYS_LIMIT"; - // automatically disable updates for feeds which failed to - // update for this amount of days; 0 disables + /** log all sent emails in the event log */ const LOG_SENT_MAIL = "LOG_SENT_MAIL"; - // log all sent emails in the event log + /** use HTTP proxy for requests */ const HTTP_PROXY = "HTTP_PROXY"; - // use HTTP proxy for requests + /** prevent users from changing passwords */ const FORBID_PASSWORD_CHANGES = "FORBID_PASSWORD_CHANGES"; - // prevent users from changing passwords + /** default session cookie name */ const SESSION_NAME = "SESSION_NAME"; - // default session cookie name + /** enable plugin update checker (using git) */ const CHECK_FOR_PLUGIN_UPDATES = "CHECK_FOR_PLUGIN_UPDATES"; - // enable plugin update checker (using git) + /** allow installing first party plugins using plugin installer in prefs */ const ENABLE_PLUGIN_INSTALLER = "ENABLE_PLUGIN_INSTALLER"; - // allow installing first party plugins using plugin installer in prefs + /** minimum amount of seconds required between authentication attempts */ const AUTH_MIN_INTERVAL = "AUTH_MIN_INTERVAL"; - // minimum amount of seconds required between authentication attempts - // default values for all of the above: + /** http user agent (changing this is not recommended) */ + const HTTP_USER_AGENT = "HTTP_USER_AGENT"; + + /** default values for all global configuration options */ private const _DEFAULTS = [ Config::DB_TYPE => [ "pgsql", Config::T_STRING ], Config::DB_HOST => [ "db", Config::T_STRING ], @@ -224,15 +243,20 @@ class Config { Config::CHECK_FOR_PLUGIN_UPDATES => [ "true", Config::T_BOOL ], Config::ENABLE_PLUGIN_INSTALLER => [ "true", Config::T_BOOL ], Config::AUTH_MIN_INTERVAL => [ 5, Config::T_INT ], + Config::HTTP_USER_AGENT => [ 'Tiny Tiny RSS/%s (https://tt-rss.org/)', + Config::T_STRING ], ]; + /** @var Config|null */ private static $instance; + /** @var array<string, array<bool|int|string>> */ private $params = []; - private $schema_version = null; + + /** @var array<string, mixed> */ private $version = []; - /** @var Db_Migrations $migrations */ + /** @var Db_Migrations|null $migrations */ private $migrations; public static function get_instance() : Config { @@ -250,24 +274,30 @@ class Config { $ref = new ReflectionClass(get_class($this)); foreach ($ref->getConstants() as $const => $cvalue) { - if (isset($this::_DEFAULTS[$const])) { - $override = getenv($this::_ENVVAR_PREFIX . $const); + if (isset(self::_DEFAULTS[$const])) { + $override = getenv(self::_ENVVAR_PREFIX . $const); - list ($defval, $deftype) = $this::_DEFAULTS[$const]; + list ($defval, $deftype) = self::_DEFAULTS[$const]; $this->params[$cvalue] = [ self::cast_to($override !== false ? $override : $defval, $deftype), $deftype ]; } } } - /* package maintainers who don't use git: if version_static.txt exists in tt-rss root - directory, its contents are displayed instead of git commit-based version, this could be generated - based on source git tree commit used when creating the package */ - + /** determine tt-rss version (using git) + * + * package maintainers who don't use git: if version_static.txt exists in tt-rss root + * directory, its contents are displayed instead of git commit-based version, this could be generated + * based on source git tree commit used when creating the package + * @return array<string, mixed>|string + */ static function get_version(bool $as_string = true) { return self::get_instance()->_get_version($as_string); } + /** + * @return array<string, mixed>|string + */ private function _get_version(bool $as_string = true) { $root_dir = dirname(__DIR__); @@ -278,6 +308,8 @@ class Config { $ttrss_version["version"] = "UNKNOWN (Unsupported, Darwin)"; } else if (file_exists("$root_dir/version_static.txt")) { $this->version["version"] = trim(file_get_contents("$root_dir/version_static.txt")) . " (Unsupported)"; + } else if (ini_get("open_basedir")) { + $this->version["version"] = "UNKNOWN (Unsupported, open_basedir)"; } else if (is_dir("$root_dir/.git")) { $this->version = self::get_version_from_git($root_dir); @@ -285,7 +317,10 @@ class Config { user_error("Unable to determine version: " . $this->version["version"], E_USER_WARNING); $this->version["version"] = "UNKNOWN (Unsupported, Git error)"; + } else if (!getenv("SCRIPT_ROOT") || !file_exists("/.dockerenv")) { + $this->version["version"] .= " (Unsupported)"; } + } else { $this->version["version"] = "UNKNOWN (Unsupported)"; } @@ -294,7 +329,10 @@ class Config { return $as_string ? $this->version["version"] : $this->version; } - static function get_version_from_git(string $dir) { + /** + * @return array<string, int|string> + */ + static function get_version_from_git(string $dir): array { $descriptorspec = [ 1 => ["pipe", "w"], // STDOUT 2 => ["pipe", "w"], // STDERR @@ -321,7 +359,7 @@ class Config { if ($check == "version") { - $rv["version"] = strftime("%y.%m", (int)$timestamp) . "-$commit"; + $rv["version"] = date("y.m", (int)$timestamp) . "-$commit"; $rv["commit"] = $commit; $rv["timestamp"] = $timestamp; @@ -360,6 +398,9 @@ class Config { return self::get_migrations()->get_version(); } + /** + * @return bool|int|string + */ static function cast_to(string $value, int $type_hint) { switch ($type_hint) { case self::T_BOOL: @@ -371,24 +412,30 @@ class Config { } } + /** + * @return bool|int|string + */ private function _get(string $param) { list ($value, $type_hint) = $this->params[$param]; return $this->cast_to($value, $type_hint); } - private function _add(string $param, string $default, int $type_hint) { - $override = getenv($this::_ENVVAR_PREFIX . $param); + private function _add(string $param, string $default, int $type_hint): void { + $override = getenv(self::_ENVVAR_PREFIX . $param); $this->params[$param] = [ self::cast_to($override !== false ? $override : $default, $type_hint), $type_hint ]; } - static function add(string $param, string $default, int $type_hint = Config::T_STRING) { + static function add(string $param, string $default, int $type_hint = Config::T_STRING): void { $instance = self::get_instance(); - return $instance->_add($param, $default, $type_hint); + $instance->_add($param, $default, $type_hint); } + /** + * @return bool|int|string + */ static function get(string $param) { $instance = self::get_instance(); @@ -427,6 +474,9 @@ class Config { /* sanity check stuff */ + /** checks for mysql tables not using InnoDB (tt-rss is incompatible with MyISAM) + * @return array<int, array<string, string>> A list of entries identifying tt-rss tables with bad config + */ private static function check_mysql_tables() { $pdo = Db::pdo(); @@ -443,7 +493,7 @@ class Config { return $bad_tables; } - static function sanity_check() { + static function sanity_check(): void { /* we don't actually need the DB object right now but some checks below might use ORM which won't be initialized @@ -460,7 +510,7 @@ class Config { array_push($errors, "Please enable at least one authentication module via PLUGINS"); } - if (function_exists('posix_getuid') && posix_getuid() == 0) { + if (function_exists('posix_getuid') && posix_getuid() == 0 && !getenv("container")) { array_push($errors, "Please don't run this script as root."); } @@ -617,11 +667,11 @@ class Config { } } - private static function format_error($msg) { + private static function format_error(string $msg): string { return "<div class=\"alert alert-danger\">$msg</div>"; } - static function get_override_links() { + static function get_override_links(): string { $rv = ""; $local_css = get_theme_path(self::get(self::LOCAL_OVERRIDE_STYLESHEET)); @@ -632,4 +682,8 @@ class Config { return $rv; } + + static function get_user_agent(): string { + return sprintf(self::get(self::HTTP_USER_AGENT), self::get_version()); + } } diff --git a/classes/counters.php b/classes/counters.php index 8a8b8bc71..8756b5acf 100644 --- a/classes/counters.php +++ b/classes/counters.php @@ -1,7 +1,10 @@ <?php class Counters { - static function get_all() { + /** + * @return array<int, array<string, int|string>> + */ + static function get_all(): array { return array_merge( self::get_global(), self::get_virt(), @@ -11,7 +14,12 @@ class Counters { ); } - static function get_conditional(array $feed_ids = null, array $label_ids = null) { + /** + * @param array<int> $feed_ids + * @param array<int> $label_ids + * @return array<int, array<string, int|string>> + */ + static function get_conditional(array $feed_ids = null, array $label_ids = null): array { return array_merge( self::get_global(), self::get_virt(), @@ -21,7 +29,10 @@ class Counters { ); } - static private function get_cat_children(int $cat_id, int $owner_uid) { + /** + * @return array<int, int> + */ + static private function get_cat_children(int $cat_id, int $owner_uid): array { $unread = 0; $marked = 0; @@ -40,7 +51,11 @@ class Counters { return [$unread, $marked]; } - private static function get_cats(array $cat_ids = null) { + /** + * @param array<int> $cat_ids + * @return array<int, array<string, int|string>> + */ + private static function get_cats(array $cat_ids = null): array { $ret = []; /* Labels category */ @@ -129,7 +144,11 @@ class Counters { return $ret; } - private static function get_feeds(array $feed_ids = null) { + /** + * @param array<int> $feed_ids + * @return array<int, array<string, int|string>> + */ + private static function get_feeds(array $feed_ids = null): array { $ret = []; @@ -199,7 +218,10 @@ class Counters { return $ret; } - private static function get_global() { + /** + * @return array<int, array<string, int|string>> + */ + private static function get_global(): array { $ret = [ [ "id" => "global-unread", @@ -219,13 +241,16 @@ class Counters { return $ret; } - private static function get_virt() { + /** + * @return array<int, array<string, int|string>> + */ + private static function get_virt(): array { $ret = []; for ($i = 0; $i >= -4; $i--) { - $count = getFeedUnread($i); + $count = Feeds::_get_counters($i, false, true); if ($i == 0 || $i == -1 || $i == -2) $auxctr = Feeds::_get_counters($i, false); @@ -248,6 +273,11 @@ class Counters { if (is_array($feeds)) { foreach ($feeds as $feed) { + /** @var IVirtualFeed $feed['sender'] */ + + if (!implements_interface($feed['sender'], 'IVirtualFeed')) + continue; + $cv = [ "id" => PluginHost::pfeed_to_feed_id($feed['id']), "counter" => $feed['sender']->get_unread($feed['id']) @@ -263,7 +293,11 @@ class Counters { return $ret; } - static function get_labels(array $label_ids = null) { + /** + * @param array<int> $label_ids + * @return array<int, array<string, int|string>> + */ + static function get_labels(array $label_ids = null): array { $ret = []; diff --git a/classes/db.php b/classes/db.php index a09c44628..2cc89f5ba 100755 --- a/classes/db.php +++ b/classes/db.php @@ -4,9 +4,7 @@ class Db /** @var Db $instance */ private static $instance; - private $link; - - /** @var PDO $pdo */ + /** @var PDO|null $pdo */ private $pdo; function __construct() { @@ -19,7 +17,7 @@ class Db } } - static function NOW() { + static function NOW(): string { return date("Y-m-d H:i:s", time()); } @@ -27,7 +25,7 @@ class Db // } - public static function get_dsn() { + public static function get_dsn(): string { $db_port = Config::get(Config::DB_PORT) ? ';port=' . Config::get(Config::DB_PORT) : ''; $db_host = Config::get(Config::DB_HOST) ? ';host=' . Config::get(Config::DB_HOST) : ''; if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET)) { @@ -90,12 +88,11 @@ class Db return self::$instance->pdo; } - public static function sql_random_function() { + public static function sql_random_function(): string { if (Config::get(Config::DB_TYPE) == "mysql") { return "RAND()"; - } else { - return "RANDOM()"; } + return "RANDOM()"; } } diff --git a/classes/db/migrations.php b/classes/db/migrations.php index 3008af535..aecd9186c 100644 --- a/classes/db/migrations.php +++ b/classes/db/migrations.php @@ -1,29 +1,46 @@ <?php class Db_Migrations { + // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ + /** @var string */ private $base_filename = "schema.sql"; + + /** @var string */ private $base_path; + + /** @var string */ private $migrations_path; + + /** @var string */ private $migrations_table; + + /** @var bool */ private $base_is_latest; + + /** @var PDO */ private $pdo; - private $cached_version; - private $cached_max_version; + /** @var int */ + private $cached_version = 0; + + /** @var int */ + private $cached_max_version = 0; + + /** @var int */ private $max_version_override; function __construct() { $this->pdo = Db::pdo(); } - function initialize_for_plugin(Plugin $plugin, bool $base_is_latest = true, string $schema_suffix = "sql") { + function initialize_for_plugin(Plugin $plugin, bool $base_is_latest = true, string $schema_suffix = "sql"): void { $plugin_dir = PluginHost::getInstance()->get_plugin_dir($plugin); $this->initialize($plugin_dir . "/${schema_suffix}", strtolower("ttrss_migrations_plugin_" . get_class($plugin)), $base_is_latest); } - function initialize(string $root_path, string $migrations_table, bool $base_is_latest = true, int $max_version_override = 0) { + function initialize(string $root_path, string $migrations_table, bool $base_is_latest = true, int $max_version_override = 0): void { $this->base_path = "$root_path/" . Config::get(Config::DB_TYPE); $this->migrations_path = $this->base_path . "/migrations"; $this->migrations_table = $migrations_table; @@ -31,7 +48,7 @@ class Db_Migrations { $this->max_version_override = $max_version_override; } - private function set_version(int $version) { + private function set_version(int $version): void { Debug::log("Updating table {$this->migrations_table} with version ${version}...", Debug::LOG_EXTENDED); $sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}"); @@ -48,7 +65,7 @@ class Db_Migrations { } function get_version() : int { - if (isset($this->cached_version)) + if ($this->cached_version) return $this->cached_version; try { @@ -66,11 +83,15 @@ class Db_Migrations { } } - private function create_migrations_table() { + private function create_migrations_table(): void { $this->pdo->query("CREATE TABLE IF NOT EXISTS {$this->migrations_table} (schema_version integer not null)"); } - private function migrate_to(int $version) { + /** + * @throws PDOException + * @return bool false if the migration failed, otherwise true (or an exception) + */ + private function migrate_to(int $version): bool { try { if ($version <= $this->get_version()) { Debug::log("Refusing to apply version $version: current version is higher", Debug::LOG_VERBOSE); @@ -110,8 +131,10 @@ class Db_Migrations { Debug::log("Migration finished, current version: " . $this->get_version(), Debug::LOG_VERBOSE); Logger::log(E_USER_NOTICE, "Applied migration to version $version for {$this->migrations_table}"); + return true; } else { Debug::log("Migration failed: schema file is empty or missing.", Debug::LOG_VERBOSE); + return false; } } catch (PDOException $e) { @@ -129,7 +152,7 @@ class Db_Migrations { if ($this->max_version_override > 0) return $this->max_version_override; - if (isset($this->cached_max_version)) + if ($this->cached_max_version) return $this->cached_max_version; $migrations = glob("{$this->migrations_path}/*.sql"); @@ -174,6 +197,9 @@ class Db_Migrations { return !$this->is_migration_needed(); } + /** + * @return array<int, string> + */ private function get_lines(int $version) : array { if ($version > 0) $filename = "{$this->migrations_path}/${version}.sql"; diff --git a/classes/db/prefs.php b/classes/db/prefs.php index 821216622..209ef58c1 100644 --- a/classes/db/prefs.php +++ b/classes/db/prefs.php @@ -2,11 +2,17 @@ class Db_Prefs { // this class is a stub for the time being (to be removed) - function read($pref_name, $user_id = false, $die_on_error = false) { + /** + * @return bool|int|null|string + */ + function read(string $pref_name, ?int $user_id = null, bool $die_on_error = false) { return get_pref($pref_name, $user_id); } - function write($pref_name, $value, $user_id = false, $strip_tags = true) { + /** + * @param mixed $value + */ + function write(string $pref_name, $value, ?int $user_id = null, bool $strip_tags = true): bool { return set_pref($pref_name, $value, $user_id, $strip_tags); } } diff --git a/classes/debug.php b/classes/debug.php index 2ae81e41a..fbdf260e0 100644 --- a/classes/debug.php +++ b/classes/debug.php @@ -1,56 +1,94 @@ <?php class Debug { const LOG_DISABLED = -1; - const LOG_NORMAL = 0; - const LOG_VERBOSE = 1; - const LOG_EXTENDED = 2; - - /** @deprecated */ + const LOG_NORMAL = 0; + const LOG_VERBOSE = 1; + const LOG_EXTENDED = 2; + + const ALL_LOG_LEVELS = [ + Debug::LOG_DISABLED, + Debug::LOG_NORMAL, + Debug::LOG_VERBOSE, + Debug::LOG_EXTENDED, + ]; + + // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ + /** + * @deprecated + * @var int + */ public static $LOG_DISABLED = self::LOG_DISABLED; - /** @deprecated */ - public static $LOG_NORMAL = self::LOG_NORMAL; + /** + * @deprecated + * @var int + */ + public static $LOG_NORMAL = self::LOG_NORMAL; + + /** + * @deprecated + * @var int + */ + public static $LOG_VERBOSE = self::LOG_VERBOSE; + + /** + * @deprecated + * @var int + */ + public static $LOG_EXTENDED = self::LOG_EXTENDED; - /** @deprecated */ - public static $LOG_VERBOSE = self::LOG_VERBOSE; + /** @var bool */ + private static $enabled = false; - /** @deprecated */ - public static $LOG_EXTENDED = self::LOG_EXTENDED; + /** @var bool */ + private static $quiet = false; - private static $enabled = false; - private static $quiet = false; - private static $logfile = false; + /** @var string|null */ + private static $logfile = null; + + /** + * @var int Debug::LOG_* + */ private static $loglevel = self::LOG_NORMAL; - public static function set_logfile($logfile) { + public static function set_logfile(string $logfile): void { self::$logfile = $logfile; } - public static function enabled() { + public static function enabled(): bool { return self::$enabled; } - public static function set_enabled($enable) { + public static function set_enabled(bool $enable): void { self::$enabled = $enable; } - public static function set_quiet($quiet) { + public static function set_quiet(bool $quiet): void { self::$quiet = $quiet; } - public static function set_loglevel($level) { + /** + * @param int $level Debug::LOG_* + */ + public static function set_loglevel(int $level): void { self::$loglevel = $level; } - public static function get_loglevel() { + /** + * @return int Debug::LOG_* + */ + public static function get_loglevel(): int { return self::$loglevel; } - public static function log($message, int $level = 0) { + /** + * @param int $level Debug::LOG_* + */ + public static function log(string $message, int $level = Debug::LOG_NORMAL): bool { if (!self::$enabled || self::$loglevel < $level) return false; - $ts = strftime("%H:%M:%S", time()); + $ts = date("H:i:s", time()); if (function_exists('posix_getpid')) { $ts = "$ts/" . posix_getpid(); } @@ -73,7 +111,7 @@ class Debug { if (!$locked) { fclose($fp); user_error("Unable to lock debugging log file: " . self::$logfile, E_USER_WARNING); - return; + return false; } } @@ -86,7 +124,7 @@ class Debug { fclose($fp); if (self::$quiet) - return; + return false; } else { user_error("Unable to open debugging log file: " . self::$logfile, E_USER_WARNING); @@ -94,5 +132,7 @@ class Debug { } print "[$ts] $message\n"; + + return true; } } diff --git a/classes/digest.php b/classes/digest.php index 94e5cd1fc..15203166b 100644 --- a/classes/digest.php +++ b/classes/digest.php @@ -1,7 +1,7 @@ <?php class Digest { - static function send_headlines_digests() { + static function send_headlines_digests(): void { $user_limit = 15; // amount of users to process (e.g. emails to send out) $limit = 1000; // maximum amount of headlines to include @@ -62,7 +62,7 @@ class Digest if ($rc && $do_catchup) { Debug::log("Marking affected articles as read..."); - Article::_catchup_by_id($affected_ids, 0, $line["id"]); + Article::_catchup_by_id($affected_ids, Article::CATCHUP_MODE_MARK_AS_READ, $line["id"]); } } else { Debug::log("No headlines"); @@ -78,6 +78,9 @@ class Digest Debug::log("All done."); } + /** + * @return array{0: string, 1: int, 2: array<int>, 3: string} + */ static function prepare_headlines_digest(int $user_id, int $days = 1, int $limit = 1000) { $tpl = new Templator(); diff --git a/classes/diskcache.php b/classes/diskcache.php index d7ea26d3b..34bba25f1 100644 --- a/classes/diskcache.php +++ b/classes/diskcache.php @@ -1,8 +1,14 @@ <?php class DiskCache { + // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ + /** @var string */ private $dir; - // https://stackoverflow.com/a/53662733 + /** + * https://stackoverflow.com/a/53662733 + * + * @var array<string, string> + */ private $mimeMap = [ 'video/3gpp2' => '3g2', 'video/3gp' => '3gp', @@ -190,21 +196,22 @@ class DiskCache { 'text/x-scriptzsh' => 'zsh' ]; - public function __construct($dir) { + public function __construct(string $dir) { $this->dir = Config::get(Config::CACHE_DIR) . "/" . basename(clean($dir)); } - public function get_dir() { + public function get_dir(): string { return $this->dir; } - public function make_dir() { + public function make_dir(): bool { if (!is_dir($this->dir)) { return mkdir($this->dir); } + return false; } - public function is_writable($filename = "") { + public function is_writable(?string $filename = null): bool { if ($filename) { if (file_exists($this->get_full_path($filename))) return is_writable($this->get_full_path($filename)); @@ -215,44 +222,75 @@ class DiskCache { } } - public function exists($filename) { + public function exists(string $filename): bool { return file_exists($this->get_full_path($filename)); } - public function get_size($filename) { + /** + * @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise + */ + public function get_size(string $filename) { if ($this->exists($filename)) return filesize($this->get_full_path($filename)); else return -1; } - public function get_full_path($filename) { + public function get_full_path(string $filename): string { return $this->dir . "/" . basename(clean($filename)); } - public function put($filename, $data) { + /** + * @param mixed $data + * + * @return int|false Bytes written or false if an error occurred. + */ + public function put(string $filename, $data) { return file_put_contents($this->get_full_path($filename), $data); } - public function touch($filename) { + public function touch(string $filename): bool { return touch($this->get_full_path($filename)); } - public function get($filename) { + /** Downloads $url to cache as $local_filename if its missing (unless $force-ed) + * @param string $url + * @param string $local_filename + * @param array<string,string|int|false> $options (additional params to UrlHelper::fetch()) + * @param bool $force + * @return bool + */ + public function download(string $url, string $local_filename, array $options = [], bool $force = false) : bool { + if ($this->exists($local_filename) && !$force) + return true; + + $data = UrlHelper::fetch(array_merge(["url" => $url, + "max_size" => Config::get(Config::MAX_CACHE_FILE_SIZE)], $options)); + + if ($data) + return $this->put($local_filename, $data) > 0; + + return false; + } + + public function get(string $filename): ?string { if ($this->exists($filename)) return file_get_contents($this->get_full_path($filename)); else return null; } - public function get_mime_type($filename) { + /** + * @return false|null|string false if detection failed, null if the file doesn't exist, string mime content type otherwise + */ + public function get_mime_type(string $filename) { if ($this->exists($filename)) return mime_content_type($this->get_full_path($filename)); else return null; } - public function get_fake_extension($filename) { + public function get_fake_extension(string $filename): string { $mimetype = $this->get_mime_type($filename); if ($mimetype) @@ -261,7 +299,10 @@ class DiskCache { return ""; } - public function send($filename) { + /** + * @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent + */ + public function send(string $filename) { $fake_extension = $this->get_fake_extension($filename); if ($fake_extension) @@ -272,7 +313,7 @@ class DiskCache { return $this->send_local_file($this->get_full_path($filename)); } - public function get_url($filename) { + public function get_url(string $filename): string { return Config::get_self_url() . "/public.php?op=cached&file=" . basename($this->dir) . "/" . basename($filename); } @@ -280,8 +321,7 @@ class DiskCache { // this is called separately after sanitize() and plugin render article hooks to allow // plugins work on original source URLs used before caching // NOTE: URLs should be already absolutized because this is called after sanitize() - static public function rewrite_urls($str) - { + static public function rewrite_urls(string $str): string { $res = trim($str); if (!$res) return ''; @@ -338,7 +378,7 @@ class DiskCache { return $res; } - static function expire() { + static function expire(): void { $dirs = array_filter(glob(Config::get(Config::CACHE_DIR) . "/*"), "is_dir"); foreach ($dirs as $cache_dir) { @@ -362,14 +402,19 @@ class DiskCache { } } - /* this is essentially a wrapper for readfile() which allows plugins to hook - output with httpd-specific "fast" implementation i.e. X-Sendfile or whatever else - - hook function should return true if request was handled (or at least attempted to) - - note that this can be called without user context so the plugin to handle this - should be loaded systemwide in config.php */ - function send_local_file($filename) { + /* */ + /** + * this is essentially a wrapper for readfile() which allows plugins to hook + * output with httpd-specific "fast" implementation i.e. X-Sendfile or whatever else + * + * hook function should return true if request was handled (or at least attempted to) + * + * note that this can be called without user context so the plugin to handle this + * should be loaded systemwide in config.php + * + * @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent + */ + function send_local_file(string $filename) { if (file_exists($filename)) { if (is_writable($filename)) touch($filename); diff --git a/classes/errors.php b/classes/errors.php index 3599c2639..aa626d017 100644 --- a/classes/errors.php +++ b/classes/errors.php @@ -7,7 +7,34 @@ class Errors { const E_SCHEMA_MISMATCH = "E_SCHEMA_MISMATCH"; const E_URL_SCHEME_MISMATCH = "E_URL_SCHEME_MISMATCH"; - static function to_json(string $code, array $params = []) { + /** + * @param Errors::E_* $code + * @param array<string, string> $params + */ + static function to_json(string $code, array $params = []): string { return json_encode(["error" => ["code" => $code, "params" => $params]]); } + + static function libxml_last_error() : string { + $error = libxml_get_last_error(); + $error_formatted = ""; + + if ($error) { + foreach (libxml_get_errors() as $error) { + if ($error->level == LIBXML_ERR_FATAL) { + // currently only the first error is reported + $error_formatted = self::format_libxml_error($error); + break; + } + } + } + + return UConverter::transcode($error_formatted, 'UTF-8', 'UTF-8'); + } + + static function format_libxml_error(LibXMLError $error) : string { + return sprintf("LibXML error %s at line %d (column %d): %s", + $error->code, $error->line, $error->column, + $error->message); + } } diff --git a/classes/feedenclosure.php b/classes/feedenclosure.php index 2435f6854..b5f5cc411 100644 --- a/classes/feedenclosure.php +++ b/classes/feedenclosure.php @@ -1,10 +1,21 @@ <?php class FeedEnclosure { + /** @var string */ public $link; + + /** @var string */ public $type; + + /** @var string */ public $length; + + /** @var string */ public $title; + + /** @var string */ public $height; + + /** @var string */ public $width; } diff --git a/classes/feeditem.php b/classes/feeditem.php index 3a5e5dc09..fd7c54883 100644 --- a/classes/feeditem.php +++ b/classes/feeditem.php @@ -1,16 +1,24 @@ <?php abstract class FeedItem { - abstract function get_id(); + abstract function get_id(): string; + + /** @return int|false a timestamp on success, false otherwise */ abstract function get_date(); - abstract function get_link(); - abstract function get_title(); - abstract function get_description(); - abstract function get_content(); - abstract function get_comments_url(); - abstract function get_comments_count(); - abstract function get_categories(); - abstract function get_enclosures(); - abstract function get_author(); - abstract function get_language(); + + abstract function get_link(): string; + abstract function get_title(): string; + abstract function get_description(): string; + abstract function get_content(): string; + abstract function get_comments_url(): string; + abstract function get_comments_count(): int; + + /** @return array<int, string> */ + abstract function get_categories(): array; + + /** @return array<int, FeedEnclosure> */ + abstract function get_enclosures(): array; + + abstract function get_author(): string; + abstract function get_language(): string; } diff --git a/classes/feeditem/atom.php b/classes/feeditem/atom.php index 51358f36c..36a2e91f5 100755 --- a/classes/feeditem/atom.php +++ b/classes/feeditem/atom.php @@ -2,7 +2,7 @@ class FeedItem_Atom extends FeedItem_Common { const NS_XML = "http://www.w3.org/XML/1998/namespace"; - function get_id() { + function get_id(): string { $id = $this->elem->getElementsByTagName("id")->item(0); if ($id) { @@ -12,6 +12,9 @@ class FeedItem_Atom extends FeedItem_Common { } } + /** + * @return int|false a timestamp on success, false otherwise + */ function get_date() { $updated = $this->elem->getElementsByTagName("updated")->item(0); @@ -30,10 +33,13 @@ class FeedItem_Atom extends FeedItem_Common { if ($date) { return strtotime($date->nodeValue); } + + // consistent with strtotime failing to parse + return false; } - function get_link() { + function get_link(): string { $links = $this->elem->getElementsByTagName("link"); foreach ($links as $link) { @@ -44,24 +50,27 @@ class FeedItem_Atom extends FeedItem_Common { $base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $link); if ($base) - return rewrite_relative_url($base, clean(trim($link->getAttribute("href")))); + return UrlHelper::rewrite_relative($base, clean(trim($link->getAttribute("href")))); else return clean(trim($link->getAttribute("href"))); - } } + + return ''; } - function get_title() { + function get_title(): string { $title = $this->elem->getElementsByTagName("title")->item(0); - - if ($title) { - return clean(trim($title->nodeValue)); - } + return $title ? clean(trim($title->nodeValue)) : ''; } - /** $base is optional (returns $content if $base is null), $content is an HTML string */ - private function rewrite_content_to_base($base, $content) { + /** + * @param string|null $base optional (returns $content if $base is null) + * @param string $content an HTML string + * + * @return string the rewritten XML or original $content + */ + private function rewrite_content_to_base(?string $base = null, string $content) { if (!empty($base) && !empty($content)) { @@ -81,14 +90,17 @@ class FeedItem_Atom extends FeedItem_Common { } } - return $tmpdoc->saveXML(); + // Fall back to $content if saveXML somehow fails (i.e. returns false) + $modified_content = $tmpdoc->saveXML(); + return $modified_content !== false ? $modified_content : $content; } } return $content; } - function get_content() { + function get_content(): string { + /** @var DOMElement|null */ $content = $this->elem->getElementsByTagName("content")->item(0); if ($content) { @@ -108,10 +120,13 @@ class FeedItem_Atom extends FeedItem_Common { return $this->rewrite_content_to_base($base, $this->subtree_or_text($content)); } + + return ''; } // TODO: duplicate code should be merged with get_content() - function get_description() { + function get_description(): string { + /** @var DOMElement|null */ $content = $this->elem->getElementsByTagName("summary")->item(0); if ($content) { @@ -132,9 +147,13 @@ class FeedItem_Atom extends FeedItem_Common { return $this->rewrite_content_to_base($base, $this->subtree_or_text($content)); } + return ''; } - function get_categories() { + /** + * @return array<int, string> + */ + function get_categories(): array { $categories = $this->elem->getElementsByTagName("category"); $cats = []; @@ -152,7 +171,10 @@ class FeedItem_Atom extends FeedItem_Common { return $this->normalize_categories($cats); } - function get_enclosures() { + /** + * @return array<int, FeedEnclosure> + */ + function get_enclosures(): array { $links = $this->elem->getElementsByTagName("link"); $encs = []; @@ -182,7 +204,7 @@ class FeedItem_Atom extends FeedItem_Common { return $encs; } - function get_language() { + function get_language(): string { $lang = $this->elem->getAttributeNS(self::NS_XML, "lang"); if (!empty($lang)) { @@ -195,5 +217,6 @@ class FeedItem_Atom extends FeedItem_Common { } } } + return ''; } } diff --git a/classes/feeditem/common.php b/classes/feeditem/common.php index 18afeaa94..6a9be8aca 100755 --- a/classes/feeditem/common.php +++ b/classes/feeditem/common.php @@ -1,16 +1,20 @@ <?php abstract class FeedItem_Common extends FeedItem { + /** @var DOMElement */ protected $elem; - protected $xpath; + + /** @var DOMDocument */ protected $doc; - function __construct($elem, $doc, $xpath) { + /** @var DOMXPath */ + protected $xpath; + + function __construct(DOMElement $elem, DOMDocument $doc, DOMXPath $xpath) { $this->elem = $elem; $this->xpath = $xpath; $this->doc = $doc; try { - $source = $elem->getElementsByTagName("source")->item(0); // we don't need <source> element @@ -21,11 +25,12 @@ abstract class FeedItem_Common extends FeedItem { } } - function get_element() { + function get_element(): DOMElement { return $this->elem; } - function get_author() { + function get_author(): string { + /** @var DOMElement|null */ $author = $this->elem->getElementsByTagName("author")->item(0); if ($author) { @@ -51,7 +56,7 @@ abstract class FeedItem_Common extends FeedItem { return implode(", ", $authors); } - function get_comments_url() { + function get_comments_url(): string { //RSS only. Use a query here to avoid namespace clashes (e.g. with slash). //might give a wrong result if a default namespace was declared (possible with XPath 2.0) $com_url = $this->xpath->query("comments", $this->elem)->item(0); @@ -65,20 +70,28 @@ abstract class FeedItem_Common extends FeedItem { if ($com_url) return clean($com_url->nodeValue); + + return ''; } - function get_comments_count() { + function get_comments_count(): int { //also query for ATE stuff here $query = "slash:comments|thread:total|atom:link[@rel='replies']/@thread:count"; $comments = $this->xpath->query($query, $this->elem)->item(0); - if ($comments) { - return clean($comments->nodeValue); + if ($comments && is_numeric($comments->nodeValue)) { + return (int) clean($comments->nodeValue); } + + return 0; } - // this is common for both Atom and RSS types and deals with various media: elements - function get_enclosures() { + /** + * this is common for both Atom and RSS types and deals with various 'media:' elements + * + * @return array<int, FeedEnclosure> + */ + function get_enclosures(): array { $encs = []; $enclosures = $this->xpath->query("media:content", $this->elem); @@ -108,6 +121,7 @@ abstract class FeedItem_Common extends FeedItem { foreach ($enclosures as $enclosure) { $enc = new FeedEnclosure(); + /** @var DOMElement|null */ $content = $this->xpath->query("media:content", $enclosure)->item(0); if ($content) { @@ -150,11 +164,14 @@ abstract class FeedItem_Common extends FeedItem { return $encs; } - function count_children($node) { + function count_children(DOMElement $node): int { return $node->getElementsByTagName("*")->length; } - function subtree_or_text($node) { + /** + * @return false|string false on failure, otherwise string contents + */ + function subtree_or_text(DOMElement $node) { if ($this->count_children($node) == 0) { return $node->nodeValue; } else { @@ -162,7 +179,12 @@ abstract class FeedItem_Common extends FeedItem { } } - static function normalize_categories($cats) { + /** + * @param array<int, string> $cats + * + * @return array<int, string> + */ + static function normalize_categories(array $cats): array { $tmp = []; diff --git a/classes/feeditem/rss.php b/classes/feeditem/rss.php index 1f7953c51..7017d04e9 100755 --- a/classes/feeditem/rss.php +++ b/classes/feeditem/rss.php @@ -1,6 +1,6 @@ <?php class FeedItem_RSS extends FeedItem_Common { - function get_id() { + function get_id(): string { $id = $this->elem->getElementsByTagName("guid")->item(0); if ($id) { @@ -10,6 +10,9 @@ class FeedItem_RSS extends FeedItem_Common { } } + /** + * @return int|false a timestamp on success, false otherwise + */ function get_date() { $pubDate = $this->elem->getElementsByTagName("pubDate")->item(0); @@ -22,9 +25,12 @@ class FeedItem_RSS extends FeedItem_Common { if ($date) { return strtotime($date->nodeValue); } + + // consistent with strtotime failing to parse + return false; } - function get_link() { + function get_link(): string { $links = $this->xpath->query("atom:link", $this->elem); foreach ($links as $link) { @@ -37,6 +43,7 @@ class FeedItem_RSS extends FeedItem_Common { } } + /** @var DOMElement|null */ $link = $this->elem->getElementsByTagName("guid")->item(0); if ($link && $link->hasAttributes() && $link->getAttribute("isPermaLink") == "true") { @@ -48,9 +55,11 @@ class FeedItem_RSS extends FeedItem_Common { if ($link) { return clean(trim($link->nodeValue)); } + + return ''; } - function get_title() { + function get_title(): string { $title = $this->xpath->query("title", $this->elem)->item(0); if ($title) { @@ -64,10 +73,15 @@ class FeedItem_RSS extends FeedItem_Common { if ($title) { return clean(trim($title->nodeValue)); } + + return ''; } - function get_content() { + function get_content(): string { + /** @var DOMElement|null */ $contentA = $this->xpath->query("content:encoded", $this->elem)->item(0); + + /** @var DOMElement|null */ $contentB = $this->elem->getElementsByTagName("description")->item(0); if ($contentA && !$contentB) { @@ -85,17 +99,24 @@ class FeedItem_RSS extends FeedItem_Common { return mb_strlen($resultA) > mb_strlen($resultB) ? $resultA : $resultB; } + + return ''; } - function get_description() { + function get_description(): string { $summary = $this->elem->getElementsByTagName("description")->item(0); if ($summary) { return $summary->nodeValue; } + + return ''; } - function get_categories() { + /** + * @return array<int, string> + */ + function get_categories(): array { $categories = $this->elem->getElementsByTagName("category"); $cats = []; @@ -112,7 +133,10 @@ class FeedItem_RSS extends FeedItem_Common { return $this->normalize_categories($cats); } - function get_enclosures() { + /** + * @return array<int, FeedEnclosure> + */ + function get_enclosures(): array { $enclosures = $this->elem->getElementsByTagName("enclosure"); $encs = array(); @@ -134,7 +158,7 @@ class FeedItem_RSS extends FeedItem_Common { return $encs; } - function get_language() { + function get_language(): string { $languages = $this->doc->getElementsByTagName('language'); if (count($languages) == 0) { @@ -143,5 +167,4 @@ class FeedItem_RSS extends FeedItem_Common { return clean($languages[0]->textContent); } - } diff --git a/classes/feedparser.php b/classes/feedparser.php index daba271fb..3ed0647d2 100644 --- a/classes/feedparser.php +++ b/classes/feedparser.php @@ -1,19 +1,35 @@ <?php class FeedParser { + + /** @var DOMDocument */ private $doc; - private $error; - private $libxml_errors = array(); - private $items; + + /** @var string|null */ + private $error = null; + + /** @var array<string> */ + private $libxml_errors = []; + + /** @var array<FeedItem> */ + private $items = []; + + /** @var string|null */ private $link; + + /** @var string|null */ private $title; + + /** @var FeedParser::FEED_*|null */ private $type; + + /** @var DOMXPath|null */ private $xpath; const FEED_RDF = 0; const FEED_RSS = 1; const FEED_ATOM = 2; - function __construct($data) { + function __construct(string $data) { libxml_use_internal_errors(true); libxml_clear_errors(); $this->doc = new DOMDocument(); @@ -26,18 +42,18 @@ class FeedParser { if ($error) { foreach (libxml_get_errors() as $error) { if ($error->level == LIBXML_ERR_FATAL) { - if(!isset($this->error)) //currently only the first error is reported + // currently only the first error is reported + if (!isset($this->error)) { $this->error = $this->format_error($error); - $this->libxml_errors [] = $this->format_error($error); + } + $this->libxml_errors[] = $this->format_error($error); } } } libxml_clear_errors(); - - $this->items = array(); } - function init() { + function init() : void { $root = $this->doc->firstChild; $xpath = new DOMXPath($this->doc); $xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom'); @@ -51,10 +67,12 @@ class FeedParser { $this->xpath = $xpath; - $root = $xpath->query("(//atom03:feed|//atom:feed|//channel|//rdf:rdf|//rdf:RDF)"); + $root_list = $xpath->query("(//atom03:feed|//atom:feed|//channel|//rdf:rdf|//rdf:RDF)"); + + if (!empty($root_list) && $root_list->length > 0) { - if (!empty($root) && $root->length > 0) { - $root = $root->item(0); + /** @var DOMElement|null $root */ + $root = $root_list->item(0); if ($root) { switch (mb_strtolower($root->tagName)) { @@ -69,7 +87,7 @@ class FeedParser { $this->type = $this::FEED_ATOM; break; default: - if( !isset($this->error) ){ + if (!isset($this->error)) { $this->error = "Unknown/unsupported feed type"; } return; @@ -100,6 +118,7 @@ class FeedParser { if (!$link) $link = $xpath->query("//atom03:feed/atom03:link[@rel='alternate']")->item(0); + /** @var DOMElement|null $link */ if ($link && $link->hasAttributes()) { $this->link = $link->getAttribute("href"); } @@ -121,6 +140,7 @@ class FeedParser { $this->title = $title->nodeValue; } + /** @var DOMElement|null $link */ $link = $xpath->query("//channel/link")->item(0); if ($link) { @@ -166,46 +186,43 @@ class FeedParser { if ($this->link) $this->link = trim($this->link); } else { - if( !isset($this->error) ){ + if (!isset($this->error)) { $this->error = "Unknown/unsupported feed type"; } return; } } - function format_error($error) { - if ($error) { - return sprintf("LibXML error %s at line %d (column %d): %s", - $error->code, $error->line, $error->column, - $error->message); - } else { - return ""; - } + /** @deprecated use Errors::format_libxml_error() instead */ + function format_error(LibXMLError $error) : string { + return Errors::format_libxml_error($error); } // libxml may have invalid unicode data in error messages - function error() { + function error() : string { return UConverter::transcode($this->error, 'UTF-8', 'UTF-8'); } - // WARNING: may return invalid unicode data - function errors() { + /** @return array<string> - WARNING: may return invalid unicode data */ + function errors() : array { return $this->libxml_errors; } - function get_link() { - return clean($this->link); + function get_link() : string { + return clean($this->link ?? ''); } - function get_title() { - return clean($this->title); + function get_title() : string { + return clean($this->title ?? ''); } - function get_items() { + /** @return array<FeedItem> */ + function get_items() : array { return $this->items; } - function get_links($rel) { + /** @return array<string> */ + function get_links(string $rel) : array { $rv = array(); switch ($this->type) { diff --git a/classes/feeds.php b/classes/feeds.php index 42673ca95..6f812b98c 100755 --- a/classes/feeds.php +++ b/classes/feeds.php @@ -5,20 +5,25 @@ class Feeds extends Handler_Protected { const NEVER_GROUP_FEEDS = [ -6, 0 ]; const NEVER_GROUP_BY_DATE = [ -2, -1, -3 ]; - private $params; + /** @var int|float int on 64-bit, float on 32-bit */ + private $viewfeed_timestamp; - private $viewfeed_timestamp; - private $viewfeed_timestamp_last; + /** @var int|float int on 64-bit, float on 32-bit */ + private $viewfeed_timestamp_last; - function csrf_ignore($method) { + function csrf_ignore(string $method): bool { $csrf_ignored = array("index"); return array_search($method, $csrf_ignored) !== false; } - private function _format_headlines_list($feed, $method, $view_mode, $limit, $cat_view, - $offset, $override_order = false, $include_children = false, $check_first_id = false, - $skip_first_id_check = false, $order_by = false) { + /** + * @param string|int $feed + * @return array{0: array<int, int>, 1: int, 2: int, 3: bool, 4: array<string, mixed>} $topmost_article_ids, $headlines_count, $feed, $disable_cache, $reply + */ + private function _format_headlines_list($feed, string $method, string $view_mode, int $limit, bool $cat_view, + int $offset, string $override_order, bool $include_children, ?int $check_first_id = null, + bool $skip_first_id_check, string $order_by): array { $disable_cache = false; @@ -65,6 +70,8 @@ class Feeds extends Handler_Protected { $qfh_ret = []; if (!$cat_view && is_numeric($feed) && $feed < PLUGIN_FEED_BASE_INDEX && $feed > LABEL_BASE_INDEX) { + + /** @var IVirtualFeed|false $handler */ $handler = PluginHost::getInstance()->get_feed_handler( PluginHost::feed_to_pfeed_id($feed)); @@ -126,7 +133,7 @@ class Feeds extends Handler_Protected { $reply['vfeed_group_enabled'] = $vfeed_group_enabled; $plugin_menu_items = ""; - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM, + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2, function ($result) use (&$plugin_menu_items) { $plugin_menu_items .= $result; }, @@ -188,7 +195,11 @@ class Feeds extends Handler_Protected { // frontend doesn't expect pdo returning booleans as strings on mysql if (Config::get(Config::DB_TYPE) == "mysql") { foreach (["unread", "marked", "published"] as $k) { - $line[$k] = $line[$k] === "1"; + if (is_integer($line[$k])) { + $line[$k] = $line[$k] === 1; + } else { + $line[$k] = $line[$k] === "1"; + } } } @@ -230,24 +241,58 @@ class Feeds extends Handler_Protected { $line["feed_title"] = $line["feed_title"] ?? ""; + $button_doc = new DOMDocument(); + $line["buttons_left"] = ""; PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_LEFT_BUTTON, - function ($result) use (&$line) { - $line["buttons_left"] .= $result; + function ($result, $plugin) use (&$line, &$button_doc) { + if ($result && $button_doc->loadXML($result)) { + + /** @var DOMElement|null */ + $child = $button_doc->firstChild; + + if ($child) { + do { + $child->setAttribute('data-plugin-name', get_class($plugin)); + } while ($child = $child->nextSibling); + + $line["buttons_left"] .= $button_doc->saveXML($button_doc->firstChild); + } + } else if ($result) { + user_error(get_class($plugin) . + " plugin: content provided in HOOK_ARTICLE_LEFT_BUTTON is not valid XML: " . + Errors::libxml_last_error() . " $result", E_USER_WARNING); + } }, $line); $line["buttons"] = ""; PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_BUTTON, - function ($result) use (&$line) { - $line["buttons"] .= $result; + function ($result, $plugin) use (&$line, &$button_doc) { + if ($result && $button_doc->loadXML($result)) { + + /** @var DOMElement|null */ + $child = $button_doc->firstChild; + + if ($child) { + do { + $child->setAttribute('data-plugin-name', get_class($plugin)); + } while ($child = $child->nextSibling); + + $line["buttons"] .= $button_doc->saveXML($button_doc->firstChild); + } + } else if ($result) { + user_error(get_class($plugin) . + " plugin: content provided in HOOK_ARTICLE_BUTTON is not valid XML: " . + Errors::libxml_last_error() . " $result", E_USER_WARNING); + } }, $line); $this->_mark_timestamp(" pre-sanitize"); $line["content"] = Sanitizer::sanitize($line["content"], - $line['hide_images'], false, $line["site_url"], $highlight_words, $line["id"]); + $line['hide_images'], null, $line["site_url"], $highlight_words, $line["id"]); $this->_mark_timestamp(" sanitize"); @@ -265,9 +310,9 @@ class Feeds extends Handler_Protected { if ($line["num_enclosures"] > 0) { $line["enclosures"] = Article::_format_enclosures($id, - $line["always_display_enclosures"], + sql_bool_to_bool($line["always_display_enclosures"]), $line["content"], - $line["hide_images"]); + sql_bool_to_bool($line["hide_images"])); } else { $line["enclosures"] = [ 'formatted' => '', 'entries' => [] ]; } @@ -275,7 +320,7 @@ class Feeds extends Handler_Protected { $this->_mark_timestamp(" enclosures"); $line["updated_long"] = TimeHelper::make_local_datetime($line["updated"],true); - $line["updated"] = TimeHelper::make_local_datetime($line["updated"], false, false, false, true); + $line["updated"] = TimeHelper::make_local_datetime($line["updated"], false, null, false, true); $line['imported'] = T_sprintf("Imported at %s", TimeHelper::make_local_datetime($line["date_entered"], false)); @@ -409,7 +454,7 @@ class Feeds extends Handler_Protected { return array($topmost_article_ids, $headlines_count, $feed, $disable_cache, $reply); } - function catchupAll() { + function catchupAll(): void { $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET last_read = NOW(), unread = false WHERE unread = true AND owner_uid = ?"); $sth->execute([$_SESSION['uid']]); @@ -417,16 +462,16 @@ class Feeds extends Handler_Protected { print json_encode(array("message" => "UPDATE_COUNTERS")); } - function view() { + function view(): void { $reply = array(); $feed = $_REQUEST["feed"]; $method = $_REQUEST["m"] ?? ""; $view_mode = $_REQUEST["view_mode"] ?? ""; $limit = 30; - $cat_view = $_REQUEST["cat"] == "true"; + $cat_view = self::_param_to_bool($_REQUEST["cat"] ?? false); $next_unread_feed = $_REQUEST["nuf"] ?? 0; - $offset = $_REQUEST["skip"] ?? 0; + $offset = (int) ($_REQUEST["skip"] ?? 0); $order_by = $_REQUEST["order_by"] ?? ""; $check_first_id = $_REQUEST["fid"] ?? 0; @@ -514,7 +559,10 @@ class Feeds extends Handler_Protected { print json_encode($reply); } - private function _generate_dashboard_feed() { + /** + * @return array<string, array<string, mixed>> + */ + private function _generate_dashboard_feed(): array { $reply = array(); $reply['headlines']['id'] = -5; @@ -556,7 +604,10 @@ class Feeds extends Handler_Protected { return $reply; } - private function _generate_error_feed($error) { + /** + * @return array<string, mixed> + */ + private function _generate_error_feed(string $error): array { $reply = array(); $reply['headlines']['id'] = -7; @@ -572,13 +623,13 @@ class Feeds extends Handler_Protected { return $reply; } - function subscribeToFeed() { + function subscribeToFeed(): void { print json_encode([ "cat_select" => \Controls\select_feeds_cats("cat") ]); } - function search() { + function search(): void { print json_encode([ "show_language" => Config::get(Config::DB_TYPE) == "pgsql", "show_syntax_help" => count(PluginHost::getInstance()->get_hooks(PluginHost::HOOK_SEARCH)) == 0, @@ -587,10 +638,31 @@ class Feeds extends Handler_Protected { ]); } - function updatedebugger() { + function opensite(): void { + $feed = ORM::for_table('ttrss_feeds') + ->find_one((int)$_REQUEST['feed_id']); + + if ($feed) { + $site_url = UrlHelper::validate($feed->site_url); + + if ($site_url) { + header("Location: $site_url"); + return; + } + } + + header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); + print "Feed not found or has an empty site URL."; + } + + function updatedebugger(): void { header("Content-type: text/html"); - $xdebug = isset($_REQUEST["xdebug"]) ? (int)$_REQUEST["xdebug"] : 1; + $xdebug = isset($_REQUEST["xdebug"]) ? (int)$_REQUEST["xdebug"] : Debug::LOG_VERBOSE; + + if (!in_array($xdebug, Debug::ALL_LOG_LEVELS)) { + $xdebug = Debug::LOG_VERBOSE; + } Debug::set_enabled(true); Debug::set_loglevel($xdebug); @@ -603,9 +675,9 @@ class Feeds extends Handler_Protected { $sth->execute([$feed_id, $_SESSION['uid']]); if (!$sth->fetch()) { - print "Access denied."; - return; - } + print "Access denied."; + return; + } ?> <!DOCTYPE html> <html> @@ -658,7 +730,7 @@ class Feeds extends Handler_Protected { <fieldset> <label> <?= \Controls\select_hash("xdebug", $xdebug, - [Debug::$LOG_VERBOSE => "LOG_VERBOSE", Debug::$LOG_EXTENDED => "LOG_EXTENDED"]); + [Debug::LOG_VERBOSE => "LOG_VERBOSE", Debug::LOG_EXTENDED => "LOG_EXTENDED"]); ?></label> </fieldset> @@ -690,7 +762,10 @@ class Feeds extends Handler_Protected { } - static function _catchup($feed, $cat_view, $owner_uid = false, $mode = 'all', $search = false) { + /** + * @param array<int, string> $search + */ + static function _catchup(string $feed_id_or_tag_name, bool $cat_view, ?int $owner_uid = null, string $mode = 'all', ?array $search = null): void { if (!$owner_uid) $owner_uid = $_SESSION['uid']; @@ -744,14 +819,16 @@ class Feeds extends Handler_Protected { $date_qpart = "true"; } - if (is_numeric($feed)) { + if (is_numeric($feed_id_or_tag_name)) { + $feed_id = (int) $feed_id_or_tag_name; + if ($cat_view) { - if ($feed >= 0) { + if ($feed_id >= 0) { - if ($feed > 0) { - $children = self::_get_child_cats($feed, $owner_uid); - array_push($children, $feed); + if ($feed_id > 0) { + $children = self::_get_child_cats($feed_id, $owner_uid); + array_push($children, $feed_id); $children = array_map("intval", $children); $children = join(",", $children); @@ -769,7 +846,7 @@ class Feeds extends Handler_Protected { (SELECT id FROM ttrss_feeds WHERE $cat_qpart) AND $date_qpart AND $search_qpart) as tmp)"); $sth->execute([$owner_uid]); - } else if ($feed == -2) { + } else if ($feed_id == -2) { $sth = $pdo->prepare("UPDATE ttrss_user_entries SET unread = false,last_read = NOW() WHERE (SELECT COUNT(*) @@ -778,18 +855,18 @@ class Feeds extends Handler_Protected { $sth->execute([$owner_uid]); } - } else if ($feed > 0) { + } else if ($feed_id > 0) { $sth = $pdo->prepare("UPDATE ttrss_user_entries SET unread = false, last_read = NOW() WHERE ref_id IN (SELECT id FROM (SELECT DISTINCT id FROM ttrss_entries, ttrss_user_entries WHERE ref_id = id AND owner_uid = ? AND unread = true AND feed_id = ? AND $date_qpart AND $search_qpart) as tmp)"); - $sth->execute([$owner_uid, $feed]); + $sth->execute([$owner_uid, $feed_id]); - } else if ($feed < 0 && $feed > LABEL_BASE_INDEX) { // special, like starred + } else if ($feed_id < 0 && $feed_id > LABEL_BASE_INDEX) { // special, like starred - if ($feed == -1) { + if ($feed_id == -1) { $sth = $pdo->prepare("UPDATE ttrss_user_entries SET unread = false, last_read = NOW() WHERE ref_id IN (SELECT id FROM @@ -798,7 +875,7 @@ class Feeds extends Handler_Protected { $sth->execute([$owner_uid]); } - if ($feed == -2) { + if ($feed_id == -2) { $sth = $pdo->prepare("UPDATE ttrss_user_entries SET unread = false, last_read = NOW() WHERE ref_id IN (SELECT id FROM @@ -807,7 +884,7 @@ class Feeds extends Handler_Protected { $sth->execute([$owner_uid]); } - if ($feed == -3) { + if ($feed_id == -3) { $intl = (int) get_pref(Prefs::FRESH_ARTICLE_MAX_AGE); @@ -826,7 +903,7 @@ class Feeds extends Handler_Protected { $sth->execute([$owner_uid]); } - if ($feed == -4) { + if ($feed_id == -4) { $sth = $pdo->prepare("UPDATE ttrss_user_entries SET unread = false, last_read = NOW() WHERE ref_id IN (SELECT id FROM @@ -834,10 +911,9 @@ class Feeds extends Handler_Protected { AND owner_uid = ? AND unread = true AND $date_qpart AND $search_qpart) as tmp)"); $sth->execute([$owner_uid]); } + } else if ($feed_id < LABEL_BASE_INDEX) { // label - } else if ($feed < LABEL_BASE_INDEX) { // label - - $label_id = Labels::feed_to_label_id($feed); + $label_id = Labels::feed_to_label_id($feed_id); $sth = $pdo->prepare("UPDATE ttrss_user_entries SET unread = false, last_read = NOW() WHERE ref_id IN @@ -846,23 +922,29 @@ class Feeds extends Handler_Protected { AND label_id = ? AND ref_id = article_id AND owner_uid = ? AND unread = true AND $date_qpart AND $search_qpart) as tmp)"); $sth->execute([$label_id, $owner_uid]); - } - } else { // tag + $tag_name = $feed_id_or_tag_name; + $sth = $pdo->prepare("UPDATE ttrss_user_entries SET unread = false, last_read = NOW() WHERE ref_id IN (SELECT id FROM (SELECT DISTINCT ttrss_entries.id FROM ttrss_entries, ttrss_user_entries, ttrss_tags WHERE ref_id = ttrss_entries.id AND post_int_id = int_id AND tag_name = ? AND ttrss_user_entries.owner_uid = ? AND unread = true AND $date_qpart AND $search_qpart) as tmp)"); - $sth->execute([$feed, $owner_uid]); - + $sth->execute([$tag_name, $owner_uid]); } } - static function _get_counters($feed, $is_cat = false, $unread_only = false, - $owner_uid = false) { + /** + * @param int|string $feed feed id or tag name + * @param bool $is_cat + * @param bool $unread_only + * @param null|int $owner_uid + * @return int + * @throws PDOException + */ + static function _get_counters($feed, bool $is_cat = false, bool $unread_only = false, ?int $owner_uid = null): int { $n_feed = (int) $feed; $need_entries = false; @@ -883,6 +965,7 @@ class Feeds extends Handler_Protected { return self::_get_cat_unread($n_feed, $owner_uid); } else if ($n_feed == -6) { return 0; + // tags } else if ($feed != "0" && $n_feed == 0) { $sth = $pdo->prepare("SELECT SUM((SELECT COUNT(int_id) @@ -893,7 +976,8 @@ class Feeds extends Handler_Protected { $sth->execute([$owner_uid, $feed]); $row = $sth->fetch(); - return $row["count"]; + // Handle 'SUM()' returning null if there are no results + return $row["count"] ?? 0; } else if ($n_feed == -1) { $match_part = "marked = true"; @@ -961,9 +1045,9 @@ class Feeds extends Handler_Protected { } } - function add() { + function add(): void { $feed = clean($_REQUEST['feed']); - $cat = clean($_REQUEST['cat'] ?? ''); + $cat = (int) clean($_REQUEST['cat'] ?? ''); $need_auth = isset($_REQUEST['need_auth']); $login = $need_auth ? clean($_REQUEST['login']) : ''; $pass = $need_auth ? clean($_REQUEST['pass']) : ''; @@ -974,7 +1058,7 @@ class Feeds extends Handler_Protected { } /** - * @return array (code => Status code, message => error message if available) + * @return array<string, mixed> (code => Status code, message => error message if available) * * 0 - OK, Feed already exists * 1 - OK, Feed added @@ -986,9 +1070,15 @@ class Feeds extends Handler_Protected { * 5 - Couldn't download the URL content. * 6 - Content is an invalid XML. * 7 - Error while creating feed database entry. + * 8 - Permission denied (ACCESS_LEVEL_READONLY). */ - static function _subscribe($url, $cat_id = 0, - $auth_login = '', $auth_pass = '') : array { + static function _subscribe(string $url, int $cat_id = 0, string $auth_login = '', string $auth_pass = ''): array { + + $user = ORM::for_table("ttrss_users")->find_one($_SESSION['uid']); + + if ($user && $user->access_level == UserHelper::ACCESS_LEVEL_READONLY) { + return ["code" => 8]; + } $pdo = Db::pdo(); @@ -996,6 +1086,13 @@ class Feeds extends Handler_Protected { if (!$url) return ["code" => 2]; + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_PRE_SUBSCRIBE, + /** @phpstan-ignore-next-line */ + function ($result) use (&$url, &$auth_login, &$auth_pass) { + // arguments are updated inside the hook (if needed) + }, + $url, $auth_login, $auth_pass); + $contents = UrlHelper::fetch($url, false, $auth_login, $auth_pass); PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_SUBSCRIBE_FEED, @@ -1054,15 +1151,18 @@ class Feeds extends Handler_Protected { } } - static function _get_icon_file($feed_id) { + static function _get_icon_file(int $feed_id): string { return Config::get(Config::ICONS_DIR) . "/$feed_id.ico"; } - static function _has_icon($id) { + static function _has_icon(int $id): bool { return is_file(Config::get(Config::ICONS_DIR) . "/$id.ico") && filesize(Config::get(Config::ICONS_DIR) . "/$id.ico") > 0; } - static function _get_icon($id) { + /** + * @return false|string false if the icon ID was unrecognized, otherwise, the icon identifier string + */ + static function _get_icon(int $id) { switch ($id) { case 0: return "archive"; @@ -1082,7 +1182,7 @@ class Feeds extends Handler_Protected { } else { $icon = self::_get_icon_file($id); - if ($icon && file_exists($icon)) { + if ($icon && file_exists($icon)) { return Config::get(Config::ICONS_URL) . "/" . basename($icon) . "?" . filemtime($icon); } } @@ -1092,6 +1192,9 @@ class Feeds extends Handler_Protected { return false; } + /** + * @return false|int false if the feed couldn't be found by URL+owner, otherwise the feed ID + */ static function _find_by_url(string $feed_url, int $owner_uid) { $feed = ORM::for_table('ttrss_feeds') ->where('owner_uid', $owner_uid) @@ -1105,8 +1208,39 @@ class Feeds extends Handler_Protected { } } - static function _get_title($id, bool $cat = false) { - $pdo = Db::pdo(); + /** + * $owner_uid defaults to $_SESSION['uid'] + * + * @return false|int false if the category/feed couldn't be found by title, otherwise its ID + */ + static function _find_by_title(string $title, bool $cat = false, int $owner_uid = 0) { + + $res = false; + + if ($cat) { + $res = ORM::for_table('ttrss_feed_categories') + ->where('owner_uid', $owner_uid ? $owner_uid : $_SESSION['uid']) + ->where('title', $title) + ->find_one(); + } else { + $res = ORM::for_table('ttrss_feeds') + ->where('owner_uid', $owner_uid ? $owner_uid : $_SESSION['uid']) + ->where('title', $title) + ->find_one(); + } + + if ($res) { + return $res->id; + } else { + return false; + } + } + + /** + * @param string|int $id + */ + static function _get_title($id, bool $cat = false): string { + $pdo = Db::pdo(); if ($cat) { return self::_get_cat_title($id); @@ -1118,7 +1252,7 @@ class Feeds extends Handler_Protected { return __("Fresh articles"); } else if ($id == -4) { return __("All articles"); - } else if ($id === 0 || $id === "0") { + } else if ($id === 0) { return __("Archived articles"); } else if ($id == -6) { return __("Recently read"); @@ -1147,12 +1281,12 @@ class Feeds extends Handler_Protected { } } else { - return $id; + return "$id"; } } // only real cats - static function _get_cat_marked(int $cat, int $owner_uid = 0) { + static function _get_cat_marked(int $cat, int $owner_uid = 0): int { if (!$owner_uid) $owner_uid = $_SESSION["uid"]; @@ -1166,16 +1300,17 @@ class Feeds extends Handler_Protected { WHERE (cat_id = :cat OR (:cat IS NULL AND cat_id IS NULL)) AND owner_uid = :uid) AND owner_uid = :uid"); + $sth->execute(["cat" => $cat ? $cat : null, "uid" => $owner_uid]); - $row = $sth->fetch(); - return $row["marked"]; - } else { - return 0; + if ($row = $sth->fetch()) { + return (int) $row["marked"]; + } } + return 0; } - static function _get_cat_unread(int $cat, int $owner_uid = 0) { + static function _get_cat_unread(int $cat, int $owner_uid = 0): int { if (!$owner_uid) $owner_uid = $_SESSION["uid"]; @@ -1186,14 +1321,15 @@ class Feeds extends Handler_Protected { $sth = $pdo->prepare("SELECT SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS unread FROM ttrss_user_entries WHERE feed_id IN (SELECT id FROM ttrss_feeds - WHERE (cat_id = :cat OR (:cat IS NULL AND cat_id IS NULL)) + WHERE (cat_id = :cat OR (:cat IS NULL AND cat_id IS NULL)) AND owner_uid = :uid) AND owner_uid = :uid"); - $sth->execute(["cat" => $cat ? $cat : null, "uid" => $owner_uid]); - $row = $sth->fetch(); - return $row["unread"]; + $sth->execute(["cat" => $cat ? $cat : null, "uid" => $owner_uid]); + if ($row = $sth->fetch()) { + return (int) $row["unread"]; + } } else if ($cat == -1) { return 0; } else if ($cat == -2) { @@ -1201,15 +1337,19 @@ class Feeds extends Handler_Protected { $sth = $pdo->prepare("SELECT COUNT(DISTINCT article_id) AS unread FROM ttrss_user_entries ue, ttrss_user_labels2 l WHERE article_id = ref_id AND unread IS true AND ue.owner_uid = :uid"); + $sth->execute(["uid" => $owner_uid]); - $row = $sth->fetch(); - return $row["unread"]; + if ($row = $sth->fetch()) { + return (int) $row["unread"]; + } } + + return 0; } // only accepts real cats (>= 0) - static function _get_cat_children_unread(int $cat, int $owner_uid = 0) { + static function _get_cat_children_unread(int $cat, int $owner_uid = 0): int { if (!$owner_uid) $owner_uid = $_SESSION["uid"]; $pdo = Db::pdo(); @@ -1228,7 +1368,7 @@ class Feeds extends Handler_Protected { return $unread; } - static function _get_global_unread(int $user_id = 0) { + static function _get_global_unread(int $user_id = 0): int { if (!$user_id) $user_id = $_SESSION["uid"]; @@ -1241,10 +1381,11 @@ class Feeds extends Handler_Protected { $sth->execute([$user_id]); $row = $sth->fetch(); - return $row["count"]; + // Handle 'SUM()' returning null if there are no articles/results (e.g. admin user with no feeds) + return $row["count"] ?? 0; } - static function _get_cat_title(int $cat_id) { + static function _get_cat_title(int $cat_id): string { switch ($cat_id) { case 0: return __("Uncategorized"); @@ -1264,7 +1405,7 @@ class Feeds extends Handler_Protected { } } - private static function _get_label_unread($label_id, int $owner_uid = 0) { + private static function _get_label_unread(int $label_id, ?int $owner_uid = null): int { if (!$owner_uid) $owner_uid = $_SESSION["uid"]; $pdo = Db::pdo(); @@ -1281,7 +1422,11 @@ class Feeds extends Handler_Protected { } } - static function _get_headlines($params) { + /** + * @param array<string, mixed> $params + * @return array<int, mixed> $result, $feed_title, $feed_site_url, $last_error, $last_updated, $highlight_words, $first_id, $is_vfeed, $query_error_override + */ + static function _get_headlines($params): array { $pdo = Db::pdo(); @@ -1362,7 +1507,7 @@ class Feeds extends Handler_Protected { $view_query_part = " "; } else if ($feed != -1) { - $unread = getFeedUnread($feed, $cat_view); + $unread = Feeds::_get_counters($feed, $cat_view, true); if ($cat_view && $feed > 0 && $include_children) $unread += self::_get_cat_children_unread($feed); @@ -1498,7 +1643,7 @@ class Feeds extends Handler_Protected { } else if ($feed <= LABEL_BASE_INDEX) { // labels $label_id = Labels::feed_to_label_id($feed); - $query_strategy_part = "label_id = ".$pdo->quote($label_id)." AND + $query_strategy_part = "label_id = $label_id AND ttrss_labels2.id = ttrss_user_labels2.label_id AND ttrss_user_labels2.article_id = ref_id"; @@ -1778,7 +1923,10 @@ class Feeds extends Handler_Protected { } - static function _get_parent_cats(int $cat, int $owner_uid) { + /** + * @return array<int, int> + */ + static function _get_parent_cats(int $cat, int $owner_uid): array { $rv = array(); $pdo = Db::pdo(); @@ -1795,7 +1943,10 @@ class Feeds extends Handler_Protected { return $rv; } - static function _get_child_cats(int $cat, int $owner_uid) { + /** + * @return array<int, int> + */ + static function _get_child_cats(int $cat, int $owner_uid): array { $rv = array(); $pdo = Db::pdo(); @@ -1812,7 +1963,11 @@ class Feeds extends Handler_Protected { return $rv; } - static function _cats_of(array $feeds, int $owner_uid, bool $with_parents = false) { + /** + * @param array<int, int> $feeds + * @return array<int, int> + */ + static function _cats_of(array $feeds, int $owner_uid, bool $with_parents = false): array { if (count($feeds) == 0) return []; @@ -1851,24 +2006,27 @@ class Feeds extends Handler_Protected { } } - private function _color_of($name) { - $colormap = [ "#1cd7d7","#d91111","#1212d7","#8e16e5","#7b7b7b", - "#39f110","#0bbea6","#ec0e0e","#1534f2","#b9e416", - "#479af2","#f36b14","#10c7e9","#1e8fe7","#e22727" ]; + private function _color_of(string $name): string { + $colormap = [ "#1cd7d7","#d91111","#1212d7","#8e16e5","#7b7b7b", + "#39f110","#0bbea6","#ec0e0e","#1534f2","#b9e416", + "#479af2","#f36b14","#10c7e9","#1e8fe7","#e22727" ]; - $sum = 0; + $sum = 0; - for ($i = 0; $i < strlen($name); $i++) { - $sum += ord($name[$i]); - } + for ($i = 0; $i < strlen($name); $i++) { + $sum += ord($name[$i]); + } - $sum %= count($colormap); + $sum %= count($colormap); - return $colormap[$sum]; + return $colormap[$sum]; } - private static function _get_feeds_from_html($url, $content) { - $url = UrlHelper::validate($url); + /** + * @return array<string, string> array of feed URL -> feed title + */ + private static function _get_feeds_from_html(string $url, string $content): array { + $url = UrlHelper::validate($url); $baseUrl = substr($url, 0, strrpos($url, '/') + 1); $feedUrls = []; @@ -1885,9 +2043,7 @@ class Feeds extends Handler_Protected { if ($title == '') { $title = $entry->getAttribute('type'); } - $feedUrl = rewrite_relative_url( - $baseUrl, $entry->getAttribute('href') - ); + $feedUrl = UrlHelper::rewrite_relative($baseUrl, $entry->getAttribute('href')); $feedUrls[$feedUrl] = $title; } } @@ -1895,11 +2051,11 @@ class Feeds extends Handler_Protected { return $feedUrls; } - static function _is_html($content) { + static function _is_html(string $content): bool { return preg_match("/<html|DOCTYPE html/i", substr($content, 0, 8192)) !== 0; } - static function _remove_cat(int $id, int $owner_uid) { + static function _remove_cat(int $id, int $owner_uid): void { $cat = ORM::for_table('ttrss_feed_categories') ->where('owner_uid', $owner_uid) ->find_one($id); @@ -1908,7 +2064,7 @@ class Feeds extends Handler_Protected { $cat->delete(); } - static function _add_cat(string $title, int $owner_uid, int $parent_cat = null, int $order_id = 0) { + static function _add_cat(string $title, int $owner_uid, int $parent_cat = null, int $order_id = 0): bool { $cat = ORM::for_table('ttrss_feed_categories') ->where('owner_uid', $owner_uid) @@ -1932,13 +2088,18 @@ class Feeds extends Handler_Protected { return false; } - static function _clear_access_keys(int $owner_uid) { + static function _clear_access_keys(int $owner_uid): void { $key = ORM::for_table('ttrss_access_keys') ->where('owner_uid', $owner_uid) ->delete_many(); } - static function _update_access_key(string $feed_id, bool $is_cat, int $owner_uid) { + /** + * @param string $feed_id may be a feed ID or tag + * + * @see Handler_Public#generate_syndicated_feed() + */ + static function _update_access_key(string $feed_id, bool $is_cat, int $owner_uid): ?string { $key = ORM::for_table('ttrss_access_keys') ->where('owner_uid', $owner_uid) ->where('feed_id', $feed_id) @@ -1948,7 +2109,12 @@ class Feeds extends Handler_Protected { return self::_get_access_key($feed_id, $is_cat, $owner_uid); } - static function _get_access_key(string $feed_id, bool $is_cat, int $owner_uid) { + /** + * @param string $feed_id may be a feed ID or tag + * + * @see Handler_Public#generate_syndicated_feed() + */ + static function _get_access_key(string $feed_id, bool $is_cat, int $owner_uid): ?string { $key = ORM::for_table('ttrss_access_keys') ->where('owner_uid', $owner_uid) ->where('feed_id', $feed_id) @@ -1957,21 +2123,23 @@ class Feeds extends Handler_Protected { if ($key) { return $key->access_key; - } else { - $key = ORM::for_table('ttrss_access_keys')->create(); + } - $key->owner_uid = $owner_uid; - $key->feed_id = $feed_id; - $key->is_cat = $is_cat; - $key->access_key = uniqid_short(); + $key = ORM::for_table('ttrss_access_keys')->create(); - if ($key->save()) { - return $key->access_key; - } + $key->owner_uid = $owner_uid; + $key->feed_id = $feed_id; + $key->is_cat = $is_cat; + $key->access_key = uniqid_short(); + + if ($key->save()) { + return $key->access_key; } + + return null; } - static function _purge(int $feed_id, int $purge_interval) { + static function _purge(int $feed_id, int $purge_interval): ?int { if (!$purge_interval) $purge_interval = self::_get_purge_interval($feed_id); @@ -1987,7 +2155,7 @@ class Feeds extends Handler_Protected { $owner_uid = $row["owner_uid"]; if (Config::get(Config::FORCE_ARTICLE_PURGE) != 0) { - Debug::log("purge_feed: FORCE_ARTICLE_PURGE is set, overriding interval to " . Config::get(Config::FORCE_ARTICLE_PURGE), Debug::$LOG_VERBOSE); + Debug::log("purge_feed: FORCE_ARTICLE_PURGE is set, overriding interval to " . Config::get(Config::FORCE_ARTICLE_PURGE), Debug::LOG_VERBOSE); $purge_unread = true; $purge_interval = Config::get(Config::FORCE_ARTICLE_PURGE); } else { @@ -1996,11 +2164,11 @@ class Feeds extends Handler_Protected { $purge_interval = (int) $purge_interval; - Debug::log("purge_feed: interval $purge_interval days for feed $feed_id, owner: $owner_uid, purge unread: $purge_unread", Debug::$LOG_VERBOSE); + Debug::log("purge_feed: interval $purge_interval days for feed $feed_id, owner: $owner_uid, purge unread: $purge_unread", Debug::LOG_VERBOSE); if ($purge_interval <= 0) { - Debug::log("purge_feed: purging disabled for this feed, nothing to do.", Debug::$LOG_VERBOSE); - return; + Debug::log("purge_feed: purging disabled for this feed, nothing to do.", Debug::LOG_VERBOSE); + return null; } if (!$purge_unread) @@ -2032,16 +2200,16 @@ class Feeds extends Handler_Protected { $rows_deleted = $sth->rowCount(); - Debug::log("purge_feed: deleted $rows_deleted articles.", Debug::$LOG_VERBOSE); + Debug::log("purge_feed: deleted $rows_deleted articles.", Debug::LOG_VERBOSE); } else { - Debug::log("purge_feed: owner of $feed_id not found", Debug::$LOG_VERBOSE); + Debug::log("purge_feed: owner of $feed_id not found", Debug::LOG_VERBOSE); } return $rows_deleted; } - private static function _get_purge_interval(int $feed_id) { + private static function _get_purge_interval(int $feed_id): int { $feed = ORM::for_table('ttrss_feeds')->find_one($feed_id); if ($feed) { @@ -2054,7 +2222,10 @@ class Feeds extends Handler_Protected { } } - private static function _search_to_sql($search, $search_language, $owner_uid) { + /** + * @return array{0: string, 1: array<int, string>} [$search_query_part, $search_words] + */ + private static function _search_to_sql(string $search, string $search_language, int $owner_uid): array { $keywords = str_getcsv(preg_replace('/(-?\w+)\:"(\w+)/', '"${1}:${2}', trim($search)), ' '); $query_keywords = array(); $search_words = array(); @@ -2147,7 +2318,7 @@ class Feeds extends Handler_Protected { array_push($query_keywords, "($not (ttrss_entries.id IN ( SELECT article_id FROM ttrss_user_labels2 WHERE - label_id = ".$pdo->quote($label_id).")))"); + label_id = $label_id)))"); } else { array_push($query_keywords, "(false)"); } @@ -2221,7 +2392,10 @@ class Feeds extends Handler_Protected { return array($search_query_part, $search_words); } - static function _order_to_override_query($order) { + /** + * @return array{0: string, 1: bool} + */ + static function _order_to_override_query(string $order): array { $query = ""; $skip_first_id = false; @@ -2231,7 +2405,9 @@ class Feeds extends Handler_Protected { }, $order); - if ($query) return [$query, $skip_first_id]; + if (is_string($query) && $query !== "") { + return [$query, $skip_first_id]; + } switch ($order) { case "title": @@ -2249,7 +2425,7 @@ class Feeds extends Handler_Protected { return [$query, $skip_first_id]; } - private function _mark_timestamp($label) { + private function _mark_timestamp(string $label): void { if (empty($_REQUEST['timestamps'])) return; diff --git a/classes/handler.php b/classes/handler.php index 09557c284..806c9cfbe 100644 --- a/classes/handler.php +++ b/classes/handler.php @@ -1,23 +1,37 @@ <?php class Handler implements IHandler { + // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ + /** @var PDO */ protected $pdo; + + /** @var array<int|string, mixed> */ protected $args; - function __construct($args) { + /** + * @param array<int|string, mixed> $args + */ + function __construct(array $args) { $this->pdo = Db::pdo(); $this->args = $args; } - function csrf_ignore($method) { + function csrf_ignore(string $method): bool { return false; } - function before($method) { + function before(string $method): bool { return true; } - function after() { + function after(): bool { return true; } + /** + * @param mixed $p + */ + protected static function _param_to_bool($p): bool { + $p = clean($p); + return $p && ($p !== "f" && $p !== "false"); + } } diff --git a/classes/handler/administrative.php b/classes/handler/administrative.php index 52dfed8b7..533cb3630 100644 --- a/classes/handler/administrative.php +++ b/classes/handler/administrative.php @@ -1,8 +1,8 @@ <?php class Handler_Administrative extends Handler_Protected { - function before($method) { + function before(string $method): bool { if (parent::before($method)) { - if (($_SESSION["access_level"] ?? 0) >= 10) { + if (($_SESSION["access_level"] ?? 0) >= UserHelper::ACCESS_LEVEL_ADMIN) { return true; } } diff --git a/classes/handler/protected.php b/classes/handler/protected.php index 8e9e5ca1d..a15fc0956 100644 --- a/classes/handler/protected.php +++ b/classes/handler/protected.php @@ -1,7 +1,7 @@ <?php class Handler_Protected extends Handler { - function before($method) { + function before(string $method): bool { return parent::before($method) && !empty($_SESSION['uid']); } } diff --git a/classes/handler/public.php b/classes/handler/public.php index 4da32e90d..3fef4c2b9 100755 --- a/classes/handler/public.php +++ b/classes/handler/public.php @@ -1,10 +1,12 @@ <?php class Handler_Public extends Handler { - // $feed may be a tag + /** + * @param string $feed may be a feed ID or tag + */ private function generate_syndicated_feed(int $owner_uid, string $feed, bool $is_cat, int $limit, int $offset, string $search, string $view_mode = "", - string $format = 'atom', string $order = "", string $orig_guid = "", string $start_ts = "") { + string $format = 'atom', string $order = "", string $orig_guid = "", string $start_ts = ""): void { $note_style = "background-color : #fff7d5; border-width : 1px; ". @@ -52,7 +54,13 @@ class Handler_Public extends Handler { PluginHost::feed_to_pfeed_id((int)$feed)); if ($handler) { + // 'get_headlines' is implemented by the plugin. + // @phpstan-ignore-next-line $qfh_ret = $handler->get_headlines(PluginHost::feed_to_pfeed_id((int)$feed), $params); + } else { + user_error("Failed to find handler for plugin feed ID: $feed", E_USER_ERROR); + + return; } } else { @@ -85,11 +93,13 @@ class Handler_Public extends Handler { $line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...')); $line["tags"] = Article::_get_tags($line["id"], $owner_uid); + $max_excerpt_length = 250; + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES, function ($result) use (&$line) { $line = $result; }, - $line); + $line, $max_excerpt_length); PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_EXPORT_FEED, function ($result) use (&$line) { @@ -105,7 +115,7 @@ class Handler_Public extends Handler { $tpl->setVariable('ARTICLE_EXCERPT', $line["content_preview"], true); $content = Sanitizer::sanitize($line["content"], false, $owner_uid, - $feed_site_url, false, $line["id"]); + $feed_site_url, null, $line["id"]); $content = DiskCache::rewrite_urls($content); @@ -203,7 +213,7 @@ class Handler_Public extends Handler { $article['link'] = $line['link']; $article['title'] = $line['title']; $article['excerpt'] = $line["content_preview"]; - $article['content'] = Sanitizer::sanitize($line["content"], false, $owner_uid, $feed_site_url, false, $line["id"]); + $article['content'] = Sanitizer::sanitize($line["content"], false, $owner_uid, $feed_site_url, null, $line["id"]); $article['updated'] = date('c', strtotime($line["updated"])); if (!empty($line['note'])) $article['note'] = $line['note']; @@ -243,7 +253,7 @@ class Handler_Public extends Handler { } } - function getUnread() { + function getUnread(): void { $login = clean($_REQUEST["login"]); $fresh = clean($_REQUEST["fresh"]) == "1"; @@ -261,7 +271,7 @@ class Handler_Public extends Handler { } } - function getProfiles() { + function getProfiles(): void { $login = clean($_REQUEST["login"]); $rv = []; @@ -284,20 +294,37 @@ class Handler_Public extends Handler { print json_encode($rv); } - function logout() { + function logout(): void { if (validate_csrf($_POST["csrf_token"])) { + + $login = $_SESSION["name"]; + $user_id = $_SESSION["uid"]; + UserHelper::logout(); - header("Location: index.php"); + + $redirect_url = ""; + + PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_POST_LOGOUT, + function ($result) use (&$redirect_url) { + if (!empty($result[0])) + $redirect_url = UrlHelper::validate($result[0]); + }, + $login, $user_id); + + if (!$redirect_url) + $redirect_url = get_self_url_prefix() . "/index.php"; + + header("Location: " . $redirect_url); } else { header("Content-Type: text/json"); print Errors::to_json(Errors::E_UNAUTHORIZED); } } - function rss() { + function rss(): void { $feed = clean($_REQUEST["id"]); $key = clean($_REQUEST["key"]); - $is_cat = clean($_REQUEST["is_cat"] ?? false); + $is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false); $limit = (int)clean($_REQUEST["limit"] ?? 0); $offset = (int)clean($_REQUEST["offset"] ?? 0); @@ -307,7 +334,7 @@ class Handler_Public extends Handler { $start_ts = clean($_REQUEST["ts"] ?? ""); $format = clean($_REQUEST['format'] ?? "atom"); - $orig_guid = clean($_REQUEST["orig_guid"] ?? false); + $orig_guid = clean($_REQUEST["orig_guid"] ?? ""); if (Config::get(Config::SINGLE_USER_MODE)) { UserHelper::authenticate("admin", null); @@ -329,21 +356,21 @@ class Handler_Public extends Handler { header('HTTP/1.1 403 Forbidden'); } - function updateTask() { + function updateTask(): void { PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK); } - function housekeepingTask() { + function housekeepingTask(): void { PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); } - function globalUpdateFeeds() { + function globalUpdateFeeds(): void { RPC::updaterandomfeed_real(); PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK); } - function login() { + function login(): void { if (!Config::get(Config::SINGLE_USER_MODE)) { $login = clean($_POST["login"]); @@ -399,12 +426,12 @@ class Handler_Public extends Handler { } } - function index() { + function index(): void { header("Content-Type: text/plain"); print Errors::to_json(Errors::E_UNKNOWN_METHOD); } - function forgotpass() { + function forgotpass(): void { startup_gettext(); session_start(); @@ -448,7 +475,7 @@ class Handler_Public extends Handler { if ($login) { $user = ORM::for_table('ttrss_users') - ->select('id', 'resetpass_token') + ->select_many('id', 'resetpass_token') ->where_raw('LOWER(login) = LOWER(?)', [$login]) ->find_one(); @@ -583,7 +610,7 @@ class Handler_Public extends Handler { print "</html>"; } - function dbupdate() { + function dbupdate(): void { startup_gettext(); if (!Config::get(Config::SINGLE_USER_MODE) && ($_SESSION["access_level"] ?? 0) < 10) { @@ -726,7 +753,7 @@ class Handler_Public extends Handler { <?php } - function cached() { + function cached(): void { list ($cache_dir, $filename) = explode("/", $_GET["file"], 2); // we do not allow files with extensions at the moment @@ -742,7 +769,7 @@ class Handler_Public extends Handler { } } - private function _make_article_tag_uri($id, $timestamp) { + private function _make_article_tag_uri(int $id, string $timestamp): string { $timestamp = date("Y-m-d", strtotime($timestamp)); @@ -752,7 +779,7 @@ class Handler_Public extends Handler { // this should be used very carefully because this endpoint is exposed to unauthenticated users // plugin data is not loaded because there's no user context and owner_uid/session may or may not be available // in general, don't do anything user-related in here and do not modify $_SESSION - public function pluginhandler() { + public function pluginhandler(): void { $host = new PluginHost(); $plugin_name = basename(clean($_REQUEST["plugin"])); @@ -784,7 +811,7 @@ class Handler_Public extends Handler { } } - static function _render_login_form(string $return_to = "") { + static function _render_login_form(string $return_to = ""): void { header('Cache-Control: public'); if ($return_to) diff --git a/classes/iauthmodule.php b/classes/iauthmodule.php index e714cc6ca..dbf8c5587 100644 --- a/classes/iauthmodule.php +++ b/classes/iauthmodule.php @@ -1,5 +1,18 @@ <?php interface IAuthModule { - function authenticate($login, $password); // + optional third parameter: $service - function hook_auth_user(...$args); // compatibility wrapper due to how hooks work + /** + * @param string $login + * @param string $password + * @param string $service + * @return int|false user_id + */ + function authenticate($login, $password, $service = ''); + + /** this is a pluginhost compatibility wrapper that invokes $this->authenticate(...$args) (Auth_Base) + * @param string $login + * @param string $password + * @param string $service + * @return int|false user_id + */ + function hook_auth_user($login, $password, $service = ''); } diff --git a/classes/ihandler.php b/classes/ihandler.php index 01c9e3109..215143370 100644 --- a/classes/ihandler.php +++ b/classes/ihandler.php @@ -1,6 +1,6 @@ <?php interface IHandler { - function csrf_ignore($method); - function before($method); - function after(); + function csrf_ignore(string $method): bool; + function before(string $method): bool; + function after(): bool; } diff --git a/classes/ivirtualfeed.php b/classes/ivirtualfeed.php new file mode 100644 index 000000000..ccd0680fc --- /dev/null +++ b/classes/ivirtualfeed.php @@ -0,0 +1,11 @@ +<?php +interface IVirtualFeed { + function get_unread(int $feed_id) : int; + function get_total(int $feed_id) : int; + /** + * @param int $feed_id + * @param array<string,int|string|bool> $options + * @return array<int,int|string> + */ + function get_headlines(int $feed_id, array $options) : array; +} diff --git a/classes/labels.php b/classes/labels.php index 570f24f4f..026e6621f 100644 --- a/classes/labels.php +++ b/classes/labels.php @@ -1,15 +1,15 @@ <?php class Labels { - static function label_to_feed_id($label) { + static function label_to_feed_id(int $label): int { return LABEL_BASE_INDEX - 1 - abs($label); } - static function feed_to_label_id($feed) { + static function feed_to_label_id(int $feed): int { return LABEL_BASE_INDEX - 1 + abs($feed); } - static function find_id($label, $owner_uid) { + static function find_id(string $label, int $owner_uid): int { $pdo = Db::pdo(); $sth = $pdo->prepare("SELECT id FROM ttrss_labels2 WHERE LOWER(caption) = LOWER(?) @@ -23,7 +23,7 @@ class Labels } } - static function find_caption($label, $owner_uid) { + static function find_caption(int $label, int $owner_uid): string { $pdo = Db::pdo(); $sth = $pdo->prepare("SELECT caption FROM ttrss_labels2 WHERE id = ? @@ -37,18 +37,24 @@ class Labels } } - static function get_as_hash($owner_uid) { + /** + * @return array<int, array<string, string>> + */ + static function get_as_hash(int $owner_uid): array { $rv = []; $labels = Labels::get_all($owner_uid); foreach ($labels as $i => $label) { - $rv[$label["id"]] = $labels[$i]; + $rv[(int)$label["id"]] = $labels[$i]; } return $rv; } - static function get_all($owner_uid) { + /** + * @return array<int, array<string, string>> An array of label detail arrays + */ + static function get_all(int $owner_uid) { $rv = array(); $pdo = Db::pdo(); @@ -64,7 +70,13 @@ class Labels return $rv; } - static function update_cache($owner_uid, $id, $labels = false, $force = false) { + /** + * @param array{'no-labels': 1}|array<int, array<int, array{0: int, 1: string, 2: string, 3: string}>> $labels + * [label_id, caption, fg_color, bg_color] + * + * @see Article::_get_labels() + */ + static function update_cache(int $owner_uid, int $id, array $labels, bool $force = false): void { $pdo = Db::pdo(); if ($force) @@ -81,7 +93,7 @@ class Labels } - static function clear_cache($id) { + static function clear_cache(int $id): void { $pdo = Db::pdo(); @@ -91,7 +103,7 @@ class Labels } - static function remove_article($id, $label, $owner_uid) { + static function remove_article(int $id, string $label, int $owner_uid): void { $label_id = self::find_id($label, $owner_uid); @@ -109,7 +121,7 @@ class Labels self::clear_cache($id); } - static function add_article($id, $label, $owner_uid) { + static function add_article(int $id, string $label, int $owner_uid): void { $label_id = self::find_id($label, $owner_uid); @@ -138,7 +150,7 @@ class Labels } - static function remove($id, $owner_uid) { + static function remove(int $id, int $owner_uid): void { if (!$owner_uid) $owner_uid = $_SESSION["uid"]; $pdo = Db::pdo(); @@ -182,7 +194,10 @@ class Labels if (!$tr_in_progress) $pdo->commit(); } - static function create($caption, $fg_color = '', $bg_color = '', $owner_uid = false) { + /** + * @return false|int false if the check for an existing label failed, otherwise the number of rows inserted (1 on success) + */ + static function create(string $caption, ?string $fg_color = '', ?string $bg_color = '', ?int $owner_uid = null) { if (!$owner_uid) $owner_uid = $_SESSION['uid']; diff --git a/classes/logger.php b/classes/logger.php index 42ab4452c..ef6173a42 100755 --- a/classes/logger.php +++ b/classes/logger.php @@ -1,6 +1,9 @@ <?php class Logger { + /** @var Logger|null */ private static $instance; + + /** @var Logger_Adapter|null */ private $adapter; const LOG_DEST_SQL = "sql"; @@ -25,11 +28,11 @@ class Logger { 16384 => 'E_USER_DEPRECATED', 32767 => 'E_ALL']; - static function log_error(int $errno, string $errstr, string $file, int $line, $context) { + static function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool { return self::get_instance()->_log_error($errno, $errstr, $file, $line, $context); } - private function _log_error($errno, $errstr, $file, $line, $context) { + private function _log_error(int $errno, string $errstr, string $file, int $line, string $context): bool { //if ($errno == E_NOTICE) return false; if ($this->adapter) @@ -38,11 +41,11 @@ class Logger { return false; } - static function log(int $errno, string $errstr, $context = "") { + static function log(int $errno, string $errstr, string $context = ""): bool { return self::get_instance()->_log($errno, $errstr, $context); } - private function _log(int $errno, string $errstr, $context = "") { + private function _log(int $errno, string $errstr, string $context = ""): bool { if ($this->adapter) return $this->adapter->log_error($errno, $errstr, '', 0, $context); else @@ -65,7 +68,7 @@ class Logger { $this->adapter = new Logger_Stdout(); break; default: - $this->adapter = false; + $this->adapter = null; } if ($this->adapter && !implements_interface($this->adapter, "Logger_Adapter")) diff --git a/classes/logger/adapter.php b/classes/logger/adapter.php index 79f641441..b0287b5fa 100644 --- a/classes/logger/adapter.php +++ b/classes/logger/adapter.php @@ -1,4 +1,4 @@ <?php interface Logger_Adapter { - function log_error(int $errno, string $errstr, string $file, int $line, $context); -}
\ No newline at end of file + function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool; +} diff --git a/classes/logger/sql.php b/classes/logger/sql.php index 784ebef31..5f3c67852 100755 --- a/classes/logger/sql.php +++ b/classes/logger/sql.php @@ -1,8 +1,6 @@ <?php class Logger_SQL implements Logger_Adapter { - private $pdo; - function __construct() { $conn = get_class($this); @@ -12,7 +10,7 @@ class Logger_SQL implements Logger_Adapter { ORM::configure('return_result_sets', true, $conn); } - function log_error(int $errno, string $errstr, string $file, int $line, $context) { + function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool { if (Config::get_schema_version() > 117) { diff --git a/classes/logger/stdout.php b/classes/logger/stdout.php index e906853ce..b15649028 100644 --- a/classes/logger/stdout.php +++ b/classes/logger/stdout.php @@ -1,7 +1,7 @@ <?php class Logger_Stdout implements Logger_Adapter { - function log_error(int $errno, string $errstr, string $file, int $line, $context) { + function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool { switch ($errno) { case E_ERROR: @@ -25,6 +25,7 @@ class Logger_Stdout implements Logger_Adapter { print "[EEE] $priority $errname ($file:$line) $errstr\n"; + return true; } } diff --git a/classes/logger/syslog.php b/classes/logger/syslog.php index 3ad9858f3..568398ee0 100644 --- a/classes/logger/syslog.php +++ b/classes/logger/syslog.php @@ -1,7 +1,7 @@ <?php class Logger_Syslog implements Logger_Adapter { - function log_error(int $errno, string $errstr, string $file, int $line, $context) { + function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool { switch ($errno) { case E_ERROR: @@ -25,6 +25,7 @@ class Logger_Syslog implements Logger_Adapter { syslog($priority, "[tt-rss] $errname ($file:$line) $errstr"); + return true; } } diff --git a/classes/mailer.php b/classes/mailer.php index 8238904ee..60b1ce4fd 100644 --- a/classes/mailer.php +++ b/classes/mailer.php @@ -1,8 +1,14 @@ <?php class Mailer { + // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ + /** @var string */ private $last_error = ""; - function mail($params) { + /** + * @param array<string, mixed> $params + * @return bool|int bool if the default mail function handled the request, otherwise an int as described in Mailer#mail() + */ + function mail(array $params) { $to_name = $params["to_name"] ?? ""; $to_address = $params["to_address"]; @@ -25,6 +31,8 @@ class Mailer { // 3. any other return value will allow cycling to the next handler and, eventually, to default mail() function // 4. set error message if needed via passed Mailer instance function set_error() + $hooks_tried = 0; + foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_SEND_MAIL) as $p) { $rc = $p->hook_send_mail($this, $params); @@ -33,6 +41,8 @@ class Mailer { if ($rc == -1) return 0; + + ++$hooks_tried; } $headers = [ "From: $from_combined", "Content-Type: text/plain; charset=UTF-8" ]; @@ -40,18 +50,18 @@ class Mailer { $rc = mail($to_combined, $subject, $message, implode("\r\n", array_merge($headers, $additional_headers))); if (!$rc) { - $this->set_error(error_get_last()['message']); + $this->set_error(error_get_last()['message'] ?? T_sprintf("Unknown error while sending mail. Hooks tried: %d.", $hooks_tried)); } return $rc; } - function set_error($message) { + function set_error(string $message): void { $this->last_error = $message; user_error("Error sending mail: $message", E_USER_WARNING); } - function error() { + function error(): string { return $this->last_error; } } diff --git a/classes/opml.php b/classes/opml.php index 2cfc890fa..b9f5f2eab 100644 --- a/classes/opml.php +++ b/classes/opml.php @@ -1,12 +1,15 @@ <?php class OPML extends Handler_Protected { - function csrf_ignore($method) { + function csrf_ignore(string $method): bool { $csrf_ignored = array("export", "import"); return array_search($method, $csrf_ignored) !== false; } + /** + * @return bool|int|void false if writing the file failed, true if printing succeeded, int if bytes were written to a file, or void if $owner_uid is missing + */ function export() { $output_name = sprintf("tt-rss_%s_%s.opml", $_SESSION["name"], date("Y-m-d")); $include_settings = $_REQUEST["include_settings"] == "1"; @@ -17,7 +20,7 @@ class OPML extends Handler_Protected { return $rc; } - function import() { + function import(): void { $owner_uid = $_SESSION["uid"]; header('Content-Type: text/html; charset=utf-8'); @@ -42,15 +45,11 @@ class OPML extends Handler_Protected { </form>"; print "</div></body></html>"; - - } // Export - private function opml_export_category($owner_uid, $cat_id, $hide_private_feeds = false, $include_settings = true) { - - $cat_id = (int) $cat_id; + private function opml_export_category(int $owner_uid, int $cat_id, bool $hide_private_feeds = false, bool $include_settings = true): string { if ($hide_private_feeds) $hide_qpart = "(private IS false AND auth_login = '' AND auth_pass = '')"; @@ -126,7 +125,10 @@ class OPML extends Handler_Protected { return $out; } - function opml_export($filename, $owner_uid, $hide_private_feeds = false, $include_settings = true, $file_output = false) { + /** + * @return bool|int|void false if writing the file failed, true if printing succeeded, int if bytes were written to a file, or void if $owner_uid is missing + */ + function opml_export(string $filename, int $owner_uid, bool $hide_private_feeds = false, bool $include_settings = true, bool $file_output = false) { if (!$owner_uid) return; if (!$file_output) @@ -189,7 +191,7 @@ class OPML extends Handler_Protected { WHERE owner_uid = ? ORDER BY id"); $sth->execute([$owner_uid]); - while ($line = $sth->fetch()) { + while ($line = $sth->fetch(PDO::FETCH_ASSOC)) { $line["rules"] = array(); $line["actions"] = array(); @@ -204,36 +206,36 @@ class OPML extends Handler_Protected { $cat_filter = $tmp_line["cat_filter"]; if (!$tmp_line["match_on"]) { - if ($cat_filter && $tmp_line["cat_id"] || $tmp_line["feed_id"]) { - $tmp_line["feed"] = Feeds::_get_title( - $cat_filter ? $tmp_line["cat_id"] : $tmp_line["feed_id"], - $cat_filter); - } else { - $tmp_line["feed"] = ""; - } - } else { - $match = []; - foreach (json_decode($tmp_line["match_on"], true) as $feed_id) { - - if (strpos($feed_id, "CAT:") === 0) { - $feed_id = (int)substr($feed_id, 4); - if ($feed_id) { - array_push($match, [Feeds::_get_cat_title($feed_id), true, false]); - } else { - array_push($match, [0, true, true]); - } - } else { - if ($feed_id) { - array_push($match, [Feeds::_get_title((int)$feed_id), false, false]); - } else { - array_push($match, [0, false, true]); - } - } - } - - $tmp_line["match"] = $match; - unset($tmp_line["match_on"]); - } + if ($cat_filter && $tmp_line["cat_id"] || $tmp_line["feed_id"]) { + $tmp_line["feed"] = Feeds::_get_title( + $cat_filter ? $tmp_line["cat_id"] : $tmp_line["feed_id"], + $cat_filter); + } else { + $tmp_line["feed"] = ""; + } + } else { + $match = []; + foreach (json_decode($tmp_line["match_on"], true) as $feed_id) { + + if (strpos($feed_id, "CAT:") === 0) { + $feed_id = (int)substr($feed_id, 4); + if ($feed_id) { + array_push($match, [Feeds::_get_cat_title($feed_id), true, false]); + } else { + array_push($match, [0, true, true]); + } + } else { + if ($feed_id) { + array_push($match, [Feeds::_get_title((int)$feed_id), false, false]); + } else { + array_push($match, [0, false, true]); + } + } + } + + $tmp_line["match"] = $match; + unset($tmp_line["match_on"]); + } unset($tmp_line["feed_id"]); unset($tmp_line["cat_id"]); @@ -292,13 +294,14 @@ class OPML extends Handler_Protected { if ($file_output) return file_put_contents($filename, $res) > 0; - else - print $res; + + print $res; + return true; } // Import - private function opml_import_feed($node, $cat_id, $owner_uid) { + private function opml_import_feed(DOMNode $node, int $cat_id, int $owner_uid, int $nest): void { $attrs = $node->attributes; $feed_title = mb_substr($attrs->getNamedItem('text')->nodeValue, 0, 250); @@ -318,7 +321,7 @@ class OPML extends Handler_Protected { if (!$sth->fetch()) { #$this->opml_notice("[FEED] [$feed_title/$feed_url] dst_CAT=$cat_id"); - $this->opml_notice(T_sprintf("Adding feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title)); + $this->opml_notice(T_sprintf("Adding feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title), $nest); if (!$cat_id) $cat_id = null; @@ -338,12 +341,12 @@ class OPML extends Handler_Protected { $sth->execute([$feed_title, $feed_url, $owner_uid, $cat_id, $site_url, $order_id, $update_interval, $purge_interval]); } else { - $this->opml_notice(T_sprintf("Duplicate feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title)); + $this->opml_notice(T_sprintf("Duplicate feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title), $nest); } } } - private function opml_import_label($node, $owner_uid) { + private function opml_import_label(DOMNode $node, int $owner_uid, int $nest): void { $attrs = $node->attributes; $label_name = $attrs->getNamedItem('label-name')->nodeValue; @@ -351,16 +354,16 @@ class OPML extends Handler_Protected { $fg_color = $attrs->getNamedItem('label-fg-color')->nodeValue; $bg_color = $attrs->getNamedItem('label-bg-color')->nodeValue; - if (!Labels::find_id($label_name, $_SESSION['uid'])) { - $this->opml_notice(T_sprintf("Adding label %s", htmlspecialchars($label_name))); + if (!Labels::find_id($label_name, $owner_uid)) { + $this->opml_notice(T_sprintf("Adding label %s", htmlspecialchars($label_name)), $nest); Labels::create($label_name, $fg_color, $bg_color, $owner_uid); } else { - $this->opml_notice(T_sprintf("Duplicate label: %s", htmlspecialchars($label_name))); + $this->opml_notice(T_sprintf("Duplicate label: %s", htmlspecialchars($label_name)), $nest); } } } - private function opml_import_preference($node) { + private function opml_import_preference(DOMNode $node, int $owner_uid, int $nest): void { $attrs = $node->attributes; $pref_name = $attrs->getNamedItem('pref-name')->nodeValue; @@ -368,13 +371,13 @@ class OPML extends Handler_Protected { $pref_value = $attrs->getNamedItem('value')->nodeValue; $this->opml_notice(T_sprintf("Setting preference key %s to %s", - $pref_name, $pref_value)); + $pref_name, $pref_value), $nest); - set_pref($pref_name, $pref_value); + set_pref($pref_name, $pref_value, $owner_uid); } } - private function opml_import_filter($node) { + private function opml_import_filter(DOMNode $node, int $owner_uid, int $nest): void { $attrs = $node->attributes; $filter_type = $attrs->getNamedItem('filter-type')->nodeValue; @@ -393,47 +396,58 @@ class OPML extends Handler_Protected { $sth = $this->pdo->prepare("INSERT INTO ttrss_filters2 (match_any_rule,enabled,inverse,title,owner_uid) VALUES (?, ?, ?, ?, ?)"); - $sth->execute([$match_any_rule, $enabled, $inverse, $title, $_SESSION['uid']]); + $sth->execute([$match_any_rule, $enabled, $inverse, $title, $owner_uid]); $sth = $this->pdo->prepare("SELECT MAX(id) AS id FROM ttrss_filters2 WHERE owner_uid = ?"); - $sth->execute([$_SESSION['uid']]); + $sth->execute([$owner_uid]); $row = $sth->fetch(); $filter_id = $row['id']; if ($filter_id) { - $this->opml_notice(T_sprintf("Adding filter %s...", $title)); + $this->opml_notice(T_sprintf("Adding filter %s...", $title), $nest); + //$this->opml_notice(json_encode($filter)); foreach ($filter["rules"] as $rule) { $feed_id = null; $cat_id = null; - if ($rule["match"]) { + if ($rule["match"] ?? false) { - $match_on = []; + $match_on = []; - foreach ($rule["match"] as $match) { - list ($name, $is_cat, $is_id) = $match; + foreach ($rule["match"] as $match) { + list ($name, $is_cat, $is_id) = $match; - if ($is_id) { - array_push($match_on, ($is_cat ? "CAT:" : "") . $name); - } else { + if ($is_id) { + array_push($match_on, ($is_cat ? "CAT:" : "") . $name); + } else { - if (!$is_cat) { - $tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds - WHERE title = ? AND owner_uid = ?"); + $match_id = Feeds::_find_by_title($name, $is_cat, $owner_uid); - $tsth->execute([$name, $_SESSION['uid']]); + if ($match_id) { + if ($is_cat) { + array_push($match_on, "CAT:$match_id"); + } else { + array_push($match_on, $match_id); + } + } + + /*if (!$is_cat) { + $tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds + WHERE title = ? AND owner_uid = ?"); + + $tsth->execute([$name, $_SESSION['uid']]); - if ($row = $tsth->fetch()) { - $match_id = $row['id']; + if ($row = $tsth->fetch()) { + $match_id = $row['id']; array_push($match_on, $match_id); - } - } else { - $tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories - WHERE title = ? AND owner_uid = ?"); + } + } else { + $tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories + WHERE title = ? AND owner_uid = ?"); $tsth->execute([$name, $_SESSION['uid']]); if ($row = $tsth->fetch()) { @@ -441,54 +455,64 @@ class OPML extends Handler_Protected { array_push($match_on, "CAT:$match_id"); } - } - } - } + } */ + } + } - $reg_exp = $rule["reg_exp"]; - $filter_type = (int)$rule["filter_type"]; - $inverse = bool_to_sql_bool($rule["inverse"]); - $match_on = json_encode($match_on); + $reg_exp = $rule["reg_exp"]; + $filter_type = (int)$rule["filter_type"]; + $inverse = bool_to_sql_bool($rule["inverse"]); + $match_on = json_encode($match_on); - $usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules + $usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules (feed_id,cat_id,match_on,filter_id,filter_type,reg_exp,cat_filter,inverse) - VALUES - (NULL, NULL, ?, ?, ?, ?, false, ?)"); - $usth->execute([$match_on, $filter_id, $filter_type, $reg_exp, $inverse]); + VALUES + (NULL, NULL, ?, ?, ?, ?, false, ?)"); + $usth->execute([$match_on, $filter_id, $filter_type, $reg_exp, $inverse]); + + } else { - } else { + $match_id = Feeds::_find_by_title($rule['feed'] ?? "", $rule['cat_filter'], $owner_uid); - if (!$rule["cat_filter"]) { - $tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds - WHERE title = ? AND owner_uid = ?"); + if ($match_id) { + if ($rule['cat_filter']) { + $cat_id = $match_id; + } else { + $feed_id = $match_id; + } + } + + /*if (!$rule["cat_filter"]) { + $tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds + WHERE title = ? AND owner_uid = ?"); - $tsth->execute([$rule['feed'], $_SESSION['uid']]); + $tsth->execute([$rule['feed'], $_SESSION['uid']]); - if ($row = $tsth->fetch()) { - $feed_id = $row['id']; - } - } else { + if ($row = $tsth->fetch()) { + $feed_id = $row['id']; + } + } else { $tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories - WHERE title = ? AND owner_uid = ?"); + WHERE title = ? AND owner_uid = ?"); $tsth->execute([$rule['feed'], $_SESSION['uid']]); if ($row = $tsth->fetch()) { $feed_id = $row['id']; } - } + } */ - $cat_filter = bool_to_sql_bool($rule["cat_filter"]); - $reg_exp = $rule["reg_exp"]; - $filter_type = (int)$rule["filter_type"]; - $inverse = bool_to_sql_bool($rule["inverse"]); + $cat_filter = bool_to_sql_bool($rule["cat_filter"]); + $reg_exp = $rule["reg_exp"]; + $filter_type = (int)$rule["filter_type"]; + $inverse = bool_to_sql_bool($rule["inverse"]); - $usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules + $usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules (feed_id,cat_id,filter_id,filter_type,reg_exp,cat_filter,inverse) - VALUES - (?, ?, ?, ?, ?, ?, ?)"); - $usth->execute([$feed_id, $cat_id, $filter_id, $filter_type, $reg_exp, $cat_filter, $inverse]); - } + VALUES + (?, ?, ?, ?, ?, ?, ?)"); + $usth->execute([$feed_id, $cat_id, $filter_id, $filter_type, $reg_exp, $cat_filter, $inverse]); + } } foreach ($filter["actions"] as $action) { @@ -507,8 +531,8 @@ class OPML extends Handler_Protected { } } - private function opml_import_category($doc, $root_node, $owner_uid, $parent_id) { - $default_cat_id = (int) $this->get_feed_category('Imported feeds', false); + private function opml_import_category(DOMDocument $doc, ?DOMNode $root_node, int $owner_uid, int $parent_id, int $nest): void { + $default_cat_id = (int) $this->get_feed_category('Imported feeds', $owner_uid, 0); if ($root_node) { $cat_title = mb_substr($root_node->attributes->getNamedItem('text')->nodeValue, 0, 250); @@ -517,13 +541,13 @@ class OPML extends Handler_Protected { $cat_title = mb_substr($root_node->attributes->getNamedItem('title')->nodeValue, 0, 250); if (!in_array($cat_title, array("tt-rss-filters", "tt-rss-labels", "tt-rss-prefs"))) { - $cat_id = $this->get_feed_category($cat_title, $parent_id); + $cat_id = $this->get_feed_category($cat_title, $owner_uid, $parent_id); - if ($cat_id === false) { + if ($cat_id === 0) { $order_id = (int) $root_node->attributes->getNamedItem('ttrssSortOrder')->nodeValue; - Feeds::_add_cat($cat_title, $_SESSION['uid'], $parent_id ? $parent_id : null, (int)$order_id); - $cat_id = $this->get_feed_category($cat_title, $parent_id); + Feeds::_add_cat($cat_title, $owner_uid, $parent_id ? $parent_id : null, (int)$order_id); + $cat_id = $this->get_feed_category($cat_title, $owner_uid, $parent_id); } } else { @@ -540,21 +564,21 @@ class OPML extends Handler_Protected { $cat_title = false; } - #$this->opml_notice("[CAT] $cat_title id: $cat_id P_id: $parent_id"); - $this->opml_notice(T_sprintf("Processing category: %s", $cat_title ? $cat_title : __("Uncategorized"))); + //$this->opml_notice("[CAT] $cat_title id: $cat_id P_id: $parent_id"); + $this->opml_notice(T_sprintf("Processing category: %s", $cat_title ? $cat_title : __("Uncategorized")), $nest); foreach ($outlines as $node) { if ($node->hasAttributes() && strtolower($node->tagName) == "outline") { $attrs = $node->attributes; - $node_cat_title = $attrs->getNamedItem('text')->nodeValue; + $node_cat_title = $attrs->getNamedItem('text') ? $attrs->getNamedItem('text')->nodeValue : false; if (!$node_cat_title) - $node_cat_title = $attrs->getNamedItem('title')->nodeValue; + $node_cat_title = $attrs->getNamedItem('title') ? $attrs->getNamedItem('title')->nodeValue : false; - $node_feed_url = $attrs->getNamedItem('xmlUrl')->nodeValue; + $node_feed_url = $attrs->getNamedItem('xmlUrl') ? $attrs->getNamedItem('xmlUrl')->nodeValue : false; if ($node_cat_title && !$node_feed_url) { - $this->opml_import_category($doc, $node, $owner_uid, $cat_id); + $this->opml_import_category($doc, $node, $owner_uid, $cat_id, $nest+1); } else { if (!$cat_id) { @@ -565,91 +589,115 @@ class OPML extends Handler_Protected { switch ($cat_title) { case "tt-rss-prefs": - $this->opml_import_preference($node); + $this->opml_import_preference($node, $owner_uid, $nest+1); break; case "tt-rss-labels": - $this->opml_import_label($node, $owner_uid); + $this->opml_import_label($node, $owner_uid, $nest+1); break; case "tt-rss-filters": - $this->opml_import_filter($node); + $this->opml_import_filter($node, $owner_uid, $nest+1); break; default: - $this->opml_import_feed($node, $dst_cat_id, $owner_uid); + $this->opml_import_feed($node, $dst_cat_id, $owner_uid, $nest+1); } } } } } - function opml_import($owner_uid) { + /** $filename is optional; assumes HTTP upload with $_FILES otherwise */ + /** + * @return bool|void false on failure, true if successful, void if $owner_uid is missing + */ + function opml_import(int $owner_uid, string $filename = "") { if (!$owner_uid) return; $doc = false; - if ($_FILES['opml_file']['error'] != 0) { - print_error(T_sprintf("Upload failed with error code %d", - $_FILES['opml_file']['error'])); - return; - } + if (!$filename) { + if ($_FILES['opml_file']['error'] != 0) { + print_error(T_sprintf("Upload failed with error code %d", + $_FILES['opml_file']['error'])); + return false; + } - if (is_uploaded_file($_FILES['opml_file']['tmp_name'])) { - $tmp_file = (string)tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'opml'); + if (is_uploaded_file($_FILES['opml_file']['tmp_name'])) { + $tmp_file = (string)tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'opml'); - $result = move_uploaded_file($_FILES['opml_file']['tmp_name'], - $tmp_file); + $result = move_uploaded_file($_FILES['opml_file']['tmp_name'], + $tmp_file); - if (!$result) { - print_error(__("Unable to move uploaded file.")); - return; + if (!$result) { + print_error(__("Unable to move uploaded file.")); + return false; + } + } else { + print_error(__('Error: please upload OPML file.')); + return false; } } else { - print_error(__('Error: please upload OPML file.')); - return; + $tmp_file = $filename; + } + + if (!is_readable($tmp_file)) { + $this->opml_notice(T_sprintf("Error: file is not readable: %s", $filename)); + return false; } $loaded = false; - if (is_file($tmp_file)) { - $doc = new DOMDocument(); + $doc = new DOMDocument(); + + if (version_compare(PHP_VERSION, '8.0.0', '<')) { libxml_disable_entity_loader(false); - $loaded = $doc->load($tmp_file); + } + + $loaded = $doc->load($tmp_file); + + if (version_compare(PHP_VERSION, '8.0.0', '<')) { libxml_disable_entity_loader(true); - unlink($tmp_file); - } else if (empty($doc)) { - print_error(__('Error: unable to find moved OPML file.')); - return; } + // only remove temporary i.e. HTTP uploaded files + if (!$filename) + unlink($tmp_file); + if ($loaded) { - $this->pdo->beginTransaction(); - $this->opml_import_category($doc, false, $owner_uid, false); - $this->pdo->commit(); + // we're using ORM while importing so we can't transaction-lock things anymore + //$this->pdo->beginTransaction(); + $this->opml_import_category($doc, null, $owner_uid, 0, 0); + //$this->pdo->commit(); } else { - print_error(__('Error while parsing document.')); + $this->opml_notice(__('Error while parsing document.')); + return false; } - } - private function opml_notice($msg) { - print "$msg<br/>"; + return true; } - function get_feed_category($feed_cat, $parent_cat_id = false) { + private function opml_notice(string $msg, int $prefix_length = 0): void { + if (php_sapi_name() == "cli") { + Debug::log(str_repeat(" ", $prefix_length) . $msg); + } else { + // TODO: use better separator i.e. CSS-defined span of certain width or something + print str_repeat(" ", $prefix_length) . $msg . "<br/>"; + } + } - $parent_cat_id = (int) $parent_cat_id; + function get_feed_category(string $feed_cat, int $owner_uid, int $parent_cat_id) : int { $sth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories WHERE title = :title AND (parent_cat = :parent OR (:parent = 0 AND parent_cat IS NULL)) AND owner_uid = :uid"); - $sth->execute([':title' => $feed_cat, ':parent' => $parent_cat_id, ':uid' => $_SESSION['uid']]); + $sth->execute([':title' => $feed_cat, ':parent' => $parent_cat_id, ':uid' => $owner_uid]); if ($row = $sth->fetch()) { return $row['id']; } else { - return false; + return 0; } } - } diff --git a/classes/plugin.php b/classes/plugin.php index ecafa7888..39af6a9a1 100644 --- a/classes/plugin.php +++ b/classes/plugin.php @@ -5,8 +5,14 @@ abstract class Plugin { /** @var PDO $pdo */ protected $pdo; - abstract function init(PluginHost $host); + /** + * @param PluginHost $host + * + * @return void + * */ + abstract function init($host); + /** @return array<null|float|string|bool> */ abstract function about(); // return array(1.0, "plugin", "No description", "No author", false); @@ -14,6 +20,7 @@ abstract class Plugin { $this->pdo = Db::pdo(); } + /** @return array<string,bool> */ function flags() { /* associative array, possible keys: needs_curl = boolean @@ -21,36 +28,65 @@ abstract class Plugin { return array(); } + /** + * @param string $method + * + * @return bool */ function is_public_method($method) { return false; } + /** + * @param string $method + * + * @return bool */ function csrf_ignore($method) { return false; } + /** @return string */ function get_js() { return ""; } + /** @return string */ + function get_css() { + return ""; + } + + /** @return string */ function get_prefs_js() { return ""; } + /** @return int */ function api_version() { return Plugin::API_VERSION_COMPAT; } /* gettext-related helpers */ + /** + * @param string $msgid + * + * @return string */ function __($msgid) { + /** @var Plugin $this -- this is a strictly template-related hack */ return _dgettext(PluginHost::object_to_domain($this), $msgid); } + /** + * @param string $singular + * @param string $plural + * @param int $number + * + * @return string */ function _ngettext($singular, $plural, $number) { + /** @var Plugin $this -- this is a strictly template-related hack */ return _dngettext(PluginHost::object_to_domain($this), $singular, $plural, $number); } + /** @return string */ function T_sprintf() { $args = func_get_args(); $msgid = array_shift($args); @@ -58,4 +94,602 @@ abstract class Plugin { return vsprintf($this->__($msgid), $args); } + /* plugin hook methods */ + + /* GLOBAL hooks are invoked in global context, only available to system plugins (loaded via .env for all users) */ + + /** Adds buttons for article (on the right) - e.g. mail, share, add note. Generated markup must be valid XML. + * @param array<string,mixed> $line + * @return string + * @see PluginHost::HOOK_ARTICLE_BUTTON + * @see Plugin::hook_article_left_button() + */ + function hook_article_button($line) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** Allows plugins to alter article data as gathered from feed XML, i.e. embed images, get full text content, etc. + * @param array<string,mixed> $article + * @return array<string,mixed> + * @see PluginHost::HOOK_ARTICLE_FILTER + */ + function hook_article_filter($article) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return []; + } + + /** Allow adding new UI elements (e.g. accordion panes) to (top) tab contents in Preferences + * @param string $tab + * @return void + * @see PluginHost::HOOK_PREFS_TAB + */ + function hook_prefs_tab($tab) { + user_error("Dummy method invoked.", E_USER_ERROR); + } + + /** Allow adding new content to various sections of preferences UI (i.e. OPML import/export pane) + * @param string $section + * @return void + * @see PluginHost::HOOK_PREFS_TAB_SECTION + */ + function hook_prefs_tab_section($section) { + user_error("Dummy method invoked.", E_USER_ERROR); + } + + /** Allows adding new (top) tabs in preferences UI + * @return void + * @see PluginHost::HOOK_PREFS_TABS + */ + function hook_prefs_tabs() { + user_error("Dummy method invoked.", E_USER_ERROR); + } + + /** Invoked when feed XML is processed by FeedParser class + * @param FeedParser $parser + * @param int $feed_id + * @return void + * @see PluginHost::HOOK_FEED_PARSED + */ + function hook_feed_parsed($parser, $feed_id) { + user_error("Dummy method invoked.", E_USER_ERROR); + } + + /** GLOBAL: Invoked when a feed update task finishes + * @param array<string,string> $cli_options + * @return void + * @see PluginHost::HOOK_UPDATE_TASK + */ + function hook_update_task($cli_options) { + user_error("Dummy method invoked.", E_USER_ERROR); + } + + /** This is a pluginhost compatibility wrapper that invokes $this->authenticate(...$args) (Auth_Base) + * @param string $login + * @param string $password + * @param string $service + * @return int|false user_id + * @see PluginHost::HOOK_AUTH_USER + */ + function hook_auth_user($login, $password, $service = '') { + user_error("Dummy method invoked.", E_USER_ERROR); + return false; + } + + /** IAuthModule only + * @param string $login + * @param string $password + * @param string $service + * @return int|false user_id + */ + function authenticate($login, $password, $service = '') { + user_error("Dummy method invoked.", E_USER_ERROR); + return false; + } + + /** Allows plugins to modify global hotkey map (hotkey sequence -> action) + * @param array<string, string> $hotkeys + * @return array<string, string> + * @see PluginHost::HOOK_HOTKEY_MAP + * @see Plugin::hook_hotkey_info() + */ + function hook_hotkey_map($hotkeys) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return []; + } + + /** Invoked when article is rendered by backend (before it gets passed to frontent JS code) - three panel mode + * @param array<string, mixed> $article + * @return array<string, mixed> + * @see PluginHost::HOOK_RENDER_ARTICLE + */ + function hook_render_article($article) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return []; + } + + /** Invoked when article is rendered by backend (before it gets passed to frontent JS code) - combined mode + * @param array<string, mixed> $article + * @return array<string, mixed> + * @see PluginHost::HOOK_RENDER_ARTICLE_CDM + */ + function hook_render_article_cdm($article) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return []; + } + + /** Invoked when raw feed XML data has been successfully downloaded (but not parsed yet) + * @param string $feed_data + * @param string $fetch_url + * @param int $owner_uid + * @param int $feed + * @return string + * @see PluginHost::HOOK_FEED_FETCHED + */ + function hook_feed_fetched($feed_data, $fetch_url, $owner_uid, $feed) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** Invoked on article content when it is sanitized (i.e. potentially harmful tags removed) + * @param DOMDocument $doc + * @param string $site_url + * @param array<string> $allowed_elements + * @param array<string> $disallowed_attributes + * @param int $article_id + * @return DOMDocument|array<int,DOMDocument|array<string>> + * @see PluginHost::HOOK_SANITIZE + */ + function hook_sanitize($doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return $doc; + } + + /** Invoked when article is rendered by backend (before it gets passed to frontent JS code) - exclusive to API clients + * @param array{'article': array<string,mixed>|null, 'headline': array<string,mixed>|null} $params + * @return array<string, string> + * @see PluginHost::HOOK_RENDER_ARTICLE_API + */ + function hook_render_article_api($params) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return []; + } + + /** Allows adding new UI elements to tt-rss main toolbar (to the right, before Actions... dropdown) + * @return string + * @see PluginHost::HOOK_TOOLBAR_BUTTON + */ + function hook_toolbar_button() { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** Allows adding new items to tt-rss main Actions... dropdown menu + * @return string + * @see PluginHost::HOOK_ACTION_ITEM + */ + function hook_action_item() { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** Allows adding new UI elements to the toolbar area related to currently loaded feed headlines + * @param int $feed_id + * @param bool $is_cat + * @return string + * @see PluginHost::HOOK_HEADLINE_TOOLBAR_BUTTON + */ + function hook_headline_toolbar_button($feed_id, $is_cat) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** Allows adding new hotkey action names and descriptions + * @param array<string, array<string, string>> $hotkeys + * @return array<string, array<string, string>> + * @see PluginHost::HOOK_HOTKEY_INFO + * @see Plugin::hook_hotkey_map() + */ + function hook_hotkey_info($hotkeys) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return []; + } + + /** Adds per-article buttons on the left side. Generated markup must be valid XML. + * @param array<string,mixed> $row + * @return string + * @see PluginHost::HOOK_ARTICLE_LEFT_BUTTON + * @see Plugin::hook_article_button() + */ + function hook_article_left_button($row) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** Allows adding new UI elements to the "Plugins" tab of the feed editor UI + * @param int $feed_id + * @return void + * @see PluginHost::HOOK_PREFS_EDIT_FEED + */ + function hook_prefs_edit_feed($feed_id) { + user_error("Dummy method invoked.", E_USER_ERROR); + } + + /** Invoked when data is saved in the feed editor + * @param int $feed_id + * @return void + * @see PluginHost::HOOK_PREFS_SAVE_FEED + */ + function hook_prefs_save_feed($feed_id) { + user_error("Dummy method invoked.", E_USER_ERROR); + } + + /** Allows overriding built-in fetching mechanism for feeds, substituting received data if necessary + * (i.e. origin site doesn't actually provide any RSS feeds), or XML is invalid + * @param string $feed_data + * @param string $fetch_url + * @param int $owner_uid + * @param int $feed + * @param int $last_article_timestamp + * @param string $auth_login + * @param string $auth_pass + * @return string (possibly mangled feed data) + * @see PluginHost::HOOK_FETCH_FEED + */ + function hook_fetch_feed($feed_data, $fetch_url, $owner_uid, $feed, $last_article_timestamp, $auth_login, $auth_pass) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** Invoked when headlines data ($row) has been retrieved from the database + * @param array<string,mixed> $row + * @param int $excerpt_length + * @return array<string,mixed> + * @see PluginHost::HOOK_QUERY_HEADLINES + */ + function hook_query_headlines($row, $excerpt_length) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return []; + } + + /** This is run periodically by the update daemon when idle (available both to user and system plugins) + * @return void + * @see PluginHost::HOOK_HOUSE_KEEPING */ + function hook_house_keeping() { + user_error("Dummy method invoked.", E_USER_ERROR); + } + + /** Allows overriding built-in article search + * @param string $query + * @return array<int, string|array<string>> - list(SQL search query, highlight keywords) + * @see PluginHost::HOOK_SEARCH + */ + function hook_search($query) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return []; + } + + /** Invoked when enclosures are rendered to HTML (when article itself is rendered) + * @param string $enclosures_formatted + * @param array<int, array<string, mixed>> $enclosures + * @param int $article_id + * @param bool $always_display_enclosures + * @param string $article_content + * @param bool $hide_images + * @return string|array<string,array<int, array<string, mixed>>> ($enclosures_formatted, $enclosures) + * @see PluginHost::HOOK_FORMAT_ENCLOSURES + */ + function hook_format_enclosures($enclosures_formatted, $enclosures, $article_id, $always_display_enclosures, $article_content, $hide_images) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** Invoked during feed subscription (after data has been fetched) + * @param string $contents + * @param string $url + * @param string $auth_login + * @param string $auth_pass + * @return string (possibly mangled feed data) + * @see PluginHost::HOOK_SUBSCRIBE_FEED + */ + function hook_subscribe_feed($contents, $url, $auth_login, $auth_pass) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** + * @param int $feed + * @param bool $is_cat + * @param array<string,mixed> $qfh_ret (headlines object) + * @return string + * @see PluginHost::HOOK_HEADLINES_BEFORE + */ + function hook_headlines_before($feed, $is_cat, $qfh_ret) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** + * @param array<string,mixed> $entry + * @param int $article_id + * @param array<string,mixed> $rv + * @return string + * @see PluginHost::HOOK_RENDER_ENCLOSURE + */ + function hook_render_enclosure($entry, $article_id, $rv) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** + * @param array<string,mixed> $article + * @param string $action + * @return array<string,mixed> ($article) + * @see PluginHost::HOOK_ARTICLE_FILTER_ACTION + */ + function hook_article_filter_action($article, $action) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return []; + } + + /** + * @param array<string,mixed> $line + * @param int $feed + * @param bool $is_cat + * @param int $owner_uid + * @return array<string,mixed> ($line) + * @see PluginHost::HOOK_ARTICLE_EXPORT_FEED + */ + function hook_article_export_feed($line, $feed, $is_cat, $owner_uid) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return []; + } + + /** Allows adding custom buttons to tt-rss main toolbar (left side) + * @return void + * @see PluginHost::HOOK_MAIN_TOOLBAR_BUTTON + */ + function hook_main_toolbar_button() { + user_error("Dummy method invoked.", E_USER_ERROR); + } + + /** Invoked for every enclosure entry as article is being rendered + * @param array<string,string> $entry + * @param int $id + * @param array{'formatted': string, 'entries': array<int, array<string, mixed>>} $rv + * @return array<string,string> ($entry) + * @see PluginHost::HOOK_ENCLOSURE_ENTRY + */ + function hook_enclosure_entry($entry, $id, $rv) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return []; + } + + /** Share plugins run this when article is being rendered as HTML for sharing + * @param string $html + * @param array<string,mixed> $row + * @return string ($html) + * @see PluginHost::HOOK_FORMAT_ARTICLE + */ + function hook_format_article($html, $row) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** Invoked when basic feed information (title, site_url) is being collected, useful to override default if feed doesn't provide anything (or feed itself is synthesized) + * @param array{"title": string, "site_url": string} $basic_info + * @param string $fetch_url + * @param int $owner_uid + * @param int $feed_id + * @param string $auth_login + * @param string $auth_pass + * @return array{"title": string, "site_url": string} + * @see PluginHost::HOOK_FEED_BASIC_INFO + */ + function hook_feed_basic_info($basic_info, $fetch_url, $owner_uid, $feed_id, $auth_login, $auth_pass) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return $basic_info; + } + + /** Invoked when file (e.g. cache entry, static data) is being sent to client, may override default mechanism + * using faster httpd-specific implementation (see nginx_xaccel) + * @param string $filename + * @return bool + * @see PluginHost::HOOK_SEND_LOCAL_FILE + */ + function hook_send_local_file($filename) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return false; + } + + /** Invoked when user tries to unsubscribe from a feed, returning true would prevent any further default actions + * @param int $feed_id + * @param int $owner_uid + * @return bool + * @see PluginHost::HOOK_UNSUBSCRIBE_FEED + */ + function hook_unsubscribe_feed($feed_id, $owner_uid) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return false; + } + + /** Invoked when mail is being sent (if no hooks are registered, tt-rss uses PHP mail() as a fallback) + * @param Mailer $mailer + * @param array<string,mixed> $params + * @return int + * @see PluginHost::HOOK_SEND_MAIL + */ + function hook_send_mail($mailer, $params) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return -1; + } + + /** Invoked when filter is triggered on an article, may be used to implement logging for filters + * NOTE: $article_filters should be renamed $filter_actions because that's what this is + * @param int $feed_id + * @param int $owner_uid + * @param array<string,mixed> $article + * @param array<string,mixed> $matched_filters + * @param array<string,string|bool|int> $matched_rules + * @param array<string,string> $article_filters + * @return void + * @see PluginHost::HOOK_FILTER_TRIGGERED + */ + function hook_filter_triggered($feed_id, $owner_uid, $article, $matched_filters, $matched_rules, $article_filters) { + user_error("Dummy method invoked.", E_USER_ERROR); + } + + /** Plugins may provide this to allow getting full article text (af_readbility implements this) + * @param string $url + * @return string|false + * @see PluginHost::HOOK_GET_FULL_TEXT + */ + function hook_get_full_text($url) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** Invoked when article flavor image is being determined, allows overriding default selection logic + * @param array<string,string> $enclosures + * @param string $content + * @param string $site_url + * @param array<string,mixed> $article + * @return string|array<int,string> + * @see PluginHost::HOOK_ARTICLE_IMAGE + */ + function hook_article_image($enclosures, $content, $site_url, $article) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** Allows adding arbitrary elements before feed tree + * @return string HTML + * @see PluginHost::HOOK_FEED_TREE + * */ + function hook_feed_tree() { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** Invoked for every iframe to determine if it is allowed to be displayed + * @param string $url + * @return bool + * @see PluginHost::HOOK_IFRAME_WHITELISTED + */ + function hook_iframe_whitelisted($url) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return false; + } + + /** + * @param object $enclosure + * @param int $feed + * @return object ($enclosure) + * @see PluginHost::HOOK_ENCLOSURE_IMPORTED + */ + function hook_enclosure_imported($enclosure, $feed) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return $enclosure; + } + + /** Allows adding custom elements to headline sort dropdown (name -> caption) + * @return array<string,string> + * @see PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP + */ + function hook_headlines_custom_sort_map() { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ["" => ""]; + } + + /** Allows overriding headline sorting (or provide custom sort methods) + * @param string $order + * @return array<int, string|bool> -- (query, skip_first_id) + * @see PluginHost::HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE + */ + function hook_headlines_custom_sort_override($order) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ["", false]; + } + + /** Allows adding custom elements to headlines Select... dropdown + * @deprecated removed, see Plugin::hook_headline_toolbar_select_menu_item2() + * @param int $feed_id + * @param int $is_cat + * @return string + * @see PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM + */ + function hook_headline_toolbar_select_menu_item($feed_id, $is_cat) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** Allows adding custom elements to headlines Select... select dropdown (<option> format) + * @param int $feed_id + * @param int $is_cat + * @return string + * @see PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2 + */ + function hook_headline_toolbar_select_menu_item2($feed_id, $is_cat) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return ""; + } + + /** Invoked when user tries to subscribe to feed, may override information (i.e. feed URL) used afterwards + * @param string $url + * @param string $auth_login + * @param string $auth_pass + * @return bool + * @see PluginHost::HOOK_PRE_SUBSCRIBE + */ + function hook_pre_subscribe(&$url, $auth_login, $auth_pass) { + user_error("Dummy method invoked.", E_USER_ERROR); + + return false; + } + + /** Invoked after user logout, may override built-in behavior (redirect back to login page) + * @param string $login + * @param int $user_id + * @return array<mixed> - [0] - if set, url to redirect to + */ + function hook_post_logout($login, $user_id) { + return [""]; + } } diff --git a/classes/pluginhandler.php b/classes/pluginhandler.php index 75b823822..5c73920e5 100644 --- a/classes/pluginhandler.php +++ b/classes/pluginhandler.php @@ -1,10 +1,10 @@ <?php class PluginHandler extends Handler_Protected { - function csrf_ignore($method) { + function csrf_ignore(string $method): bool { return true; } - function catchall($method) { + function catchall(string $method): void { $plugin_name = clean($_REQUEST["plugin"]); $plugin = PluginHost::getInstance()->get_plugin($plugin_name); $csrf_token = ($_POST["csrf_token"] ?? ""); diff --git a/classes/pluginhost.php b/classes/pluginhost.php index ee4107ae7..952d4df77 100755 --- a/classes/pluginhost.php +++ b/classes/pluginhost.php @@ -1,90 +1,219 @@ <?php class PluginHost { - private $pdo; - /* separate handle for plugin data so transaction while saving wouldn't clash with possible main - tt-rss code transactions; only initialized when first needed */ - private $pdo_data; - private $hooks = array(); - private $plugins = array(); - private $handlers = array(); - private $commands = array(); - private $storage = array(); - private $feeds = array(); - private $api_methods = array(); - private $plugin_actions = array(); - private $owner_uid; - private $last_registered; - private $data_loaded; - private static $instance; + // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ + /** @var PDO|null */ + private $pdo = null; + + /** + * separate handle for plugin data so transaction while saving wouldn't clash with possible main + * tt-rss code transactions; only initialized when first needed + * + * @var PDO|null + */ + private $pdo_data = null; + + /** @var array<string, array<int, array<int, Plugin>>> hook types -> priority levels -> Plugins */ + private $hooks = []; + + /** @var array<string, Plugin> */ + private $plugins = []; + + /** @var array<string, array<string, Plugin>> handler type -> method type -> Plugin */ + private $handlers = []; + + /** @var array<string, array{'description': string, 'suffix': string, 'arghelp': string, 'class': Plugin}> command type -> details array */ + private $commands = []; + + /** @var array<string, array<string, mixed>> plugin name -> (potential profile array) -> key -> value */ + private $storage = []; + + /** @var array<int, array<int, array{'id': int, 'title': string, 'sender': Plugin, 'icon': string}>> */ + private $feeds = []; + + /** @var array<string, Plugin> API method name, Plugin sender */ + private $api_methods = []; + + /** @var array<string, array<int, array{'action': string, 'description': string, 'sender': Plugin}>> */ + private $plugin_actions = []; + + /** @var int|null */ + private $owner_uid = null; + + /** @var bool */ + private $data_loaded = false; + + /** @var PluginHost|null */ + private static $instance = null; const API_VERSION = 2; const PUBLIC_METHOD_DELIMITER = "--"; - // Hooks marked with *1 are run in global context and available - // to plugins loaded in config.php only - - const HOOK_ARTICLE_BUTTON = "hook_article_button"; // hook_article_button($line) - const HOOK_ARTICLE_FILTER = "hook_article_filter"; // hook_article_filter($article) - const HOOK_PREFS_TAB = "hook_prefs_tab"; // hook_prefs_tab($tab) - const HOOK_PREFS_TAB_SECTION = "hook_prefs_tab_section"; // hook_prefs_tab_section($section) - const HOOK_PREFS_TABS = "hook_prefs_tabs"; // hook_prefs_tabs() - const HOOK_FEED_PARSED = "hook_feed_parsed"; // hook_feed_parsed($parser, $feed_id) - const HOOK_UPDATE_TASK = "hook_update_task"; //*1 // GLOBAL: hook_update_task($cli_options) - const HOOK_AUTH_USER = "hook_auth_user"; // hook_auth_user($login, $password, $service) (byref) - const HOOK_HOTKEY_MAP = "hook_hotkey_map"; // hook_hotkey_map($hotkeys) (byref) - const HOOK_RENDER_ARTICLE = "hook_render_article"; // hook_render_article($article) - const HOOK_RENDER_ARTICLE_CDM = "hook_render_article_cdm"; // hook_render_article_cdm($article) - const HOOK_FEED_FETCHED = "hook_feed_fetched"; // hook_feed_fetched($feed_data, $fetch_url, $owner_uid, $feed) (byref) - const HOOK_SANITIZE = "hook_sanitize"; // hook_sanitize($doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id) (byref) - const HOOK_RENDER_ARTICLE_API = "hook_render_article_api"; // hook_render_article_api($params) - const HOOK_TOOLBAR_BUTTON = "hook_toolbar_button"; // hook_toolbar_button() - const HOOK_ACTION_ITEM = "hook_action_item"; // hook_action_item() - const HOOK_HEADLINE_TOOLBAR_BUTTON = "hook_headline_toolbar_button"; // hook_headline_toolbar_button($feed_id, $is_cat) - const HOOK_HOTKEY_INFO = "hook_hotkey_info"; // hook_hotkey_info($hotkeys) (byref) - const HOOK_ARTICLE_LEFT_BUTTON = "hook_article_left_button"; // hook_article_left_button($row) - const HOOK_PREFS_EDIT_FEED = "hook_prefs_edit_feed"; // hook_prefs_edit_feed($feed_id) - const HOOK_PREFS_SAVE_FEED = "hook_prefs_save_feed"; // hook_prefs_save_feed($feed_id) - const HOOK_FETCH_FEED = "hook_fetch_feed"; // hook_fetch_feed($feed_data, $fetch_url, $owner_uid, $feed, $last_article_timestamp, $auth_login, $auth_pass) (byref) - const HOOK_QUERY_HEADLINES = "hook_query_headlines"; // hook_query_headlines($row) (byref) - const HOOK_HOUSE_KEEPING = "hook_house_keeping"; //*1 // GLOBAL: hook_house_keeping() - const HOOK_SEARCH = "hook_search"; // hook_search($query) - const HOOK_FORMAT_ENCLOSURES = "hook_format_enclosures"; // hook__format_enclosures($rv, $result, $id, $always_display_enclosures, $article_content, $hide_images) (byref) - const HOOK_SUBSCRIBE_FEED = "hook_subscribe_feed"; // hook_subscribe_feed($contents, $url, $auth_login, $auth_pass) (byref) - const HOOK_HEADLINES_BEFORE = "hook_headlines_before"; // hook_headlines_before($feed, $is_cat, $qfh_ret) - const HOOK_RENDER_ENCLOSURE = "hook_render_enclosure"; // hook_render_enclosure($entry, $id, $rv) - const HOOK_ARTICLE_FILTER_ACTION = "hook_article_filter_action"; // hook_article_filter_action($article, $action) - const HOOK_ARTICLE_EXPORT_FEED = "hook_article_export_feed"; // hook_article_export_feed($line, $feed, $is_cat, $owner_uid) (byref) - const HOOK_MAIN_TOOLBAR_BUTTON = "hook_main_toolbar_button"; // hook_main_toolbar_button() - const HOOK_ENCLOSURE_ENTRY = "hook_enclosure_entry"; // hook_enclosure_entry($entry, $id, $rv) (byref) - const HOOK_FORMAT_ARTICLE = "hook_format_article"; // hook_format_article($html, $row) - const HOOK_FORMAT_ARTICLE_CDM = "hook_format_article_cdm"; /* RIP */ - const HOOK_FEED_BASIC_INFO = "hook_feed_basic_info"; // hook_feed_basic_info($basic_info, $fetch_url, $owner_uid, $feed_id, $auth_login, $auth_pass) (byref) - const HOOK_SEND_LOCAL_FILE = "hook_send_local_file"; // hook_send_local_file($filename) - const HOOK_UNSUBSCRIBE_FEED = "hook_unsubscribe_feed"; // hook_unsubscribe_feed($feed_id, $owner_uid) - const HOOK_SEND_MAIL = "hook_send_mail"; // hook_send_mail(Mailer $mailer, $params) - const HOOK_FILTER_TRIGGERED = "hook_filter_triggered"; // hook_filter_triggered($feed_id, $owner_uid, $article, $matched_filters, $matched_rules, $article_filters) - const HOOK_GET_FULL_TEXT = "hook_get_full_text"; // hook_get_full_text($url) - const HOOK_ARTICLE_IMAGE = "hook_article_image"; // hook_article_image($enclosures, $content, $site_url) - const HOOK_FEED_TREE = "hook_feed_tree"; // hook_feed_tree() - const HOOK_IFRAME_WHITELISTED = "hook_iframe_whitelisted"; // hook_iframe_whitelisted($url) - const HOOK_ENCLOSURE_IMPORTED = "hook_enclosure_imported"; // hook_enclosure_imported($enclosure, $feed) - const HOOK_HEADLINES_CUSTOM_SORT_MAP = "hook_headlines_custom_sort_map"; // hook_headlines_custom_sort_map() + /** @see Plugin::hook_article_button() */ + const HOOK_ARTICLE_BUTTON = "hook_article_button"; + + /** @see Plugin::hook_article_filter() */ + const HOOK_ARTICLE_FILTER = "hook_article_filter"; + + /** @see Plugin::hook_prefs_tab() */ + const HOOK_PREFS_TAB = "hook_prefs_tab"; + + /** @see Plugin::hook_prefs_tab_section() */ + const HOOK_PREFS_TAB_SECTION = "hook_prefs_tab_section"; + + /** @see Plugin::hook_prefs_tabs() */ + const HOOK_PREFS_TABS = "hook_prefs_tabs"; + + /** @see Plugin::hook_feed_parsed() */ + const HOOK_FEED_PARSED = "hook_feed_parsed"; + + /** @see Plugin::hook_update_task() */ + const HOOK_UPDATE_TASK = "hook_update_task"; //*1 + + /** @see Plugin::hook_auth_user() */ + const HOOK_AUTH_USER = "hook_auth_user"; + + /** @see Plugin::hook_hotkey_map() */ + const HOOK_HOTKEY_MAP = "hook_hotkey_map"; + + /** @see Plugin::hook_render_article() */ + const HOOK_RENDER_ARTICLE = "hook_render_article"; + + /** @see Plugin::hook_render_article_cdm() */ + const HOOK_RENDER_ARTICLE_CDM = "hook_render_article_cdm"; + + /** @see Plugin::hook_feed_fetched() */ + const HOOK_FEED_FETCHED = "hook_feed_fetched"; + + /** @see Plugin::hook_sanitize() */ + const HOOK_SANITIZE = "hook_sanitize"; + + /** @see Plugin::hook_render_article_api() */ + const HOOK_RENDER_ARTICLE_API = "hook_render_article_api"; + + /** @see Plugin::hook_toolbar_button() */ + const HOOK_TOOLBAR_BUTTON = "hook_toolbar_button"; + + /** @see Plugin::hook_action_item() */ + const HOOK_ACTION_ITEM = "hook_action_item"; + + /** @see Plugin::hook_headline_toolbar_button() */ + const HOOK_HEADLINE_TOOLBAR_BUTTON = "hook_headline_toolbar_button"; + + /** @see Plugin::hook_hotkey_info() */ + const HOOK_HOTKEY_INFO = "hook_hotkey_info"; + + /** @see Plugin::hook_article_left_button() */ + const HOOK_ARTICLE_LEFT_BUTTON = "hook_article_left_button"; + + /** @see Plugin::hook_prefs_edit_feed() */ + const HOOK_PREFS_EDIT_FEED = "hook_prefs_edit_feed"; + + /** @see Plugin::hook_prefs_save_feed() */ + const HOOK_PREFS_SAVE_FEED = "hook_prefs_save_feed"; + + /** @see Plugin::hook_fetch_feed() */ + const HOOK_FETCH_FEED = "hook_fetch_feed"; + + /** @see Plugin::hook_query_headlines() */ + const HOOK_QUERY_HEADLINES = "hook_query_headlines"; + + /** @see Plugin::hook_house_keeping() */ + const HOOK_HOUSE_KEEPING = "hook_house_keeping"; //*1 + + /** @see Plugin::hook_search() */ + const HOOK_SEARCH = "hook_search"; + + /** @see Plugin::hook_format_enclosures() */ + const HOOK_FORMAT_ENCLOSURES = "hook_format_enclosures"; + + /** @see Plugin::hook_subscribe_feed() */ + const HOOK_SUBSCRIBE_FEED = "hook_subscribe_feed"; + + /** @see Plugin::hook_headlines_before() */ + const HOOK_HEADLINES_BEFORE = "hook_headlines_before"; + + /** @see Plugin::hook_render_enclosure() */ + const HOOK_RENDER_ENCLOSURE = "hook_render_enclosure"; + + /** @see Plugin::hook_article_filter_action() */ + const HOOK_ARTICLE_FILTER_ACTION = "hook_article_filter_action"; + + /** @see Plugin::hook_article_export_feed() */ + const HOOK_ARTICLE_EXPORT_FEED = "hook_article_export_feed"; + + /** @see Plugin::hook_main_toolbar_button() */ + const HOOK_MAIN_TOOLBAR_BUTTON = "hook_main_toolbar_button"; + + /** @see Plugin::hook_enclosure_entry() */ + const HOOK_ENCLOSURE_ENTRY = "hook_enclosure_entry"; + + /** @see Plugin::hook_format_article() */ + const HOOK_FORMAT_ARTICLE = "hook_format_article"; + + /** @see Plugin::hook_format_article_cdm() */ + const HOOK_FORMAT_ARTICLE_CDM = "hook_format_article_cdm"; + + /** @see Plugin::hook_feed_basic_info() */ + const HOOK_FEED_BASIC_INFO = "hook_feed_basic_info"; + + /** @see Plugin::hook_send_local_file() */ + const HOOK_SEND_LOCAL_FILE = "hook_send_local_file"; + + /** @see Plugin::hook_unsubscribe_feed() */ + const HOOK_UNSUBSCRIBE_FEED = "hook_unsubscribe_feed"; + + /** @see Plugin::hook_send_mail() */ + const HOOK_SEND_MAIL = "hook_send_mail"; + + /** @see Plugin::hook_filter_triggered() */ + const HOOK_FILTER_TRIGGERED = "hook_filter_triggered"; + + /** @see Plugin::hook_get_full_text() */ + const HOOK_GET_FULL_TEXT = "hook_get_full_text"; + + /** @see Plugin::hook_article_image() */ + const HOOK_ARTICLE_IMAGE = "hook_article_image"; + + /** @see Plugin::hook_feed_tree() */ + const HOOK_FEED_TREE = "hook_feed_tree"; + + /** @see Plugin::hook_iframe_whitelisted() */ + const HOOK_IFRAME_WHITELISTED = "hook_iframe_whitelisted"; + + /** @see Plugin::hook_enclosure_imported() */ + const HOOK_ENCLOSURE_IMPORTED = "hook_enclosure_imported"; + + /** @see Plugin::hook_headlines_custom_sort_map() */ + const HOOK_HEADLINES_CUSTOM_SORT_MAP = "hook_headlines_custom_sort_map"; + + /** @see Plugin::hook_headlines_custom_sort_override() */ const HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE = "hook_headlines_custom_sort_override"; - // hook_headlines_custom_sort_override($order) + + /** @see Plugin::hook_headline_toolbar_select_menu_item() + * @deprecated removed, see PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2 + */ const HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM = "hook_headline_toolbar_select_menu_item"; - // hook_headline_toolbar_select_menu_item($feed_id, $is_cat) + + /** @see Plugin::hook_headline_toolbar_select_menu_item() */ + const HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2 = "hook_headline_toolbar_select_menu_item2"; + + /** @see Plugin::hook_pre_subscribe() */ + const HOOK_PRE_SUBSCRIBE = "hook_pre_subscribe"; + + /** @see Plugin::hook_post_logout() */ + const HOOK_POST_LOGOUT = "hook_post_logout"; const KIND_ALL = 1; const KIND_SYSTEM = 2; const KIND_USER = 3; - static function object_to_domain(Plugin $plugin) { + static function object_to_domain(Plugin $plugin): string { return strtolower(get_class($plugin)); } function __construct() { $this->pdo = Db::pdo(); - $this->storage = array(); + $this->storage = []; } private function __clone() { @@ -98,18 +227,18 @@ class PluginHost { return self::$instance; } - private function register_plugin(string $name, Plugin $plugin) { + private function register_plugin(string $name, Plugin $plugin): void { //array_push($this->plugins, $plugin); $this->plugins[$name] = $plugin; } - // needed for compatibility with API 1 - function get_link() { + /** needed for compatibility with API 1 */ + function get_link(): bool { return false; } - // needed for compatibility with API 2 (?) - function get_dbh() { + /** needed for compatibility with API 2 (?) */ + function get_dbh(): bool { return false; } @@ -117,8 +246,11 @@ class PluginHost { return $this->pdo; } - function get_plugin_names() { - $names = array(); + /** + * @return array<int, string> + */ + function get_plugin_names(): array { + $names = []; foreach ($this->plugins as $p) { array_push($names, get_class($p)); @@ -127,18 +259,26 @@ class PluginHost { return $names; } - function get_plugins() { + /** + * @return array<Plugin> + */ + function get_plugins(): array { return $this->plugins; } - function get_plugin(string $name) { + function get_plugin(string $name): ?Plugin { return $this->plugins[strtolower($name)] ?? null; } - function run_hooks(string $hook, ...$args) { - $method = strtolower($hook); + /** + * @param PluginHost::HOOK_* $hook + * @param mixed $args + */ + function run_hooks(string $hook, ...$args): void { - foreach ($this->get_hooks($hook) as $plugin) { + $method = strtolower((string)$hook); + + foreach ($this->get_hooks((string)$hook) as $plugin) { //Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE); try { @@ -151,10 +291,15 @@ class PluginHost { } } - function run_hooks_until(string $hook, $check, ...$args) { - $method = strtolower($hook); + /** + * @param PluginHost::HOOK_* $hook + * @param mixed $args + * @param mixed $check + */ + function run_hooks_until(string $hook, $check, ...$args): bool { + $method = strtolower((string)$hook); - foreach ($this->get_hooks($hook) as $plugin) { + foreach ($this->get_hooks((string)$hook) as $plugin) { try { $result = $plugin->$method(...$args); @@ -171,10 +316,14 @@ class PluginHost { return false; } - function run_hooks_callback(string $hook, Closure $callback, ...$args) { - $method = strtolower($hook); + /** + * @param PluginHost::HOOK_* $hook + * @param mixed $args + */ + function run_hooks_callback(string $hook, Closure $callback, ...$args): void { + $method = strtolower((string)$hook); - foreach ($this->get_hooks($hook) as $plugin) { + foreach ($this->get_hooks((string)$hook) as $plugin) { //Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE); try { @@ -188,10 +337,14 @@ class PluginHost { } } - function chain_hooks_callback(string $hook, Closure $callback, &...$args) { - $method = strtolower($hook); + /** + * @param PluginHost::HOOK_* $hook + * @param mixed $args + */ + function chain_hooks_callback(string $hook, Closure $callback, &...$args): void { + $method = strtolower((string)$hook); - foreach ($this->get_hooks($hook) as $plugin) { + foreach ($this->get_hooks((string)$hook) as $plugin) { //Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE); try { @@ -205,10 +358,13 @@ class PluginHost { } } - function add_hook(string $type, Plugin $sender, int $priority = 50) { + /** + * @param PluginHost::HOOK_* $type + */ + function add_hook(string $type, Plugin $sender, int $priority = 50): void { $priority = (int) $priority; - if (!method_exists($sender, strtolower($type))) { + if (!method_exists($sender, strtolower((string)$type))) { user_error( sprintf("Plugin %s tried to register a hook without implementation: %s", get_class($sender), $type), @@ -229,7 +385,10 @@ class PluginHost { ksort($this->hooks[$type]); } - function del_hook(string $type, Plugin $sender) { + /** + * @param PluginHost::HOOK_* $type + */ + function del_hook(string $type, Plugin $sender): void { if (is_array($this->hooks[$type])) { foreach (array_keys($this->hooks[$type]) as $prio) { $key = array_search($sender, $this->hooks[$type][$prio]); @@ -241,6 +400,10 @@ class PluginHost { } } + /** + * @param PluginHost::HOOK_* $type + * @return array<int, Plugin> + */ function get_hooks(string $type) { if (isset($this->hooks[$type])) { $tmp = []; @@ -250,11 +413,14 @@ class PluginHost { } return $tmp; - } else { - return []; } + return []; } - function load_all(int $kind, int $owner_uid = null, bool $skip_init = false) { + + /** + * @param PluginHost::KIND_* $kind + */ + function load_all(int $kind, int $owner_uid = null, bool $skip_init = false): void { $plugins = array_merge(glob("plugins/*"), glob("plugins.local/*")); $plugins = array_filter($plugins, "is_dir"); @@ -262,10 +428,13 @@ class PluginHost { asort($plugins); - $this->load(join(",", $plugins), $kind, $owner_uid, $skip_init); + $this->load(join(",", $plugins), (int)$kind, $owner_uid, $skip_init); } - function load(string $classlist, int $kind, int $owner_uid = null, bool $skip_init = false) { + /** + * @param PluginHost::KIND_* $kind + */ + function load(string $classlist, int $kind, int $owner_uid = null, bool $skip_init = false): void { $plugins = explode(",", $classlist); $this->owner_uid = (int) $owner_uid; @@ -285,8 +454,28 @@ class PluginHost { } if (!isset($this->plugins[$class])) { + + // WIP hack + // we can't catch incompatible method signatures via Throwable + // this also enables global tt-rss safe mode in case there are more plugins like this + if (($_SESSION["plugin_blacklist"][$class] ?? 0)) { + + // only report once per-plugin per-session + if ($_SESSION["plugin_blacklist"][$class] < 2) { + user_error("Plugin $class has caused a PHP fatal error so it won't be loaded again in this session.", E_USER_WARNING); + $_SESSION["plugin_blacklist"][$class] = 2; + } + + $_SESSION["safe_mode"] = 1; + + continue; + } + try { - if (file_exists($file)) require_once $file; + $_SESSION["plugin_blacklist"][$class] = 1; + require_once $file; + unset($_SESSION["plugin_blacklist"][$class]); + } catch (Error $err) { user_error($err, E_USER_WARNING); continue; @@ -307,8 +496,6 @@ class PluginHost { _bind_textdomain_codeset($class, "UTF-8"); } - $this->last_registered = $class; - try { switch ($kind) { case $this::KIND_SYSTEM: @@ -340,27 +527,27 @@ class PluginHost { $this->load_data(); } - function is_system(Plugin $plugin) { + function is_system(Plugin $plugin): bool { $about = $plugin->about(); - return $about[3] ?? false; + return ($about[3] ?? false) === true; } // only system plugins are allowed to modify routing - function add_handler(string $handler, $method, Plugin $sender) { + function add_handler(string $handler, string $method, Plugin $sender): void { $handler = str_replace("-", "_", strtolower($handler)); $method = strtolower($method); if ($this->is_system($sender)) { if (!isset($this->handlers[$handler])) { - $this->handlers[$handler] = array(); + $this->handlers[$handler] = []; } $this->handlers[$handler][$method] = $sender; } } - function del_handler(string $handler, $method, Plugin $sender) { + function del_handler(string $handler, string $method, Plugin $sender): void { $handler = str_replace("-", "_", strtolower($handler)); $method = strtolower($method); @@ -369,7 +556,10 @@ class PluginHost { } } - function lookup_handler($handler, $method) { + /** + * @return false|Plugin false if the handler couldn't be found, otherwise the Plugin/handler + */ + function lookup_handler(string $handler, string $method) { $handler = str_replace("-", "_", strtolower($handler)); $method = strtolower($method); @@ -384,7 +574,7 @@ class PluginHost { return false; } - function add_command(string $command, string $description, Plugin $sender, string $suffix = "", string $arghelp = "") { + function add_command(string $command, string $description, Plugin $sender, string $suffix = "", string $arghelp = ""): void { $command = str_replace("-", "_", strtolower($command)); $this->commands[$command] = array("description" => $description, @@ -393,27 +583,34 @@ class PluginHost { "class" => $sender); } - function del_command(string $command) { + function del_command(string $command): void { $command = "-" . strtolower($command); unset($this->commands[$command]); } - function lookup_command($command) { + /** + * @return false|Plugin false if the command couldn't be found, otherwise the registered Plugin + */ + function lookup_command(string $command) { $command = "-" . strtolower($command); - if (is_array($this->commands[$command])) { + if (array_key_exists($command, $this->commands) && is_array($this->commands[$command])) { return $this->commands[$command]["class"]; } else { return false; } } + /** @return array<string, array{'description': string, 'suffix': string, 'arghelp': string, 'class': Plugin}>> command type -> details array */ function get_commands() { return $this->commands; } - function run_commands(array $args) { + /** + * @param array<string, mixed> $args + */ + function run_commands(array $args): void { foreach ($this->get_commands() as $command => $data) { if (isset($args[$command])) { $command = str_replace("-", "", $command); @@ -422,7 +619,7 @@ class PluginHost { } } - private function load_data() { + private function load_data(): void { if ($this->owner_uid && !$this->data_loaded && get_schema_version() > 100) { $sth = $this->pdo->prepare("SELECT name, content FROM ttrss_plugin_storage WHERE owner_uid = ?"); @@ -436,7 +633,7 @@ class PluginHost { } } - private function save_data(string $plugin) { + private function save_data(string $plugin): void { if ($this->owner_uid) { if (!$this->pdo_data) @@ -449,7 +646,7 @@ class PluginHost { $sth->execute([$this->owner_uid, $plugin]); if (!isset($this->storage[$plugin])) - $this->storage[$plugin] = array(); + $this->storage[$plugin] = []; $content = serialize($this->storage[$plugin]); @@ -469,8 +666,12 @@ class PluginHost { } } - // same as set(), but sets data to current preference profile - function profile_set(Plugin $sender, string $name, $value) { + /** + * same as set(), but sets data to current preference profile + * + * @param mixed $value + */ + function profile_set(Plugin $sender, string $name, $value): void { $profile_id = $_SESSION["profile"] ?? null; if ($profile_id) { @@ -488,26 +689,32 @@ class PluginHost { $this->save_data(get_class($sender)); } else { - return $this->set($sender, $name, $value); + $this->set($sender, $name, $value); } } - function set(Plugin $sender, string $name, $value) { + /** + * @param mixed $value + */ + function set(Plugin $sender, string $name, $value): void { $idx = get_class($sender); if (!isset($this->storage[$idx])) - $this->storage[$idx] = array(); + $this->storage[$idx] = []; $this->storage[$idx][$name] = $value; $this->save_data(get_class($sender)); } - function set_array(Plugin $sender, array $params) { + /** + * @param array<int|string, mixed> $params + */ + function set_array(Plugin $sender, array $params): void { $idx = get_class($sender); if (!isset($this->storage[$idx])) - $this->storage[$idx] = array(); + $this->storage[$idx] = []; foreach ($params as $name => $value) $this->storage[$idx][$name] = $value; @@ -515,7 +722,12 @@ class PluginHost { $this->save_data(get_class($sender)); } - // same as get(), but sets data to current preference profile + /** + * same as get(), but sets data to current preference profile + * + * @param mixed $default_value + * @return mixed + */ function profile_get(Plugin $sender, string $name, $default_value = false) { $profile_id = $_SESSION["profile"] ?? null; @@ -535,6 +747,10 @@ class PluginHost { } } + /** + * @param mixed $default_value + * @return mixed + */ function get(Plugin $sender, string $name, $default_value = false) { $idx = get_class($sender); @@ -547,6 +763,10 @@ class PluginHost { } } + /** + * @param array<int|string, mixed> $default_value + * @return array<int|string, mixed> + */ function get_array(Plugin $sender, string $name, array $default_value = []) { $tmp = $this->get($sender, $name); @@ -555,13 +775,16 @@ class PluginHost { return $tmp; } - function get_all($sender) { + /** + * @return array<string, mixed> + */ + function get_all(Plugin $sender) { $idx = get_class($sender); return $this->storage[$idx] ?? []; } - function clear_data(Plugin $sender) { + function clear_data(Plugin $sender): void { if ($this->owner_uid) { $idx = get_class($sender); @@ -576,7 +799,7 @@ class PluginHost { // Plugin feed functions are *EXPERIMENTAL*! // cat_id: only -1 is supported (Special) - function add_feed(int $cat_id, string $title, string $icon, Plugin $sender) { + function add_feed(int $cat_id, string $title, string $icon, Plugin $sender): int { if (empty($this->feeds[$cat_id])) $this->feeds[$cat_id] = []; @@ -589,12 +812,15 @@ class PluginHost { return $id; } + /** + * @return array<int, array{'id': int, 'title': string, 'sender': Plugin, 'icon': string}> + */ function get_feeds(int $cat_id) { return $this->feeds[$cat_id] ?? []; } // convert feed_id (e.g. -129) to pfeed_id first - function get_feed_handler(int $pfeed_id) { + function get_feed_handler(int $pfeed_id): ?Plugin { foreach ($this->feeds as $cat) { foreach ($cat as $feed) { if ($feed['id'] == $pfeed_id) { @@ -602,46 +828,54 @@ class PluginHost { } } } + return null; } - static function pfeed_to_feed_id(int $pfeed) { + static function pfeed_to_feed_id(int $pfeed): int { return PLUGIN_FEED_BASE_INDEX - 1 - abs($pfeed); } - static function feed_to_pfeed_id(int $feed) { + static function feed_to_pfeed_id(int $feed): int { return PLUGIN_FEED_BASE_INDEX - 1 + abs($feed); } - function add_api_method(string $name, Plugin $sender) { + function add_api_method(string $name, Plugin $sender): void { if ($this->is_system($sender)) { $this->api_methods[strtolower($name)] = $sender; } } - function get_api_method(string $name) { - return $this->api_methods[$name]; + function get_api_method(string $name): ?Plugin { + return $this->api_methods[$name] ?? null; } - function add_filter_action(Plugin $sender, string $action_name, string $action_desc) { + function add_filter_action(Plugin $sender, string $action_name, string $action_desc): void { $sender_class = get_class($sender); if (!isset($this->plugin_actions[$sender_class])) - $this->plugin_actions[$sender_class] = array(); + $this->plugin_actions[$sender_class] = []; array_push($this->plugin_actions[$sender_class], array("action" => $action_name, "description" => $action_desc, "sender" => $sender)); } + /** + * @return array<string, array<int, array{'action': string, 'description': string, 'sender': Plugin}>> + */ function get_filter_actions() { return $this->plugin_actions; } - function get_owner_uid() { + function get_owner_uid(): ?int { return $this->owner_uid; } - // handled by classes/pluginhandler.php, requires valid session - function get_method_url(Plugin $sender, string $method, $params = []) { + /** + * handled by classes/pluginhandler.php, requires valid session + * + * @param array<int|string, mixed> $params + */ + function get_method_url(Plugin $sender, string $method, array $params = []): string { return Config::get_self_url() . "/backend.php?" . http_build_query( array_merge( @@ -664,8 +898,12 @@ class PluginHost { $params)); } */ - // WARNING: endpoint in public.php, exposed to unauthenticated users - function get_public_method_url(Plugin $sender, string $method, $params = []) { + /** + * WARNING: endpoint in public.php, exposed to unauthenticated users + * + * @param array<int|string, mixed> $params + */ + function get_public_method_url(Plugin $sender, string $method, array $params = []): ?string { if ($sender->is_public_method($method)) { return Config::get_self_url() . "/public.php?" . http_build_query( @@ -674,18 +912,18 @@ class PluginHost { "op" => strtolower(get_class($sender) . self::PUBLIC_METHOD_DELIMITER . $method), ], $params)); - } else { - user_error("get_public_method_url: requested method '$method' of '" . get_class($sender) . "' is private."); } + user_error("get_public_method_url: requested method '$method' of '" . get_class($sender) . "' is private."); + return null; } - function get_plugin_dir(Plugin $plugin) { + function get_plugin_dir(Plugin $plugin): string { $ref = new ReflectionClass(get_class($plugin)); return dirname($ref->getFileName()); } // TODO: use get_plugin_dir() - function is_local(Plugin $plugin) { + function is_local(Plugin $plugin): bool { $ref = new ReflectionClass(get_class($plugin)); return basename(dirname(dirname($ref->getFileName()))) == "plugins.local"; } diff --git a/classes/pref/feeds.php b/classes/pref/feeds.php index 5f7635736..b90ce49b4 100755 --- a/classes/pref/feeds.php +++ b/classes/pref/feeds.php @@ -5,29 +5,25 @@ class Pref_Feeds extends Handler_Protected { const E_ICON_UPLOAD_FAILED = 'E_ICON_UPLOAD_FAILED'; const E_ICON_UPLOAD_SUCCESS = 'E_ICON_UPLOAD_SUCCESS'; - function csrf_ignore($method) { + function csrf_ignore(string $method): bool { $csrf_ignored = array("index", "getfeedtree", "savefeedorder"); return array_search($method, $csrf_ignored) !== false; } - public static function get_ts_languages() { - $rv = []; - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $dbh = Db::pdo(); - - $res = $dbh->query("SELECT cfgname FROM pg_ts_config"); - - while ($row = $res->fetch()) { - array_push($rv, ucfirst($row['cfgname'])); - } + /** + * @return array<int, string> + */ + public static function get_ts_languages(): array { + if (Config::get(Config::DB_TYPE) == 'pgsql') { + return array_map('ucfirst', + array_column(ORM::for_table('pg_ts_config')->select('cfgname')->find_array(), 'cfgname')); } - return $rv; + return []; } - function renameCat() { + function renameCat(): void { $cat = ORM::for_table("ttrss_feed_categories") ->where("owner_uid", $_SESSION["uid"]) ->find_one($_REQUEST['id']); @@ -40,7 +36,10 @@ class Pref_Feeds extends Handler_Protected { } } - private function get_category_items($cat_id) { + /** + * @return array<int, array<string, bool|int|string>> + */ + private function get_category_items(int $cat_id): array { if (clean($_REQUEST['mode'] ?? 0) != 2) $search = $_SESSION["prefs_feed_search"] ?? ""; @@ -48,74 +47,76 @@ class Pref_Feeds extends Handler_Protected { $search = ""; // first one is set by API - $show_empty_cats = clean($_REQUEST['force_show_empty'] ?? false) || + $show_empty_cats = self::_param_to_bool($_REQUEST['force_show_empty'] ?? false) || (clean($_REQUEST['mode'] ?? 0) != 2 && !$search); - $items = array(); - - $sth = $this->pdo->prepare("SELECT id, title FROM ttrss_feed_categories - WHERE owner_uid = ? AND parent_cat = ? ORDER BY order_id, title"); - $sth->execute([$_SESSION['uid'], $cat_id]); - - while ($line = $sth->fetch()) { - - $cat = array(); - $cat['id'] = 'CAT:' . $line['id']; - $cat['bare_id'] = (int)$line['id']; - $cat['name'] = $line['title']; - $cat['items'] = array(); - $cat['checkbox'] = false; - $cat['type'] = 'category'; - $cat['unread'] = -1; - $cat['child_unread'] = -1; - $cat['auxcounter'] = -1; - $cat['parent_id'] = $cat_id; - - $cat['items'] = $this->get_category_items($line['id']); + $items = []; + + $feed_categories = ORM::for_table('ttrss_feed_categories') + ->select_many('id', 'title') + ->where(['owner_uid' => $_SESSION['uid'], 'parent_cat' => $cat_id]) + ->order_by_asc('order_id') + ->order_by_asc('title') + ->find_many(); + + foreach ($feed_categories as $feed_category) { + $cat = [ + 'id' => 'CAT:' . $feed_category->id, + 'bare_id' => (int)$feed_category->id, + 'name' => $feed_category->title, + 'items' => $this->get_category_items($feed_category->id), + 'checkbox' => false, + 'type' => 'category', + 'unread' => -1, + 'child_unread' => -1, + 'auxcounter' => -1, + 'parent_id' => $cat_id, + ]; $num_children = $this->calculate_children_count($cat); $cat['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', (int) $num_children), $num_children); if ($num_children > 0 || $show_empty_cats) array_push($items, $cat); + } + + $feeds_obj = ORM::for_table('ttrss_feeds') + ->select_many('id', 'title', 'last_error', 'update_interval') + ->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated') + ->where(['cat_id' => $cat_id, 'owner_uid' => $_SESSION['uid']]) + ->order_by_asc('order_id') + ->order_by_asc('title'); + if ($search) { + $feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]); } - $fsth = $this->pdo->prepare("SELECT id, title, last_error, - ".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated, update_interval - FROM ttrss_feeds - WHERE cat_id = :cat AND - owner_uid = :uid AND - (:search = '' OR (LOWER(title) LIKE :search OR LOWER(feed_url) LIKE :search)) - ORDER BY order_id, title"); - - $fsth->execute([":cat" => $cat_id, ":uid" => $_SESSION['uid'], ":search" => $search ? "%$search%" : ""]); - - while ($feed_line = $fsth->fetch()) { - $feed = array(); - $feed['id'] = 'FEED:' . $feed_line['id']; - $feed['bare_id'] = (int)$feed_line['id']; - $feed['auxcounter'] = -1; - $feed['name'] = $feed_line['title']; - $feed['checkbox'] = false; - $feed['unread'] = -1; - $feed['error'] = $feed_line['last_error']; - $feed['icon'] = Feeds::_get_icon($feed_line['id']); - $feed['param'] = TimeHelper::make_local_datetime( - $feed_line['last_updated'], true); - $feed['updates_disabled'] = (int)($feed_line['update_interval'] < 0); - - array_push($items, $feed); + foreach ($feeds_obj->find_many() as $feed) { + array_push($items, [ + 'id' => 'FEED:' . $feed->id, + 'bare_id' => (int) $feed->id, + 'auxcounter' => -1, + 'name' => $feed->title, + 'checkbox' => false, + 'unread' => -1, + 'error' => $feed->last_error, + 'icon' => Feeds::_get_icon($feed->id), + 'param' => TimeHelper::make_local_datetime($feed->last_updated, true), + 'updates_disabled' => (int)($feed->update_interval < 0), + ]); } return $items; } - function getfeedtree() { + function getfeedtree(): void { print json_encode($this->_makefeedtree()); } - function _makefeedtree() { + /** + * @return array<string, array<int|string, mixed>|string> + */ + function _makefeedtree(): array { if (clean($_REQUEST['mode'] ?? 0) != 2) $search = $_SESSION["prefs_feed_search"] ?? ""; @@ -181,24 +182,23 @@ class Pref_Feeds extends Handler_Protected { if (get_pref(Prefs::ENABLE_FEED_CATS)) { $cat = $this->feedlist_init_cat(-2); } else { - $cat['items'] = array(); + $cat['items'] = []; } - $num_labels = 0; - while ($line = $sth->fetch()) { - ++$num_labels; - - $label_id = Labels::label_to_feed_id($line['id']); - - $feed = $this->feedlist_init_feed($label_id, false, 0); - - $feed['fg_color'] = $line['fg_color']; - $feed['bg_color'] = $line['bg_color']; - - array_push($cat['items'], $feed); - } + $labels = ORM::for_table('ttrss_labels2') + ->where('owner_uid', $_SESSION['uid']) + ->order_by_asc('caption') + ->find_many(); + + if (count($labels)) { + foreach ($labels as $label) { + $label_id = Labels::label_to_feed_id($label->id); + $feed = $this->feedlist_init_feed($label_id, null, false); + $feed['fg_color'] = $label->fg_color; + $feed['bg_color'] = $label->bg_color; + array_push($cat['items'], $feed); + } - if ($num_labels) { if ($enable_cats) { array_push($root['items'], $cat); } else { @@ -208,26 +208,29 @@ class Pref_Feeds extends Handler_Protected { } if ($enable_cats) { - $show_empty_cats = clean($_REQUEST['force_show_empty'] ?? false) || + $show_empty_cats = self::_param_to_bool($_REQUEST['force_show_empty'] ?? false) || (clean($_REQUEST['mode'] ?? 0) != 2 && !$search); - $sth = $this->pdo->prepare("SELECT id, title FROM ttrss_feed_categories - WHERE owner_uid = ? AND parent_cat IS NULL ORDER BY order_id, title"); - $sth->execute([$_SESSION['uid']]); - - while ($line = $sth->fetch()) { - $cat = array(); - $cat['id'] = 'CAT:' . $line['id']; - $cat['bare_id'] = (int)$line['id']; - $cat['auxcounter'] = -1; - $cat['name'] = $line['title']; - $cat['items'] = array(); - $cat['checkbox'] = false; - $cat['type'] = 'category'; - $cat['unread'] = -1; - $cat['child_unread'] = -1; - - $cat['items'] = $this->get_category_items($line['id']); + $feed_categories = ORM::for_table('ttrss_feed_categories') + ->select_many('id', 'title') + ->where('owner_uid', $_SESSION['uid']) + ->where_null('parent_cat') + ->order_by_asc('order_id') + ->order_by_asc('title') + ->find_many(); + + foreach ($feed_categories as $feed_category) { + $cat = [ + 'id' => 'CAT:' . $feed_category->id, + 'bare_id' => (int) $feed_category->id, + 'auxcounter' => -1, + 'name' => $feed_category->title, + 'items' => $this->get_category_items($feed_category->id), + 'checkbox' => false, + 'type' => 'category', + 'unread' => -1, + 'child_unread' => -1, + ]; $num_children = $this->calculate_children_count($cat); $cat['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', (int) $num_children), $num_children); @@ -235,47 +238,48 @@ class Pref_Feeds extends Handler_Protected { if ($num_children > 0 || $show_empty_cats) array_push($root['items'], $cat); - $root['param'] += count($cat['items']); + //$root['param'] += count($cat['items']); } /* Uncategorized is a special case */ + $cat = [ + 'id' => 'CAT:0', + 'bare_id' => 0, + 'auxcounter' => -1, + 'name' => __('Uncategorized'), + 'items' => [], + 'type' => 'category', + 'checkbox' => false, + 'unread' => -1, + 'child_unread' => -1, + ]; + + $feeds_obj = ORM::for_table('ttrss_feeds') + ->select_many('id', 'title', 'last_error', 'update_interval') + ->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated') + ->where('owner_uid', $_SESSION['uid']) + ->where_null('cat_id') + ->order_by_asc('order_id') + ->order_by_asc('title'); - $cat = array(); - $cat['id'] = 'CAT:0'; - $cat['bare_id'] = 0; - $cat['auxcounter'] = -1; - $cat['name'] = __("Uncategorized"); - $cat['items'] = array(); - $cat['type'] = 'category'; - $cat['checkbox'] = false; - $cat['unread'] = -1; - $cat['child_unread'] = -1; - - $fsth = $this->pdo->prepare("SELECT id, title,last_error, - ".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated, update_interval - FROM ttrss_feeds - WHERE cat_id IS NULL AND - owner_uid = :uid AND - (:search = '' OR (LOWER(title) LIKE :search OR LOWER(feed_url) LIKE :search)) - ORDER BY order_id, title"); - $fsth->execute([":uid" => $_SESSION['uid'], ":search" => $search ? "%$search%" : ""]); - - while ($feed_line = $fsth->fetch()) { - $feed = array(); - $feed['id'] = 'FEED:' . $feed_line['id']; - $feed['bare_id'] = (int)$feed_line['id']; - $feed['auxcounter'] = -1; - $feed['name'] = $feed_line['title']; - $feed['checkbox'] = false; - $feed['error'] = $feed_line['last_error']; - $feed['icon'] = Feeds::_get_icon($feed_line['id']); - $feed['param'] = TimeHelper::make_local_datetime( - $feed_line['last_updated'], true); - $feed['unread'] = -1; - $feed['type'] = 'feed'; - $feed['updates_disabled'] = (int)($feed_line['update_interval'] < 0); - - array_push($cat['items'], $feed); + if ($search) { + $feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]); + } + + foreach ($feeds_obj->find_many() as $feed) { + array_push($cat['items'], [ + 'id' => 'FEED:' . $feed->id, + 'bare_id' => (int) $feed->id, + 'auxcounter' => -1, + 'name' => $feed->title, + 'checkbox' => false, + 'error' => $feed->last_error, + 'icon' => Feeds::_get_icon($feed->id), + 'param' => TimeHelper::make_local_datetime($feed->last_updated, true), + 'unread' => -1, + 'type' => 'feed', + 'updates_disabled' => (int)($feed->update_interval < 0), + ]); } $cat['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', count($cat['items'])), count($cat['items'])); @@ -287,61 +291,59 @@ class Pref_Feeds extends Handler_Protected { $root['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', (int) $num_children), $num_children); } else { - $fsth = $this->pdo->prepare("SELECT id, title, last_error, - ".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated, update_interval - FROM ttrss_feeds - WHERE owner_uid = :uid AND - (:search = '' OR (LOWER(title) LIKE :search OR LOWER(feed_url) LIKE :search)) - ORDER BY order_id, title"); - $fsth->execute([":uid" => $_SESSION['uid'], ":search" => $search ? "%$search%" : ""]); - - while ($feed_line = $fsth->fetch()) { - $feed = array(); - $feed['id'] = 'FEED:' . $feed_line['id']; - $feed['bare_id'] = (int)$feed_line['id']; - $feed['auxcounter'] = -1; - $feed['name'] = $feed_line['title']; - $feed['checkbox'] = false; - $feed['error'] = $feed_line['last_error']; - $feed['icon'] = Feeds::_get_icon($feed_line['id']); - $feed['param'] = TimeHelper::make_local_datetime( - $feed_line['last_updated'], true); - $feed['unread'] = -1; - $feed['type'] = 'feed'; - $feed['updates_disabled'] = (int)($feed_line['update_interval'] < 0); - - array_push($root['items'], $feed); - } + $feeds_obj = ORM::for_table('ttrss_feeds') + ->select_many('id', 'title', 'last_error', 'update_interval') + ->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated') + ->where('owner_uid', $_SESSION['uid']) + ->order_by_asc('order_id') + ->order_by_asc('title'); - $root['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', count($root['items'])), count($root['items'])); - } + if ($search) { + $feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]); + } - $fl = array(); - $fl['identifier'] = 'id'; - $fl['label'] = 'name'; + foreach ($feeds_obj->find_many() as $feed) { + array_push($root['items'], [ + 'id' => 'FEED:' . $feed->id, + 'bare_id' => (int) $feed->id, + 'auxcounter' => -1, + 'name' => $feed->title, + 'checkbox' => false, + 'error' => $feed->last_error, + 'icon' => Feeds::_get_icon($feed->id), + 'param' => TimeHelper::make_local_datetime($feed->last_updated, true), + 'unread' => -1, + 'type' => 'feed', + 'updates_disabled' => (int)($feed->update_interval < 0), + ]); + } - if (clean($_REQUEST['mode'] ?? 0) != 2) { - $fl['items'] = array($root); - } else { - $fl['items'] = $root['items']; + $root['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', count($root['items'])), count($root['items'])); } - return $fl; + return [ + 'identifier' => 'id', + 'label' => 'name', + 'items' => clean($_REQUEST['mode'] ?? 0) != 2 ? [$root] : $root['items'], + ]; } - function catsortreset() { + function catsortreset(): void { $sth = $this->pdo->prepare("UPDATE ttrss_feed_categories SET order_id = 0 WHERE owner_uid = ?"); $sth->execute([$_SESSION['uid']]); } - function feedsortreset() { + function feedsortreset(): void { $sth = $this->pdo->prepare("UPDATE ttrss_feeds SET order_id = 0 WHERE owner_uid = ?"); $sth->execute([$_SESSION['uid']]); } - private function process_category_order(&$data_map, $item_id, $parent_id = false, $nest_level = 0) { + /** + * @param array<string, mixed> $data_map + */ + private function process_category_order(array &$data_map, string $item_id = '', string $parent_id = '', int $nest_level = 0): void { $prefix = ""; for ($i = 0; $i < $nest_level; $i++) @@ -359,10 +361,14 @@ class Pref_Feeds extends Handler_Protected { $parent_qpart = null; } - $sth = $this->pdo->prepare("UPDATE ttrss_feed_categories - SET parent_cat = ? WHERE id = ? AND - owner_uid = ?"); - $sth->execute([$parent_qpart, $bare_item_id, $_SESSION['uid']]); + $feed_category = ORM::for_table('ttrss_feed_categories') + ->where('owner_uid', $_SESSION['uid']) + ->find_one($bare_item_id); + + if ($feed_category) { + $feed_category->parent_cat = $parent_qpart; + $feed_category->save(); + } } $order_id = 1; @@ -380,22 +386,27 @@ class Pref_Feeds extends Handler_Protected { if (strpos($id, "FEED") === 0) { - $cat_id = ($item_id != "root") ? $bare_item_id : null; - - $sth = $this->pdo->prepare("UPDATE ttrss_feeds - SET order_id = ?, cat_id = ? - WHERE id = ? AND owner_uid = ?"); - - $sth->execute([$order_id, $cat_id ? $cat_id : null, $bare_id, $_SESSION['uid']]); + $feed = ORM::for_table('ttrss_feeds') + ->where('owner_uid', $_SESSION['uid']) + ->find_one($bare_id); + if ($feed) { + $feed->order_id = $order_id; + $feed->cat_id = ($item_id != "root" && $bare_item_id) ? $bare_item_id : null; + $feed->save(); + } } else if (strpos($id, "CAT:") === 0) { $this->process_category_order($data_map, $item['_reference'], $item_id, $nest_level+1); - $sth = $this->pdo->prepare("UPDATE ttrss_feed_categories - SET order_id = ? WHERE id = ? AND - owner_uid = ?"); - $sth->execute([$order_id, $bare_id, $_SESSION['uid']]); + $feed_category = ORM::for_table('ttrss_feed_categories') + ->where('owner_uid', $_SESSION['uid']) + ->find_one($bare_id); + + if ($feed_category) { + $feed_category->order_id = $order_id; + $feed_category->save(); + } } } @@ -404,7 +415,7 @@ class Pref_Feeds extends Handler_Protected { } } - function savefeedorder() { + function savefeedorder(): void { $data = json_decode($_POST['payload'], true); #file_put_contents("/tmp/saveorder.json", clean($_POST['payload'])); @@ -418,8 +429,9 @@ class Pref_Feeds extends Handler_Protected { if (is_array($data) && is_array($data['items'])) { # $cat_order_id = 0; + /** @var array<int, mixed> */ $data_map = array(); - $root_item = false; + $root_item = ''; foreach ($data['items'] as $item) { @@ -440,7 +452,7 @@ class Pref_Feeds extends Handler_Protected { } } - function removeIcon() { + function removeIcon(): void { $feed_id = (int) $_REQUEST["feed_id"]; $icon_file = Config::get(Config::ICONS_DIR) . "/$feed_id.ico"; @@ -460,7 +472,7 @@ class Pref_Feeds extends Handler_Protected { } } - function uploadIcon() { + function uploadIcon(): void { $feed_id = (int) $_REQUEST['feed_id']; $tmp_file = tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'icon'); @@ -503,7 +515,7 @@ class Pref_Feeds extends Handler_Protected { print json_encode(['rc' => $rc, 'icon_url' => Feeds::_get_icon($feed_id)]); } - function editfeed() { + function editfeed(): void { global $purge_intervals; global $update_intervals; @@ -539,6 +551,8 @@ class Pref_Feeds extends Handler_Protected { $local_purge_intervals = [ T_nsprintf('%d day', '%d days', $purge_interval, $purge_interval) ]; } + $user = ORM::for_table("ttrss_users")->find_one($_SESSION["uid"]); + print json_encode([ "feed" => $row, "cats" => [ @@ -551,6 +565,9 @@ class Pref_Feeds extends Handler_Protected { "update" => $local_update_intervals, "purge" => $local_purge_intervals, ], + "user" => [ + "access_level" => $user->access_level + ], "lang" => [ "enabled" => Config::get(Config::DB_TYPE) == "pgsql", "default" => get_pref(Prefs::DEFAULT_SEARCH_LANGUAGE), @@ -560,12 +577,12 @@ class Pref_Feeds extends Handler_Protected { } } - private function _batch_toggle_checkbox($name) { + private function _batch_toggle_checkbox(string $name): string { return \Controls\checkbox_tag("", false, "", ["data-control-for" => $name, "title" => __("Check to enable field"), "onchange" => "App.dialogOf(this).toggleField(this)"]); } - function editfeeds() { + function editfeeds(): void { global $purge_intervals; global $update_intervals; @@ -673,15 +690,15 @@ class Pref_Feeds extends Handler_Protected { <?php } - function batchEditSave() { - return $this->editsaveops(true); + function batchEditSave(): void { + $this->editsaveops(true); } - function editSave() { - return $this->editsaveops(false); + function editSave(): void { + $this->editsaveops(false); } - private function editsaveops($batch) { + private function editsaveops(bool $batch): void { $feed_title = clean($_POST["title"]); $feed_url = clean($_POST["feed_url"]); @@ -770,11 +787,11 @@ class Pref_Feeds extends Handler_Protected { break; case "update_interval": - $qpart = "update_interval = " . $this->pdo->quote($upd_intl); + $qpart = "update_interval = " . $upd_intl; // made int above break; case "purge_interval": - $qpart = "purge_interval =" . $this->pdo->quote($purge_intl); + $qpart = "purge_interval = " . $purge_intl; // made int above break; case "auth_login": @@ -786,33 +803,33 @@ class Pref_Feeds extends Handler_Protected { break; case "private": - $qpart = "private = " . $this->pdo->quote($private); + $qpart = "private = " . $private; // made int above break; case "include_in_digest": - $qpart = "include_in_digest = " . $this->pdo->quote($include_in_digest); + $qpart = "include_in_digest = " . $include_in_digest; // made int above break; case "always_display_enclosures": - $qpart = "always_display_enclosures = " . $this->pdo->quote($always_display_enclosures); + $qpart = "always_display_enclosures = " . $always_display_enclosures; // made int above break; case "mark_unread_on_update": - $qpart = "mark_unread_on_update = " . $this->pdo->quote($mark_unread_on_update); + $qpart = "mark_unread_on_update = " . $mark_unread_on_update; // made int above break; case "cache_images": - $qpart = "cache_images = " . $this->pdo->quote($cache_images); + $qpart = "cache_images = " . $cache_images; // made int above break; case "hide_images": - $qpart = "hide_images = " . $this->pdo->quote($hide_images); + $qpart = "hide_images = " . $hide_images; // made int above break; case "cat_id": if (get_pref(Prefs::ENABLE_FEED_CATS)) { if ($cat_id) { - $qpart = "cat_id = " . $this->pdo->quote($cat_id); + $qpart = "cat_id = " . $cat_id; // made int above } else { $qpart = 'cat_id = NULL'; } @@ -837,39 +854,36 @@ class Pref_Feeds extends Handler_Protected { $this->pdo->commit(); } - return; } - function remove() { - - $ids = explode(",", clean($_REQUEST["ids"])); + function remove(): void { + /** @var array<int, int> */ + $ids = array_map('intval', explode(",", clean($_REQUEST["ids"]))); foreach ($ids as $id) { self::remove_feed($id, $_SESSION["uid"]); } - - return; } - function removeCat() { + function removeCat(): void { $ids = explode(",", clean($_REQUEST["ids"])); foreach ($ids as $id) { Feeds::_remove_cat((int)$id, $_SESSION["uid"]); } } - function addCat() { + function addCat(): void { $feed_cat = clean($_REQUEST["cat"]); Feeds::_add_cat($feed_cat, $_SESSION['uid']); } - function importOpml() { + function importOpml(): void { $opml = new OPML($_REQUEST); $opml->opml_import($_SESSION["uid"]); } - private function index_feeds() { + private function index_feeds(): void { $error_button = "<button dojoType='dijit.form.Button' id='pref_feeds_errors_btn' style='display : none' onclick='CommonDialogs.showFeedsWithErrors()'>". @@ -980,7 +994,7 @@ class Pref_Feeds extends Handler_Protected { } - private function index_opml() { + private function index_opml(): void { ?> <form id='opml_import_form' method='post' enctype='multipart/form-data'> @@ -1016,7 +1030,7 @@ class Pref_Feeds extends Handler_Protected { PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefFeedsOPML"); } - private function index_shared() { + private function index_shared(): void { ?> <?= format_notice('Published articles can be subscribed by anyone who knows the following URL:') ?></h3> @@ -1036,7 +1050,7 @@ class Pref_Feeds extends Handler_Protected { PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefFeedsPublishedGenerated"); } - function index() { + function index(): void { ?> <div dojoType='dijit.layout.TabContainer' tabPosition='left-h'> @@ -1075,44 +1089,44 @@ class Pref_Feeds extends Handler_Protected { <?php } - private function feedlist_init_cat($cat_id) { - $obj = array(); - $cat_id = (int) $cat_id; - - $obj['id'] = 'CAT:' . $cat_id; - $obj['items'] = array(); - $obj['name'] = Feeds::_get_cat_title($cat_id); - $obj['type'] = 'category'; - $obj['unread'] = -1; //(int) Feeds::_get_cat_unread($cat_id); - $obj['bare_id'] = $cat_id; - - return $obj; + /** + * @return array<string, mixed> + */ + private function feedlist_init_cat(int $cat_id): array { + return [ + 'id' => 'CAT:' . $cat_id, + 'items' => array(), + 'name' => Feeds::_get_cat_title($cat_id), + 'type' => 'category', + 'unread' => -1, //(int) Feeds::_get_cat_unread($cat_id); + 'bare_id' => $cat_id, + ]; } - private function feedlist_init_feed($feed_id, $title = false, $unread = false, $error = '', $updated = '') { - $obj = array(); - $feed_id = (int) $feed_id; - + /** + * @return array<string, mixed> + */ + private function feedlist_init_feed(int $feed_id, ?string $title = null, bool $unread = false, string $error = '', string $updated = ''): array { if (!$title) $title = Feeds::_get_title($feed_id, false); if ($unread === false) - $unread = getFeedUnread($feed_id, false); - - $obj['id'] = 'FEED:' . $feed_id; - $obj['name'] = $title; - $obj['unread'] = (int) $unread; - $obj['type'] = 'feed'; - $obj['error'] = $error; - $obj['updated'] = $updated; - $obj['icon'] = Feeds::_get_icon($feed_id); - $obj['bare_id'] = $feed_id; - $obj['auxcounter'] = 0; - - return $obj; + $unread = Feeds::_get_counters($feed_id, false, true); + + return [ + 'id' => 'FEED:' . $feed_id, + 'name' => $title, + 'unread' => (int) $unread, + 'type' => 'feed', + 'error' => $error, + 'updated' => $updated, + 'icon' => Feeds::_get_icon($feed_id), + 'bare_id' => $feed_id, + 'auxcounter' => 0, + ]; } - function inactiveFeeds() { + function inactiveFeeds(): void { if (Config::get(Config::DB_TYPE) == "pgsql") { $interval_qpart = "NOW() - INTERVAL '3 months'"; @@ -1120,44 +1134,41 @@ class Pref_Feeds extends Handler_Protected { $interval_qpart = "DATE_SUB(NOW(), INTERVAL 3 MONTH)"; } - $sth = $this->pdo->prepare("SELECT ttrss_feeds.title, ttrss_feeds.site_url, - ttrss_feeds.feed_url, ttrss_feeds.id, MAX(updated) AS last_article - FROM ttrss_feeds, ttrss_entries, ttrss_user_entries WHERE - (SELECT MAX(updated) FROM ttrss_entries, ttrss_user_entries WHERE - ttrss_entries.id = ref_id AND - ttrss_user_entries.feed_id = ttrss_feeds.id) < $interval_qpart - AND ttrss_feeds.owner_uid = ? AND - ttrss_user_entries.feed_id = ttrss_feeds.id AND - ttrss_entries.id = ref_id - GROUP BY ttrss_feeds.title, ttrss_feeds.id, ttrss_feeds.site_url, ttrss_feeds.feed_url - ORDER BY last_article"); - $sth->execute([$_SESSION['uid']]); - - $rv = []; - - while ($row = $sth->fetch(PDO::FETCH_ASSOC)) { - $row['last_article'] = TimeHelper::make_local_datetime($row['last_article'], false); - array_push($rv, $row); + $inactive_feeds = ORM::for_table('ttrss_feeds') + ->table_alias('f') + ->select_many('f.id', 'f.title', 'f.site_url', 'f.feed_url') + ->select_expr('MAX(e.updated)', 'last_article') + ->join('ttrss_user_entries', [ 'ue.feed_id', '=', 'f.id'], 'ue') + ->join('ttrss_entries', ['e.id', '=', 'ue.ref_id'], 'e') + ->where('f.owner_uid', $_SESSION['uid']) + ->where_raw( + "(SELECT MAX(ttrss_entries.updated) + FROM ttrss_entries + JOIN ttrss_user_entries ON ttrss_entries.id = ttrss_user_entries.ref_id + WHERE ttrss_user_entries.feed_id = f.id) < $interval_qpart") + ->group_by('f.title') + ->group_by('f.id') + ->group_by('f.site_url') + ->group_by('f.feed_url') + ->order_by_asc('last_article') + ->find_array(); + + foreach ($inactive_feeds as $inactive_feed) { + $inactive_feed['last_article'] = TimeHelper::make_local_datetime($inactive_feed['last_article'], false); } - print json_encode($rv); + print json_encode($inactive_feeds); } - function feedsWithErrors() { - $sth = $this->pdo->prepare("SELECT id,title,feed_url,last_error,site_url - FROM ttrss_feeds WHERE last_error != '' AND owner_uid = ?"); - $sth->execute([$_SESSION['uid']]); - - $rv = []; - - while ($row = $sth->fetch()) { - array_push($rv, $row); - } - - print json_encode($rv); + function feedsWithErrors(): void { + print json_encode(ORM::for_table('ttrss_feeds') + ->select_many('id', 'title', 'feed_url', 'last_error', 'site_url') + ->where_not_equal('last_error', '') + ->where('owner_uid', $_SESSION['uid']) + ->find_array()); } - static function remove_feed($id, $owner_uid) { + static function remove_feed(int $id, int $owner_uid): void { if (PluginHost::getInstance()->run_hooks_until(PluginHost::HOOK_UNSUBSCRIBE_FEED, true, $id, $owner_uid)) return; @@ -1198,19 +1209,26 @@ class Pref_Feeds extends Handler_Protected { } } - function batchSubscribe() { + function batchSubscribe(): void { print json_encode([ "enable_cats" => (int)get_pref(Prefs::ENABLE_FEED_CATS), "cat_select" => \Controls\select_feeds_cats("cat") ]); } - function batchAddFeeds() { + function batchAddFeeds(): void { $cat_id = clean($_REQUEST['cat']); $feeds = explode("\n", clean($_REQUEST['feeds'])); $login = clean($_REQUEST['login']); $pass = clean($_REQUEST['pass']); + $user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]); + + // TODO: we should return some kind of error code to frontend here + if ($user->access_level == UserHelper::ACCESS_LEVEL_READONLY) { + return; + } + $csth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE feed_url = ? AND owner_uid = ?"); @@ -1236,22 +1254,22 @@ class Pref_Feeds extends Handler_Protected { } } - function clearKeys() { - return Feeds::_clear_access_keys($_SESSION['uid']); + function clearKeys(): void { + Feeds::_clear_access_keys($_SESSION['uid']); } - function regenFeedKey() { + function regenFeedKey(): void { $feed_id = clean($_REQUEST['id']); - $is_cat = clean($_REQUEST['is_cat']); + $is_cat = self::_param_to_bool($_REQUEST['is_cat'] ?? false); $new_key = Feeds::_update_access_key($feed_id, $is_cat, $_SESSION["uid"]); print json_encode(["link" => $new_key]); } - function getSharedURL() { + function getSharedURL(): void { $feed_id = clean($_REQUEST['id']); - $is_cat = clean($_REQUEST['is_cat']) == "true"; + $is_cat = self::_param_to_bool($_REQUEST['is_cat'] ?? false); $search = clean($_REQUEST['search']); $link = Config::get_self_url() . "/public.php?" . http_build_query([ @@ -1268,11 +1286,14 @@ class Pref_Feeds extends Handler_Protected { ]); } - private function calculate_children_count($cat) { + /** + * @param array<string, mixed> $cat + */ + private function calculate_children_count(array $cat): int { $c = 0; foreach ($cat['items'] ?? [] as $child) { - if ($child['type'] ?? '' == 'category') { + if (($child['type'] ?? '') == 'category') { $c += $this->calculate_children_count($child); } else { $c += 1; diff --git a/classes/pref/filters.php b/classes/pref/filters.php index 29d309dbb..04178f1a6 100755 --- a/classes/pref/filters.php +++ b/classes/pref/filters.php @@ -1,20 +1,28 @@ <?php class Pref_Filters extends Handler_Protected { - function csrf_ignore($method) { + const ACTION_TAG = 4; + const ACTION_SCORE = 6; + const ACTION_LABEL = 7; + const ACTION_PLUGIN = 9; + const ACTION_REMOVE_TAG = 10; + + const PARAM_ACTIONS = [self::ACTION_TAG, self::ACTION_SCORE, + self::ACTION_LABEL, self::ACTION_PLUGIN, self::ACTION_REMOVE_TAG]; + + function csrf_ignore(string $method): bool { $csrf_ignored = array("index", "getfiltertree", "savefilterorder"); return array_search($method, $csrf_ignored) !== false; } - function filtersortreset() { + function filtersortreset(): void { $sth = $this->pdo->prepare("UPDATE ttrss_filters2 SET order_id = 0 WHERE owner_uid = ?"); $sth->execute([$_SESSION['uid']]); - return; } - function savefilterorder() { + function savefilterorder(): void { $data = json_decode($_POST['payload'], true); #file_put_contents("/tmp/saveorder.json", clean($_POST['payload'])); @@ -40,11 +48,9 @@ class Pref_Filters extends Handler_Protected { } } } - - return; } - function testFilterDo() { + function testFilterDo(): void { $offset = (int) clean($_REQUEST["offset"]); $limit = (int) clean($_REQUEST["limit"]); @@ -59,7 +65,9 @@ class Pref_Filters extends Handler_Protected { $res = $this->pdo->query("SELECT id,name FROM ttrss_filter_types"); - $filter_types = array(); + /** @var array<int, string> */ + $filter_types = []; + while ($line = $res->fetch()) { $filter_types[$line["id"]] = $line["name"]; } @@ -67,7 +75,10 @@ class Pref_Filters extends Handler_Protected { $scope_qparts = array(); $rctr = 0; + + /** @var string $r */ foreach (clean($_REQUEST["rule"]) AS $r) { + /** @var array{'reg_exp': string, 'filter_type': int, 'feed_id': array<int, int|string>, 'name': string}|null */ $rule = json_decode($r, true); if ($rule && $rctr < 5) { @@ -75,19 +86,21 @@ class Pref_Filters extends Handler_Protected { unset($rule["filter_type"]); $scope_inner_qparts = []; + + /** @var int|string $feed_id may be a category string (e.g. 'CAT:7') or feed ID int */ foreach ($rule["feed_id"] as $feed_id) { - if (strpos($feed_id, "CAT:") === 0) { - $cat_id = (int) substr($feed_id, 4); - array_push($scope_inner_qparts, "cat_id = " . $this->pdo->quote($cat_id)); - } else if ($feed_id > 0) { - array_push($scope_inner_qparts, "feed_id = " . $this->pdo->quote($feed_id)); - } - } + if (strpos("$feed_id", "CAT:") === 0) { + $cat_id = (int) substr("$feed_id", 4); + array_push($scope_inner_qparts, "cat_id = " . $cat_id); + } else if (is_numeric($feed_id) && $feed_id > 0) { + array_push($scope_inner_qparts, "feed_id = " . (int)$feed_id); + } + } - if (count($scope_inner_qparts) > 0) { - array_push($scope_qparts, "(" . implode(" OR ", $scope_inner_qparts) . ")"); - } + if (count($scope_inner_qparts) > 0) { + array_push($scope_qparts, "(" . implode(" OR ", $scope_inner_qparts) . ")"); + } array_push($filter["rules"], $rule); @@ -162,7 +175,7 @@ class Pref_Filters extends Handler_Protected { print json_encode($rv); } - private function _get_rules_list($filter_id) { + private function _get_rules_list(int $filter_id): string { $sth = $this->pdo->prepare("SELECT reg_exp, inverse, match_on, @@ -203,7 +216,7 @@ class Pref_Filters extends Handler_Protected { } else { $where = $line["cat_filter"] ? - Feeds::_get_cat_title($line["cat_id"]) : + Feeds::_get_cat_title($line["cat_id"] ?? 0) : ($line["feed_id"] ? Feeds::_get_title($line["feed_id"]) : __("All feeds")); } @@ -222,7 +235,7 @@ class Pref_Filters extends Handler_Protected { return $rv; } - function getfiltertree() { + function getfiltertree(): void { $root = array(); $root['id'] = 'root'; $root['name'] = __('Filters'); @@ -270,7 +283,7 @@ class Pref_Filters extends Handler_Protected { } } - if ($line['action_id'] == 7) { + if ($line['action_id'] == self::ACTION_LABEL) { $label_sth = $this->pdo->prepare("SELECT fg_color, bg_color FROM ttrss_labels2 WHERE caption = ? AND owner_uid = ?"); @@ -307,10 +320,9 @@ class Pref_Filters extends Handler_Protected { $fl['items'] = array($root); print json_encode($fl); - return; } - function edit() { + function edit(): void { $filter_id = (int) clean($_REQUEST["id"] ?? 0); @@ -406,7 +418,10 @@ class Pref_Filters extends Handler_Protected { } } - private function _get_rule_name($rule) { + /** + * @param array<string, mixed>|null $rule + */ + private function _get_rule_name(?array $rule = null): string { if (!$rule) $rule = json_decode(clean($_REQUEST["rule"]), true); $feeds = $rule["feed_id"]; @@ -446,11 +461,18 @@ class Pref_Filters extends Handler_Protected { "<span class='field'>$filter_type</span>", "<span class='feed'>$feed</span>", isset($rule["inverse"]) ? __("(inverse)") : "") . "</span>"; } - function printRuleName() { + function printRuleName(): void { print $this->_get_rule_name(json_decode(clean($_REQUEST["rule"]), true)); } - private function _get_action_name($action) { + /** + * @param array<string, mixed>|null $action + */ + private function _get_action_name(?array $action = null): string { + if (!$action) { + return ""; + } + $sth = $this->pdo->prepare("SELECT description FROM ttrss_filter_actions WHERE id = ?"); $sth->execute([(int)$action["action_id"]]); @@ -461,11 +483,7 @@ class Pref_Filters extends Handler_Protected { $title = __($row["description"]); - if ($action["action_id"] == 4 || $action["action_id"] == 6 || - $action["action_id"] == 7) - $title .= ": " . $action["action_param"]; - - if ($action["action_id"] == 9) { + if ($action["action_id"] == self::ACTION_PLUGIN) { list ($pfclass, $pfaction) = explode(":", $action["action_param"]); $filter_actions = PluginHost::getInstance()->get_filter_actions(); @@ -478,18 +496,20 @@ class Pref_Filters extends Handler_Protected { } } } + } else if (in_array($action["action_id"], self::PARAM_ACTIONS)) { + $title .= ": " . $action["action_param"]; } } return $title; } - function printActionName() { - print $this->_get_action_name(json_decode(clean($_REQUEST["action"]), true)); + function printActionName(): void { + print $this->_get_action_name(json_decode(clean($_REQUEST["action"] ?? ""), true)); } - function editSave() { - $filter_id = clean($_REQUEST["id"]); + function editSave(): void { + $filter_id = (int) clean($_REQUEST["id"]); $enabled = checkbox_to_sql_bool(clean($_REQUEST["enabled"] ?? false)); $match_any_rule = checkbox_to_sql_bool(clean($_REQUEST["match_any_rule"] ?? false)); $inverse = checkbox_to_sql_bool(clean($_REQUEST["inverse"] ?? false)); @@ -510,7 +530,7 @@ class Pref_Filters extends Handler_Protected { $this->pdo->commit(); } - function remove() { + function remove(): void { $ids = explode(",", clean($_REQUEST["ids"])); $ids_qmarks = arr_qmarks($ids); @@ -520,7 +540,7 @@ class Pref_Filters extends Handler_Protected { $sth->execute(array_merge($ids, [$_SESSION['uid']])); } - private function _save_rules_and_actions($filter_id) { + private function _save_rules_and_actions(int $filter_id): void { $sth = $this->pdo->prepare("DELETE FROM ttrss_filters2_rules WHERE filter_id = ?"); $sth->execute([$filter_id]); @@ -583,21 +603,26 @@ class Pref_Filters extends Handler_Protected { $action_param = $action["action_param"]; $action_param_label = $action["action_param_label"]; - if ($action_id == 7) { + if ($action_id == self::ACTION_LABEL) { $action_param = $action_param_label; } - if ($action_id == 6) { + if ($action_id == self::ACTION_SCORE) { $action_param = (int)str_replace("+", "", $action_param); } + if (in_array($action_id, [self::ACTION_TAG, self::ACTION_REMOVE_TAG])) { + $action_param = implode(", ", FeedItem_Common::normalize_categories( + explode(",", $action_param))); + } + $asth->execute([$filter_id, $action_id, $action_param]); } } } } - function add () { + function add(): void { $enabled = checkbox_to_sql_bool(clean($_REQUEST["enabled"] ?? false)); $match_any_rule = checkbox_to_sql_bool(clean($_REQUEST["match_any_rule"] ?? false)); $title = clean($_REQUEST["title"]); @@ -625,7 +650,7 @@ class Pref_Filters extends Handler_Protected { $this->pdo->commit(); } - function index() { + function index(): void { if (array_key_exists("search", $_REQUEST)) { $filter_search = clean($_REQUEST["search"]); $_SESSION["prefs_filter_search"] = $filter_search; @@ -691,7 +716,8 @@ class Pref_Filters extends Handler_Protected { <?php } - function editrule() { + function editrule(): void { + /** @var array<int, int|string> */ $feed_ids = explode(",", clean($_REQUEST["ids"])); print json_encode([ @@ -699,7 +725,10 @@ class Pref_Filters extends Handler_Protected { ]); } - private function _get_name($id) { + /** + * @return array<int, string> + */ + private function _get_name(int $id): array { $sth = $this->pdo->prepare( "SELECT title,match_any_rule,f.inverse AS inverse,COUNT(DISTINCT r.id) AS num_rules,COUNT(DISTINCT a.id) AS num_actions @@ -745,8 +774,9 @@ class Pref_Filters extends Handler_Protected { return []; } - function join() { - $ids = explode(",", clean($_REQUEST["ids"])); + function join(): void { + /** @var array<int, int> */ + $ids = array_map("intval", explode(",", clean($_REQUEST["ids"]))); if (count($ids) > 1) { $base_id = array_shift($ids); @@ -775,7 +805,7 @@ class Pref_Filters extends Handler_Protected { } } - private function _optimize($id) { + private function _optimize(int $id): void { $this->pdo->beginTransaction(); @@ -830,9 +860,11 @@ class Pref_Filters extends Handler_Protected { $this->pdo->commit(); } - private function _feed_multi_select($id, $default_ids = [], - $attributes = "", $include_all_feeds = true, - $root_id = null, $nest_level = 0) { + /** + * @param array<int, int|string> $default_ids + */ + private function _feed_multi_select(string $id, array $default_ids = [], string $attributes = "", + bool $include_all_feeds = true, ?int $root_id = null, int $nest_level = 0): string { $pdo = Db::pdo(); diff --git a/classes/pref/labels.php b/classes/pref/labels.php index 2cdb919ce..a50a85a66 100644 --- a/classes/pref/labels.php +++ b/classes/pref/labels.php @@ -1,13 +1,13 @@ <?php class Pref_Labels extends Handler_Protected { - function csrf_ignore($method) { + function csrf_ignore(string $method): bool { $csrf_ignored = array("index", "getlabeltree"); return array_search($method, $csrf_ignored) !== false; } - function edit() { + function edit(): void { $label = ORM::for_table('ttrss_labels2') ->where('owner_uid', $_SESSION['uid']) ->find_one($_REQUEST['id']); @@ -17,7 +17,7 @@ class Pref_Labels extends Handler_Protected { } } - function getlabeltree() { + function getlabeltree(): void { $root = array(); $root['id'] = 'root'; $root['name'] = __('Labels'); @@ -48,10 +48,9 @@ class Pref_Labels extends Handler_Protected { $fl['items'] = array($root); print json_encode($fl); - return; } - function colorset() { + function colorset(): void { $kind = clean($_REQUEST["kind"]); $ids = explode(',', clean($_REQUEST["ids"])); $color = clean($_REQUEST["color"]); @@ -84,7 +83,7 @@ class Pref_Labels extends Handler_Protected { } } - function colorreset() { + function colorreset(): void { $ids = explode(',', clean($_REQUEST["ids"])); foreach ($ids as $id) { @@ -101,7 +100,7 @@ class Pref_Labels extends Handler_Protected { } } - function save() { + function save(): void { $id = clean($_REQUEST["id"]); $caption = clean($_REQUEST["caption"]); @@ -148,9 +147,9 @@ class Pref_Labels extends Handler_Protected { } - function remove() { - - $ids = explode(",", clean($_REQUEST["ids"])); + function remove(): void { + /** @var array<int, int> */ + $ids = array_map("intval", explode(",", clean($_REQUEST["ids"]))); foreach ($ids as $id) { Labels::remove($id, $_SESSION["uid"]); @@ -158,9 +157,9 @@ class Pref_Labels extends Handler_Protected { } - function add() { + function add(): void { $caption = clean($_REQUEST["caption"]); - $output = clean($_REQUEST["output"]); + $output = clean($_REQUEST["output"] ?? false); if ($caption) { if (Labels::create($caption)) { @@ -171,7 +170,7 @@ class Pref_Labels extends Handler_Protected { } } - function index() { + function index(): void { ?> <div dojoType='dijit.layout.BorderContainer' gutters='false'> <div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'> diff --git a/classes/pref/prefs.php b/classes/pref/prefs.php index cb666e945..2d72a7732 100644 --- a/classes/pref/prefs.php +++ b/classes/pref/prefs.php @@ -2,12 +2,21 @@ use chillerlan\QRCode; class Pref_Prefs extends Handler_Protected { - + // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ + /** @var array<Prefs::*, array<int, string>> */ private $pref_help = []; + + /** @var array<string, array<int, string>> pref items are Prefs::*|Pref_Prefs::BLOCK_SEPARATOR (PHPStan was complaining) */ private $pref_item_map = []; + + /** @var array<string, string> */ private $pref_help_bottom = []; + + /** @var array<int, string> */ private $pref_blacklist = []; + private const BLOCK_SEPARATOR = 'BLOCK_SEPARATOR'; + const PI_RES_ALREADY_INSTALLED = "PI_RES_ALREADY_INSTALLED"; const PI_RES_SUCCESS = "PI_RES_SUCCESS"; const PI_ERR_NO_CLASS = "PI_ERR_NO_CLASS"; @@ -17,7 +26,8 @@ class Pref_Prefs extends Handler_Protected { const PI_ERR_PLUGIN_NOT_FOUND = "PI_ERR_PLUGIN_NOT_FOUND"; const PI_ERR_NO_WORKDIR = "PI_ERR_NO_WORKDIR"; - function csrf_ignore($method) { + /** @param string $method */ + function csrf_ignore(string $method) : bool { $csrf_ignored = array("index", "updateself", "otpqrcode"); return array_search($method, $csrf_ignored) !== false; @@ -30,35 +40,35 @@ class Pref_Prefs extends Handler_Protected { __('General') => [ Prefs::USER_LANGUAGE, Prefs::USER_TIMEZONE, - 'BLOCK_SEPARATOR', + self::BLOCK_SEPARATOR, Prefs::USER_CSS_THEME, - 'BLOCK_SEPARATOR', + self::BLOCK_SEPARATOR, Prefs::ENABLE_API_ACCESS, ], __('Feeds') => [ Prefs::DEFAULT_UPDATE_INTERVAL, Prefs::FRESH_ARTICLE_MAX_AGE, Prefs::DEFAULT_SEARCH_LANGUAGE, - 'BLOCK_SEPARATOR', + self::BLOCK_SEPARATOR, Prefs::ENABLE_FEED_CATS, - 'BLOCK_SEPARATOR', + self::BLOCK_SEPARATOR, Prefs::CONFIRM_FEED_CATCHUP, Prefs::ON_CATCHUP_SHOW_NEXT_FEED, - 'BLOCK_SEPARATOR', + self::BLOCK_SEPARATOR, Prefs::HIDE_READ_FEEDS, Prefs::HIDE_READ_SHOWS_SPECIAL, ], __('Articles') => [ Prefs::PURGE_OLD_DAYS, Prefs::PURGE_UNREAD_ARTICLES, - 'BLOCK_SEPARATOR', + self::BLOCK_SEPARATOR, Prefs::COMBINED_DISPLAY_MODE, Prefs::CDM_EXPANDED, Prefs::CDM_ENABLE_GRID, - 'BLOCK_SEPARATOR', + self::BLOCK_SEPARATOR, Prefs::CDM_AUTO_CATCHUP, Prefs::VFEED_GROUP_BY_FEED, - 'BLOCK_SEPARATOR', + self::BLOCK_SEPARATOR, Prefs::SHOW_CONTENT_PREVIEW, Prefs::STRIP_IMAGES, ], @@ -69,12 +79,12 @@ class Pref_Prefs extends Handler_Protected { ], __('Advanced') => [ Prefs::BLACKLISTED_TAGS, - 'BLOCK_SEPARATOR', + self::BLOCK_SEPARATOR, Prefs::LONG_DATE_FORMAT, Prefs::SHORT_DATE_FORMAT, - 'BLOCK_SEPARATOR', + self::BLOCK_SEPARATOR, Prefs::SSL_CERT_SERIAL, - 'BLOCK_SEPARATOR', + self::BLOCK_SEPARATOR, Prefs::DISABLE_CONDITIONAL_COUNTERS, Prefs::HEADLINES_NO_DISTINCT, ], @@ -127,7 +137,7 @@ class Pref_Prefs extends Handler_Protected { ]; } - function changepassword() { + function changepassword(): void { if (Config::get(Config::FORBID_PASSWORD_CHANGES)) { print "ERROR: ".format_error("Access forbidden."); @@ -173,7 +183,7 @@ class Pref_Prefs extends Handler_Protected { } } - function saveconfig() { + function saveconfig(): void { $boolean_prefs = explode(",", clean($_POST["boolean_prefs"])); foreach ($boolean_prefs as $pref) { @@ -223,7 +233,7 @@ class Pref_Prefs extends Handler_Protected { } } - function changePersonalData() { + function changePersonalData(): void { $user = ORM::for_table('ttrss_users')->find_one($_SESSION['uid']); $new_email = clean($_POST['email']); @@ -264,13 +274,13 @@ class Pref_Prefs extends Handler_Protected { print __("Your personal data has been saved."); } - function resetconfig() { + function resetconfig(): void { Prefs::reset($_SESSION["uid"], $_SESSION["profile"]); print "PREFS_NEED_RELOAD"; } - private function index_auth_personal() { + private function index_auth_personal(): void { $user = ORM::for_table('ttrss_users')->find_one($_SESSION['uid']); @@ -310,7 +320,7 @@ class Pref_Prefs extends Handler_Protected { <?php } - private function index_auth_password() { + private function index_auth_password(): void { if ($_SESSION["auth_module"]) { $authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]); } else { @@ -385,7 +395,7 @@ class Pref_Prefs extends Handler_Protected { } } - private function index_auth_app_passwords() { + private function index_auth_app_passwords(): void { print_notice("Separate passwords used for API clients. Required if you enable OTP."); ?> @@ -409,7 +419,7 @@ class Pref_Prefs extends Handler_Protected { <?php } - private function index_auth_2fa() { + private function index_auth_2fa(): void { $otp_enabled = UserHelper::is_otp_enabled($_SESSION["uid"]); if ($_SESSION["auth_module"] == "auth_internal") { @@ -515,7 +525,7 @@ class Pref_Prefs extends Handler_Protected { } } - function index_auth() { + function index_auth(): void { ?> <div dojoType='dijit.layout.TabContainer'> <div dojoType='dijit.layout.ContentPane' title="<?= __('Personal data') ?>"> @@ -534,35 +544,38 @@ class Pref_Prefs extends Handler_Protected { <?php } - private function index_prefs_list() { + private function index_prefs_list(): void { $profile = $_SESSION["profile"] ?? null; if ($profile) { print_notice(__("Some preferences are only available in default profile.")); } + /** @var array<string, array{'type_hint': Config::T_*, 'value': bool|int|string, 'help_text': string, 'short_desc': string}> */ $prefs_available = []; + + /** @var array<int, string> */ $listed_boolean_prefs = []; - foreach (Prefs::get_all($_SESSION["uid"], $profile) as $line) { + foreach (Prefs::get_all($_SESSION["uid"], $profile) as $pref) { - if (in_array($line["pref_name"], $this->pref_blacklist)) { + if (in_array($pref["pref_name"], $this->pref_blacklist)) { continue; } - if ($profile && in_array($line["pref_name"], Prefs::_PROFILE_BLACKLIST)) { + if ($profile && in_array($pref["pref_name"], Prefs::_PROFILE_BLACKLIST)) { continue; } - $pref_name = $line["pref_name"]; + $pref_name = $pref["pref_name"]; $short_desc = $this->_get_short_desc($pref_name); if (!$short_desc) continue; $prefs_available[$pref_name] = [ - 'type_hint' => $line['type_hint'], - 'value' => $line['value'], + 'type_hint' => $pref['type_hint'], + 'value' => $pref['value'], 'help_text' => $this->_get_help_text($pref_name), 'short_desc' => $short_desc ]; @@ -574,12 +587,12 @@ class Pref_Prefs extends Handler_Protected { foreach ($this->pref_item_map[$section] as $pref_name) { - if ($pref_name == 'BLOCK_SEPARATOR' && !$profile) { + if ($pref_name == self::BLOCK_SEPARATOR && !$profile) { print "<hr/>"; continue; } - if ($pref_name == "DEFAULT_SEARCH_LANGUAGE" && Config::get(Config::DB_TYPE) != "pgsql") { + if ($pref_name == Prefs::DEFAULT_SEARCH_LANGUAGE && Config::get(Config::DB_TYPE) != "pgsql") { continue; } @@ -596,17 +609,17 @@ class Pref_Prefs extends Handler_Protected { $value = $item['value']; $type_hint = $item['type_hint']; - if ($pref_name == "USER_LANGUAGE") { + if ($pref_name == Prefs::USER_LANGUAGE) { print \Controls\select_hash($pref_name, $value, get_translations(), ["style" => 'width : 220px; margin : 0px']); - } else if ($pref_name == "USER_TIMEZONE") { + } else if ($pref_name == Prefs::USER_TIMEZONE) { $timezones = explode("\n", file_get_contents("lib/timezones.txt")); print \Controls\select_tag($pref_name, $value, $timezones, ["dojoType" => "dijit.form.FilteringSelect"]); - } else if ($pref_name == "BLACKLISTED_TAGS") { # TODO: other possible <textarea> prefs go here + } else if ($pref_name == Prefs::BLACKLISTED_TAGS) { # TODO: other possible <textarea> prefs go here print "<div>"; @@ -618,7 +631,7 @@ class Pref_Prefs extends Handler_Protected { print "</div>"; - } else if ($pref_name == "USER_CSS_THEME") { + } else if ($pref_name == Prefs::USER_CSS_THEME) { $theme_files = array_map("basename", array_merge(glob("themes/*.php"), @@ -642,13 +655,13 @@ class Pref_Prefs extends Handler_Protected { <?php - } else if ($pref_name == "DEFAULT_UPDATE_INTERVAL") { + } else if ($pref_name == Prefs::DEFAULT_UPDATE_INTERVAL) { global $update_intervals_nodefault; print \Controls\select_hash($pref_name, $value, $update_intervals_nodefault); - } else if ($pref_name == "DEFAULT_SEARCH_LANGUAGE") { + } else if ($pref_name == Prefs::DEFAULT_SEARCH_LANGUAGE) { print \Controls\select_tag($pref_name, $value, Pref_Feeds::get_ts_languages()); @@ -656,7 +669,7 @@ class Pref_Prefs extends Handler_Protected { array_push($listed_boolean_prefs, $pref_name); - if ($pref_name == "PURGE_UNREAD_ARTICLES" && Config::get(Config::FORCE_ARTICLE_PURGE) != 0) { + if ($pref_name == Prefs::PURGE_UNREAD_ARTICLES && Config::get(Config::FORCE_ARTICLE_PURGE) != 0) { $is_disabled = true; $is_checked = true; } else { @@ -672,10 +685,10 @@ class Pref_Prefs extends Handler_Protected { ['onclick' => 'Helpers.Digest.preview()', 'style' => 'margin-left : 10px']); } - } else if (in_array($pref_name, ['FRESH_ARTICLE_MAX_AGE', - 'PURGE_OLD_DAYS', 'LONG_DATE_FORMAT', 'SHORT_DATE_FORMAT'])) { + } else if (in_array($pref_name, [Prefs::FRESH_ARTICLE_MAX_AGE, + Prefs::PURGE_OLD_DAYS, Prefs::LONG_DATE_FORMAT, Prefs::SHORT_DATE_FORMAT])) { - if ($pref_name == "PURGE_OLD_DAYS" && Config::get(Config::FORCE_ARTICLE_PURGE) != 0) { + if ($pref_name == Prefs::PURGE_OLD_DAYS && Config::get(Config::FORCE_ARTICLE_PURGE) != 0) { $attributes = ["disabled" => true, "required" => true]; $value = Config::get(Config::FORCE_ARTICLE_PURGE); } else { @@ -687,7 +700,7 @@ class Pref_Prefs extends Handler_Protected { else print \Controls\input_tag($pref_name, $value, "text", $attributes); - } else if ($pref_name == "SSL_CERT_SERIAL") { + } else if ($pref_name == Prefs::SSL_CERT_SERIAL) { print \Controls\input_tag($pref_name, $value, "text", ["readonly" => true], "SSL_CERT_SERIAL"); @@ -727,7 +740,7 @@ class Pref_Prefs extends Handler_Protected { print \Controls\hidden_tag("boolean_prefs", htmlspecialchars(join(",", $listed_boolean_prefs))); } - private function index_prefs() { + private function index_prefs(): void { ?> <form dojoType='dijit.form.Form' id='changeSettingsForm'> <?= \Controls\hidden_tag("op", "pref-prefs") ?> @@ -783,7 +796,7 @@ class Pref_Prefs extends Handler_Protected { <?php } - function getPluginsList() { + function getPluginsList(): void { $system_enabled = array_map("trim", explode(",", (string)Config::get(Config::PLUGINS))); $user_enabled = array_map("trim", explode(",", get_pref(Prefs::_ENABLED_PLUGINS))); @@ -813,10 +826,10 @@ class Pref_Prefs extends Handler_Protected { usort($rv, function($a, $b) { return strcmp($a["name"], $b["name"]); }); - print json_encode(['plugins' => $rv, 'is_admin' => $_SESSION['access_level'] >= 10]); + print json_encode(['plugins' => $rv, 'is_admin' => $_SESSION['access_level'] >= UserHelper::ACCESS_LEVEL_ADMIN]); } - function index_plugins() { + function index_plugins(): void { ?> <form dojoType="dijit.form.Form" id="changePluginsForm"> @@ -890,7 +903,7 @@ class Pref_Prefs extends Handler_Protected { <?= \Controls\button_tag(\Controls\icon("refresh"), "", ["title" => __("Reload"), "onclick" => "Helpers.Plugins.reload()"]) ?> - <?php if ($_SESSION["access_level"] >= 10) { ?> + <?php if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN) { ?> <?php if (Config::get(Config::CHECK_FOR_UPDATES) && Config::get(Config::CHECK_FOR_PLUGIN_UPDATES)) { ?> <button class='alt-warning' dojoType='dijit.form.Button' onclick="Helpers.Plugins.update()"> @@ -912,7 +925,7 @@ class Pref_Prefs extends Handler_Protected { <?php } - function index() { + function index(): void { ?> <div dojoType='dijit.layout.AccordionContainer' region='center'> <div dojoType='dijit.layout.AccordionPane' title="<i class='material-icons'>person</i> <?= __('Personal data / Authentication')?>"> @@ -937,7 +950,7 @@ class Pref_Prefs extends Handler_Protected { <?php } - function _get_otp_qrcode_img() { + function _get_otp_qrcode_img(): ?string { $secret = UserHelper::get_otp_secret($_SESSION["uid"]); $login = UserHelper::get_login_by_id($_SESSION["uid"]); @@ -949,15 +962,16 @@ class Pref_Prefs extends Handler_Protected { return $qrcode->render($otpurl); } - return false; + return null; } - function otpenable() { + function otpenable(): void { $password = clean($_REQUEST["password"]); $otp_check = clean($_REQUEST["otp"]); $authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]); + /** @var Auth_Internal|false $authenticator -- this is only here to make check_password() visible to static analyzer */ if ($authenticator->check_password($_SESSION["uid"], $password)) { if (UserHelper::enable_otp($_SESSION["uid"], $otp_check)) { print "OK"; @@ -969,9 +983,10 @@ class Pref_Prefs extends Handler_Protected { } } - function otpdisable() { + function otpdisable(): void { $password = clean($_REQUEST["password"]); + /** @var Auth_Internal|false $authenticator -- this is only here to make check_password() visible to static analyzer */ $authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]); if ($authenticator->check_password($_SESSION["uid"], $password)) { @@ -1008,38 +1023,42 @@ class Pref_Prefs extends Handler_Protected { } - function setplugins() { + function setplugins(): void { $plugins = array_filter($_REQUEST["plugins"], 'clean') ?? []; set_pref(Prefs::_ENABLED_PLUGINS, implode(",", $plugins)); } - function _get_plugin_version(Plugin $plugin) { + function _get_plugin_version(Plugin $plugin): string { $about = $plugin->about(); if (!empty($about[0])) { return T_sprintf("v%.2f, by %s", $about[0], $about[2]); - } else { - $ref = new ReflectionClass(get_class($plugin)); + } - $plugin_dir = dirname($ref->getFileName()); + $ref = new ReflectionClass(get_class($plugin)); - if (basename($plugin_dir) == "plugins") { - return ""; - } + $plugin_dir = dirname($ref->getFileName()); - if (is_dir("$plugin_dir/.git")) { - $ver = Config::get_version_from_git($plugin_dir); + if (basename($plugin_dir) == "plugins") { + return ""; + } - return $ver["status"] == 0 ? T_sprintf("v%s, by %s", $ver["version"], $about[2]) : $ver["version"]; - } + if (is_dir("$plugin_dir/.git")) { + $ver = Config::get_version_from_git($plugin_dir); + + return $ver["status"] == 0 ? T_sprintf("v%s, by %s", $ver["version"], $about[2]) : $ver["version"]; } + + return ""; } - static function _get_updated_plugins() { + /** + * @return array<int, array{'plugin': string, 'rv': array{'stdout': false|string, 'stderr': false|string, 'git_status': int, 'need_update': bool}|null}> + */ + static function _get_updated_plugins(): array { $root_dir = dirname(dirname(__DIR__)); # we're in classes/pref/ $plugin_dirs = array_filter(glob("$root_dir/plugins.local/*"), "is_dir"); - $rv = []; foreach ($plugin_dirs as $dir) { @@ -1057,7 +1076,10 @@ class Pref_Prefs extends Handler_Protected { return $rv; } - private static function _plugin_needs_update($root_dir, $plugin_name) { + /** + * @return array{'stdout': false|string, 'stderr': false|string, 'git_status': int, 'need_update': bool}|null + */ + private static function _plugin_needs_update(string $root_dir, string $plugin_name): ?array { $plugin_dir = "$root_dir/plugins.local/" . basename($plugin_name); $rv = null; @@ -1086,7 +1108,10 @@ class Pref_Prefs extends Handler_Protected { } - private function _update_plugin($root_dir, $plugin_name) { + /** + * @return array{'stdout': false|string, 'stderr': false|string, 'git_status': int} + */ + private function _update_plugin(string $root_dir, string $plugin_name): array { $plugin_dir = "$root_dir/plugins.local/" . basename($plugin_name); $rv = []; @@ -1112,7 +1137,7 @@ class Pref_Prefs extends Handler_Protected { } // https://gist.github.com/mindplay-dk/a4aad91f5a4f1283a5e2#gistcomment-2036828 - private function _recursive_rmdir(string $dir, bool $keep_root = false) { + private function _recursive_rmdir(string $dir, bool $keep_root = false): bool { // Handle bad arguments. if (empty($dir) || !file_exists($dir)) { return true; // No such file/dir$dir exists. @@ -1137,7 +1162,7 @@ class Pref_Prefs extends Handler_Protected { } // https://stackoverflow.com/questions/7153000/get-class-name-from-file - private function _get_class_name_from_file($file) { + private function _get_class_name_from_file(string $file): string { $tokens = token_get_all(file_get_contents($file)); for ($i = 0; $i < count($tokens); $i++) { @@ -1149,10 +1174,12 @@ class Pref_Prefs extends Handler_Protected { } } } + + return ""; } - function uninstallPlugin() { - if ($_SESSION["access_level"] >= 10) { + function uninstallPlugin(): void { + if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN) { $plugin_name = basename(clean($_REQUEST['plugin'])); $status = 0; @@ -1166,8 +1193,8 @@ class Pref_Prefs extends Handler_Protected { } } - function installPlugin() { - if ($_SESSION["access_level"] >= 10 && Config::get(Config::ENABLE_PLUGIN_INSTALLER)) { + function installPlugin(): void { + if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN && Config::get(Config::ENABLE_PLUGIN_INSTALLER)) { $plugin_name = basename(clean($_REQUEST['plugin'])); $all_plugins = $this->_get_available_plugins(); $plugin_dir = dirname(dirname(__DIR__)) . "/plugins.local"; @@ -1251,47 +1278,59 @@ class Pref_Prefs extends Handler_Protected { } } - private function _get_available_plugins() { - if ($_SESSION["access_level"] >= 10 && Config::get(Config::ENABLE_PLUGIN_INSTALLER)) { - return json_decode(UrlHelper::fetch(['url' => 'https://tt-rss.org/plugins.json']), true); + /** + * @return array<int, array{'name': string, 'description': string, 'topics': array<int, string>, 'html_url': string, 'clone_url': string, 'last_update': string}> + */ + private function _get_available_plugins(): array { + if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN && Config::get(Config::ENABLE_PLUGIN_INSTALLER)) { + $content = json_decode(UrlHelper::fetch(['url' => 'https://tt-rss.org/plugins.json']), true); + + if ($content) { + return $content; + } } + + return []; } - function getAvailablePlugins() { - if ($_SESSION["access_level"] >= 10) { + + function getAvailablePlugins(): void { + if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN) { print json_encode($this->_get_available_plugins()); + } else { + print "[]"; } } - function checkForPluginUpdates() { - if ($_SESSION["access_level"] >= 10 && Config::get(Config::CHECK_FOR_UPDATES) && Config::get(Config::CHECK_FOR_PLUGIN_UPDATES)) { + function checkForPluginUpdates(): void { + if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN && Config::get(Config::CHECK_FOR_UPDATES) && Config::get(Config::CHECK_FOR_PLUGIN_UPDATES)) { $plugin_name = $_REQUEST["name"] ?? ""; - $root_dir = dirname(dirname(__DIR__)); # we're in classes/pref/ - if (!empty($plugin_name)) { - $rv = [["plugin" => $plugin_name, "rv" => self::_plugin_needs_update($root_dir, $plugin_name)]]; - } else { - $rv = self::_get_updated_plugins(); - } + $rv = empty($plugin_name) ? self::_get_updated_plugins() : [ + ["plugin" => $plugin_name, "rv" => self::_plugin_needs_update($root_dir, $plugin_name)], + ]; print json_encode($rv); } } - function updateLocalPlugins() { - if ($_SESSION["access_level"] >= 10) { + function updateLocalPlugins(): void { + if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN) { $plugins = explode(",", $_REQUEST["plugins"] ?? ""); + if ($plugins !== false) { + $plugins = array_filter($plugins, 'strlen'); + } + # we're in classes/pref/ $root_dir = dirname(dirname(__DIR__)); $rv = []; - if (count($plugins) > 0) { + if ($plugins) { foreach ($plugins as $plugin_name) { array_push($rv, ["plugin" => $plugin_name, "rv" => $this->_update_plugin($root_dir, $plugin_name)]); } - // @phpstan-ignore-next-line } else { $plugin_dirs = array_filter(glob("$root_dir/plugins.local/*"), "is_dir"); @@ -1301,7 +1340,7 @@ class Pref_Prefs extends Handler_Protected { $test = self::_plugin_needs_update($root_dir, $plugin_name); - if (!empty($test["o"])) + if (!empty($test["stdout"])) array_push($rv, ["plugin" => $plugin_name, "rv" => $this->_update_plugin($root_dir, $plugin_name)]); } } @@ -1311,21 +1350,21 @@ class Pref_Prefs extends Handler_Protected { } } - function clearplugindata() { + function clearplugindata(): void { $name = clean($_REQUEST["name"]); PluginHost::getInstance()->clear_data(PluginHost::getInstance()->get_plugin($name)); } - function customizeCSS() { + function customizeCSS(): void { $value = get_pref(Prefs::USER_STYLESHEET); $value = str_replace("<br/>", "\n", $value); print json_encode(["value" => $value]); } - function activateprofile() { - $id = (int) $_REQUEST['id'] ?? 0; + function activateprofile(): void { + $id = (int) ($_REQUEST['id'] ?? 0); $profile = ORM::for_table('ttrss_settings_profiles') ->where('owner_uid', $_SESSION['uid']) @@ -1338,7 +1377,7 @@ class Pref_Prefs extends Handler_Protected { } } - function cloneprofile() { + function cloneprofile(): void { $old_profile = $_REQUEST["old_profile"] ?? 0; $new_title = clean($_REQUEST["new_title"]); @@ -1367,7 +1406,7 @@ class Pref_Prefs extends Handler_Protected { } } - function remprofiles() { + function remprofiles(): void { $ids = $_REQUEST["ids"] ?? []; ORM::for_table('ttrss_settings_profiles') @@ -1377,7 +1416,7 @@ class Pref_Prefs extends Handler_Protected { ->delete_many(); } - function addprofile() { + function addprofile(): void { $title = clean($_REQUEST["title"]); if ($title) { @@ -1396,7 +1435,7 @@ class Pref_Prefs extends Handler_Protected { } } - function saveprofile() { + function saveprofile(): void { $id = (int)$_REQUEST["id"]; $title = clean($_REQUEST["value"]); @@ -1413,7 +1452,7 @@ class Pref_Prefs extends Handler_Protected { } // TODO: this maybe needs to be unified with Public::getProfiles() - function getProfiles() { + function getProfiles(): void { $rv = []; $profiles = ORM::for_table('ttrss_settings_profiles') @@ -1442,21 +1481,21 @@ class Pref_Prefs extends Handler_Protected { print json_encode($rv); } - private function _get_short_desc($pref_name) { + private function _get_short_desc(string $pref_name): string { if (isset($this->pref_help[$pref_name][0])) { return $this->pref_help[$pref_name][0]; } return ""; } - private function _get_help_text($pref_name) { + private function _get_help_text(string $pref_name): string { if (isset($this->pref_help[$pref_name][1])) { return $this->pref_help[$pref_name][1]; } return ""; } - private function appPasswordList() { + private function appPasswordList(): void { ?> <div dojoType='fox.Toolbar'> <div dojoType='fox.form.DropDownButton'> @@ -1506,7 +1545,7 @@ class Pref_Prefs extends Handler_Protected { <?php } - function deleteAppPasswords() { + function deleteAppPasswords(): void { $passwords = ORM::for_table('ttrss_app_passwords') ->where('owner_uid', $_SESSION['uid']) ->where_in('id', $_REQUEST['ids'] ?? []) @@ -1515,7 +1554,7 @@ class Pref_Prefs extends Handler_Protected { $this->appPasswordList(); } - function generateAppPassword() { + function generateAppPassword(): void { $title = clean($_REQUEST['title']); $new_password = make_password(16); $new_salt = UserHelper::get_salt(); @@ -1536,11 +1575,11 @@ class Pref_Prefs extends Handler_Protected { $this->appPasswordList(); } - function previewDigest() { + function previewDigest(): void { print json_encode(Digest::prepare_headlines_digest($_SESSION["uid"], 1, 16)); } - static function _get_ssl_certificate_id() { + static function _get_ssl_certificate_id(): string { if ($_SERVER["REDIRECT_SSL_CLIENT_M_SERIAL"] ?? false) { return sha1($_SERVER["REDIRECT_SSL_CLIENT_M_SERIAL"] . $_SERVER["REDIRECT_SSL_CLIENT_V_START"] . @@ -1556,7 +1595,7 @@ class Pref_Prefs extends Handler_Protected { return ""; } - private function format_otp_secret($secret) { + private function format_otp_secret(string $secret): string { return implode(" ", str_split($secret, 4)); } } diff --git a/classes/pref/system.php b/classes/pref/system.php index 8bebcc7ce..10f196b55 100644 --- a/classes/pref/system.php +++ b/classes/pref/system.php @@ -2,19 +2,19 @@ class Pref_System extends Handler_Administrative { - private $log_page_limit = 15; + private const LOG_PAGE_LIMIT = 15; - function csrf_ignore($method) { + function csrf_ignore(string $method): bool { $csrf_ignored = array("index"); return array_search($method, $csrf_ignored) !== false; } - function clearLog() { + function clearLog(): void { $this->pdo->query("DELETE FROM ttrss_error_log"); } - function sendTestEmail() { + function sendTestEmail(): void { $mail_address = clean($_REQUEST["mail_address"]); $mailer = new Mailer(); @@ -28,7 +28,7 @@ class Pref_System extends Handler_Administrative { print json_encode(['rc' => $rc, 'error' => $mailer->error()]); } - function getphpinfo() { + function getphpinfo(): void { ob_start(); phpinfo(); $info = ob_get_contents(); @@ -37,7 +37,7 @@ class Pref_System extends Handler_Administrative { print preg_replace( '%^.*<body>(.*)</body>.*$%ms','$1', (string)$info); } - private function _log_viewer(int $page, int $severity) { + private function _log_viewer(int $page, int $severity): void { $errno_values = []; switch ($severity) { @@ -56,8 +56,7 @@ class Pref_System extends Handler_Administrative { $errno_filter_qpart = "true"; } - $limit = $this->log_page_limit; - $offset = $limit * $page; + $offset = self::LOG_PAGE_LIMIT * $page; $sth = $this->pdo->prepare("SELECT COUNT(id) AS total_pages @@ -69,7 +68,7 @@ class Pref_System extends Handler_Administrative { $sth->execute($errno_values); if ($res = $sth->fetch()) { - $total_pages = (int)($res["total_pages"] / $limit); + $total_pages = (int)($res["total_pages"] / self::LOG_PAGE_LIMIT); } else { $total_pages = 0; } @@ -134,7 +133,7 @@ class Pref_System extends Handler_Administrative { $errno_filter_qpart ORDER BY ttrss_error_log.id DESC - LIMIT $limit OFFSET $offset"); + LIMIT ". self::LOG_PAGE_LIMIT ." OFFSET $offset"); $sth->execute($errno_values); @@ -159,7 +158,7 @@ class Pref_System extends Handler_Administrative { <?php } - function index() { + function index(): void { $severity = (int) ($_REQUEST["severity"] ?? E_USER_WARNING); $page = (int) ($_REQUEST["page"] ?? 0); diff --git a/classes/pref/users.php b/classes/pref/users.php index 76a879efd..c48619614 100644 --- a/classes/pref/users.php +++ b/classes/pref/users.php @@ -1,10 +1,10 @@ <?php class Pref_Users extends Handler_Administrative { - function csrf_ignore($method) { + function csrf_ignore(string $method): bool { return $method == "index"; } - function edit() { + function edit(): void { $user = ORM::for_table('ttrss_users') ->select_expr("id,login,access_level,email,full_name,otp_enabled") ->find_one((int)$_REQUEST["id"]) @@ -20,7 +20,7 @@ class Pref_Users extends Handler_Administrative { } } - function userdetails() { + function userdetails(): void { $id = (int) clean($_REQUEST["id"]); $sth = $this->pdo->prepare("SELECT login, @@ -103,7 +103,7 @@ class Pref_Users extends Handler_Administrative { } - function editSave() { + function editSave(): void { $id = (int)$_REQUEST['id']; $password = clean($_REQUEST["password"]); $user = ORM::for_table('ttrss_users')->find_one($id); @@ -132,7 +132,7 @@ class Pref_Users extends Handler_Administrative { } } - function remove() { + function remove(): void { $ids = explode(",", clean($_REQUEST["ids"])); foreach ($ids as $id) { @@ -149,7 +149,7 @@ class Pref_Users extends Handler_Administrative { } } - function add() { + function add(): void { $login = clean($_REQUEST["login"]); if (!$login) return; // no blank usernames @@ -167,7 +167,7 @@ class Pref_Users extends Handler_Administrative { $user->created = Db::NOW(); $user->save(); - if ($new_uid = UserHelper::find_user_by_login($login)) { + if (!is_null(UserHelper::find_user_by_login($login))) { print T_sprintf("Added user %s with password %s", $login, $new_password); } else { @@ -178,11 +178,11 @@ class Pref_Users extends Handler_Administrative { } } - function resetPass() { + function resetPass(): void { UserHelper::reset_password(clean($_REQUEST["id"])); } - function index() { + function index(): void { global $access_level_names; diff --git a/classes/prefs.php b/classes/prefs.php index 85e7c34db..7e6033f4d 100644 --- a/classes/prefs.php +++ b/classes/prefs.php @@ -141,7 +141,10 @@ class Prefs { Prefs::_PREFS_MIGRATED ]; + /** @var Prefs|null */ private static $instance; + + /** @var array<string, bool|int|string> */ private $cache = []; /** @var PDO */ @@ -154,10 +157,13 @@ class Prefs { return self::$instance; } - static function is_valid(string $pref_name) { + static function is_valid(string $pref_name): bool { return isset(self::_DEFAULTS[$pref_name]); } + /** + * @return bool|int|null|string + */ static function get_default(string $pref_name) { if (self::is_valid($pref_name)) return self::_DEFAULTS[$pref_name][0]; @@ -181,10 +187,16 @@ class Prefs { // } + /** + * @return array<int, array<string, bool|int|null|string>> + */ static function get_all(int $owner_uid, int $profile_id = null) { return self::get_instance()->_get_all($owner_uid, $profile_id); } + /** + * @return array<int, array<string, bool|int|null|string>> + */ private function _get_all(int $owner_uid, int $profile_id = null) { $rv = []; @@ -205,7 +217,7 @@ class Prefs { return $rv; } - private function cache_all(int $owner_uid, $profile_id = null) { + private function cache_all(int $owner_uid, ?int $profile_id): void { if (!$profile_id) $profile_id = null; // fill cache with defaults @@ -232,11 +244,17 @@ class Prefs { } } - static function get(string $pref_name, int $owner_uid, int $profile_id = null) { + /** + * @return bool|int|null|string + */ + static function get(string $pref_name, int $owner_uid, ?int $profile_id) { return self::get_instance()->_get($pref_name, $owner_uid, $profile_id); } - private function _get(string $pref_name, int $owner_uid, int $profile_id = null) { + /** + * @return bool|int|null|string + */ + private function _get(string $pref_name, int $owner_uid, ?int $profile_id) { if (isset(self::_DEFAULTS[$pref_name])) { if (!$profile_id || in_array($pref_name, self::_PROFILE_BLACKLIST)) $profile_id = null; @@ -274,12 +292,15 @@ class Prefs { return null; } - private function _is_cached(string $pref_name, int $owner_uid, int $profile_id = null) { + private function _is_cached(string $pref_name, int $owner_uid, ?int $profile_id): bool { $cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name); return isset($this->cache[$cache_key]); } - private function _get_cache(string $pref_name, int $owner_uid, int $profile_id = null) { + /** + * @return bool|int|null|string + */ + private function _get_cache(string $pref_name, int $owner_uid, ?int $profile_id) { $cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name); if (isset($this->cache[$cache_key])) @@ -288,17 +309,23 @@ class Prefs { return null; } - private function _set_cache(string $pref_name, $value, int $owner_uid, int $profile_id = null) { + /** + * @param bool|int|string $value + */ + private function _set_cache(string $pref_name, $value, int $owner_uid, ?int $profile_id): void { $cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name); $this->cache[$cache_key] = $value; } - static function set(string $pref_name, $value, int $owner_uid, int $profile_id = null, bool $strip_tags = true) { + /** + * @param bool|int|string $value + */ + static function set(string $pref_name, $value, int $owner_uid, ?int $profile_id, bool $strip_tags = true): bool { return self::get_instance()->_set($pref_name, $value, $owner_uid, $profile_id); } - private function _delete(string $pref_name, int $owner_uid, int $profile_id = null) { + private function _delete(string $pref_name, int $owner_uid, ?int $profile_id): bool { $sth = $this->pdo->prepare("DELETE FROM ttrss_user_prefs2 WHERE pref_name = :name AND owner_uid = :uid AND (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); @@ -306,7 +333,10 @@ class Prefs { return $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name ]); } - private function _set(string $pref_name, $value, int $owner_uid, int $profile_id = null, bool $strip_tags = true) { + /** + * @param bool|int|string $value + */ + private function _set(string $pref_name, $value, int $owner_uid, ?int $profile_id, bool $strip_tags = true): bool { if (!$profile_id) $profile_id = null; if ($profile_id && in_array($pref_name, self::_PROFILE_BLACKLIST)) @@ -359,7 +389,7 @@ class Prefs { return false; } - function migrate(int $owner_uid, int $profile_id = null) { + function migrate(int $owner_uid, ?int $profile_id): void { if (get_schema_version() < 141) return; @@ -401,7 +431,7 @@ class Prefs { } } - static function reset(int $owner_uid, int $profile_id = null) { + static function reset(int $owner_uid, ?int $profile_id): void { if (!$profile_id) $profile_id = null; $sth = Db::pdo()->prepare("DELETE FROM ttrss_user_prefs2 diff --git a/classes/rpc.php b/classes/rpc.php index 23a45d951..dbb54cec5 100755 --- a/classes/rpc.php +++ b/classes/rpc.php @@ -1,13 +1,16 @@ <?php class RPC extends Handler_Protected { - /*function csrf_ignore($method) { + /*function csrf_ignore(string $method): bool { $csrf_ignored = array("completelabels"); return array_search($method, $csrf_ignored) !== false; }*/ - private function _translations_as_array() { + /** + * @return array<string, string> + */ + private function _translations_as_array(): array { global $text_domains; @@ -37,7 +40,7 @@ class RPC extends Handler_Protected { } - function togglepref() { + function togglepref(): void { $key = clean($_REQUEST["key"]); set_pref($key, !get_pref($key)); $value = get_pref($key); @@ -45,7 +48,7 @@ class RPC extends Handler_Protected { print json_encode(array("param" =>$key, "value" => $value)); } - function setpref() { + function setpref(): void { // set_pref escapes input, so no need to double escape it here $key = clean($_REQUEST['key']); $value = $_REQUEST['value']; @@ -55,7 +58,7 @@ class RPC extends Handler_Protected { print json_encode(array("param" =>$key, "value" => $value)); } - function mark() { + function mark(): void { $mark = clean($_REQUEST["mark"]); $id = clean($_REQUEST["id"]); @@ -68,7 +71,7 @@ class RPC extends Handler_Protected { print json_encode(array("message" => "UPDATE_COUNTERS")); } - function delete() { + function delete(): void { $ids = explode(",", clean($_REQUEST["ids"])); $ids_qmarks = arr_qmarks($ids); @@ -81,7 +84,7 @@ class RPC extends Handler_Protected { print json_encode(array("message" => "UPDATE_COUNTERS")); } - function publ() { + function publ(): void { $pub = clean($_REQUEST["pub"]); $id = clean($_REQUEST["id"]); @@ -94,7 +97,7 @@ class RPC extends Handler_Protected { print json_encode(array("message" => "UPDATE_COUNTERS")); } - function getRuntimeInfo() { + function getRuntimeInfo(): void { $reply = [ 'runtime-info' => $this->_make_runtime_info() ]; @@ -102,11 +105,11 @@ class RPC extends Handler_Protected { print json_encode($reply); } - function getAllCounters() { + function getAllCounters(): void { @$seq = (int) $_REQUEST['seq']; - $feed_id_count = (int)$_REQUEST["feed_id_count"]; - $label_id_count = (int)$_REQUEST["label_id_count"]; + $feed_id_count = (int) ($_REQUEST["feed_id_count"] ?? -1); + $label_id_count = (int) ($_REQUEST["label_id_count"] ?? -1); // it seems impossible to distinguish empty array [] from a null - both become unset in $_REQUEST // so, count is >= 0 means we had an array, -1 means null @@ -133,7 +136,7 @@ class RPC extends Handler_Protected { } /* GET["cmode"] = 0 - mark as read, 1 - as unread, 2 - toggle */ - function catchupSelected() { + function catchupSelected(): void { $ids = array_map("intval", clean($_REQUEST["ids"] ?? [])); $cmode = (int)clean($_REQUEST["cmode"]); @@ -145,7 +148,7 @@ class RPC extends Handler_Protected { "feeds" => Article::_feeds_of($ids)]); } - function markSelected() { + function markSelected(): void { $ids = array_map("intval", clean($_REQUEST["ids"] ?? [])); $cmode = (int)clean($_REQUEST["cmode"]); @@ -157,7 +160,7 @@ class RPC extends Handler_Protected { "feeds" => Article::_feeds_of($ids)]); } - function publishSelected() { + function publishSelected(): void { $ids = array_map("intval", clean($_REQUEST["ids"] ?? [])); $cmode = (int)clean($_REQUEST["cmode"]); @@ -169,8 +172,8 @@ class RPC extends Handler_Protected { "feeds" => Article::_feeds_of($ids)]); } - function sanityCheck() { - $_SESSION["hasSandbox"] = clean($_REQUEST["hasSandbox"]) === "true"; + function sanityCheck(): void { + $_SESSION["hasSandbox"] = self::_param_to_bool($_REQUEST["hasSandbox"] ?? false); $_SESSION["clientTzOffset"] = clean($_REQUEST["clientTzOffset"]); $client_location = $_REQUEST["clientLocation"]; @@ -220,14 +223,14 @@ class RPC extends Handler_Protected { print "</ul>"; }*/ - function catchupFeed() { + function catchupFeed(): void { $feed_id = clean($_REQUEST['feed_id']); - $is_cat = clean($_REQUEST['is_cat']) == "true"; + $is_cat = self::_param_to_bool($_REQUEST['is_cat'] ?? false); $mode = clean($_REQUEST['mode'] ?? ''); $search_query = clean($_REQUEST['search_query']); $search_lang = clean($_REQUEST['search_lang']); - Feeds::_catchup($feed_id, $is_cat, false, $mode, [$search_query, $search_lang]); + Feeds::_catchup($feed_id, $is_cat, null, $mode, [$search_query, $search_lang]); // return counters here synchronously so that frontend can figure out next unread feed properly print json_encode(['counters' => Counters::get_all()]); @@ -235,7 +238,7 @@ class RPC extends Handler_Protected { //print json_encode(array("message" => "UPDATE_COUNTERS")); } - function setWidescreen() { + function setWidescreen(): void { $wide = (int) clean($_REQUEST["wide"]); set_pref(Prefs::WIDESCREEN_MODE, $wide); @@ -243,7 +246,7 @@ class RPC extends Handler_Protected { print json_encode(["wide" => $wide]); } - static function updaterandomfeed_real() { + static function updaterandomfeed_real(): void { $default_interval = (int) Prefs::get_default(Prefs::DEFAULT_UPDATE_INTERVAL); @@ -299,7 +302,8 @@ class RPC extends Handler_Protected { ttrss_feeds f, ttrss_users u LEFT JOIN ttrss_user_prefs2 p ON (p.owner_uid = u.id AND profile IS NULL AND pref_name = 'DEFAULT_UPDATE_INTERVAL') WHERE - f.owner_uid = u.id + f.owner_uid = u.id AND + u.access_level NOT IN (".sprintf("%d, %d", UserHelper::ACCESS_LEVEL_DISABLED, UserHelper::ACCESS_LEVEL_READONLY).") $owner_check_qpart $update_limit_qpart $updstart_thresh_qpart @@ -335,19 +339,22 @@ class RPC extends Handler_Protected { } - function updaterandomfeed() { + function updaterandomfeed(): void { self::updaterandomfeed_real(); } - private function markArticlesById($ids, $cmode) { + /** + * @param array<int, int> $ids + */ + private function markArticlesById(array $ids, int $cmode): void { $ids_qmarks = arr_qmarks($ids); - if ($cmode == 0) { + if ($cmode == Article::CATCHUP_MODE_MARK_AS_READ) { $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET marked = false, last_marked = NOW() WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); - } else if ($cmode == 1) { + } else if ($cmode == Article::CATCHUP_MODE_MARK_AS_UNREAD) { $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET marked = true, last_marked = NOW() WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); @@ -360,15 +367,18 @@ class RPC extends Handler_Protected { $sth->execute(array_merge($ids, [$_SESSION['uid']])); } - private function publishArticlesById($ids, $cmode) { + /** + * @param array<int, int> $ids + */ + private function publishArticlesById(array $ids, int $cmode): void { $ids_qmarks = arr_qmarks($ids); - if ($cmode == 0) { + if ($cmode == Article::CATCHUP_MODE_MARK_AS_READ) { $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET published = false, last_published = NOW() WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); - } else if ($cmode == 1) { + } else if ($cmode == Article::CATCHUP_MODE_MARK_AS_UNREAD) { $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET published = true, last_published = NOW() WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); @@ -381,7 +391,7 @@ class RPC extends Handler_Protected { $sth->execute(array_merge($ids, [$_SESSION['uid']])); } - function log() { + function log(): void { $msg = clean($_REQUEST['msg'] ?? ""); $file = basename(clean($_REQUEST['file'] ?? "")); $line = (int) clean($_REQUEST['line'] ?? 0); @@ -395,7 +405,7 @@ class RPC extends Handler_Protected { } } - function checkforupdates() { + function checkforupdates(): void { $rv = ["changeset" => [], "plugins" => []]; $version = Config::get_version(false); @@ -403,7 +413,7 @@ class RPC extends Handler_Protected { $git_timestamp = $version["timestamp"] ?? false; $git_commit = $version["commit"] ?? false; - if (Config::get(Config::CHECK_FOR_UPDATES) && $_SESSION["access_level"] >= 10 && $git_timestamp) { + if (Config::get(Config::CHECK_FOR_UPDATES) && $_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN && $git_timestamp) { $content = @UrlHelper::fetch(["url" => "https://tt-rss.org/version.json"]); if ($content) { @@ -424,7 +434,10 @@ class RPC extends Handler_Protected { print json_encode($rv); } - private function _make_init_params() { + /** + * @return array<string, mixed> + */ + private function _make_init_params(): array { $params = array(); foreach ([Prefs::ON_CATCHUP_SHOW_NEXT_FEED, Prefs::HIDE_READ_FEEDS, @@ -480,7 +493,7 @@ class RPC extends Handler_Protected { return $params; } - private function image_to_base64($filename) { + private function image_to_base64(string $filename): string { if (file_exists($filename)) { $ext = pathinfo($filename, PATHINFO_EXTENSION); @@ -492,7 +505,10 @@ class RPC extends Handler_Protected { } } - static function _make_runtime_info() { + /** + * @return array<string, mixed> + */ + static function _make_runtime_info(): array { $data = array(); $pdo = Db::pdo(); @@ -510,7 +526,7 @@ class RPC extends Handler_Protected { $data['cdm_expanded'] = get_pref(Prefs::CDM_EXPANDED); $data["labels"] = Labels::get_all($_SESSION["uid"]); - if (Config::get(Config::LOG_DESTINATION) == 'sql' && $_SESSION['access_level'] >= 10) { + if (Config::get(Config::LOG_DESTINATION) == 'sql' && $_SESSION['access_level'] >= UserHelper::ACCESS_LEVEL_ADMIN) { if (Config::get(Config::DB_TYPE) == 'pgsql') { $log_interval = "created_at > NOW() - interval '1 hour'"; } else { @@ -522,6 +538,7 @@ class RPC extends Handler_Protected { WHERE errno NOT IN (".E_USER_NOTICE.", ".E_USER_DEPRECATED.") AND $log_interval AND + errstr NOT LIKE '%Returning bool from comparison function is deprecated%' AND errstr NOT LIKE '%imagecreatefromstring(): Data is not in a recognized format%'"); $sth->execute(); @@ -560,7 +577,10 @@ class RPC extends Handler_Protected { return $data; } - static function get_hotkeys_info() { + /** + * @return array<string, array<string, string>> + */ + static function get_hotkeys_info(): array { $hotkeys = array( __("Navigation") => array( "next_feed" => __("Open next feed"), @@ -640,8 +660,12 @@ class RPC extends Handler_Protected { return $hotkeys; } - // {3} - 3 panel mode only - // {C} - combined mode only + /** + * {3} - 3 panel mode only + * {C} - combined mode only + * + * @return array{0: array<int, string>, 1: array<string, string>} $prefixes, $hotkeys + */ static function get_hotkeys_map() { $hotkeys = array( "k" => "next_feed", @@ -726,7 +750,7 @@ class RPC extends Handler_Protected { return array($prefixes, $hotkeys); } - function hotkeyHelp() { + function hotkeyHelp(): void { $info = self::get_hotkeys_info(); $imap = self::get_hotkeys_map(); $omap = array(); diff --git a/classes/rssutils.php b/classes/rssutils.php index 216792a0e..9995b0e43 100755 --- a/classes/rssutils.php +++ b/classes/rssutils.php @@ -1,6 +1,9 @@ <?php class RSSUtils { - static function calculate_article_hash($article, $pluginhost) { + /** + * @param array<string, mixed> $article + */ + static function calculate_article_hash(array $article, PluginHost $pluginhost): string { $tmp = ""; $ignored_fields = [ "feed", "guid", "guid_hashed", "owner_uid", "force_catchup" ]; @@ -21,16 +24,16 @@ class RSSUtils { } // Strips utf8mb4 characters (i.e. emoji) for mysql - static function strip_utf8mb4(string $str) { + static function strip_utf8mb4(string $str): string { return preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $str); } - static function cleanup_feed_browser() { + static function cleanup_feed_browser(): void { $pdo = Db::pdo(); $pdo->query("DELETE FROM ttrss_feedbrowser_cache"); } - static function cleanup_feed_icons() { + static function cleanup_feed_icons(): void { $pdo = Db::pdo(); $sth = $pdo->prepare("SELECT id FROM ttrss_feeds WHERE id = ?"); @@ -52,7 +55,10 @@ class RSSUtils { } } - static function update_daemon_common(int $limit = 0, array $options = []) { + /** + * @param array<string, false|string> $options + */ + static function update_daemon_common(int $limit = 0, array $options = []): int { if (!$limit) $limit = Config::get(Config::DAEMON_FEED_LIMIT); if (Config::get_schema_version() != Config::SCHEMA_VERSION) { @@ -123,7 +129,8 @@ class RSSUtils { ttrss_feeds f, ttrss_users u LEFT JOIN ttrss_user_prefs2 p ON (p.owner_uid = u.id AND profile IS NULL AND pref_name = 'DEFAULT_UPDATE_INTERVAL') WHERE - f.owner_uid = u.id + f.owner_uid = u.id AND + u.access_level NOT IN (".sprintf("%d, %d", UserHelper::ACCESS_LEVEL_DISABLED, UserHelper::ACCESS_LEVEL_READONLY).") $login_thresh_qpart $update_limit_qpart $updstart_thresh_qpart @@ -163,7 +170,8 @@ class RSSUtils { FROM ttrss_feeds f, ttrss_users u LEFT JOIN ttrss_user_prefs2 p ON (p.owner_uid = u.id AND profile IS NULL AND pref_name = 'DEFAULT_UPDATE_INTERVAL') WHERE - f.owner_uid = u.id + f.owner_uid = u.id AND + u.access_level NOT IN (".sprintf("%d, %d", UserHelper::ACCESS_LEVEL_DISABLED, UserHelper::ACCESS_LEVEL_READONLY).") AND feed_url = :feed $login_thresh_qpart $update_limit_qpart @@ -270,7 +278,7 @@ class RSSUtils { } /** this is used when subscribing */ - static function update_basic_info(int $feed_id) { + static function update_basic_info(int $feed_id): void { $feed = ORM::for_table('ttrss_feeds') ->select_many('id', 'owner_uid', 'feed_url', 'auth_pass', 'auth_login', 'title', 'site_url') ->find_one($feed_id); @@ -352,6 +360,19 @@ class RSSUtils { if (!$feed_language) $feed_language = mb_strtolower(get_pref(Prefs::DEFAULT_SEARCH_LANGUAGE, $feed_obj->owner_uid)); if (!$feed_language) $feed_language = 'simple'; + $user = ORM::for_table('ttrss_users')->find_one($feed_obj->owner_uid); + + if ($user) { + if ($user->access_level == UserHelper::ACCESS_LEVEL_READONLY) { + Debug::log("error: denied update for $feed: permission denied by owner access level"); + return false; + } + } else { + // this would indicate database corruption of some kind + Debug::log("error: owner not found for feed: $feed"); + return false; + } + } else { Debug::log("error: feeds table record not found for feed: $feed"); return false; @@ -536,7 +557,7 @@ class RSSUtils { Debug::log("language: $feed_language", Debug::LOG_VERBOSE); Debug::log("processing feed data...", Debug::LOG_VERBOSE); - $site_url = mb_substr(rewrite_relative_url($feed_obj->feed_url, clean($rss->get_link())), 0, 245); + $site_url = mb_substr(UrlHelper::rewrite_relative($feed_obj->feed_url, clean($rss->get_link())), 0, 245); Debug::log("site_url: $site_url", Debug::LOG_VERBOSE); Debug::log("feed_title: {$rss->get_title()}", Debug::LOG_VERBOSE); @@ -646,7 +667,7 @@ class RSSUtils { $entry_title = strip_tags($item->get_title()); - $entry_link = rewrite_relative_url($site_url, clean($item->get_link())); + $entry_link = UrlHelper::rewrite_relative($site_url, clean($item->get_link()), "a", "href"); $entry_language = mb_substr(trim($item->get_language()), 0, 2); @@ -666,7 +687,7 @@ class RSSUtils { } $entry_comments = mb_substr(strip_tags($item->get_comments_url()), 0, 245); - $num_comments = (int) $item->get_comments_count(); + $num_comments = $item->get_comments_count(); $entry_author = strip_tags($item->get_author()); $entry_guid = mb_substr($entry_guid, 0, 245); @@ -713,8 +734,9 @@ class RSSUtils { }, $e, $feed); + // TODO: Just use FeedEnclosure (and modify it to cover whatever justified this)? $e_item = array( - rewrite_relative_url($site_url, $e->link), + UrlHelper::rewrite_relative($site_url, $e->link, "", "", $e->type), $e->type, $e->length, $e->title, $e->width, $e->height); // Yet another episode of "mysql utf8_general_ci is gimped" @@ -764,13 +786,13 @@ class RSSUtils { // dupes when the entry gets purged and reinserted again e.g. // in the case of SLOW SLOW OMG SLOW updating feeds + $pdo->commit(); + $entry_obj = ORM::for_table('ttrss_entries') ->find_one($base_entry_id) ->set('date_updated', Db::NOW()) ->save(); - $pdo->commit(); - continue; } @@ -825,7 +847,8 @@ class RSSUtils { if (count($matched_filter_ids) > 0) { $filter_objs = ORM::for_table('ttrss_filters2') ->where('owner_uid', $feed_obj->owner_uid) - ->where_in('id', $matched_filter_ids); + ->where_in('id', $matched_filter_ids) + ->find_many(); foreach ($filter_objs as $filter_obj) { $filter_obj->set('last_triggered', Db::NOW()); @@ -898,7 +921,7 @@ class RSSUtils { $entry_timestamp = time(); } - $entry_timestamp_fmt = strftime("%Y/%m/%d %H:%M:%S", $entry_timestamp); + $entry_timestamp_fmt = date("Y/m/d H:i:s", $entry_timestamp); Debug::log("date: $entry_timestamp ($entry_timestamp_fmt)", Debug::LOG_VERBOSE); Debug::log("num_comments: $num_comments", Debug::LOG_VERBOSE); @@ -1142,32 +1165,30 @@ class RSSUtils { } // check for manual tags (we have to do it here since they're loaded from filters) - foreach ($article_filters as $f) { if ($f["type"] == "tag") { + $entry_tags = array_merge($entry_tags, + FeedItem_Common::normalize_categories(explode(",", $f["param"]))); + } + } - $manual_tags = array_map('trim', explode(",", mb_strtolower($f["param"]))); - - foreach ($manual_tags as $tag) { - array_push($entry_tags, $tag); - } + // like boring tags, but filter-based + foreach ($article_filters as $f) { + if ($f["type"] == "ignore-tag") { + $entry_tags = array_diff($entry_tags, + FeedItem_Common::normalize_categories(explode(",", $f["param"]))); } } // Skip boring tags - - $boring_tags = array_map('trim', - explode(",", mb_strtolower( - get_pref(Prefs::BLACKLISTED_TAGS, $feed_obj->owner_uid)))); - $entry_tags = FeedItem_Common::normalize_categories( - array_unique( - array_diff($entry_tags, $boring_tags))); + array_diff($entry_tags, + FeedItem_Common::normalize_categories(explode(",", + get_pref(Prefs::BLACKLISTED_TAGS, $feed_obj->owner_uid))))); - Debug::log("filtered tags: " . implode(", ", $entry_tags), Debug::LOG_VERBOSE); + Debug::log("resulting article tags: " . implode(", ", $entry_tags), Debug::LOG_VERBOSE); // Save article tags in the database - if (count($entry_tags) > 0) { $tsth = $pdo->prepare("SELECT id FROM ttrss_tags @@ -1250,15 +1271,21 @@ class RSSUtils { return true; } - /* TODO: move to DiskCache? */ - static function cache_enclosures($enclosures, $site_url) { + /** + * TODO: move to DiskCache? + * + * @param array<int, array<string>> $enclosures An array of "enclosure arrays" [string $link, string $type, string $length, string, $title, string $width, string $height] + * @see RSSUtils::update_rss_feed() + * @see FeedEnclosure + */ + static function cache_enclosures(array $enclosures, string $site_url): void { $cache = new DiskCache("images"); if ($cache->is_writable()) { foreach ($enclosures as $enc) { if (preg_match("/(image|audio|video)/", $enc[1])) { - $src = rewrite_relative_url($site_url, $enc[0]); + $src = UrlHelper::rewrite_relative($site_url, $enc[0]); $local_filename = sha1($src); @@ -1283,8 +1310,8 @@ class RSSUtils { } /* TODO: move to DiskCache? */ - static function cache_media_url($cache, $url, $site_url) { - $url = rewrite_relative_url($site_url, $url); + static function cache_media_url(DiskCache $cache, string $url, string $site_url): void { + $url = UrlHelper::rewrite_relative($site_url, $url); $local_filename = sha1($url); Debug::log("cache_media: checking $url", Debug::LOG_VERBOSE); @@ -1307,7 +1334,7 @@ class RSSUtils { } /* TODO: move to DiskCache? */ - static function cache_media($html, $site_url) { + static function cache_media(string $html, string $site_url): void { $cache = new DiskCache("images"); if ($html && $cache->is_writable()) { @@ -1336,7 +1363,7 @@ class RSSUtils { } } - static function expire_error_log() { + static function expire_error_log(): void { Debug::log("Removing old error log entries..."); $pdo = Db::pdo(); @@ -1350,14 +1377,16 @@ class RSSUtils { } } - // deprecated; table not used - static function expire_feed_archive() { + /** + * @deprecated table not used + */ + static function expire_feed_archive(): void { $pdo = Db::pdo(); $pdo->query("DELETE FROM ttrss_archived_feeds"); } - static function expire_lock_files() { + static function expire_lock_files(): void { Debug::log("Removing old lock files...", Debug::LOG_VERBOSE); $num_deleted = 0; @@ -1398,7 +1427,15 @@ class RSSUtils { return $params; } */ - static function get_article_filters($filters, $title, $content, $link, $author, $tags, &$matched_rules = false, &$matched_filters = false) { + /** + * @param array<int, array<string, mixed>> $filters + * @param array<int, string> $tags + * @param array<int, array<string, mixed>>|null $matched_rules + * @param array<int, array<string, mixed>>|null $matched_filters + * + * @return array<int, array<string, string>> An array of filter action arrays with keys "type" and "param" + */ + static function get_article_filters(array $filters, string $title, string $content, string $link, string $author, array $tags, array &$matched_rules = null, array &$matched_filters = null): array { $matches = array(); foreach ($filters as $filter) { @@ -1482,16 +1519,26 @@ class RSSUtils { return $matches; } - static function find_article_filter($filters, $filter_name) { + /** + * @param array<int, array<string, string>> $filters An array of filter action arrays with keys "type" and "param" + * + * @return array<string, string>|null A filter action array with keys "type" and "param" + */ + static function find_article_filter(array $filters, string $filter_name): ?array { foreach ($filters as $f) { if ($f["type"] == $filter_name) { return $f; }; } - return false; + return null; } - static function find_article_filters($filters, $filter_name) { + /** + * @param array<int, array<string, string>> $filters An array of filter action arrays with keys "type" and "param" + * + * @return array<int, array<string, string>> An array of filter action arrays with keys "type" and "param" + */ + static function find_article_filters(array $filters, string $filter_name): array { $results = array(); foreach ($filters as $f) { @@ -1502,7 +1549,10 @@ class RSSUtils { return $results; } - static function calculate_article_score($filters) { + /** + * @param array<int, array<string, string>> $filters An array of filter action arrays with keys "type" and "param" + */ + static function calculate_article_score(array $filters): int { $score = 0; foreach ($filters as $f) { @@ -1513,7 +1563,12 @@ class RSSUtils { return $score; } - static function labels_contains_caption($labels, $caption) { + /** + * @param array<int, array<int, int|string>> $labels An array of label arrays like [int $feed_id, string $caption, string $fg_color, string $bg_color] + * + * @see Article::_get_labels() + */ + static function labels_contains_caption(array $labels, string $caption): bool { foreach ($labels as $label) { if ($label[1] == $caption) { return true; @@ -1523,7 +1578,11 @@ class RSSUtils { return false; } - static function assign_article_to_label_filters($id, $filters, $owner_uid, $article_labels) { + /** + * @param array<int, array<string, string>> $filters An array of filter action arrays with keys "type" and "param" + * @param array<int, array<int, int|string>> $article_labels An array of label arrays like [int $feed_id, string $caption, string $fg_color, string $bg_color] + */ + static function assign_article_to_label_filters(int $id, array $filters, int $owner_uid, $article_labels): void { foreach ($filters as $f) { if ($f["type"] == "label") { if (!self::labels_contains_caption($article_labels, $f["param"])) { @@ -1533,20 +1592,20 @@ class RSSUtils { } } - static function make_guid_from_title($title) { + static function make_guid_from_title(string $title): ?string { return preg_replace("/[ \"\',.:;]/", "-", mb_strtolower(strip_tags($title), 'utf-8')); } /* counter cache is no longer used, if called truncate leftover data */ - static function cleanup_counters_cache() { + static function cleanup_counters_cache(): void { $pdo = Db::pdo(); $pdo->query("DELETE FROM ttrss_counters_cache"); $pdo->query("DELETE FROM ttrss_cat_counters_cache"); } - static function disable_failed_feeds() { + static function disable_failed_feeds(): void { if (Config::get(Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT) > 0) { $pdo = Db::pdo(); @@ -1584,7 +1643,7 @@ class RSSUtils { } } - static function housekeeping_user($owner_uid) { + static function housekeeping_user(int $owner_uid): void { $tmph = new PluginHost(); UserHelper::load_user_plugins($owner_uid, $tmph); @@ -1592,7 +1651,7 @@ class RSSUtils { $tmph->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); } - static function housekeeping_common() { + static function housekeeping_common(): void { DiskCache::expire(); self::expire_lock_files(); @@ -1608,6 +1667,9 @@ class RSSUtils { PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); } + /** + * @return false|string + */ static function update_favicon(string $site_url, int $feed) { $icon_file = Config::get(Config::ICONS_DIR) . "/$feed.ico"; @@ -1672,11 +1734,14 @@ class RSSUtils { return $icon_file; } - static function is_gzipped($feed_data) { + static function is_gzipped(string $feed_data): bool { return strpos(substr($feed_data, 0, 3), "\x1f" . "\x8b" . "\x08", 0) === 0; } + /** + * @return array<int, array<string, mixed>> An array of filter arrays with keys "id", "match_any_rule", "inverse", "rules", and "actions" + */ static function load_filters(int $feed_id, int $owner_uid) { $filters = array(); @@ -1794,7 +1859,7 @@ class RSSUtils { * * @param string $url A feed or page URL * @access public - * @return mixed The favicon URL, or false if none was found. + * @return false|string The favicon URL string, or false if none was found. */ static function get_favicon_url(string $url) { @@ -1808,14 +1873,14 @@ class RSSUtils { $base = $xpath->query('/html/head/base[@href]'); foreach ($base as $b) { - $url = rewrite_relative_url($url, $b->getAttribute("href")); + $url = UrlHelper::rewrite_relative($url, $b->getAttribute("href")); break; } $entries = $xpath->query('/html/head/link[@rel="shortcut icon" or @rel="icon"]'); if (count($entries) > 0) { foreach ($entries as $entry) { - $favicon_url = rewrite_relative_url($url, $entry->getAttribute("href")); + $favicon_url = UrlHelper::rewrite_relative($url, $entry->getAttribute("href")); break; } } @@ -1823,13 +1888,17 @@ class RSSUtils { } if (!$favicon_url) - $favicon_url = rewrite_relative_url($url, "/favicon.ico"); + $favicon_url = UrlHelper::rewrite_relative($url, "/favicon.ico"); return $favicon_url; } - // https://community.tt-rss.org/t/problem-with-img-srcset/3519 - static function decode_srcset($srcset) { + /** + * @see https://community.tt-rss.org/t/problem-with-img-srcset/3519 + * + * @return array<int, array<string, string>> An array of srcset subitem arrays with keys "url" and "size" + */ + static function decode_srcset(string $srcset): array { $matches = []; preg_match_all( @@ -1847,7 +1916,10 @@ class RSSUtils { return $matches; } - static function encode_srcset($matches) { + /** + * @param array<int, array<string, string>> $matches An array of srcset subitem arrays with keys "url" and "size" + */ + static function encode_srcset(array $matches): string { $tokens = []; foreach ($matches as $m) { @@ -1857,7 +1929,7 @@ class RSSUtils { return implode(",", $tokens); } - static function function_enabled($func) { + static function function_enabled(string $func): bool { return !in_array($func, explode(',', str_replace(" ", "", ini_get('disable_functions')))); } diff --git a/classes/sanitizer.php b/classes/sanitizer.php index 0a444a296..e2055930b 100644 --- a/classes/sanitizer.php +++ b/classes/sanitizer.php @@ -1,6 +1,10 @@ <?php class Sanitizer { - private static function strip_harmful_tags($doc, $allowed_elements, $disallowed_attributes) { + /** + * @param array<int, string> $allowed_elements + * @param array<int, string> $disallowed_attributes + */ + private static function strip_harmful_tags(DOMDocument $doc, array $allowed_elements, $disallowed_attributes): DOMDocument { $xpath = new DOMXPath($doc); $entries = $xpath->query('//*'); @@ -40,7 +44,7 @@ class Sanitizer { return $doc; } - public static function iframe_whitelisted($entry) { + public static function iframe_whitelisted(DOMElement $entry): bool { $src = parse_url($entry->getAttribute("src"), PHP_URL_HOST); if (!empty($src)) @@ -49,11 +53,16 @@ class Sanitizer { return false; } - private static function is_prefix_https() { + private static function is_prefix_https(): bool { return parse_url(Config::get(Config::SELF_URL_PATH), PHP_URL_SCHEME) == 'https'; } - public static function sanitize($str, $force_remove_images = false, $owner = false, $site_url = false, $highlight_words = false, $article_id = false) { + /** + * @param array<int, string>|null $highlight_words Words to highlight in the HTML output. + * + * @return false|string The HTML, or false if an error occurred. + */ + public static function sanitize(string $str, ?bool $force_remove_images = false, int $owner = null, string $site_url = null, array $highlight_words = null, int $article_id = null) { if (!$owner && isset($_SESSION["uid"])) $owner = $_SESSION["uid"]; @@ -68,7 +77,7 @@ class Sanitizer { // $rewrite_base_url = $site_url ? $site_url : Config::get_self_url(); $rewrite_base_url = $site_url ? $site_url : "http://domain.invalid/"; - $entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src])'); + $entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src]|//video[@poster])'); foreach ($entries as $entry) { @@ -100,6 +109,11 @@ class Sanitizer { $entry->setAttribute("srcset", RSSUtils::encode_srcset($matches)); } + if ($entry->hasAttribute('poster')) { + $entry->setAttribute('poster', + UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('poster'), $entry->tagName, "poster")); + } + if ($entry->hasAttribute('src') && ($owner && get_pref(Prefs::STRIP_IMAGES, $owner)) || $force_remove_images || ($_SESSION["bw_limit"] ?? false)) { @@ -178,7 +192,7 @@ class Sanitizer { $div->appendChild($entry); } - if ($highlight_words && is_array($highlight_words)) { + if (is_array($highlight_words)) { foreach ($highlight_words as $word) { // http://stackoverflow.com/questions/4081372/highlight-keywords-in-a-paragraph diff --git a/classes/timehelper.php b/classes/timehelper.php index 4317f343f..453ee0cee 100644 --- a/classes/timehelper.php +++ b/classes/timehelper.php @@ -1,7 +1,7 @@ <?php class TimeHelper { - static function smart_date_time($timestamp, $tz_offset = 0, $owner_uid = false, $eta_min = false) { + static function smart_date_time(int $timestamp, int $tz_offset = 0, int $owner_uid = null, bool $eta_min = false): string { if (!$owner_uid) $owner_uid = $_SESSION['uid']; if ($eta_min && time() + $tz_offset - $timestamp < 3600) { @@ -21,8 +21,8 @@ class TimeHelper { } } - static function make_local_datetime($timestamp, $long, $owner_uid = false, - $no_smart_dt = false, $eta_min = false) { + static function make_local_datetime(?string $timestamp, bool $long, int $owner_uid = null, + bool $no_smart_dt = false, bool $eta_min = false): string { if (!$owner_uid) $owner_uid = $_SESSION['uid']; if (!$timestamp) $timestamp = '1970-01-01 0:00'; @@ -67,7 +67,7 @@ class TimeHelper { } } - static function convert_timestamp($timestamp, $source_tz, $dest_tz) { + static function convert_timestamp(int $timestamp, string $source_tz, string $dest_tz): int { try { $source_tz = new DateTimeZone($source_tz); diff --git a/classes/urlhelper.php b/classes/urlhelper.php index b2c1331b6..2dfb22a5d 100644 --- a/classes/urlhelper.php +++ b/classes/urlhelper.php @@ -6,16 +6,39 @@ class UrlHelper { "tel" ]; + const EXTRA_SCHEMES_BY_CONTENT_TYPE = [ + "application/x-bittorrent" => [ "magnet" ], + ]; + + // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ + /** @var string */ static $fetch_last_error; + + /** @var int */ static $fetch_last_error_code; + + /** @var string */ static $fetch_last_error_content; + + /** @var string */ static $fetch_last_content_type; + + /** @var string */ static $fetch_last_modified; + + /** @var string */ static $fetch_effective_url; + + /** @var string */ static $fetch_effective_ip_addr; + + /** @var bool */ static $fetch_curl_used; - static function build_url($parts) { + /** + * @param array<string, string|int> $parts + */ + static function build_url(array $parts): string { $tmp = $parts['scheme'] . "://" . $parts['host']; if (isset($parts['path'])) $tmp .= $parts['path']; @@ -33,13 +56,29 @@ class UrlHelper { * @param string $rel_url Possibly relative URL in the document * @param string $owner_element Owner element tag name (i.e. "a") (optional) * @param string $owner_attribute Owner attribute (i.e. "href") (optional) + * @param string $content_type URL content type as specified by enclosures, etc. * - * @return string Absolute URL + * @return false|string Absolute URL or false on failure (either during URL parsing or validation) */ - public static function rewrite_relative($base_url, $rel_url, string $owner_element = "", string $owner_attribute = "") { + public static function rewrite_relative($base_url, + $rel_url, + string $owner_element = "", + string $owner_attribute = "", + string $content_type = "") { $rel_parts = parse_url($rel_url); + /** + * If parse_url failed to parse $rel_url return false to match the current "invalid thing" behavior + * of UrlHelper::validate(). + * + * TODO: There are many places where a string return value is assumed. We should either update those + * to account for the possibility of failure, or look into updating this function's return values. + */ + if ($rel_parts === false) { + return false; + } + if (!empty($rel_parts['host']) && !empty($rel_parts['scheme'])) { return self::validate($rel_url); @@ -51,8 +90,13 @@ class UrlHelper { $owner_element == "a" && $owner_attribute == "href") { return $rel_url; + // allow some extra schemes for links with feed-specified content type i.e. enclosures + } else if ($content_type && + isset(self::EXTRA_SCHEMES_BY_CONTENT_TYPE[$content_type]) && + in_array($rel_parts["scheme"], self::EXTRA_SCHEMES_BY_CONTENT_TYPE[$content_type])) { + return $rel_url; // allow limited subset of inline base64-encoded images for IMG elements - } else if ($rel_parts["scheme"] ?? "" == "data" && + } else if (($rel_parts["scheme"] ?? "") == "data" && preg_match('%^image/(webp|gif|jpg|png|svg);base64,%', $rel_parts["path"]) && $owner_element == "img" && $owner_attribute == "src") { @@ -60,8 +104,8 @@ class UrlHelper { } else { $base_parts = parse_url($base_url); - $rel_parts['host'] = $base_parts['host']; - $rel_parts['scheme'] = $base_parts['scheme']; + $rel_parts['host'] = $base_parts['host'] ?? ""; + $rel_parts['scheme'] = $base_parts['scheme'] ?? ""; if (isset($rel_parts['path'])) { @@ -80,8 +124,10 @@ class UrlHelper { } } - // extended filtering involves validation for safe ports and loopback - static function validate($url, $extended_filtering = false) { + /** extended filtering involves validation for safe ports and loopback + * @return false|string false if something went wrong, otherwise the URL string + */ + static function validate(string $url, bool $extended_filtering = false) { $url = clean($url); @@ -107,6 +153,11 @@ class UrlHelper { } else { $tokens['host'] = idn_to_ascii($tokens['host']); } + + // if `idn_to_ascii` failed + if ($tokens['host'] === false) { + return false; + } } } @@ -138,7 +189,10 @@ class UrlHelper { return $url; } - static function resolve_redirects($url, $timeout, $nest = 0) { + /** + * @return false|string + */ + static function resolve_redirects(string $url, int $timeout, int $nest = 0) { // too many redirects if ($nest > 10) @@ -162,8 +216,12 @@ class UrlHelper { $context = stream_context_create($context_options); + // PHP 8 changed the second param from int to bool, but we still support PHP >= 7.1.0 + // @phpstan-ignore-next-line $headers = get_headers($url, 0, $context); } else { + // PHP 8 changed the second param from int to bool, but we still support PHP >= 7.1.0 + // @phpstan-ignore-next-line $headers = get_headers($url, 0); } @@ -185,12 +243,16 @@ class UrlHelper { return false; } - // TODO: max_size currently only works for CURL transfers + /** + * @param array<string, bool|int|string>|string $options + * @return false|string false if something went wrong, otherwise string contents + */ + // TODO: max_size currently only works for CURL transfers // TODO: multiple-argument way is deprecated, first parameter is a hash now public static function fetch($options /* previously: 0: $url , 1: $type = false, 2: $login = false, 3: $pass = false, 4: $post_query = false, 5: $timeout = false, 6: $timestamp = 0, 7: $useragent = false*/) { - self::$fetch_last_error = false; + self::$fetch_last_error = ""; self::$fetch_last_error_code = -1; self::$fetch_last_error_content = ""; self::$fetch_last_content_type = ""; @@ -239,6 +301,8 @@ class UrlHelper { $url = ltrim($url, ' '); $url = str_replace(' ', '%20', $url); + Debug::log("[UrlHelper] fetching: $url", Debug::LOG_EXTENDED); + $url = self::validate($url, true); if (!$url) { @@ -275,15 +339,15 @@ class UrlHelper { curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout ? $timeout : Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT)); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout ? $timeout : Config::get(Config::FILE_FETCH_TIMEOUT)); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, !ini_get("open_basedir") && $followlocation); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, $followlocation); curl_setopt($ch, CURLOPT_MAXREDIRS, 20); curl_setopt($ch, CURLOPT_BINARYTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HEADER, true); curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_ANY); - curl_setopt($ch, CURLOPT_USERAGENT, $useragent ? $useragent : - SELF_USER_AGENT); + curl_setopt($ch, CURLOPT_USERAGENT, $useragent ? $useragent : Config::get_user_agent()); curl_setopt($ch, CURLOPT_ENCODING, ""); + curl_setopt($ch, CURLOPT_COOKIEJAR, "/dev/null"); if ($http_referrer) curl_setopt($ch, CURLOPT_REFERER, $http_referrer); @@ -298,7 +362,7 @@ class UrlHelper { //Debug::log("[curl progressfunction] $downloaded $max_size", Debug::$LOG_EXTENDED); if ($downloaded > $max_size) { - Debug::log("curl: reached max size of $max_size bytes requesting $url, aborting.", Debug::LOG_VERBOSE); + Debug::log("[UrlHelper] fetch error: curl reached max size of $max_size bytes downloading $url, aborting.", Debug::LOG_VERBOSE); return 1; } @@ -307,10 +371,6 @@ class UrlHelper { } - if (!ini_get("open_basedir")) { - curl_setopt($ch, CURLOPT_COOKIEJAR, "/dev/null"); - } - if (Config::get(Config::HTTP_PROXY)) { curl_setopt($ch, CURLOPT_PROXY, Config::get(Config::HTTP_PROXY)); } @@ -374,6 +434,8 @@ class UrlHelper { if (curl_errno($ch) != 0) { self::$fetch_last_error .= "; " . curl_errno($ch) . " " . curl_error($ch); + } else { + self::$fetch_last_error = "HTTP Code: $http_code "; } self::$fetch_last_error_content = $contents; @@ -510,7 +572,10 @@ class UrlHelper { } } - public static function url_to_youtube_vid($url) { + /** + * @return false|string false if the provided URL didn't match expected patterns, otherwise the video ID string + */ + public static function url_to_youtube_vid(string $url) { $url = str_replace("youtube.com", "youtube-nocookie.com", $url); $regexps = [ diff --git a/classes/userhelper.php b/classes/userhelper.php index 1cdd320a1..90d073d55 100644 --- a/classes/userhelper.php +++ b/classes/userhelper.php @@ -17,7 +17,22 @@ class UserHelper { self::HASH_ALGO_SHA1 ]; - static function authenticate(string $login = null, string $password = null, bool $check_only = false, string $service = null) { + /** forbidden to login */ + const ACCESS_LEVEL_DISABLED = -2; + + /** can't subscribe to new feeds, feeds are not updated */ + const ACCESS_LEVEL_READONLY = -1; + + /** no restrictions, regular user */ + const ACCESS_LEVEL_USER = 0; + + /** not used, same as regular user */ + const ACCESS_LEVEL_POWERUSER = 5; + + /** has administrator permissions */ + const ACCESS_LEVEL_ADMIN = 10; + + static function authenticate(string $login = null, string $password = null, bool $check_only = false, string $service = null): bool { if (!Config::get(Config::SINGLE_USER_MODE)) { $user_id = false; $auth_module = false; @@ -41,7 +56,7 @@ class UserHelper { $user = ORM::for_table('ttrss_users')->find_one($user_id); - if ($user) { + if ($user && $user->access_level != self::ACCESS_LEVEL_DISABLED) { $_SESSION["uid"] = $user_id; $_SESSION["auth_module"] = $auth_module; $_SESSION["name"] = $user->login; @@ -68,7 +83,7 @@ class UserHelper { $_SESSION["uid"] = 1; $_SESSION["name"] = "admin"; - $_SESSION["access_level"] = 10; + $_SESSION["access_level"] = self::ACCESS_LEVEL_ADMIN; $_SESSION["hide_hello"] = true; $_SESSION["hide_logout"] = true; @@ -84,7 +99,7 @@ class UserHelper { } } - static function load_user_plugins(int $owner_uid, PluginHost $pluginhost = null) { + static function load_user_plugins(int $owner_uid, PluginHost $pluginhost = null): void { if (!$pluginhost) $pluginhost = PluginHost::getInstance(); @@ -99,7 +114,7 @@ class UserHelper { } } - static function login_sequence() { + static function login_sequence(): void { $pdo = Db::pdo(); if (Config::get(Config::SINGLE_USER_MODE)) { @@ -144,7 +159,7 @@ class UserHelper { } } - static function print_user_stylesheet() { + static function print_user_stylesheet(): void { $value = get_pref(Prefs::USER_STYLESHEET); if ($value) { @@ -155,7 +170,7 @@ class UserHelper { } - static function get_user_ip() { + static function get_user_ip(): ?string { foreach (["HTTP_X_REAL_IP", "REMOTE_ADDR"] as $hdr) { if (isset($_SERVER[$hdr])) return $_SERVER[$hdr]; @@ -164,7 +179,7 @@ class UserHelper { return null; } - static function get_login_by_id(int $id) { + static function get_login_by_id(int $id): ?string { $user = ORM::for_table('ttrss_users') ->find_one($id); @@ -174,7 +189,7 @@ class UserHelper { return null; } - static function find_user_by_login(string $login) { + static function find_user_by_login(string $login): ?int { $user = ORM::for_table('ttrss_users') ->where('login', $login) ->find_one(); @@ -185,7 +200,7 @@ class UserHelper { return null; } - static function logout() { + static function logout(): void { if (session_status() === PHP_SESSION_ACTIVE) session_destroy(); @@ -196,11 +211,11 @@ class UserHelper { session_commit(); } - static function get_salt() { + static function get_salt(): string { return substr(bin2hex(get_random_bytes(125)), 0, 250); } - static function reset_password($uid, $format_output = false, $new_password = "") { + static function reset_password(int $uid, bool $format_output = false, string $new_password = ""): void { $user = ORM::for_table('ttrss_users')->find_one($uid); $message = ""; @@ -283,7 +298,7 @@ class UserHelper { } } - static function get_otp_secret(int $owner_uid, bool $show_if_enabled = false) { + static function get_otp_secret(int $owner_uid, bool $show_if_enabled = false): ?string { $user = ORM::for_table('ttrss_users')->find_one($owner_uid); if ($user) { @@ -318,7 +333,9 @@ class UserHelper { return null; } - static function is_default_password() { + static function is_default_password(): bool { + + /** @var Auth_Internal|false $authenticator -- this is only here to make check_password() visible to static analyzer */ $authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]); if ($authenticator && @@ -330,10 +347,12 @@ class UserHelper { return false; } - static function hash_password(string $pass, string $salt, string $algo = "") { - - if (!$algo) $algo = self::HASH_ALGOS[0]; - + /** + * @param string $algo should be one of UserHelper::HASH_ALGO_* + * + * @return false|string False if the password couldn't be hashed, otherwise the hash string. + */ + static function hash_password(string $pass, string $salt, string $algo = self::HASH_ALGOS[0]) { $pass_hash = ""; switch ($algo) { |