From 865ecc87963dc3b26e66296616eef2a1cc41ac3f Mon Sep 17 00:00:00 2001 From: Andrew Dolgov Date: Wed, 25 Oct 2023 12:55:09 +0300 Subject: move to psr-4 autoloader --- backend.php | 2 +- classes/API.php | 967 ++++++++++++++ classes/Article.php | 730 ++++++++++ classes/Auth_Base.php | 59 + classes/Cache_Adapter.php | 36 + classes/Cache_Local.php | 148 +++ classes/Config.php | 704 ++++++++++ classes/Counters.php | 362 +++++ classes/Db.php | 102 ++ classes/Db_Migrations.php | 203 +++ classes/Db_Prefs.php | 18 + classes/Debug.php | 161 +++ classes/Digest.php | 209 +++ classes/DiskCache.php | 492 +++++++ classes/Errors.php | 40 + classes/FeedEnclosure.php | 21 + classes/FeedItem.php | 24 + classes/FeedItem_Atom.php | 224 ++++ classes/FeedItem_Common.php | 221 +++ classes/FeedItem_RSS.php | 169 +++ classes/FeedParser.php | 245 ++++ classes/Feeds.php | 2507 +++++++++++++++++++++++++++++++++++ classes/Handler.php | 35 + classes/Handler_Administrative.php | 11 + classes/Handler_Protected.php | 7 + classes/Handler_Public.php | 838 ++++++++++++ classes/IAuthModule.php | 18 + classes/ICatchall.php | 4 + classes/IHandler.php | 6 + classes/IVirtualFeed.php | 11 + classes/Labels.php | 233 ++++ classes/Logger.php | 89 ++ classes/Logger_Adapter.php | 4 + classes/Logger_SQL.php | 61 + classes/Logger_Stdout.php | 31 + classes/Logger_Syslog.php | 31 + classes/Mailer.php | 65 + classes/OPML.php | 703 ++++++++++ classes/Plugin.php | 709 ++++++++++ classes/PluginHandler.php | 29 + classes/PluginHost.php | 948 +++++++++++++ classes/Pref_Feeds.php | 1301 ++++++++++++++++++ classes/Pref_Filters.php | 966 ++++++++++++++ classes/Pref_Labels.php | 225 ++++ classes/Pref_Prefs.php | 1603 ++++++++++++++++++++++ classes/Pref_System.php | 225 ++++ classes/Pref_Users.php | 294 ++++ classes/Prefs.php | 431 ++++++ classes/RPC.php | 846 ++++++++++++ classes/RSSUtils.php | 2085 +++++++++++++++++++++++++++++ classes/Sanitizer.php | 238 ++++ classes/Templator.php | 21 + classes/TimeHelper.php | 89 ++ classes/Tracer.php | 216 +++ classes/UrlHelper.php | 656 +++++++++ classes/UserHelper.php | 520 ++++++++ classes/api.php | 967 -------------- classes/article.php | 730 ---------- classes/auth/base.php | 59 - classes/cache/adapter.php | 36 - classes/cache/local.php | 148 --- classes/config.php | 704 ---------- classes/counters.php | 362 ----- classes/db.php | 102 -- classes/db/migrations.php | 203 --- classes/db/prefs.php | 18 - classes/debug.php | 161 --- classes/digest.php | 209 --- classes/diskcache.php | 492 ------- classes/errors.php | 40 - classes/feedenclosure.php | 21 - classes/feeditem.php | 24 - classes/feeditem/atom.php | 224 ---- classes/feeditem/common.php | 221 --- classes/feeditem/rss.php | 169 --- classes/feedparser.php | 245 ---- classes/feeds.php | 2507 ----------------------------------- classes/handler.php | 35 - classes/handler/administrative.php | 11 - classes/handler/protected.php | 7 - classes/handler/public.php | 838 ------------ classes/iauthmodule.php | 18 - classes/icatchall.php | 4 - classes/ihandler.php | 6 - classes/ivirtualfeed.php | 11 - classes/labels.php | 233 ---- classes/logger.php | 89 -- classes/logger/adapter.php | 4 - classes/logger/sql.php | 61 - classes/logger/stdout.php | 31 - classes/logger/syslog.php | 31 - classes/mailer.php | 65 - classes/opml.php | 703 ---------- classes/plugin.php | 709 ---------- classes/pluginhandler.php | 29 - classes/pluginhost.php | 948 ------------- classes/pref/feeds.php | 1301 ------------------ classes/pref/filters.php | 966 -------------- classes/pref/labels.php | 225 ---- classes/pref/prefs.php | 1603 ---------------------- classes/pref/system.php | 225 ---- classes/pref/users.php | 294 ---- classes/prefs.php | 431 ------ classes/rpc.php | 846 ------------ classes/rssutils.php | 2085 ----------------------------- classes/sanitizer.php | 238 ---- classes/templator.php | 21 - classes/timehelper.php | 89 -- classes/tracer.php | 216 --- classes/urlhelper.php | 656 --------- classes/userhelper.php | 520 -------- composer.json | 5 + include/autoload.php | 15 - js/App.js | 28 +- js/Article.js | 8 +- js/CommonDialogs.js | 24 +- js/CommonFilters.js | 16 +- js/FeedTree.js | 4 +- js/Feeds.js | 16 +- js/Headlines.js | 20 +- js/PrefFeedStore.js | 2 +- js/PrefFeedTree.js | 30 +- js/PrefFilterStore.js | 2 +- js/PrefFilterTree.js | 8 +- js/PrefHelpers.js | 46 +- js/PrefLabelTree.js | 12 +- js/PrefUsers.js | 14 +- prefs.php | 12 +- vendor/composer/autoload_psr4.php | 1 + vendor/composer/autoload_static.php | 5 + 130 files changed, 21324 insertions(+), 21328 deletions(-) create mode 100644 classes/API.php create mode 100644 classes/Article.php create mode 100644 classes/Auth_Base.php create mode 100644 classes/Cache_Adapter.php create mode 100644 classes/Cache_Local.php create mode 100644 classes/Config.php create mode 100644 classes/Counters.php create mode 100644 classes/Db.php create mode 100644 classes/Db_Migrations.php create mode 100644 classes/Db_Prefs.php create mode 100644 classes/Debug.php create mode 100644 classes/Digest.php create mode 100644 classes/DiskCache.php create mode 100644 classes/Errors.php create mode 100644 classes/FeedEnclosure.php create mode 100644 classes/FeedItem.php create mode 100644 classes/FeedItem_Atom.php create mode 100644 classes/FeedItem_Common.php create mode 100644 classes/FeedItem_RSS.php create mode 100644 classes/FeedParser.php create mode 100644 classes/Feeds.php create mode 100644 classes/Handler.php create mode 100644 classes/Handler_Administrative.php create mode 100644 classes/Handler_Protected.php create mode 100644 classes/Handler_Public.php create mode 100644 classes/IAuthModule.php create mode 100644 classes/ICatchall.php create mode 100644 classes/IHandler.php create mode 100644 classes/IVirtualFeed.php create mode 100644 classes/Labels.php create mode 100644 classes/Logger.php create mode 100644 classes/Logger_Adapter.php create mode 100644 classes/Logger_SQL.php create mode 100644 classes/Logger_Stdout.php create mode 100644 classes/Logger_Syslog.php create mode 100644 classes/Mailer.php create mode 100644 classes/OPML.php create mode 100644 classes/Plugin.php create mode 100644 classes/PluginHandler.php create mode 100644 classes/PluginHost.php create mode 100644 classes/Pref_Feeds.php create mode 100644 classes/Pref_Filters.php create mode 100644 classes/Pref_Labels.php create mode 100644 classes/Pref_Prefs.php create mode 100644 classes/Pref_System.php create mode 100644 classes/Pref_Users.php create mode 100644 classes/Prefs.php create mode 100644 classes/RPC.php create mode 100644 classes/RSSUtils.php create mode 100644 classes/Sanitizer.php create mode 100644 classes/Templator.php create mode 100644 classes/TimeHelper.php create mode 100644 classes/Tracer.php create mode 100644 classes/UrlHelper.php create mode 100644 classes/UserHelper.php delete mode 100755 classes/api.php delete mode 100755 classes/article.php delete mode 100644 classes/auth/base.php delete mode 100644 classes/cache/adapter.php delete mode 100644 classes/cache/local.php delete mode 100644 classes/config.php delete mode 100644 classes/counters.php delete mode 100755 classes/db.php delete mode 100644 classes/db/migrations.php delete mode 100644 classes/db/prefs.php delete mode 100644 classes/debug.php delete mode 100644 classes/digest.php delete mode 100644 classes/diskcache.php delete mode 100644 classes/errors.php delete mode 100644 classes/feedenclosure.php delete mode 100644 classes/feeditem.php delete mode 100755 classes/feeditem/atom.php delete mode 100755 classes/feeditem/common.php delete mode 100755 classes/feeditem/rss.php delete mode 100644 classes/feedparser.php delete mode 100755 classes/feeds.php delete mode 100644 classes/handler.php delete mode 100644 classes/handler/administrative.php delete mode 100644 classes/handler/protected.php delete mode 100755 classes/handler/public.php delete mode 100644 classes/iauthmodule.php delete mode 100644 classes/icatchall.php delete mode 100644 classes/ihandler.php delete mode 100644 classes/ivirtualfeed.php delete mode 100644 classes/labels.php delete mode 100755 classes/logger.php delete mode 100644 classes/logger/adapter.php delete mode 100755 classes/logger/sql.php delete mode 100644 classes/logger/stdout.php delete mode 100644 classes/logger/syslog.php delete mode 100644 classes/mailer.php delete mode 100644 classes/opml.php delete mode 100644 classes/plugin.php delete mode 100644 classes/pluginhandler.php delete mode 100755 classes/pluginhost.php delete mode 100755 classes/pref/feeds.php delete mode 100755 classes/pref/filters.php delete mode 100644 classes/pref/labels.php delete mode 100644 classes/pref/prefs.php delete mode 100644 classes/pref/system.php delete mode 100644 classes/pref/users.php delete mode 100644 classes/prefs.php delete mode 100755 classes/rpc.php delete mode 100755 classes/rssutils.php delete mode 100644 classes/sanitizer.php delete mode 100644 classes/templator.php delete mode 100644 classes/timehelper.php delete mode 100644 classes/tracer.php delete mode 100644 classes/urlhelper.php delete mode 100644 classes/userhelper.php diff --git a/backend.php b/backend.php index c316bcc44..44d4284c3 100644 --- a/backend.php +++ b/backend.php @@ -112,7 +112,7 @@ $op = "pluginhandler"; } */ - $op = str_replace("-", "_", $op); + // $op = str_replace(, "_", $op); $override = PluginHost::getInstance()->lookup_handler($op, $method); diff --git a/classes/API.php b/classes/API.php new file mode 100644 index 000000000..3a3ae0e63 --- /dev/null +++ b/classes/API.php @@ -0,0 +1,967 @@ + $reply + */ + private function _wrap(int $status, array $reply): bool { + print json_encode([ + "seq" => $this->seq, + "status" => $status, + "content" => $reply + ]); + return true; + } + + function before(string $method): bool { + if (parent::before($method)) { + header("Content-Type: text/json"); + + if (empty($_SESSION["uid"]) && $method != "login" && $method != "isloggedin") { + $this->_wrap(self::STATUS_ERR, array("error" => self::E_NOT_LOGGED_IN)); + return false; + } + + if (!empty($_SESSION["uid"]) && $method != "logout" && !get_pref(Prefs::ENABLE_API_ACCESS)) { + $this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED)); + return false; + } + + $this->seq = (int) clean($_REQUEST['seq'] ?? 0); + + return true; + } + return false; + } + + function getVersion(): bool { + $rv = array("version" => Config::get_version()); + return $this->_wrap(self::STATUS_OK, $rv); + } + + function getApiLevel(): bool { + $rv = array("level" => self::API_LEVEL); + return $this->_wrap(self::STATUS_OK, $rv); + } + + function login(): bool { + + if (session_status() == PHP_SESSION_ACTIVE) { + session_destroy(); + } + + session_start(); + + $login = clean($_REQUEST["user"]); + $password = clean($_REQUEST["password"]); + + if (Config::get(Config::SINGLE_USER_MODE)) $login = "admin"; + + if ($uid = UserHelper::find_user_by_login($login)) { + if (get_pref(Prefs::ENABLE_API_ACCESS, $uid)) { + if (UserHelper::authenticate($login, $password, false, Auth_Base::AUTH_SERVICE_API)) { + + // needed for _get_config() + UserHelper::load_user_plugins($_SESSION['uid']); + + return $this->_wrap(self::STATUS_OK, array("session_id" => session_id(), + "config" => $this->_get_config(), + "api_level" => self::API_LEVEL)); + } else { + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR)); + } + } else { + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED)); + } + } + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR)); + } + + function logout(): bool { + UserHelper::logout(); + return $this->_wrap(self::STATUS_OK, array("status" => "OK")); + } + + function isLoggedIn(): bool { + return $this->_wrap(self::STATUS_OK, array("status" => (bool)($_SESSION["uid"] ?? ''))); + } + + function getUnread(): bool { + $feed_id = clean($_REQUEST["feed_id"] ?? ""); + $is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false); + + if ($feed_id) { + return $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_counters($feed_id, $is_cat, true))); + } else { + return $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_global_unread())); + } + } + + /* Method added for ttrss-reader for Android */ + function getCounters(): bool { + return $this->_wrap(self::STATUS_OK, Counters::get_all()); + } + + 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($_REQUEST["include_nested"] ?? false); + + $feeds = $this->_api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested); + + return $this->_wrap(self::STATUS_OK, $feeds); + } + + 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 + + $categories = ORM::for_table('ttrss_feed_categories') + ->select_many('id', 'title', 'order_id') + ->select_many_expr([ + 'num_feeds' => '(SELECT COUNT(id) FROM ttrss_feeds WHERE ttrss_feed_categories.id IS NOT NULL AND cat_id = ttrss_feed_categories.id)', + 'num_cats' => '(SELECT COUNT(id) FROM ttrss_feed_categories AS c2 WHERE c2.parent_cat = ttrss_feed_categories.id)', + ]) + ->where('owner_uid', $_SESSION['uid']); + + if ($enable_nested) { + $categories->where_null('parent_cat'); + } + + $cats = []; + + foreach ($categories->find_many() as $category) { + if ($include_empty || $category->num_feeds > 0 || $category->num_cats > 0) { + $unread = Feeds::_get_counters($category->id, true, true); + + if ($enable_nested) + $unread += Feeds::_get_cat_children_unread($category->id); + + if ($unread || !$unread_only) { + array_push($cats, [ + 'id' => (int) $category->id, + 'title' => $category->title, + 'unread' => (int) $unread, + 'order_id' => (int) $category->order_id, + ]); + } + } + } + + foreach ([Feeds::CATEGORY_LABELS, Feeds::CATEGORY_SPECIAL, Feeds::CATEGORY_UNCATEGORIZED] as $cat_id) { + if ($include_empty || !$this->_is_cat_empty($cat_id)) { + $unread = Feeds::_get_counters($cat_id, true, true); + + if ($unread || !$unread_only) { + array_push($cats, [ + 'id' => $cat_id, + 'title' => Feeds::_get_cat_title($cat_id), + 'unread' => (int) $unread, + ]); + } + } + } + + return $this->_wrap(self::STATUS_OK, $cats); + } + + 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($_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($_REQUEST["include_attachments"] ?? false); + $since_id = (int)clean($_REQUEST["since_id"] ?? 0); + $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($_REQUEST["include_header"] ?? false); + + $_SESSION['hasSandbox'] = $has_sandbox; + + list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query(clean($_REQUEST["order_by"] ?? "")); + + /* do not rely on params below */ + + $search = clean($_REQUEST["search"] ?? ""); + + list($headlines, $headlines_header) = $this->_api_get_headlines($feed_id, $limit, $offset, + $filter, $is_cat, $show_excerpt, $show_content, $view_mode, $override_order, + $include_attachments, $since_id, $search, + $include_nested, $sanitize_content, $force_update, $excerpt_length, $check_first_id, $skip_first_id_check); + + if ($include_header) { + return $this->_wrap(self::STATUS_OK, array($headlines_header, $headlines)); + } else { + return $this->_wrap(self::STATUS_OK, $headlines); + } + } else { + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); + } + } + + function updateArticle(): bool { + $article_ids = explode(",", clean($_REQUEST["article_ids"])); + $mode = (int) clean($_REQUEST["mode"]); + $data = clean($_REQUEST["data"] ?? ""); + $field_raw = (int)clean($_REQUEST["field"]); + + $field = ""; + $set_to = ""; + $additional_fields = ""; + + switch ($field_raw) { + case 0: + $field = "marked"; + $additional_fields = ",last_marked = NOW()"; + break; + case 1: + $field = "published"; + $additional_fields = ",last_published = NOW()"; + break; + case 2: + $field = "unread"; + $additional_fields = ",last_read = NOW()"; + break; + case 3: + $field = "note"; + break; + case 4: + $field = "score"; + break; + }; + + switch ($mode) { + case 1: + $set_to = "true"; + break; + case 0: + $set_to = "false"; + break; + case 2: + $set_to = "NOT $field"; + break; + } + + if ($field == "note") $set_to = $this->pdo->quote($data); + if ($field == "score") $set_to = (int) $data; + + if ($field && $set_to && count($article_ids) > 0) { + + $article_qmarks = arr_qmarks($article_ids); + + $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET + $field = $set_to $additional_fields + WHERE ref_id IN ($article_qmarks) AND owner_uid = ?"); + $sth->execute([...$article_ids, $_SESSION['uid']]); + + $num_updated = $sth->rowCount(); + + return $this->_wrap(self::STATUS_OK, array("status" => "OK", + "updated" => $num_updated)); + + } else { + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); + } + } + + 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') + ->select_many('e.id', 'e.guid', 'e.title', 'e.link', 'e.author', 'e.content', 'e.lang', 'e.comments', + 'ue.feed_id', 'ue.int_id', 'ue.marked', 'ue.unread', 'ue.published', 'ue.score', 'ue.note') + ->select_many_expr([ + 'updated' => SUBSTRING_FOR_DATE.'(updated,1,16)', + 'feed_title' => '(SELECT title FROM ttrss_feeds WHERE id = ue.feed_id)', + 'site_url' => '(SELECT site_url FROM ttrss_feeds WHERE id = ue.feed_id)', + 'hide_images' => '(SELECT hide_images FROM ttrss_feeds WHERE id = feed_id)', + ]) + ->join('ttrss_user_entries', [ 'ue.ref_id', '=', 'e.id'], 'ue') + ->where_in('e.id', array_map('intval', $article_ids)) + ->where('ue.owner_uid', $_SESSION['uid']) + ->find_many(); + + $articles = []; + + foreach ($entries as $entry) { + $article = [ + 'id' => $entry->id, + 'guid' => $entry->guid, + 'title' => $entry->title, + 'link' => $entry->link, + 'labels' => Article::_get_labels($entry->id), + 'unread' => self::_param_to_bool($entry->unread), + 'marked' => self::_param_to_bool($entry->marked), + 'published' => self::_param_to_bool($entry->published), + 'comments' => $entry->comments, + 'author' => $entry->author, + 'updated' => (int) strtotime($entry->updated ?? ''), + 'feed_id' => $entry->feed_id, + 'attachments' => Article::_get_enclosures($entry->id), + 'score' => (int) $entry->score, + 'feed_title' => $entry->feed_title, + 'note' => $entry->note, + 'lang' => $entry->lang, + ]; + + if ($sanitize_content) { + $article['content'] = Sanitizer::sanitize( + $entry->content, + self::_param_to_bool($entry->hide_images), + null, $entry->site_url, null, $entry->id); + } else { + $article['content'] = $entry->content; + } + + $hook_object = ['article' => &$article]; + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API, + function ($result) use (&$article) { + $article = $result; + }, + $hook_object); + + $article['content'] = DiskCache::rewrite_urls($article['content']); + + array_push($articles, $article); + } + + return $this->_wrap(self::STATUS_OK, $articles); + } else { + return $this->_wrap(self::STATUS_ERR, ['error' => self::E_INCORRECT_USAGE]); + } + } + + /** + * @return array|bool|int|string> + */ + private function _get_config(): array { + $config = [ + "icons_dir" => Config::get(Config::ICONS_DIR), + "icons_url" => Config::get(Config::ICONS_URL) + ]; + + $config["daemon_is_running"] = file_is_locked("update_daemon.lock"); + $config["custom_sort_types"] = $this->_get_custom_sort_types(); + + $config["num_feeds"] = ORM::for_table('ttrss_feeds') + ->where('owner_uid', $_SESSION['uid']) + ->count(); + + return $config; + } + + function getConfig(): bool { + $config = $this->_get_config(); + + return $this->_wrap(self::STATUS_OK, $config); + } + + function updateFeed(): bool { + $feed_id = (int) clean($_REQUEST["feed_id"]); + + if (!ini_get("open_basedir")) { + RSSUtils::update_rss_feed($feed_id); + } + + return $this->_wrap(self::STATUS_OK, array("status" => "OK")); + } + + function catchupFeed(): bool { + $feed_id = clean($_REQUEST["feed_id"]); + $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); + + return $this->_wrap(self::STATUS_OK, array("status" => "OK")); + } + + function getPref(): bool { + $pref_name = clean($_REQUEST["pref_name"]); + + return $this->_wrap(self::STATUS_OK, array("value" => get_pref($pref_name))); + } + + function getLabels(): bool { + $article_id = (int)clean($_REQUEST['article_id'] ?? -1); + + $rv = []; + + $labels = ORM::for_table('ttrss_labels2') + ->where('owner_uid', $_SESSION['uid']) + ->order_by_asc('caption') + ->find_many(); + + if ($article_id) + $article_labels = Article::_get_labels($article_id); + else + $article_labels = []; + + foreach ($labels as $label) { + $checked = false; + foreach ($article_labels as $al) { + if (Labels::feed_to_label_id($al[0]) == $label->id) { + $checked = true; + break; + } + } + + array_push($rv, [ + 'id' => (int) Labels::label_to_feed_id($label->id), + 'caption' => $label->caption, + 'fg_color' => $label->fg_color, + 'bg_color' => $label->bg_color, + 'checked' => $checked, + ]); + } + + return $this->_wrap(self::STATUS_OK, $rv); + } + + function setArticleLabel(): bool { + + $article_ids = explode(",", clean($_REQUEST["article_ids"])); + $label_id = (int) clean($_REQUEST['label_id']); + $assign = self::_param_to_bool(clean($_REQUEST['assign'])); + + $label = Labels::find_caption(Labels::feed_to_label_id($label_id), $_SESSION["uid"]); + + $num_updated = 0; + + if ($label) { + + foreach ($article_ids as $id) { + + if ($assign) + Labels::add_article((int)$id, $label, $_SESSION["uid"]); + else + Labels::remove_article((int)$id, $label, $_SESSION["uid"]); + + ++$num_updated; + + } + } + + return $this->_wrap(self::STATUS_OK, array("status" => "OK", + "updated" => $num_updated)); + + } + + function index(string $method): bool { + $plugin = PluginHost::getInstance()->get_api_method(strtolower($method)); + + if ($plugin && method_exists($plugin, $method)) { + $reply = $plugin->$method(); + + return $this->_wrap($reply[0], $reply[1]); + + } else { + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_UNKNOWN_METHOD, "method" => $method)); + } + } + + function shareToPublished(): bool { + $title = clean($_REQUEST["title"]); + $url = clean($_REQUEST["url"]); + $sanitize_content = self::_param_to_bool($_REQUEST["sanitize"] ?? true); + + if ($sanitize_content) + $content = clean($_REQUEST["content"]); + else + $content = $_REQUEST["content"]; + + if (Article::_create_published_article($title, $url, $content, "", $_SESSION["uid"])) { + return $this->_wrap(self::STATUS_OK, array("status" => 'OK')); + } else { + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED)); + } + } + + /** + * @return array + */ + private static function _api_get_feeds(int $cat_id, bool $unread_only, int $limit, int $offset, bool $include_nested = false): array { + $feeds = []; + + /* Labels */ + + /* API only: -4 (Feeds::CATEGORY_ALL) All feeds, including virtual feeds */ + if ($cat_id == Feeds::CATEGORY_ALL || $cat_id == Feeds::CATEGORY_LABELS) { + $counters = Counters::get_labels(); + + foreach (array_values($counters) as $cv) { + $unread = $cv['counter']; + + if ($unread || !$unread_only) { + $row = [ + 'id' => (int) $cv['id'], + 'title' => $cv['description'], + 'unread' => $cv['counter'], + 'cat_id' => Feeds::CATEGORY_LABELS, + ]; + + array_push($feeds, $row); + } + } + } + + /* Virtual feeds */ + + $vfeeds = PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL); + + if (is_array($vfeeds)) { + foreach ($vfeeds as $feed) { + if (!implements_interface($feed['sender'], 'IVirtualFeed')) + continue; + + /** @var IVirtualFeed $feed['sender'] */ + $unread = $feed['sender']->get_unread($feed['id']); + + if ($unread || !$unread_only) { + $row = [ + 'id' => PluginHost::pfeed_to_feed_id($feed['id']), + 'title' => $feed['title'], + 'unread' => $unread, + 'cat_id' => Feeds::CATEGORY_SPECIAL, + ]; + + array_push($feeds, $row); + } + } + } + + if ($cat_id == Feeds::CATEGORY_ALL || $cat_id == Feeds::CATEGORY_SPECIAL) { + foreach ([Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED, Feeds::FEED_FRESH, + Feeds::FEED_ALL, Feeds::FEED_RECENTLY_READ, Feeds::FEED_ARCHIVED] as $i) { + $unread = Feeds::_get_counters($i, false, true); + + if ($unread || !$unread_only) { + $title = Feeds::_get_title($i); + + $row = [ + 'id' => $i, + 'title' => $title, + 'unread' => $unread, + 'cat_id' => Feeds::CATEGORY_SPECIAL, + ]; + + array_push($feeds, $row); + } + } + } + + /* Child cats */ + + if ($include_nested && $cat_id) { + $categories = ORM::for_table('ttrss_feed_categories') + ->where(['parent_cat' => $cat_id, 'owner_uid' => $_SESSION['uid']]) + ->order_by_asc('order_id') + ->order_by_asc('title') + ->find_many(); + + foreach ($categories as $category) { + $unread = Feeds::_get_counters($category->id, true, true) + + Feeds::_get_cat_children_unread($category->id); + + if ($unread || !$unread_only) { + $row = [ + 'id' => (int) $category->id, + 'title' => $category->title, + 'unread' => $unread, + 'is_cat' => true, + 'order_id' => (int) $category->order_id, + ]; + array_push($feeds, $row); + } + } + } + + /* Real feeds */ + + /* API only: -3 (Feeds::CATEGORY_ALL_EXCEPT_VIRTUAL) All feeds, excluding virtual feeds (e.g. Labels and such) */ + $feeds_obj = ORM::for_table('ttrss_feeds') + ->select_many('id', 'feed_url', 'cat_id', 'title', 'order_id') + ->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'); + + if ($limit) $feeds_obj->limit($limit); + if ($offset) $feeds_obj->offset($offset); + + if ($cat_id != Feeds::CATEGORY_ALL_EXCEPT_VIRTUAL && $cat_id != Feeds::CATEGORY_ALL) { + $feeds_obj->where_raw('(cat_id = ? OR (? = 0 AND cat_id IS NULL))', [$cat_id, $cat_id]); + } + + foreach ($feeds_obj->find_many() as $feed) { + $unread = Feeds::_get_counters($feed->id, false, true); + $has_icon = Feeds::_has_icon($feed->id); + + if ($unread || !$unread_only) { + $row = [ + 'feed_url' => $feed->feed_url, + 'title' => $feed->title, + 'id' => (int) $feed->id, + 'unread' => (int) $unread, + 'has_icon' => $has_icon, + 'cat_id' => (int) $feed->cat_id, + 'last_updated' => (int) strtotime($feed->last_updated ?? ''), + 'order_id' => (int) $feed->order_id, + ]; + + array_push($feeds, $row); + } + } + + return $feeds; + } + + /** + * @param string|int $feed_id + * @return array{0: array>, 1: array} $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') + ->select_many('id', 'cache_images') + ->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated') + ->find_one($feed_id); + + if ($feed) { + $last_updated = strtotime($feed->last_updated ?? ''); + $cache_images = self::_param_to_bool($feed->cache_images); + + if (!$cache_images && time() - $last_updated > 120) { + RSSUtils::update_rss_feed($feed_id, true); + } else { + $feed->last_updated = '1970-01-01'; + $feed->last_update_started = '1970-01-01'; + $feed->save(); + } + } + } + + $params = array( + "feed" => $feed_id, + "limit" => $limit, + "view_mode" => $view_mode, + "cat_view" => $is_cat, + "search" => $search, + "override_order" => $order, + "offset" => $offset, + "since_id" => $since_id, + "include_children" => $include_nested, + "check_first_id" => $check_first_id, + "skip_first_id_check" => $skip_first_id_check + ); + + $qfh_ret = []; + + if (!$is_cat && is_numeric($feed_id) && $feed_id < PLUGIN_FEED_BASE_INDEX && $feed_id > LABEL_BASE_INDEX) { + $pfeed_id = PluginHost::feed_to_pfeed_id($feed_id); + + /** @var IVirtualFeed|false $handler */ + $handler = PluginHost::getInstance()->get_feed_handler($pfeed_id); + + if ($handler) { + $params = array( + "feed" => $feed_id, + "limit" => $limit, + "view_mode" => $view_mode, + "cat_view" => $is_cat, + "search" => $search, + "override_order" => $order, + "offset" => $offset, + "since_id" => 0, + "include_children" => $include_nested, + "check_first_id" => $check_first_id, + "skip_first_id_check" => $skip_first_id_check + ); + + $qfh_ret = $handler->get_headlines($pfeed_id, $params); + } + + } else { + + $params = array( + "feed" => $feed_id, + "limit" => $limit, + "view_mode" => $view_mode, + "cat_view" => $is_cat, + "search" => $search, + "override_order" => $order, + "offset" => $offset, + "since_id" => $since_id, + "include_children" => $include_nested, + "check_first_id" => $check_first_id, + "skip_first_id_check" => $skip_first_id_check + ); + + $qfh_ret = Feeds::_get_headlines($params); + } + + $result = $qfh_ret[0]; + $feed_title = $qfh_ret[1]; + $first_id = $qfh_ret[6]; + + $headlines = array(); + + $headlines_header = array( + 'id' => $feed_id, + 'first_id' => $first_id, + 'is_cat' => $is_cat); + + if (!is_numeric($result)) { + while ($line = $result->fetch()) { + $line["content_preview"] = truncate_string(strip_tags($line["content"]), $excerpt_length); + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES, + function ($result) use (&$line) { + $line = $result; + }, + $line, $excerpt_length); + + $is_updated = ($line["last_read"] == "" && + ($line["unread"] != "t" && $line["unread"] != "1")); + + $tags = explode(",", $line["tag_cache"]); + + $label_cache = $line["label_cache"]; + $labels = false; + + if ($label_cache) { + $label_cache = json_decode($label_cache, true); + + if ($label_cache) { + if (($label_cache["no-labels"] ?? 0) == 1) + $labels = []; + else + $labels = $label_cache; + } + } + + if (!is_array($labels)) $labels = Article::_get_labels($line["id"]); + + $headline_row = array( + "id" => (int)$line["id"], + "guid" => $line["guid"], + "unread" => self::_param_to_bool($line["unread"]), + "marked" => self::_param_to_bool($line["marked"]), + "published" => self::_param_to_bool($line["published"]), + "updated" => (int)strtotime($line["updated"] ?? ''), + "is_updated" => $is_updated, + "title" => $line["title"], + "link" => $line["link"], + "feed_id" => $line["feed_id"] ? $line['feed_id'] : 0, + "tags" => $tags, + ); + + $enclosures = Article::_get_enclosures($line['id']); + + if ($include_attachments) + $headline_row['attachments'] = $enclosures; + + if ($show_excerpt) + $headline_row["excerpt"] = $line["content_preview"]; + + if ($show_content) { + + if ($sanitize_content) { + $headline_row["content"] = Sanitizer::sanitize( + $line["content"], + self::_param_to_bool($line['hide_images']), + null, $line["site_url"], null, $line["id"]); + } else { + $headline_row["content"] = $line["content"]; + } + } + + // unify label output to ease parsing + if (($labels["no-labels"] ?? 0) == 1) $labels = []; + + $headline_row["labels"] = $labels; + + $headline_row["feed_title"] = isset($line["feed_title"]) ? $line["feed_title"] : $feed_title; + + $headline_row["comments_count"] = (int)$line["num_comments"]; + $headline_row["comments_link"] = $line["comments"]; + + $headline_row["always_display_attachments"] = self::_param_to_bool($line["always_display_enclosures"]); + + $headline_row["author"] = $line["author"]; + + $headline_row["score"] = (int)$line["score"]; + $headline_row["note"] = $line["note"]; + $headline_row["lang"] = $line["lang"]; + + $headline_row["site_url"] = $line["site_url"]; + + if ($show_content) { + $hook_object = ["headline" => &$headline_row]; + + list ($flavor_image, $flavor_stream, $flavor_kind) = Article::_get_image($enclosures, + $line["content"], // unsanitized + $line["site_url"] ?? "", // could be null if archived article + $headline_row); + + $headline_row["flavor_image"] = $flavor_image; + $headline_row["flavor_stream"] = $flavor_stream; + + /* optional */ + if ($flavor_kind) + $headline_row["flavor_kind"] = $flavor_kind; + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API, + function ($result) use (&$headline_row) { + $headline_row = $result; + }, + $hook_object); + + $headline_row["content"] = DiskCache::rewrite_urls($headline_row['content']); + } + + array_push($headlines, $headline_row); + } + } else if (is_numeric($result) && $result == -1) { + $headlines_header['first_id_changed'] = true; + } + + return array($headlines, $headlines_header); + } + + function unsubscribeFeed(): bool { + $feed_id = (int) clean($_REQUEST["feed_id"]); + + $feed_exists = ORM::for_table('ttrss_feeds') + ->where(['id' => $feed_id, 'owner_uid' => $_SESSION['uid']]) + ->count(); + + if ($feed_exists) { + Pref_Feeds::remove_feed($feed_id, $_SESSION['uid']); + return $this->_wrap(self::STATUS_OK, ['status' => 'OK']); + } else { + return $this->_wrap(self::STATUS_ERR, ['error' => self::E_OPERATION_FAILED]); + } + } + + function subscribeToFeed(): bool { + $feed_url = clean($_REQUEST["feed_url"]); + $category_id = (int) clean($_REQUEST["category_id"]); + $login = clean($_REQUEST["login"] ?? ""); + $password = clean($_REQUEST["password"] ?? ""); + + if ($feed_url) { + $rc = Feeds::_subscribe($feed_url, $category_id, $login, $password); + + return $this->_wrap(self::STATUS_OK, array("status" => $rc)); + } else { + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); + } + } + + function getFeedTree(): bool { + $include_empty = self::_param_to_bool($_REQUEST['include_empty'] ?? false); + + $pf = new Pref_Feeds($_REQUEST); + + $_REQUEST['mode'] = 2; + $_REQUEST['force_show_empty'] = $include_empty; + + return $this->_wrap(self::STATUS_OK, + array("categories" => $pf->_makefeedtree())); + } + + function getFeedIcon(): bool { + $id = (int)$_REQUEST['id']; + $cache = DiskCache::instance('feed-icons'); + + if ($cache->exists((string)$id)) { + return $cache->send((string)$id) > 0; + } else { + return $this->_wrap(self::STATUS_ERR, array("error" => self::E_NOT_FOUND)); + } + } + + // only works for labels or uncategorized for the time being + private function _is_cat_empty(int $id): bool { + if ($id == Feeds::CATEGORY_LABELS) { + $label_count = ORM::for_table('ttrss_labels2') + ->where('owner_uid', $_SESSION['uid']) + ->count(); + + return $label_count == 0; + } else if ($id == Feeds::CATEGORY_UNCATEGORIZED) { + $uncategorized_count = ORM::for_table('ttrss_feeds') + ->where_null('cat_id') + ->where('owner_uid', $_SESSION['uid']) + ->count(); + + return $uncategorized_count == 0; + } + + return false; + } + + /** @return array */ + private function _get_custom_sort_types(): array { + $ret = []; + + PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP, function ($result) use (&$ret) { + foreach ($result as $sort_value => $sort_title) { + $ret[$sort_value] = $sort_title; + } + }); + + return $ret; + } +} diff --git a/classes/Article.php b/classes/Article.php new file mode 100644 index 000000000..0b446479b --- /dev/null +++ b/classes/Article.php @@ -0,0 +1,730 @@ +table_alias('e') + ->join('ttrss_user_entries', [ 'ref_id', '=', 'e.id'], 'ue') + ->where('ue.owner_uid', $_SESSION['uid']) + ->find_one((int)$_REQUEST['id']); + + if ($article) { + $article_url = UrlHelper::validate($article->link); + + if ($article_url) { + header("Location: $article_url"); + return; + } + } + + header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); + print "Article not found or has an empty URL."; + } + + 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 + + if (!$content) { + $pluginhost = new PluginHost(); + $pluginhost->load_all(PluginHost::KIND_ALL, $owner_uid); + //$pluginhost->load_data(); + + $pluginhost->run_hooks_callback(PluginHost::HOOK_GET_FULL_TEXT, + function ($result) use (&$content) { + if ($result) { + $content = $result; + return true; + } + }, + $url); + } + + $content_hash = sha1($content); + + if ($labels_str != "") { + $labels = explode(",", $labels_str); + } else { + $labels = array(); + } + + $rc = false; + + if (!$title) $title = $url; + if (!$title && !$url) return false; + + if (filter_var($url, FILTER_VALIDATE_URL) === false) return false; + + $pdo = Db::pdo(); + + $pdo->beginTransaction(); + + // only check for our user data here, others might have shared this with different content etc + $sth = $pdo->prepare("SELECT id FROM ttrss_entries, ttrss_user_entries WHERE + guid = ? AND ref_id = id AND owner_uid = ? LIMIT 1"); + $sth->execute([$guid, $owner_uid]); + + if ($row = $sth->fetch()) { + $ref_id = $row['id']; + + $sth = $pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE + ref_id = ? AND owner_uid = ? LIMIT 1"); + $sth->execute([$ref_id, $owner_uid]); + + if ($row = $sth->fetch()) { + $int_id = $row['int_id']; + + $sth = $pdo->prepare("UPDATE ttrss_entries SET + content = ?, content_hash = ? WHERE id = ?"); + $sth->execute([$content, $content_hash, $ref_id]); + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $sth = $pdo->prepare("UPDATE ttrss_entries + SET tsvector_combined = to_tsvector( :ts_content) + WHERE id = :id"); + $params = [ + ":ts_content" => mb_substr(\Soundasleep\Html2Text::convert($content), 0, 900000), + ":id" => $ref_id]; + $sth->execute($params); + } + + $sth = $pdo->prepare("UPDATE ttrss_user_entries SET published = true, + last_published = NOW() WHERE + int_id = ? AND owner_uid = ?"); + $sth->execute([$int_id, $owner_uid]); + + } else { + + $sth = $pdo->prepare("INSERT INTO ttrss_user_entries + (ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache, + last_read, note, unread, last_published) + VALUES + (?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())"); + $sth->execute([$ref_id, $owner_uid]); + } + + if (count($labels) != 0) { + foreach ($labels as $label) { + Labels::add_article($ref_id, trim($label), $owner_uid); + } + } + + $rc = true; + + } else { + $sth = $pdo->prepare("INSERT INTO ttrss_entries + (title, guid, link, updated, content, content_hash, date_entered, date_updated) + VALUES + (?, ?, ?, NOW(), ?, ?, NOW(), NOW())"); + $sth->execute([$title, $guid, $url, $content, $content_hash]); + + $sth = $pdo->prepare("SELECT id FROM ttrss_entries WHERE guid = ?"); + $sth->execute([$guid]); + + if ($row = $sth->fetch()) { + $ref_id = $row["id"]; + if (Config::get(Config::DB_TYPE) == "pgsql"){ + $sth = $pdo->prepare("UPDATE ttrss_entries + SET tsvector_combined = to_tsvector( :ts_content) + WHERE id = :id"); + $params = [ + ":ts_content" => mb_substr(\Soundasleep\Html2Text::convert($content), 0, 900000), + ":id" => $ref_id]; + $sth->execute($params); + } + $sth = $pdo->prepare("INSERT INTO ttrss_user_entries + (ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache, + last_read, note, unread, last_published) + VALUES + (?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())"); + $sth->execute([$ref_id, $owner_uid]); + + if (count($labels) != 0) { + foreach ($labels as $label) { + Labels::add_article($ref_id, trim($label), $owner_uid); + } + } + + $rc = true; + } + } + + $pdo->commit(); + + return $rc; + } + + function printArticleTags(): void { + $id = (int) clean($_REQUEST['id'] ?? 0); + + print json_encode(["id" => $id, + "tags" => self::_get_tags($id)]); + } + + function setScore(): void { + $ids = array_map("intval", clean($_REQUEST['ids'] ?? [])); + $score = (int)clean($_REQUEST['score']); + + $ids_qmarks = arr_qmarks($ids); + + $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET + score = ? WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); + + $sth->execute([$score, ...$ids, $_SESSION['uid']]); + + print json_encode(["id" => $ids, "score" => $score]); + } + + 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"] ?? ""))); + + $this->pdo->beginTransaction(); + + $sth = $this->pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE + ref_id = ? AND owner_uid = ? LIMIT 1"); + $sth->execute([$id, $_SESSION['uid']]); + + if ($row = $sth->fetch()) { + + $tags_to_cache = array(); + + $int_id = $row['int_id']; + + $dsth = $this->pdo->prepare("DELETE FROM ttrss_tags WHERE + post_int_id = ? AND owner_uid = ?"); + $dsth->execute([$int_id, $_SESSION['uid']]); + + $csth = $this->pdo->prepare("SELECT post_int_id FROM ttrss_tags + WHERE post_int_id = ? AND owner_uid = ? AND tag_name = ?"); + + $usth = $this->pdo->prepare("INSERT INTO ttrss_tags + (post_int_id, owner_uid, tag_name) + VALUES (?, ?, ?)"); + + foreach ($tags as $tag) { + $csth->execute([$int_id, $_SESSION['uid'], $tag]); + + if (!$csth->fetch()) { + $usth->execute([$int_id, $_SESSION['uid'], $tag]); + } + + array_push($tags_to_cache, $tag); + } + + /* update tag cache */ + + $tags_str = join(",", $tags_to_cache); + + $sth = $this->pdo->prepare("UPDATE ttrss_user_entries + SET tag_cache = ? WHERE ref_id = ? AND owner_uid = ?"); + $sth->execute([$tags_str, $id, $_SESSION['uid']]); + } + + $this->pdo->commit(); + + // get latest tags from the database, original $tags is sometimes JSON-encoded as a hash ({}) - ??? + print json_encode(["id" => (int)$id, "tags" => $this->_get_tags($id)]); + } + + function completeTags(): void { + $search = clean($_REQUEST["search"]); + + $sth = $this->pdo->prepare("SELECT DISTINCT tag_name FROM ttrss_tags + WHERE owner_uid = ? AND + tag_name LIKE ? ORDER BY tag_name + LIMIT 10"); + + $sth->execute([$_SESSION['uid'], "$search%"]); + + $results = []; + + while ($line = $sth->fetch()) { + array_push($results, $line["tag_name"]); + } + + print json_encode($results); + } + + function assigntolabel(): void { + $this->_label_ops(true); + } + + function removefromlabel(): void { + $this->_label_ops(false); + } + + private function _label_ops(bool $assign): void { + $reply = array(); + + $ids = array_map("intval", array_filter(explode(",", clean($_REQUEST["ids"] ?? "")), "strlen")); + $label_id = clean($_REQUEST["lid"]); + + $label = Labels::find_caption($label_id, $_SESSION["uid"]); + + $reply["labels-for"] = []; + + if ($label) { + foreach ($ids as $id) { + if ($assign) + Labels::add_article($id, $label, $_SESSION["uid"]); + else + Labels::remove_article($id, $label, $_SESSION["uid"]); + + array_push($reply["labels-for"], + ["id" => (int)$id, "labels" => $this->_get_labels($id)]); + } + } + + $reply["message"] = "UPDATE_COUNTERS"; + + print json_encode($reply); + } + + /** + * @param int $id article id + * @return array{'formatted': string, 'entries': array>} + */ + static function _format_enclosures(int $id, bool $always_display_enclosures, string $article_content, bool $hide_images = false): array { + $span = Tracer::start(__METHOD__); + + $enclosures = self::_get_enclosures($id); + $enclosures_formatted = ""; + + /*foreach ($enclosures as &$enc) { + array_push($enclosures, [ + "type" => $enc["content_type"], + "filename" => basename($enc["content_url"]), + "url" => $enc["content_url"], + "title" => $enc["title"], + "width" => (int) $enc["width"], + "height" => (int) $enc["height"] + ]); + }*/ + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_FORMAT_ENCLOSURES, + function ($result) use (&$enclosures_formatted, &$enclosures) { + if (is_array($result)) { + $enclosures_formatted = $result[0]; + $enclosures = $result[1]; + } else { + $enclosures_formatted = $result; + } + }, + $enclosures_formatted, $enclosures, $id, $always_display_enclosures, $article_content, $hide_images); + + if (!empty($enclosures_formatted)) { + $span->end(); + return [ + 'formatted' => $enclosures_formatted, + 'entries' => [] + ]; + } + + $rv = [ + 'formatted' => '', + 'entries' => [] + ]; + + $rv['can_inline'] = isset($_SESSION["uid"]) && + empty($_SESSION["bw_limit"]) && + !get_pref(Prefs::STRIP_IMAGES) && + ($always_display_enclosures || !preg_match("/chain_hooks_callback(PluginHost::HOOK_RENDER_ENCLOSURE, + function ($result) use (&$rendered_enc) { + $rendered_enc = $result; + }, + $enc, $id, $rv); + + if ($rendered_enc) { + $rv['formatted'] .= $rendered_enc; + } else { + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ENCLOSURE_ENTRY, + function ($result) use (&$enc) { + $enc = $result; + }, + $enc, $id, $rv); + + array_push($rv['entries'], $enc); + } + } + + $span->end(); + return $rv; + } + + /** + * @return array + */ + static function _get_tags(int $id, int $owner_uid = 0, ?string $tag_cache = null): array { + $span = Tracer::start(__METHOD__); + + $a_id = $id; + + if (!$owner_uid) $owner_uid = $_SESSION["uid"]; + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT DISTINCT tag_name, + owner_uid as owner FROM ttrss_tags + WHERE post_int_id = (SELECT int_id FROM ttrss_user_entries WHERE + ref_id = ? AND owner_uid = ? LIMIT 1) ORDER BY tag_name"); + + $tags = array(); + + /* check cache first */ + + 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 ($tag_cache) { + $tags = explode(",", $tag_cache); + } else { + + /* do it the hard way */ + + $sth->execute([$a_id, $owner_uid]); + + while ($tmp_line = $sth->fetch()) { + array_push($tags, $tmp_line["tag_name"]); + } + + /* update the cache */ + + $tags_str = join(",", $tags); + + $sth = $pdo->prepare("UPDATE ttrss_user_entries + SET tag_cache = ? WHERE ref_id = ? + AND owner_uid = ?"); + $sth->execute([$tags_str, $id, $owner_uid]); + } + + $span->end(); + return $tags; + } + + function getmetadatabyid(): void { + $article = ORM::for_table('ttrss_entries') + ->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue') + ->where('ue.owner_uid', $_SESSION['uid']) + ->find_one((int)$_REQUEST['id']); + + if ($article) { + echo json_encode(["link" => $article->link, "title" => $article->title]); + } else { + echo json_encode([]); + } + } + + /** + * @return array> + */ + static function _get_enclosures(int $id): array { + $encs = ORM::for_table('ttrss_enclosures') + ->where('post_id', $id) + ->find_many(); + + $rv = []; + + $cache = DiskCache::instance("images"); + + foreach ($encs as $enc) { + $cache_key = sha1($enc->content_url); + + if ($cache->exists($cache_key)) { + $enc->content_url = $cache->get_url($cache_key); + } + + array_push($rv, $enc->as_array()); + } + + return $rv; + + } + + static function _purge_orphans(): void { + + // purge orphaned posts in main content table + + if (Config::get(Config::DB_TYPE) == "mysql") + $limit_qpart = "LIMIT 5000"; + else + $limit_qpart = ""; + + $pdo = Db::pdo(); + $res = $pdo->query("DELETE FROM ttrss_entries WHERE + NOT EXISTS (SELECT ref_id FROM ttrss_user_entries WHERE ref_id = id) $limit_qpart"); + + if (Debug::enabled()) { + $rows = $res->rowCount(); + Debug::log("Purged $rows orphaned posts."); + } + } + + /** + * @param array $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"]; + + $pdo = Db::pdo(); + + $ids_qmarks = arr_qmarks($ids); + + 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 == 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 = ?"); + } else { + $sth = $pdo->prepare("UPDATE ttrss_user_entries SET + unread = false,last_read = NOW() + WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); + } + + $sth->execute([...$ids, $owner_uid]); + } + + /** + * @return array> + */ + static function _get_labels(int $id, ?int $owner_uid = null): array { + $span = Tracer::start(__METHOD__); + + $rv = array(); + + if (!$owner_uid) $owner_uid = $_SESSION["uid"]; + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT label_cache FROM + ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?"); + $sth->execute([$id, $owner_uid]); + + if ($row = $sth->fetch()) { + $label_cache = $row["label_cache"]; + + if ($label_cache) { + $tmp = json_decode($label_cache, true); + + if (empty($tmp) || ($tmp["no-labels"] ?? 0) == 1) + return $rv; + else + return $tmp; + } + } + + $sth = $pdo->prepare("SELECT DISTINCT label_id,caption,fg_color,bg_color + FROM ttrss_labels2, ttrss_user_labels2 + WHERE id = label_id + AND article_id = ? + AND owner_uid = ? + ORDER BY caption"); + $sth->execute([$id, $owner_uid]); + + while ($line = $sth->fetch()) { + $rk = array(Labels::label_to_feed_id($line["label_id"]), + $line["caption"], $line["fg_color"], + $line["bg_color"]); + array_push($rv, $rk); + } + + 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)); + + $span->end(); + + return $rv; + } + + /** + * @param array> $enclosures + * @param array $headline + * + * @return array + */ + static function _get_image(array $enclosures, string $content, string $site_url, array $headline) { + $span = Tracer::start(__METHOD__); + + $article_image = ""; + $article_stream = ""; + $article_kind = 0; + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_IMAGE, + function ($result, $plugin) use (&$article_image, &$article_stream, &$content) { + list ($article_image, $article_stream, $content) = $result; + + // run until first hard match + return !empty($article_image); + }, + $enclosures, $content, $site_url, $headline); + + if (!$article_image && !$article_stream) { + $tmpdoc = new DOMDocument(); + + if (@$tmpdoc->loadHTML('' . mb_substr($content, 0, 131070))) { + $tmpxpath = new DOMXPath($tmpdoc); + $elems = $tmpxpath->query('(//img[@src]|//video[@poster]|//iframe[contains(@src , "youtube.com/embed/")])'); + + foreach ($elems as $e) { + if ($e->nodeName == "iframe") { + $matches = []; + if ($rrr = preg_match("/\/embed\/([\w-]+)/", $e->getAttribute("src"), $matches)) { + $article_image = "https://img.youtube.com/vi/" . $matches[1] . "/hqdefault.jpg"; + $article_stream = "https://youtu.be/" . $matches[1]; + $article_kind = Article::ARTICLE_KIND_YOUTUBE; + break; + } + } else if ($e->nodeName == "video") { + $article_image = $e->getAttribute("poster"); + + /** @var DOMElement|null $src */ + $src = $tmpxpath->query("//source[@src]", $e)->item(0); + + if ($src) { + $article_stream = $src->getAttribute("src"); + $article_kind = Article::ARTICLE_KIND_VIDEO; + } + + break; + } else if ($e->nodeName == 'img') { + if (mb_strpos($e->getAttribute("src"), "data:") !== 0) { + $article_image = $e->getAttribute("src"); + } + break; + } + } + } + + if (!$article_image) + foreach ($enclosures as $enc) { + if (strpos($enc["content_type"], "image/") !== false) { + $article_image = $enc["content_url"]; + break; + } + } + + if ($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 = UrlHelper::rewrite_relative($site_url, $article_stream); + } + + $cache = DiskCache::instance("images"); + + if ($article_image && $cache->exists(sha1($article_image))) + $article_image = $cache->get_url(sha1($article_image)); + + if ($article_stream && $cache->exists(sha1($article_stream))) + $article_stream = $cache->get_url(sha1($article_stream)); + + $span->end(); + + return [$article_image, $article_stream, $article_kind]; + } + + /** + * only cached, returns label ids (not label feed ids) + * + * @param array $article_ids + * @return array + */ + static function _labels_of(array $article_ids) { + if (count($article_ids) == 0) + return []; + + $span = Tracer::start(__METHOD__); + + $entries = ORM::for_table('ttrss_entries') + ->table_alias('e') + ->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue') + ->where_in('id', $article_ids) + ->find_many(); + + $rv = []; + + foreach ($entries as $entry) { + $labels = json_decode($entry->label_cache); + + if (isset($labels) && is_array($labels)) { + foreach ($labels as $label) { + if (empty($label["no-labels"])) + array_push($rv, Labels::feed_to_label_id($label[0])); + } + } + } + + $span->end(); + + return array_unique($rv); + } + + /** + * @param array $article_ids + * @return array + */ + static function _feeds_of(array $article_ids) { + if (count($article_ids) == 0) + return []; + + $span = Tracer::start(__METHOD__); + + $entries = ORM::for_table('ttrss_entries') + ->table_alias('e') + ->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue') + ->where_in('id', $article_ids) + ->find_many(); + + $rv = []; + + foreach ($entries as $entry) { + array_push($rv, $entry->feed_id); + } + + $span->end(); + + return array_unique($rv); + } +} diff --git a/classes/Auth_Base.php b/classes/Auth_Base.php new file mode 100644 index 000000000..d8128400d --- /dev/null +++ b/classes/Auth_Base.php @@ -0,0 +1,59 @@ +pdo = Db::pdo(); + } + + 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 + * @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); + + if (!$user_id) { + + if (!$password) $password = make_password(); + + $user = ORM::for_table('ttrss_users')->create(); + + $user->salt = UserHelper::get_salt(); + $user->login = mb_strtolower($login); + $user->pwd_hash = UserHelper::hash_password($password, $user->salt); + $user->access_level = 0; + $user->created = Db::NOW(); + $user->save(); + + return UserHelper::find_user_by_login($login); + + } else { + return $user_id; + } + } + + return UserHelper::find_user_by_login($login); + } + + + /** 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/Cache_Adapter.php b/classes/Cache_Adapter.php new file mode 100644 index 000000000..fecfc7667 --- /dev/null +++ b/classes/Cache_Adapter.php @@ -0,0 +1,36 @@ +get_full_path($filename)); + } + + public function get_mtime(string $filename) { + return filemtime($this->get_full_path($filename)); + } + + public function set_dir(string $dir) : void { + $this->dir = Config::get(Config::CACHE_DIR) . "/" . basename(clean($dir)); + + $this->make_dir(); + } + + public function get_dir(): string { + return $this->dir; + } + + public function make_dir(): bool { + if (!is_dir($this->dir)) { + return mkdir($this->dir); + } + return false; + } + + 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)); + else + return is_writable($this->dir); + } else { + return is_writable($this->dir); + } + } + + public function exists(string $filename): bool { + return file_exists($this->get_full_path($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(string $filename): string { + return $this->dir . "/" . basename(clean($filename)); + } + + public function get(string $filename): ?string { + if ($this->exists($filename)) + return file_get_contents($this->get_full_path($filename)); + else + return null; + } + + /** + * @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); + } + + /** + * @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; + } + + /** + * @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) { + return $this->send_local_file($this->get_full_path($filename)); + } + + public function expire_all(): void { + $dirs = array_filter(glob(Config::get(Config::CACHE_DIR) . "/*"), "is_dir"); + + foreach ($dirs as $cache_dir) { + $num_deleted = 0; + + if (is_writable($cache_dir) && !file_exists("$cache_dir/.no-auto-expiry")) { + $files = glob("$cache_dir/*"); + + if ($files) { + foreach ($files as $file) { + if (time() - filemtime($file) > 86400 * Config::get(Config::CACHE_MAX_DAYS)) { + unlink($file); + + ++$num_deleted; + } + } + } + + Debug::log("Expired $cache_dir: removed $num_deleted files."); + } + } + } + + /** + * 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 + */ + private function send_local_file(string $filename) { + if (file_exists($filename)) { + + if (is_writable($filename) && !$this->exists('.no-auto-expiry')) { + touch($filename); + } + + $tmppluginhost = new PluginHost(); + + $tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_SYSTEM); + //$tmppluginhost->load_data(); + + if ($tmppluginhost->run_hooks_until(PluginHost::HOOK_SEND_LOCAL_FILE, true, $filename)) + return true; + + return readfile($filename); + } else { + return false; + } + } + +} diff --git a/classes/Config.php b/classes/Config.php new file mode 100644 index 000000000..72d6c5106 --- /dev/null +++ b/classes/Config.php @@ -0,0 +1,704 @@ + [ "pgsql", Config::T_STRING ], + Config::DB_HOST => [ "db", Config::T_STRING ], + Config::DB_USER => [ "", Config::T_STRING ], + Config::DB_NAME => [ "", Config::T_STRING ], + Config::DB_PASS => [ "", Config::T_STRING ], + Config::DB_PORT => [ "5432", Config::T_STRING ], + Config::MYSQL_CHARSET => [ "UTF8", Config::T_STRING ], + Config::SELF_URL_PATH => [ "https://example.com/tt-rss", Config::T_STRING ], + Config::SINGLE_USER_MODE => [ "", Config::T_BOOL ], + Config::SIMPLE_UPDATE_MODE => [ "", Config::T_BOOL ], + Config::PHP_EXECUTABLE => [ "/usr/bin/php", Config::T_STRING ], + Config::LOCK_DIRECTORY => [ "lock", Config::T_STRING ], + Config::CACHE_DIR => [ "cache", Config::T_STRING ], + Config::ICONS_DIR => [ "feed-icons", Config::T_STRING ], + Config::ICONS_URL => [ "feed-icons", Config::T_STRING ], + Config::AUTH_AUTO_CREATE => [ "true", Config::T_BOOL ], + Config::AUTH_AUTO_LOGIN => [ "true", Config::T_BOOL ], + Config::FORCE_ARTICLE_PURGE => [ 0, Config::T_INT ], + Config::SESSION_COOKIE_LIFETIME => [ 86400, Config::T_INT ], + Config::SMTP_FROM_NAME => [ "Tiny Tiny RSS", Config::T_STRING ], + Config::SMTP_FROM_ADDRESS => [ "noreply@localhost", Config::T_STRING ], + Config::DIGEST_SUBJECT => [ "[tt-rss] New headlines for last 24 hours", + Config::T_STRING ], + Config::CHECK_FOR_UPDATES => [ "true", Config::T_BOOL ], + Config::PLUGINS => [ "auth_internal", Config::T_STRING ], + Config::LOG_DESTINATION => [ Logger::LOG_DEST_SQL, Config::T_STRING ], + Config::LOCAL_OVERRIDE_STYLESHEET => [ "local-overrides.css", + Config::T_STRING ], + Config::LOCAL_OVERRIDE_JS => [ "local-overrides.js", + Config::T_STRING ], + Config::DAEMON_MAX_CHILD_RUNTIME => [ 1800, Config::T_INT ], + Config::DAEMON_MAX_JOBS => [ 2, Config::T_INT ], + Config::FEED_FETCH_TIMEOUT => [ 45, Config::T_INT ], + Config::FEED_FETCH_NO_CACHE_TIMEOUT => [ 15, Config::T_INT ], + Config::FILE_FETCH_TIMEOUT => [ 45, Config::T_INT ], + Config::FILE_FETCH_CONNECT_TIMEOUT => [ 15, Config::T_INT ], + Config::DAEMON_UPDATE_LOGIN_LIMIT => [ 30, Config::T_INT ], + Config::DAEMON_FEED_LIMIT => [ 500, Config::T_INT ], + Config::DAEMON_SLEEP_INTERVAL => [ 120, Config::T_INT ], + Config::MAX_CACHE_FILE_SIZE => [ 64*1024*1024, Config::T_INT ], + Config::MAX_DOWNLOAD_FILE_SIZE => [ 16*1024*1024, Config::T_INT ], + Config::MAX_FAVICON_FILE_SIZE => [ 1*1024*1024, Config::T_INT ], + Config::CACHE_MAX_DAYS => [ 7, Config::T_INT ], + Config::MAX_CONDITIONAL_INTERVAL => [ 3600*12, Config::T_INT ], + Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT => [ 30, Config::T_INT ], + Config::LOG_SENT_MAIL => [ "", Config::T_BOOL ], + Config::HTTP_PROXY => [ "", Config::T_STRING ], + Config::FORBID_PASSWORD_CHANGES => [ "", Config::T_BOOL ], + Config::SESSION_NAME => [ "ttrss_sid", Config::T_STRING ], + 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 ], + Config::HTTP_429_THROTTLE_INTERVAL => [ 3600, Config::T_INT ], + Config::OPENTELEMETRY_ENDPOINT => [ "", Config::T_STRING ], + Config::OPENTELEMETRY_SERVICE => [ "tt-rss", Config::T_STRING ], + ]; + + /** @var Config|null */ + private static $instance; + + /** @var array> */ + private $params = []; + + /** @var array */ + private $version = []; + + /** @var Db_Migrations|null $migrations */ + private $migrations; + + public static function get_instance() : Config { + if (self::$instance == null) + self::$instance = new self(); + + return self::$instance; + } + + private function __clone() { + // + } + + function __construct() { + $ref = new ReflectionClass(get_class($this)); + + foreach ($ref->getConstants() as $const => $cvalue) { + if (isset(self::_DEFAULTS[$const])) { + $override = getenv(self::_ENVVAR_PREFIX . $const); + + list ($defval, $deftype) = self::_DEFAULTS[$const]; + + $this->params[$cvalue] = [ self::cast_to($override !== false ? $override : $defval, $deftype), $deftype ]; + } + } + } + + /** 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 + */ + static function get_version(bool $as_string = true) { + return self::get_instance()->_get_version($as_string); + } + + // returns version showing (if possible) full timestamp of commit id + static function get_version_html() : string { + $version = self::get_version(false); + + return sprintf("%s", + date("Y-m-d H:i:s", ($version['timestamp'] ?? 0)), + $version['commit'] ?? '', + $version['branch'] ?? '', + $version['version']); + } + + /** + * @return array|string + */ + private function _get_version(bool $as_string = true) { + $root_dir = dirname(__DIR__); + + if (empty($this->version)) { + $this->version["status"] = -1; + + if (getenv("CI_COMMIT_SHORT_SHA") && getenv("CI_COMMIT_TIMESTAMP")) { + + $this->version["branch"] = getenv("CI_COMMIT_BRANCH"); + $this->version["timestamp"] = strtotime(getenv("CI_COMMIT_TIMESTAMP")); + $this->version["version"] = sprintf("%s-%s", date("y.m", $this->version["timestamp"]), getenv("CI_COMMIT_SHORT_SHA")); + $this->version["commit"] = getenv("CI_COMMIT_SHORT_SHA"); + $this->version["status"] = 0; + + } else if (PHP_OS === "Darwin") { + $this->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); + + if ($this->version["status"] != 0) { + 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)"; + } + } + + return $as_string ? $this->version["version"] : $this->version; + } + + /** + * @return array + */ + static function get_version_from_git(string $dir): array { + $descriptorspec = [ + 1 => ["pipe", "w"], // STDOUT + 2 => ["pipe", "w"], // STDERR + ]; + + $rv = [ + "status" => -1, + "version" => "", + "branch" => "", + "commit" => "", + "timestamp" => 0, + ]; + + $proc = proc_open("git --no-pager log --pretty=\"version-%ct-%h\" -n1 HEAD", + $descriptorspec, $pipes, $dir); + + if (is_resource($proc)) { + $stdout = trim(stream_get_contents($pipes[1])); + $stderr = trim(stream_get_contents($pipes[2])); + $status = proc_close($proc); + + $rv["status"] = $status; + + list($check, $timestamp, $commit) = explode("-", $stdout); + + if ($check == "version") { + + $rv["version"] = sprintf("%s-%s", date("y.m", (int)$timestamp), $commit); + $rv["commit"] = $commit; + $rv["timestamp"] = $timestamp; + + // proc_close() may return -1 even if command completed successfully + // so if it looks like we got valid data, we ignore it + + if ($rv["status"] == -1) + $rv["status"] = 0; + + } else { + $rv["version"] = T_sprintf("Git error [RC=%d]: %s", $status, $stderr); + } + } + + return $rv; + } + + static function get_migrations() : Db_Migrations { + return self::get_instance()->_get_migrations(); + } + + private function _get_migrations() : Db_Migrations { + if (empty($this->migrations)) { + $this->migrations = new Db_Migrations(); + $this->migrations->initialize(dirname(__DIR__) . "/sql", "ttrss_version", true, self::SCHEMA_VERSION); + } + + return $this->migrations; + } + + static function is_migration_needed() : bool { + return self::get_migrations()->is_migration_needed(); + } + + static function get_schema_version() : int { + 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: + return sql_bool_to_bool($value); + case self::T_INT: + return (int) $value; + default: + return $value; + } + } + + /** + * @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): 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): void { + $instance = self::get_instance(); + + $instance->_add($param, $default, $type_hint); + } + + /** + * @return bool|int|string + */ + static function get(string $param) { + $instance = self::get_instance(); + + return $instance->_get($param); + } + + static function is_server_https() : bool { + return (!empty($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] != 'off')) || + (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https'); + } + + /** returns fully-qualified external URL to tt-rss (no trailing slash) + * SELF_URL_PATH configuration variable is used as a fallback for the CLI SAPI + * */ + static function get_self_url(bool $always_detect = false) : string { + if (!$always_detect && php_sapi_name() == "cli") { + return self::get(Config::SELF_URL_PATH); + } else { + $proto = self::is_server_https() ? 'https' : 'http'; + + $self_url_path = $proto . '://' . $_SERVER["HTTP_HOST"] . parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH); + $self_url_path = preg_replace("/(\/api\/{1,})?(\w+\.php)?(\?.*$)?$/", "", $self_url_path); + + if (substr($self_url_path, -1) === "/") { + return substr($self_url_path, 0, -1); + } else { + return $self_url_path; + } + } + } + /* sanity check stuff */ + + /** checks for mysql tables not using InnoDB (tt-rss is incompatible with MyISAM) + * @return array> A list of entries identifying tt-rss tables with bad config + */ + private static function check_mysql_tables() { + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT engine, table_name FROM information_schema.tables WHERE + table_schema = ? AND table_name LIKE 'ttrss_%' AND engine != 'InnoDB'"); + $sth->execute([self::get(Config::DB_NAME)]); + + $bad_tables = []; + + while ($line = $sth->fetch()) { + array_push($bad_tables, $line); + } + + return $bad_tables; + } + + 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 + because it is set up in the Db constructor, which is why it's a good idea to invoke it as early as possible + + it is a bit of a hack, maybe ORM should be initialized somewhere else (functions.php?) + */ + + $pdo = Db::pdo(); + + $errors = []; + + if (strpos(self::get(Config::PLUGINS), "auth_") === false) { + array_push($errors, "Please enable at least one authentication module via PLUGINS"); + } + + /* we assume our dependencies are sane under docker, so some sanity checks are skipped. + this also allows tt-rss process to run under root if requested (I'm using this for development + under podman because of uidmapping issues with rootless containers, don't use in production -fox) */ + if (!getenv("container")) { + if (function_exists('posix_getuid') && posix_getuid() == 0) { + array_push($errors, "Please don't run this script as root."); + } + + if (version_compare(PHP_VERSION, '7.4.0', '<')) { + array_push($errors, "PHP version 7.4.0 or newer required. You're using " . PHP_VERSION . "."); + } + + if (!class_exists("UConverter")) { + array_push($errors, "PHP UConverter class is missing, it's provided by the Internationalization (intl) module."); + } + + if (!function_exists("curl_init") && !ini_get("allow_url_fopen")) { + array_push($errors, "PHP configuration option allow_url_fopen is disabled, and CURL functions are not present. Either enable allow_url_fopen or install PHP extension for CURL."); + } + + if (!function_exists("json_encode")) { + array_push($errors, "PHP support for JSON is required, but was not found."); + } + + if (!function_exists("flock")) { + array_push($errors, "PHP support for flock() function is required."); + } + + if (!class_exists("PDO")) { + array_push($errors, "PHP support for PDO is required but was not found."); + } + + if (!function_exists("mb_strlen")) { + array_push($errors, "PHP support for mbstring functions is required but was not found."); + } + + if (!function_exists("hash")) { + array_push($errors, "PHP support for hash() function is required but was not found."); + } + + if (ini_get("safe_mode")) { + array_push($errors, "PHP safe mode setting is obsolete and not supported by tt-rss."); + } + + if (!function_exists("mime_content_type")) { + array_push($errors, "PHP function mime_content_type() is missing, try enabling fileinfo module."); + } + + if (!class_exists("DOMDocument")) { + array_push($errors, "PHP support for DOMDocument is required, but was not found."); + } + } + + if (!is_writable(self::get(Config::CACHE_DIR) . "/images")) { + array_push($errors, "Image cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/images)"); + } + + if (!is_writable(self::get(Config::CACHE_DIR) . "/upload")) { + array_push($errors, "Upload cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/upload)"); + } + + if (!is_writable(self::get(Config::CACHE_DIR) . "/export")) { + array_push($errors, "Data export cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/export)"); + } + + if (!is_writable(self::get(Config::ICONS_DIR))) { + array_push($errors, "ICONS_DIR defined in config.php is not writable (chmod -R 777 ".self::get(Config::ICONS_DIR).").\n"); + } + + if (!is_writable(self::get(Config::LOCK_DIRECTORY))) { + array_push($errors, "LOCK_DIRECTORY is not writable (chmod -R 777 ".self::get(Config::LOCK_DIRECTORY).").\n"); + } + + // ttrss_users won't be there on initial startup (before migrations are done) + if (!Config::is_migration_needed() && self::get(Config::SINGLE_USER_MODE)) { + if (UserHelper::get_login_by_id(1) != "admin") { + array_push($errors, "SINGLE_USER_MODE is enabled but default admin account (ID: 1) is not found."); + } + } + + // skip check for CLI scripts so that we could install database schema if it is missing. + if (php_sapi_name() != "cli") { + if (self::get_schema_version() < 0) { + array_push($errors, "Base database schema is missing. Either load it manually or perform a migration (update.php --update-schema)"); + } + } + + if (self::get(Config::DB_TYPE) == "mysql") { + $bad_tables = self::check_mysql_tables(); + + if (count($bad_tables) > 0) { + $bad_tables_fmt = []; + + foreach ($bad_tables as $bt) { + array_push($bad_tables_fmt, sprintf("%s (%s)", $bt['table_name'], $bt['engine'])); + } + + $msg = "

The following tables use an unsupported MySQL engine: " . + implode(", ", $bad_tables_fmt) . ".

"; + + $msg .= "

The only supported engine on MySQL is InnoDB. MyISAM lacks functionality to run + tt-rss. + Please backup your data (via OPML) and re-import the schema before continuing.

+

WARNING: importing the schema would mean LOSS OF ALL YOUR DATA.

"; + + + array_push($errors, $msg); + } + } + + if (count($errors) > 0 && php_sapi_name() != "cli") { ?> + + + + Startup failed + + + + +
+

Startup failed

+ +

Please fix errors indicated by the following messages:

+ + + +

You might want to check tt-rss wiki or the + forums for more information. Please search the forums before creating new topic + for your question.

+
+ + + + 0) { + echo "Please fix errors indicated by the following messages:\n\n"; + + foreach ($errors as $error) { + echo " * " . strip_tags($error)."\n"; + } + + echo "\nYou might want to check tt-rss wiki or the forums for more information.\n"; + echo "Please search the forums before creating new topic for your question.\n"; + + exit(1); + } + } + + private static function format_error(string $msg): string { + return "
$msg
"; + } + + static function get_override_links(): string { + $rv = ""; + + $local_css = get_theme_path(self::get(self::LOCAL_OVERRIDE_STYLESHEET)); + if ($local_css) $rv .= stylesheet_tag($local_css); + + $local_js = get_theme_path(self::get(self::LOCAL_OVERRIDE_JS)); + if ($local_js) $rv .= javascript_tag($local_js); + + 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 new file mode 100644 index 000000000..948e6ee1d --- /dev/null +++ b/classes/Counters.php @@ -0,0 +1,362 @@ +> + */ + static function get_all(): array { + return [ + ...self::get_global(), + ...self::get_virt(), + ...self::get_labels(), + ...self::get_feeds(), + ...self::get_cats(), + ]; + } + + /** + * @param array $feed_ids + * @param array $label_ids + * @return array> + */ + static function get_conditional(array $feed_ids = null, array $label_ids = null): array { + return [ + ...self::get_global(), + ...self::get_virt(), + ...self::get_labels($label_ids), + ...self::get_feeds($feed_ids), + ...self::get_cats(is_array($feed_ids) ? Feeds::_cats_of($feed_ids, $_SESSION["uid"], true) : null) + ]; + } + + /** + * @return array + */ + static private function get_cat_children(int $cat_id, int $owner_uid): array { + $unread = 0; + $marked = 0; + + $cats = ORM::for_table('ttrss_feed_categories') + ->where('owner_uid', $owner_uid) + ->where('parent_cat', $cat_id) + ->find_many(); + + foreach ($cats as $cat) { + list ($tmp_unread, $tmp_marked) = self::get_cat_children($cat->id, $owner_uid); + + $unread += $tmp_unread + Feeds::_get_cat_unread($cat->id, $owner_uid); + $marked += $tmp_marked + Feeds::_get_cat_marked($cat->id, $owner_uid); + } + + return [$unread, $marked]; + } + + /** + * @param array $cat_ids + * @return array> + */ + private static function get_cats(array $cat_ids = null): array { + $ret = []; + + /* Labels category */ + + $cv = array("id" => Feeds::CATEGORY_LABELS, "kind" => "cat", + "counter" => Feeds::_get_cat_unread(Feeds::CATEGORY_LABELS)); + + array_push($ret, $cv); + + $pdo = Db::pdo(); + + if (is_array($cat_ids)) { + if (count($cat_ids) == 0) + return []; + + $cat_ids_qmarks = arr_qmarks($cat_ids); + + $sth = $pdo->prepare("SELECT fc.id, + SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count, + SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked, + (SELECT COUNT(id) FROM ttrss_feed_categories fcc + WHERE fcc.parent_cat = fc.id) AS num_children + FROM ttrss_feed_categories fc + LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id) + LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id) + WHERE fc.owner_uid = ? AND fc.id IN ($cat_ids_qmarks) + GROUP BY fc.id + UNION + SELECT 0, + SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count, + SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked, + 0 + FROM ttrss_feeds f, ttrss_user_entries ue + WHERE f.cat_id IS NULL AND + ue.feed_id = f.id AND + ue.owner_uid = ?"); + + $sth->execute([$_SESSION['uid'], ...$cat_ids, $_SESSION['uid']]); + + } else { + $sth = $pdo->prepare("SELECT fc.id, + SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count, + SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked, + (SELECT COUNT(id) FROM ttrss_feed_categories fcc + WHERE fcc.parent_cat = fc.id) AS num_children + FROM ttrss_feed_categories fc + LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id) + LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id) + WHERE fc.owner_uid = :uid + GROUP BY fc.id + UNION + SELECT 0, + SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count, + SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked, + 0 + FROM ttrss_feeds f, ttrss_user_entries ue + WHERE f.cat_id IS NULL AND + ue.feed_id = f.id AND + ue.owner_uid = :uid"); + + $sth->execute(["uid" => $_SESSION['uid']]); + } + + while ($line = $sth->fetch()) { + if ($line["num_children"] > 0) { + list ($child_counter, $child_marked_counter) = self::get_cat_children($line["id"], $_SESSION["uid"]); + } else { + $child_counter = 0; + $child_marked_counter = 0; + } + + $cv = [ + "id" => (int)$line["id"], + "kind" => "cat", + "markedcounter" => (int) $line["count_marked"] + $child_marked_counter, + "counter" => (int) $line["count"] + $child_counter + ]; + + array_push($ret, $cv); + } + + return $ret; + } + + /** + * @param array $feed_ids + * @return array> + */ + private static function get_feeds(array $feed_ids = null): array { + $span = Tracer::start(__METHOD__); + + $ret = []; + + $pdo = Db::pdo(); + + if (is_array($feed_ids)) { + if (count($feed_ids) == 0) + return []; + + $feed_ids_qmarks = arr_qmarks($feed_ids); + + $sth = $pdo->prepare("SELECT f.id, + f.title, + ".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated, + f.last_error, + SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count, + SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked + FROM ttrss_feeds f, ttrss_user_entries ue + WHERE f.id = ue.feed_id AND ue.owner_uid = ? AND f.id IN ($feed_ids_qmarks) + GROUP BY f.id"); + + $sth->execute([$_SESSION['uid'], ...$feed_ids]); + } else { + $sth = $pdo->prepare("SELECT f.id, + f.title, + ".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated, + f.last_error, + SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count, + SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked + FROM ttrss_feeds f, ttrss_user_entries ue + WHERE f.id = ue.feed_id AND ue.owner_uid = :uid + GROUP BY f.id"); + + $sth->execute(["uid" => $_SESSION['uid']]); + } + + while ($line = $sth->fetch()) { + + $id = $line["id"]; + $last_updated = TimeHelper::make_local_datetime($line['last_updated'], false); + + if (Feeds::_has_icon($id)) { + $ts = filemtime(Feeds::_get_icon_file($id)); + } else { + $ts = 0; + } + + // hide default un-updated timestamp i.e. 1970-01-01 (?) -fox + if ((int)date('Y') - (int)date('Y', strtotime($line['last_updated'] ?? '')) > 2) + $last_updated = ''; + + $cv = [ + "id" => $id, + "updated" => $last_updated, + "counter" => (int) $line["count"], + "markedcounter" => (int) $line["count_marked"], + "ts" => (int) $ts + ]; + + $cv["error"] = $line["last_error"]; + $cv["title"] = truncate_string($line["title"], 30); + + array_push($ret, $cv); + + } + + $span->end(); + + return $ret; + } + + /** + * @return array> + */ + private static function get_global(): array { + $span = Tracer::start(__METHOD__); + + $ret = [ + [ + "id" => "global-unread", + "counter" => (int) Feeds::_get_global_unread() + ] + ]; + + $subcribed_feeds = ORM::for_table('ttrss_feeds') + ->where('owner_uid', $_SESSION['uid']) + ->count(); + + array_push($ret, [ + "id" => "subscribed-feeds", + "counter" => $subcribed_feeds + ]); + + $span->end(); + + return $ret; + } + + /** + * @return array> + */ + private static function get_virt(): array { + $span = Tracer::start(__METHOD__); + + $ret = []; + + foreach ([Feeds::FEED_ARCHIVED, Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED, + Feeds::FEED_FRESH, Feeds::FEED_ALL] as $feed_id) { + + $count = Feeds::_get_counters($feed_id, false, true); + + if (in_array($feed_id, [Feeds::FEED_ARCHIVED, Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED])) + $auxctr = Feeds::_get_counters($feed_id, false); + else + $auxctr = 0; + + $cv = [ + "id" => $feed_id, + "counter" => (int) $count, + "auxcounter" => (int) $auxctr + ]; + + if ($feed_id == Feeds::FEED_STARRED) + $cv["markedcounter"] = $auxctr; + + array_push($ret, $cv); + } + + $feeds = PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL); + + 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']) + ]; + + if (method_exists($feed['sender'], 'get_total')) + $cv["auxcounter"] = $feed['sender']->get_total($feed['id']); + + array_push($ret, $cv); + } + } + + $span->end(); + return $ret; + } + + /** + * @param array $label_ids + * @return array> + */ + static function get_labels(array $label_ids = null): array { + $span = Tracer::start(__METHOD__); + + $ret = []; + + $pdo = Db::pdo(); + + if (is_array($label_ids)) { + if (count($label_ids) == 0) + return []; + + $label_ids_qmarks = arr_qmarks($label_ids); + + $sth = $pdo->prepare("SELECT id, + caption, + SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread, + SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked, + COUNT(u1.unread) AS total + FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON + (ttrss_labels2.id = label_id) + LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = ? + WHERE ttrss_labels2.owner_uid = ? AND ttrss_labels2.id IN ($label_ids_qmarks) + GROUP BY ttrss_labels2.id, ttrss_labels2.caption"); + $sth->execute([$_SESSION["uid"], $_SESSION["uid"], ...$label_ids]); + } else { + $sth = $pdo->prepare("SELECT id, + caption, + SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread, + SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked, + COUNT(u1.unread) AS total + FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON + (ttrss_labels2.id = label_id) + LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = :uid + WHERE ttrss_labels2.owner_uid = :uid + GROUP BY ttrss_labels2.id, ttrss_labels2.caption"); + $sth->execute([":uid" => $_SESSION['uid']]); + } + + while ($line = $sth->fetch()) { + + $id = Labels::label_to_feed_id($line["id"]); + + $cv = [ + "id" => $id, + "counter" => (int) $line["count_unread"], + "auxcounter" => (int) $line["total"], + "markedcounter" => (int) $line["count_marked"], + "description" => $line["caption"] + ]; + + array_push($ret, $cv); + } + + $span->end(); + return $ret; + } +} diff --git a/classes/Db.php b/classes/Db.php new file mode 100644 index 000000000..4331b662e --- /dev/null +++ b/classes/Db.php @@ -0,0 +1,102 @@ + 'SET NAMES ' . Config::get(Config::MYSQL_CHARSET))); + } + } + + /** + * @param int $delta adjust generated timestamp by this value in seconds (either positive or negative) + * @return string + */ + static function NOW(int $delta = 0): string { + return date("Y-m-d H:i:s", time() + $delta); + } + + private function __clone() { + // + } + + 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)) { + $db_charset = ';charset=' . Config::get(Config::MYSQL_CHARSET); + } else { + $db_charset = ''; + } + + return Config::get(Config::DB_TYPE) . ':dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port . $db_charset; + } + + // this really shouldn't be used unless a separate PDO connection is needed + // normal usage is Db::pdo()->prepare(...) etc + public function pdo_connect() : PDO { + + try { + $pdo = new PDO(self::get_dsn(), + Config::get(Config::DB_USER), + Config::get(Config::DB_PASS)); + } catch (Exception $e) { + print "
Exception while creating PDO object:" . $e->getMessage() . "
"; + exit(101); + } + + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + if (Config::get(Config::DB_TYPE) == "pgsql") { + + $pdo->query("set client_encoding = 'UTF-8'"); + $pdo->query("set datestyle = 'ISO, european'"); + $pdo->query("set TIME ZONE 0"); + $pdo->query("set cpu_tuple_cost = 0.5"); + + } else if (Config::get(Config::DB_TYPE) == "mysql") { + $pdo->query("SET time_zone = '+0:0'"); + + if (Config::get(Config::MYSQL_CHARSET)) { + $pdo->query("SET NAMES " . Config::get(Config::MYSQL_CHARSET)); + } + } + + return $pdo; + } + + public static function instance() : Db { + if (self::$instance == null) + self::$instance = new self(); + + return self::$instance; + } + + public static function pdo() : PDO { + if (self::$instance == null) + self::$instance = new self(); + + if (empty(self::$instance->pdo)) { + self::$instance->pdo = self::$instance->pdo_connect(); + } + + return self::$instance->pdo; + } + + public static function sql_random_function(): string { + if (Config::get(Config::DB_TYPE) == "mysql") { + return "RAND()"; + } + return "RANDOM()"; + } + +} diff --git a/classes/Db_Migrations.php b/classes/Db_Migrations.php new file mode 100644 index 000000000..d63736987 --- /dev/null +++ b/classes/Db_Migrations.php @@ -0,0 +1,203 @@ +pdo = Db::pdo(); + } + + 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): void { + $this->base_path = "$root_path/" . Config::get(Config::DB_TYPE); + $this->migrations_path = $this->base_path . "/migrations"; + $this->migrations_table = $migrations_table; + $this->base_is_latest = $base_is_latest; + $this->max_version_override = $max_version_override; + } + + 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}"); + + if ($res = $sth->fetch()) { + $sth = $this->pdo->prepare("UPDATE {$this->migrations_table} SET schema_version = ?"); + } else { + $sth = $this->pdo->prepare("INSERT INTO {$this->migrations_table} (schema_version) VALUES (?)"); + } + + $sth->execute([$version]); + + $this->cached_version = $version; + } + + function get_version() : int { + if ($this->cached_version) + return $this->cached_version; + + try { + $sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}"); + + if ($res = $sth->fetch()) { + return (int) $res['schema_version']; + } else { + return -1; + } + } catch (PDOException $e) { + $this->create_migrations_table(); + + return -1; + } + } + + private function create_migrations_table(): void { + $this->pdo->query("CREATE TABLE IF NOT EXISTS {$this->migrations_table} (schema_version integer not null)"); + } + + /** + * @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); + return false; + } + + if ($version == 0) + Debug::log("Loading base database schema...", Debug::LOG_VERBOSE); + else + Debug::log("Starting migration to $version...", Debug::LOG_VERBOSE); + + $lines = $this->get_lines($version); + + if (count($lines) > 0) { + // mysql doesn't support transactions for DDL statements + if (Config::get(Config::DB_TYPE) != "mysql") + $this->pdo->beginTransaction(); + + foreach ($lines as $line) { + Debug::log($line, Debug::LOG_EXTENDED); + try { + $this->pdo->query($line); + } catch (PDOException $e) { + Debug::log("Failed on line: $line", Debug::LOG_VERBOSE); + throw $e; + } + } + + if ($version == 0 && $this->base_is_latest) + $this->set_version($this->get_max_version()); + else + $this->set_version($version); + + if (Config::get(Config::DB_TYPE) != "mysql") + $this->pdo->commit(); + + 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) { + Debug::log("Migration failed: " . $e->getMessage(), Debug::LOG_VERBOSE); + try { + $this->pdo->rollback(); + } catch (PDOException $ie) { + // + } + throw $e; + } + } + + function get_max_version() : int { + if ($this->max_version_override > 0) + return $this->max_version_override; + + if ($this->cached_max_version) + return $this->cached_max_version; + + $migrations = glob("{$this->migrations_path}/*.sql"); + + if (count($migrations) > 0) { + natsort($migrations); + + $this->cached_max_version = (int) basename(array_pop($migrations), ".sql"); + + } else { + $this->cached_max_version = 0; + } + + return $this->cached_max_version; + } + + function is_migration_needed() : bool { + return $this->get_version() != $this->get_max_version(); + } + + function migrate() : bool { + + if ($this->get_version() == -1) { + try { + $this->migrate_to(0); + } catch (PDOException $e) { + user_error("Failed to load base schema for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING); + return false; + } + } + + for ($i = $this->get_version() + 1; $i <= $this->get_max_version(); $i++) { + try { + $this->migrate_to($i); + } catch (PDOException $e) { + user_error("Failed to apply migration {$i} for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING); + return false; + //throw $e; + } + } + + return !$this->is_migration_needed(); + } + + /** + * @return array + */ + private function get_lines(int $version) : array { + if ($version > 0) + $filename = "{$this->migrations_path}/{$version}.sql"; + else + $filename = "{$this->base_path}/{$this->base_filename}"; + + if (file_exists($filename)) { + $lines = array_filter(preg_split("/[\r\n]/", file_get_contents($filename)), + fn($line) => strlen(trim($line)) > 0 && strpos($line, "--") !== 0); + + return array_filter(explode(";", implode("", $lines)), + fn($line) => strlen(trim($line)) > 0 && !in_array(strtolower($line), ["begin", "commit"])); + + } else { + user_error("Requested schema file {$filename} not found.", E_USER_ERROR); + return []; + } + } +} diff --git a/classes/Db_Prefs.php b/classes/Db_Prefs.php new file mode 100644 index 000000000..209ef58c1 --- /dev/null +++ b/classes/Db_Prefs.php @@ -0,0 +1,18 @@ +"; + + const ALL_LOG_LEVELS = [ + Debug::LOG_DISABLED, + Debug::LOG_NORMAL, + Debug::LOG_VERBOSE, + Debug::LOG_EXTENDED, + ]; + + /** + * @deprecated + */ + public static int $LOG_DISABLED = self::LOG_DISABLED; + + /** + * @deprecated + */ + public static int $LOG_NORMAL = self::LOG_NORMAL; + + /** + * @deprecated + */ + public static int $LOG_VERBOSE = self::LOG_VERBOSE; + + /** + * @deprecated + */ + public static int $LOG_EXTENDED = self::LOG_EXTENDED; + + private static bool $enabled = false; + private static bool $quiet = false; + private static ?string $logfile = null; + private static bool $enable_html = false; + + private static int $loglevel = self::LOG_NORMAL; + + public static function set_logfile(string $logfile): void { + self::$logfile = $logfile; + } + + public static function enabled(): bool { + return self::$enabled; + } + + public static function set_enabled(bool $enable): void { + self::$enabled = $enable; + } + + public static function set_quiet(bool $quiet): void { + self::$quiet = $quiet; + } + + /** + * @param Debug::LOG_* $level + */ + public static function set_loglevel(int $level): void { + self::$loglevel = $level; + } + + /** + * @return int Debug::LOG_* + */ + public static function get_loglevel(): int { + return self::$loglevel; + } + + /** + * @param int $level integer loglevel value + * @return Debug::LOG_* if valid, warn and return LOG_DISABLED otherwise + */ + public static function map_loglevel(int $level) : int { + if (in_array($level, self::ALL_LOG_LEVELS)) { + /** @phpstan-ignore-next-line */ + return $level; + } else { + user_error("Passed invalid debug log level: $level", E_USER_WARNING); + return self::LOG_DISABLED; + } + } + + public static function enable_html(bool $enable) : void { + self::$enable_html = $enable; + } + + /** + * @param Debug::LOG_* $level log level + */ + public static function log(string $message, int $level = Debug::LOG_NORMAL): bool { + + if (!self::$enabled || self::$loglevel < $level) return false; + + $ts = date("H:i:s", time()); + if (function_exists('posix_getpid')) { + $ts = "$ts/" . posix_getpid(); + } + + $orig_message = $message; + + if ($message === self::SEPARATOR) { + $message = self::$enable_html ? "
" : + "================================================================================================================================="; + } + + if (self::$logfile) { + $fp = fopen(self::$logfile, 'a+'); + + if ($fp) { + $locked = false; + + if (function_exists("flock")) { + $tries = 0; + + // try to lock logfile for writing + while ($tries < 5 && !$locked = flock($fp, LOCK_EX | LOCK_NB)) { + sleep(1); + ++$tries; + } + + if (!$locked) { + fclose($fp); + user_error("Unable to lock debugging log file: " . self::$logfile, E_USER_WARNING); + return false; + } + } + + fputs($fp, "[$ts] $message\n"); + + if (function_exists("flock")) { + flock($fp, LOCK_UN); + } + + fclose($fp); + + if (self::$quiet) + return false; + + } else { + user_error("Unable to open debugging log file: " . self::$logfile, E_USER_WARNING); + } + } + + if (self::$enable_html) { + if ($orig_message === self::SEPARATOR) { + print "$message\n"; + } else { + print "$ts $message\n"; + } + } else { + print "[$ts] $message\n"; + } + + return true; + } +} diff --git a/classes/Digest.php b/classes/Digest.php new file mode 100644 index 000000000..27009530f --- /dev/null +++ b/classes/Digest.php @@ -0,0 +1,209 @@ +query("SELECT id,email FROM ttrss_users + WHERE email != '' AND (last_digest_sent IS NULL OR $interval_qpart)"); + + while ($line = $res->fetch()) { + + if (get_pref(Prefs::DIGEST_ENABLE, $line['id'])) { + $preferred_ts = strtotime(get_pref(Prefs::DIGEST_PREFERRED_TIME, $line['id']) ?? ''); + + // try to send digests within 2 hours of preferred time + if ($preferred_ts && time() >= $preferred_ts && + time() - $preferred_ts <= 7200 + ) { + + Debug::log("Sending digest for UID:" . $line['id'] . " - " . $line["email"]); + + $do_catchup = get_pref(Prefs::DIGEST_CATCHUP, $line['id']); + + global $tz_offset; + + // reset tz_offset global to prevent tz cache clash between users + $tz_offset = -1; + + $tuple = Digest::prepare_headlines_digest($line["id"], 1, $limit); + $digest = $tuple[0]; + $headlines_count = $tuple[1]; + $affected_ids = $tuple[2]; + $digest_text = $tuple[3]; + + if ($headlines_count > 0) { + + $mailer = new Mailer(); + + //$rc = $mail->quickMail($line["email"], $line["login"], Config::get(Config::DIGEST_SUBJECT), $digest, $digest_text); + + $rc = $mailer->mail(["to_name" => $line["login"], + "to_address" => $line["email"], + "subject" => Config::get(Config::DIGEST_SUBJECT), + "message" => $digest_text, + "message_html" => $digest]); + + //if (!$rc && $debug) Debug::log("ERROR: " . $mailer->lastError()); + + Debug::log("RC=$rc"); + + if ($rc && $do_catchup) { + Debug::log("Marking affected articles as read..."); + Article::_catchup_by_id($affected_ids, Article::CATCHUP_MODE_MARK_AS_READ, $line["id"]); + } + } else { + Debug::log("No headlines"); + } + + $sth = $pdo->prepare("UPDATE ttrss_users SET last_digest_sent = NOW() + WHERE id = ?"); + $sth->execute([$line["id"]]); + + } + } + } + + $span->end(); + Debug::log("All done."); + } + + /** + * @return array{0: string, 1: int, 2: array, 3: string} + */ + static function prepare_headlines_digest(int $user_id, int $days = 1, int $limit = 1000) { + + $tpl = new Templator(); + $tpl_t = new Templator(); + + $tpl->readTemplateFromFile("digest_template_html.txt"); + $tpl_t->readTemplateFromFile("digest_template.txt"); + + $user_tz_string = get_pref(Prefs::USER_TIMEZONE, $user_id); + + if ($user_tz_string == 'Automatic') + $user_tz_string = 'GMT'; + + $local_ts = TimeHelper::convert_timestamp(time(), 'UTC', $user_tz_string); + + $tpl->setVariable('CUR_DATE', date('Y/m/d', $local_ts)); + $tpl->setVariable('CUR_TIME', date('G:i', $local_ts)); + $tpl->setVariable('TTRSS_HOST', Config::get_self_url()); + + $tpl_t->setVariable('CUR_DATE', date('Y/m/d', $local_ts)); + $tpl_t->setVariable('CUR_TIME', date('G:i', $local_ts)); + $tpl_t->setVariable('TTRSS_HOST', Config::get_self_url()); + + $affected_ids = array(); + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $interval_qpart = "ttrss_entries.date_updated > NOW() - INTERVAL '$days days'"; + } else /* if (Config::get(Config::DB_TYPE) == "mysql") */ { + $interval_qpart = "ttrss_entries.date_updated > DATE_SUB(NOW(), INTERVAL $days DAY)"; + } + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT ttrss_entries.title, + ttrss_feeds.title AS feed_title, + COALESCE(ttrss_feed_categories.title, '" . __('Uncategorized') . "') AS cat_title, + date_updated, + ttrss_user_entries.ref_id, + link, + score, + content, + ".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated + FROM + ttrss_user_entries,ttrss_entries,ttrss_feeds + LEFT JOIN + ttrss_feed_categories ON (cat_id = ttrss_feed_categories.id) + WHERE + ref_id = ttrss_entries.id AND feed_id = ttrss_feeds.id + AND include_in_digest = true + AND $interval_qpart + AND ttrss_user_entries.owner_uid = :user_id + AND unread = true + AND score >= 0 + ORDER BY ttrss_feed_categories.title, ttrss_feeds.title, score DESC, date_updated DESC + LIMIT " . (int)$limit); + $sth->execute([':user_id' => $user_id]); + + $headlines_count = 0; + $headlines = array(); + + while ($line = $sth->fetch()) { + array_push($headlines, $line); + $headlines_count++; + } + + for ($i = 0; $i < sizeof($headlines); $i++) { + + $line = $headlines[$i]; + + array_push($affected_ids, $line["ref_id"]); + + $updated = TimeHelper::make_local_datetime($line['last_updated'], false, + $user_id); + + if (get_pref(Prefs::ENABLE_FEED_CATS, $user_id)) { + $line['feed_title'] = $line['cat_title'] . " / " . $line['feed_title']; + } + + $article_labels = Article::_get_labels($line["ref_id"], $user_id); + $article_labels_formatted = ""; + + if (is_array($article_labels) && count($article_labels) > 0) { + $article_labels_formatted = implode(", ", array_map(fn($a) => $a[1], $article_labels)); + } + + $tpl->setVariable('FEED_TITLE', $line["feed_title"]); + $tpl->setVariable('ARTICLE_TITLE', $line["title"]); + $tpl->setVariable('ARTICLE_LINK', $line["link"]); + $tpl->setVariable('ARTICLE_UPDATED', $updated); + $tpl->setVariable('ARTICLE_EXCERPT', + truncate_string(strip_tags($line["content"]), 300)); +// $tpl->setVariable('ARTICLE_CONTENT', +// strip_tags($article_content)); + $tpl->setVariable('ARTICLE_LABELS', $article_labels_formatted, true); + + $tpl->addBlock('article'); + + $tpl_t->setVariable('FEED_TITLE', $line["feed_title"]); + $tpl_t->setVariable('ARTICLE_TITLE', $line["title"]); + $tpl_t->setVariable('ARTICLE_LINK', $line["link"]); + $tpl_t->setVariable('ARTICLE_UPDATED', $updated); + $tpl_t->setVariable('ARTICLE_LABELS', $article_labels_formatted, true); + $tpl_t->setVariable('ARTICLE_EXCERPT', + truncate_string(strip_tags($line["content"]), 300, "..."), true); + + $tpl_t->addBlock('article'); + + if ($headlines[$i]['feed_title'] != $headlines[$i + 1]['feed_title']) { + $tpl->addBlock('feed'); + $tpl_t->addBlock('feed'); + } + + } + + $tpl->addBlock('digest'); + $tpl->generateOutputToString($tmp); + + $tpl_t->addBlock('digest'); + $tpl_t->generateOutputToString($tmp_t); + + return array($tmp, $headlines_count, $affected_ids, $tmp_t); + } +} diff --git a/classes/DiskCache.php b/classes/DiskCache.php new file mode 100644 index 000000000..290fbd9c3 --- /dev/null +++ b/classes/DiskCache.php @@ -0,0 +1,492 @@ + $instances */ + private static $instances = []; + + /** + * https://stackoverflow.com/a/53662733 + * + * @var array + */ + private array $mimeMap = [ + 'video/3gpp2' => '3g2', + 'video/3gp' => '3gp', + 'video/3gpp' => '3gp', + 'application/x-compressed' => '7zip', + 'audio/x-acc' => 'aac', + 'audio/ac3' => 'ac3', + 'application/postscript' => 'ai', + 'audio/x-aiff' => 'aif', + 'audio/aiff' => 'aif', + 'audio/x-au' => 'au', + 'video/x-msvideo' => 'avi', + 'video/msvideo' => 'avi', + 'video/avi' => 'avi', + 'application/x-troff-msvideo' => 'avi', + 'application/macbinary' => 'bin', + 'application/mac-binary' => 'bin', + 'application/x-binary' => 'bin', + 'application/x-macbinary' => 'bin', + 'image/bmp' => 'bmp', + 'image/x-bmp' => 'bmp', + 'image/x-bitmap' => 'bmp', + 'image/x-xbitmap' => 'bmp', + 'image/x-win-bitmap' => 'bmp', + 'image/x-windows-bmp' => 'bmp', + 'image/ms-bmp' => 'bmp', + 'image/x-ms-bmp' => 'bmp', + 'application/bmp' => 'bmp', + 'application/x-bmp' => 'bmp', + 'application/x-win-bitmap' => 'bmp', + 'application/cdr' => 'cdr', + 'application/coreldraw' => 'cdr', + 'application/x-cdr' => 'cdr', + 'application/x-coreldraw' => 'cdr', + 'image/cdr' => 'cdr', + 'image/x-cdr' => 'cdr', + 'zz-application/zz-winassoc-cdr' => 'cdr', + 'application/mac-compactpro' => 'cpt', + 'application/pkix-crl' => 'crl', + 'application/pkcs-crl' => 'crl', + 'application/x-x509-ca-cert' => 'crt', + 'application/pkix-cert' => 'crt', + 'text/css' => 'css', + 'text/x-comma-separated-values' => 'csv', + 'text/comma-separated-values' => 'csv', + 'application/vnd.msexcel' => 'csv', + 'application/x-director' => 'dcr', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', + 'application/x-dvi' => 'dvi', + 'message/rfc822' => 'eml', + 'application/x-msdownload' => 'exe', + 'video/x-f4v' => 'f4v', + 'audio/x-flac' => 'flac', + 'video/x-flv' => 'flv', + 'image/gif' => 'gif', + 'application/gpg-keys' => 'gpg', + 'application/x-gtar' => 'gtar', + 'application/x-gzip' => 'gzip', + 'application/mac-binhex40' => 'hqx', + 'application/mac-binhex' => 'hqx', + 'application/x-binhex40' => 'hqx', + 'application/x-mac-binhex40' => 'hqx', + 'text/html' => 'html', + 'image/x-icon' => 'ico', + 'image/x-ico' => 'ico', + 'image/vnd.microsoft.icon' => 'ico', + 'text/calendar' => 'ics', + 'application/java-archive' => 'jar', + 'application/x-java-application' => 'jar', + 'application/x-jar' => 'jar', + 'image/jp2' => 'jp2', + 'video/mj2' => 'jp2', + 'image/jpx' => 'jp2', + 'image/jpm' => 'jp2', + 'image/jpeg' => 'jpg', + 'image/pjpeg' => 'jpg', + 'application/x-javascript' => 'js', + 'application/json' => 'json', + 'text/json' => 'json', + 'application/vnd.google-earth.kml+xml' => 'kml', + 'application/vnd.google-earth.kmz' => 'kmz', + 'text/x-log' => 'log', + 'audio/x-m4a' => 'm4a', + 'audio/mp4' => 'm4a', + 'application/vnd.mpegurl' => 'm4u', + 'audio/midi' => 'mid', + 'application/vnd.mif' => 'mif', + 'video/quicktime' => 'mov', + 'video/x-sgi-movie' => 'movie', + 'audio/mpeg' => 'mp3', + 'audio/mpg' => 'mp3', + 'audio/mpeg3' => 'mp3', + 'audio/mp3' => 'mp3', + 'video/mp4' => 'mp4', + 'video/mpeg' => 'mpeg', + 'application/oda' => 'oda', + 'audio/ogg' => 'ogg', + 'video/ogg' => 'ogg', + 'application/ogg' => 'ogg', + 'font/otf' => 'otf', + 'application/x-pkcs10' => 'p10', + 'application/pkcs10' => 'p10', + 'application/x-pkcs12' => 'p12', + 'application/x-pkcs7-signature' => 'p7a', + 'application/pkcs7-mime' => 'p7c', + 'application/x-pkcs7-mime' => 'p7c', + 'application/x-pkcs7-certreqresp' => 'p7r', + 'application/pkcs7-signature' => 'p7s', + 'application/pdf' => 'pdf', + 'application/octet-stream' => 'pdf', + 'application/x-x509-user-cert' => 'pem', + 'application/x-pem-file' => 'pem', + 'application/pgp' => 'pgp', + 'application/x-httpd-php' => 'php', + 'application/php' => 'php', + 'application/x-php' => 'php', + 'text/php' => 'php', + 'text/x-php' => 'php', + 'application/x-httpd-php-source' => 'php', + 'image/png' => 'png', + 'image/x-png' => 'png', + 'application/powerpoint' => 'ppt', + 'application/vnd.ms-powerpoint' => 'ppt', + 'application/vnd.ms-office' => 'ppt', + 'application/msword' => 'ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', + 'application/x-photoshop' => 'psd', + 'image/vnd.adobe.photoshop' => 'psd', + 'audio/x-realaudio' => 'ra', + 'audio/x-pn-realaudio' => 'ram', + 'application/x-rar' => 'rar', + 'application/rar' => 'rar', + 'application/x-rar-compressed' => 'rar', + 'audio/x-pn-realaudio-plugin' => 'rpm', + 'application/x-pkcs7' => 'rsa', + 'text/rtf' => 'rtf', + 'text/richtext' => 'rtx', + 'video/vnd.rn-realvideo' => 'rv', + 'application/x-stuffit' => 'sit', + 'application/smil' => 'smil', + 'text/srt' => 'srt', + 'image/svg+xml' => 'svg', + 'application/x-shockwave-flash' => 'swf', + 'application/x-tar' => 'tar', + 'application/x-gzip-compressed' => 'tgz', + 'image/tiff' => 'tiff', + 'font/ttf' => 'ttf', + 'text/plain' => 'txt', + 'text/x-vcard' => 'vcf', + 'application/videolan' => 'vlc', + 'text/vtt' => 'vtt', + 'audio/x-wav' => 'wav', + 'audio/wave' => 'wav', + 'audio/wav' => 'wav', + 'application/wbxml' => 'wbxml', + 'video/webm' => 'webm', + 'image/webp' => 'webp', + 'audio/x-ms-wma' => 'wma', + 'application/wmlc' => 'wmlc', + 'video/x-ms-wmv' => 'wmv', + 'video/x-ms-asf' => 'wmv', + 'font/woff' => 'woff', + 'font/woff2' => 'woff2', + 'application/xhtml+xml' => 'xhtml', + 'application/excel' => 'xl', + 'application/msexcel' => 'xls', + 'application/x-msexcel' => 'xls', + 'application/x-ms-excel' => 'xls', + 'application/x-excel' => 'xls', + 'application/x-dos_ms_excel' => 'xls', + 'application/xls' => 'xls', + 'application/x-xls' => 'xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', + 'application/vnd.ms-excel' => 'xlsx', + 'application/xml' => 'xml', + 'text/xml' => 'xml', + 'text/xsl' => 'xsl', + 'application/xspf+xml' => 'xspf', + 'application/x-compress' => 'z', + 'application/x-zip' => 'zip', + 'application/zip' => 'zip', + 'application/x-zip-compressed' => 'zip', + 'application/s-compressed' => 'zip', + 'multipart/x-zip' => 'zip', + 'text/x-scriptzsh' => 'zsh' + ]; + + public static function instance(string $dir) : DiskCache { + if ((self::$instances[$dir] ?? null) == null) + self::$instances[$dir] = new self($dir); + + return self::$instances[$dir]; + } + + public function __construct(string $dir) { + foreach (PluginHost::getInstance()->get_plugins() as $n => $p) { + if (implements_interface($p, "Cache_Adapter")) { + + /** @var Cache_Adapter $p */ + $this->adapter = clone $p; // we need separate object instances for separate directories + $this->adapter->set_dir($dir); + return; + } + } + + $this->adapter = new Cache_Local(); + $this->adapter->set_dir($dir); + } + + public function remove(string $filename): bool { + $span = Tracer::start(__METHOD__); + $span->setAttribute('file.name', $filename); + + $rc = $this->adapter->remove($filename); + $span->end(); + + return $rc; + } + + public function set_dir(string $dir) : void { + $this->adapter->set_dir($dir); + } + + /** + * @return int|false -1 if the file doesn't exist, false if an error occurred, timestamp otherwise + */ + public function get_mtime(string $filename) { + return $this->adapter->get_mtime(basename($filename)); + } + + public function make_dir(): bool { + return $this->adapter->make_dir(); + } + + /** @param string|null $filename null means check that cache directory itself is writable */ + public function is_writable(?string $filename = null): bool { + return $this->adapter->is_writable($filename ? basename($filename) : null); + } + + public function exists(string $filename): bool { + $span = OpenTelemetry\API\Trace\Span::getCurrent(); + $span->addEvent("DiskCache::exists: $filename"); + + $rc = $this->adapter->exists(basename($filename)); + + return $rc; + } + + /** + * @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) { + $span = Tracer::start(__METHOD__); + $span->setAttribute('file.name', $filename); + + $rc = $this->adapter->get_size(basename($filename)); + $span->end(); + + return $rc; + } + + /** + * @param mixed $data + * + * @return int|false Bytes written or false if an error occurred. + */ + public function put(string $filename, $data) { + $span = Tracer::start(__METHOD__); + $rc = $this->adapter->put(basename($filename), $data); + $span->end(); + + return $rc; + } + + /** @deprecated we can't assume cached files are local, and other storages + * might not support this operation (object metadata may be immutable) */ + public function touch(string $filename): bool { + user_error("DiskCache: called unsupported method touch() for $filename", E_USER_DEPRECATED); + + return false; + } + + public function get(string $filename): ?string { + return $this->adapter->get(basename($filename)); + } + + public function expire_all(): void { + $this->adapter->expire_all(); + } + + public function get_dir(): string { + return $this->adapter->get_dir(); + } + + /** Downloads $url to cache as $local_filename if its missing (unless $force-ed) + * @param string $url + * @param string $local_filename + * @param array $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 send(string $filename) { + $span = Tracer::start(__METHOD__); + $span->setAttribute('file.name', $filename); + + $filename = basename($filename); + + if (!$this->exists($filename)) { + header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); + echo "File not found."; + + $span->setAttribute('error', '404 not found'); + $span->end(); + return false; + } + + $file_mtime = $this->get_mtime($filename); + $gmt_modified = gmdate("D, d M Y H:i:s", (int)$file_mtime) . " GMT"; + + if (($_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '') == $gmt_modified || ($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') == $file_mtime) { + header('HTTP/1.1 304 Not Modified'); + + $span->setAttribute('error', '304 not modified'); + $span->end(); + return false; + } + + $mimetype = $this->get_mime_type($filename); + + if ($mimetype == "application/octet-stream") + $mimetype = "video/mp4"; + + # block SVG because of possible embedded javascript (.....) + $mimetype_blacklist = [ "image/svg+xml" ]; + + /* only serve video and images */ + if (!preg_match("/(image|audio|video)\//", (string)$mimetype) || in_array($mimetype, $mimetype_blacklist)) { + http_response_code(400); + header("Content-type: text/plain"); + + print "Stored file has disallowed content type ($mimetype)"; + + $span->setAttribute('error', '400 disallowed content type'); + $span->end(); + return false; + } + + $fake_extension = $this->get_fake_extension($filename); + + if ($fake_extension) + $fake_extension = ".$fake_extension"; + + header("Content-Disposition: inline; filename=\"{$filename}{$fake_extension}\""); + header("Content-type: $mimetype"); + + $stamp_expires = gmdate("D, d M Y H:i:s", + (int)$this->get_mtime($filename) + 86400 * Config::get(Config::CACHE_MAX_DAYS)) . " GMT"; + + header("Expires: $stamp_expires", true); + header("Last-Modified: $gmt_modified", true); + header("Cache-Control: no-cache"); + header("ETag: $file_mtime"); + + header_remove("Pragma"); + + $span->setAttribute('mimetype', $mimetype); + + $rc = $this->adapter->send($filename); + + $span->end(); + + return $rc; + } + + public function get_full_path(string $filename): string { + return $this->adapter->get_full_path(basename($filename)); + } + + public function get_mime_type(string $filename) { + return $this->adapter->get_mime_type(basename($filename)); + } + + public function get_fake_extension(string $filename): string { + $mimetype = $this->adapter->get_mime_type(basename($filename)); + + if ($mimetype) + return isset($this->mimeMap[$mimetype]) ? $this->mimeMap[$mimetype] : ""; + else + return ""; + } + + public function get_url(string $filename): string { + return Config::get_self_url() . "/public.php?op=cached&file=" . basename($this->adapter->get_dir()) . "/" . basename($filename); + } + + // check for locally cached (media) URLs and rewrite to local versions + // 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(string $str): string { + $span = OpenTelemetry\API\Trace\Span::getCurrent(); + $span->addEvent("DiskCache::rewrite_urls"); + + $res = trim($str); + + if (!$res) { + $span->end(); + return ''; + } + + $doc = new DOMDocument(); + if (@$doc->loadHTML('' . $res)) { + $xpath = new DOMXPath($doc); + $cache = DiskCache::instance("images"); + + $entries = $xpath->query('(//img[@src]|//source[@src|@srcset]|//video[@poster|@src])'); + + $need_saving = false; + + foreach ($entries as $entry) { + $span->addEvent("entry: " . $entry->tagName); + + foreach (array('src', 'poster') as $attr) { + if ($entry->hasAttribute($attr)) { + $url = $entry->getAttribute($attr); + $cached_filename = sha1($url); + + if ($cache->exists($cached_filename)) { + $url = $cache->get_url($cached_filename); + + $entry->setAttribute($attr, $url); + $entry->removeAttribute("srcset"); + + $need_saving = true; + } + } + } + + if ($entry->hasAttribute("srcset")) { + $matches = RSSUtils::decode_srcset($entry->getAttribute('srcset')); + + for ($i = 0; $i < count($matches); $i++) { + $cached_filename = sha1($matches[$i]["url"]); + + if ($cache->exists($cached_filename)) { + $matches[$i]["url"] = $cache->get_url($cached_filename); + + $need_saving = true; + } + } + + $entry->setAttribute("srcset", RSSUtils::encode_srcset($matches)); + } + } + + if ($need_saving) { + if (isset($doc->firstChild)) + $doc->removeChild($doc->firstChild); //remove doctype + + $res = $doc->saveHTML(); + } + } + + return $res; + } +} diff --git a/classes/Errors.php b/classes/Errors.php new file mode 100644 index 000000000..aa626d017 --- /dev/null +++ b/classes/Errors.php @@ -0,0 +1,40 @@ + $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 new file mode 100644 index 000000000..b5f5cc411 --- /dev/null +++ b/classes/FeedEnclosure.php @@ -0,0 +1,21 @@ + */ + abstract function get_categories(): array; + + /** @return array */ + 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 new file mode 100644 index 000000000..f6c96f959 --- /dev/null +++ b/classes/FeedItem_Atom.php @@ -0,0 +1,224 @@ +elem->getElementsByTagName("id")->item(0); + + if ($id) { + return $id->nodeValue; + } else { + return clean($this->get_link()); + } + } + + /** + * @return int|false a timestamp on success, false otherwise + */ + function get_date() { + $updated = $this->elem->getElementsByTagName("updated")->item(0); + + if ($updated) { + return strtotime($updated->nodeValue ?? ''); + } + + $published = $this->elem->getElementsByTagName("published")->item(0); + + if ($published) { + return strtotime($published->nodeValue ?? ''); + } + + $date = $this->xpath->query("dc:date", $this->elem)->item(0); + + if ($date) { + return strtotime($date->nodeValue ?? ''); + } + + // consistent with strtotime failing to parse + return false; + } + + + function get_link(): string { + $links = $this->elem->getElementsByTagName("link"); + + foreach ($links as $link) { + /** @phpstan-ignore-next-line */ + if ($link->hasAttribute("href") && + (!$link->hasAttribute("rel") + || $link->getAttribute("rel") == "alternate" + || $link->getAttribute("rel") == "standout")) { + $base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $link); + + if ($base) + return UrlHelper::rewrite_relative($base, clean(trim($link->getAttribute("href")))); + else + return clean(trim($link->getAttribute("href"))); + } + } + + return ''; + } + + function get_title(): string { + $title = $this->elem->getElementsByTagName("title")->item(0); + return $title ? clean(trim($title->nodeValue)) : ''; + } + + /** + * @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)) { + + $tmpdoc = new DOMDocument(); + if (@$tmpdoc->loadHTML('' . $content)) { + $tmpxpath = new DOMXPath($tmpdoc); + + $elems = $tmpxpath->query("(//*[@href]|//*[@src])"); + + foreach ($elems as $elem) { + if ($elem->hasAttribute("href")) { + $elem->setAttribute("href", + UrlHelper::rewrite_relative($base, $elem->getAttribute("href"))); + } else if ($elem->hasAttribute("src")) { + $elem->setAttribute("src", + UrlHelper::rewrite_relative($base, $elem->getAttribute("src"))); + } + } + + // 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(): string { + /** @var DOMElement|null */ + $content = $this->elem->getElementsByTagName("content")->item(0); + + if ($content) { + $base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $content); + + if ($content->hasAttribute('type')) { + if ($content->getAttribute('type') == 'xhtml') { + for ($i = 0; $i < $content->childNodes->length; $i++) { + $child = $content->childNodes->item($i); + + if ($child->hasChildNodes()) { + return $this->rewrite_content_to_base($base, $this->doc->saveHTML($child)); + } + } + } + } + + 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(): string { + /** @var DOMElement|null */ + $content = $this->elem->getElementsByTagName("summary")->item(0); + + if ($content) { + $base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $content); + + if ($content->hasAttribute('type')) { + if ($content->getAttribute('type') == 'xhtml') { + for ($i = 0; $i < $content->childNodes->length; $i++) { + $child = $content->childNodes->item($i); + + if ($child->hasChildNodes()) { + return $this->rewrite_content_to_base($base, $this->doc->saveHTML($child)); + } + } + } + } + + return $this->rewrite_content_to_base($base, $this->subtree_or_text($content)); + } + + return ''; + } + + /** + * @return array + */ + function get_categories(): array { + $categories = $this->elem->getElementsByTagName("category"); + $cats = []; + + foreach ($categories as $cat) { + if ($cat->hasAttribute("term")) + array_push($cats, $cat->getAttribute("term")); + } + + $categories = $this->xpath->query("dc:subject", $this->elem); + + foreach ($categories as $cat) { + array_push($cats, $cat->nodeValue); + } + + return $this->normalize_categories($cats); + } + + /** + * @return array + */ + function get_enclosures(): array { + $links = $this->elem->getElementsByTagName("link"); + + $encs = []; + + foreach ($links as $link) { + /** @phpstan-ignore-next-line */ + if ($link->hasAttribute("href") && $link->hasAttribute("rel")) { + $base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $link); + + if ($link->getAttribute("rel") == "enclosure") { + $enc = new FeedEnclosure(); + + $enc->type = clean($link->getAttribute("type")); + $enc->length = clean($link->getAttribute("length")); + $enc->link = clean($link->getAttribute("href")); + + if (!empty($base)) { + $enc->link = UrlHelper::rewrite_relative($base, $enc->link); + } + + array_push($encs, $enc); + } + } + } + + array_push($encs, ...parent::get_enclosures()); + + return $encs; + } + + function get_language(): string { + $lang = $this->elem->getAttributeNS(self::NS_XML, "lang"); + + if (!empty($lang)) { + return clean($lang); + } else { + // Fall back to the language declared on the feed, if any. + foreach ($this->doc->childNodes as $child) { + if (method_exists($child, "getAttributeNS")) { + return clean($child->getAttributeNS(self::NS_XML, "lang")); + } + } + } + return ''; + } +} diff --git a/classes/FeedItem_Common.php b/classes/FeedItem_Common.php new file mode 100644 index 000000000..fde481179 --- /dev/null +++ b/classes/FeedItem_Common.php @@ -0,0 +1,221 @@ +elem = $elem; + $this->xpath = $xpath; + $this->doc = $doc; + + try { + $source = $elem->getElementsByTagName("source")->item(0); + + // we don't need element + if ($source) + $elem->removeChild($source); + } catch (DOMException $e) { + // + } + } + + function get_element(): DOMElement { + return $this->elem; + } + + function get_author(): string { + /** @var DOMElement|null */ + $author = $this->elem->getElementsByTagName("author")->item(0); + + if ($author) { + $name = $author->getElementsByTagName("name")->item(0); + + if ($name) return clean($name->nodeValue); + + $email = $author->getElementsByTagName("email")->item(0); + + if ($email) return clean($email->nodeValue); + + if ($author->nodeValue) + return clean($author->nodeValue); + } + + $author_elems = $this->xpath->query("dc:creator", $this->elem); + $authors = []; + + foreach ($author_elems as $author) { + array_push($authors, clean($author->nodeValue)); + } + + return implode(", ", $authors); + } + + 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); + + if ($com_url) + return clean($com_url->nodeValue); + + //Atom Threading Extension (RFC 4685) stuff. Could be used in RSS feeds, so it's in common. + //'text/html' for type is too restrictive? + $com_url = $this->xpath->query("atom:link[@rel='replies' and contains(@type,'text/html')]/@href", $this->elem)->item(0); + + if ($com_url) + return clean($com_url->nodeValue); + + return ''; + } + + 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 && 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 + * + * @return array + */ + function get_enclosures(): array { + $encs = []; + + $enclosures = $this->xpath->query("media:content", $this->elem); + + foreach ($enclosures as $enclosure) { + $enc = new FeedEnclosure(); + + $enc->type = clean($enclosure->getAttribute("type")); + $enc->link = clean($enclosure->getAttribute("url")); + $enc->length = clean($enclosure->getAttribute("length")); + $enc->height = clean($enclosure->getAttribute("height")); + $enc->width = clean($enclosure->getAttribute("width")); + + $medium = clean($enclosure->getAttribute("medium")); + if (!$enc->type && $medium) { + $enc->type = strtolower("$medium/generic"); + } + + $desc = $this->xpath->query("media:description", $enclosure)->item(0); + if ($desc) $enc->title = clean($desc->nodeValue); + + array_push($encs, $enc); + } + + $enclosures = $this->xpath->query("media:group", $this->elem); + + foreach ($enclosures as $enclosure) { + $enc = new FeedEnclosure(); + + /** @var DOMElement|null */ + $content = $this->xpath->query("media:content", $enclosure)->item(0); + + if ($content) { + $enc->type = clean($content->getAttribute("type")); + $enc->link = clean($content->getAttribute("url")); + $enc->length = clean($content->getAttribute("length")); + $enc->height = clean($content->getAttribute("height")); + $enc->width = clean($content->getAttribute("width")); + + $medium = clean($content->getAttribute("medium")); + if (!$enc->type && $medium) { + $enc->type = strtolower("$medium/generic"); + } + + $desc = $this->xpath->query("media:description", $content)->item(0); + if ($desc) { + $enc->title = clean($desc->nodeValue); + } else { + $desc = $this->xpath->query("media:description", $enclosure)->item(0); + if ($desc) $enc->title = clean($desc->nodeValue); + } + + array_push($encs, $enc); + } + } + + $enclosures = $this->xpath->query("media:thumbnail", $this->elem); + + foreach ($enclosures as $enclosure) { + $enc = new FeedEnclosure(); + + $enc->type = "image/generic"; + $enc->link = clean($enclosure->getAttribute("url")); + $enc->height = clean($enclosure->getAttribute("height")); + $enc->width = clean($enclosure->getAttribute("width")); + + array_push($encs, $enc); + } + + return $encs; + } + + function count_children(DOMElement $node): int { + return $node->getElementsByTagName("*")->length; + } + + /** + * @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 { + return $node->c14n(); + } + } + + /** + * @param array $cats + * + * @return array + */ + static function normalize_categories(array $cats): array { + + $tmp = []; + + foreach ($cats as $rawcat) { + array_push($tmp, ...explode(",", $rawcat)); + } + + $tmp = array_map(function($srccat) { + $cat = clean(trim(mb_strtolower($srccat))); + + // we don't support numeric tags + if (is_numeric($cat)) + $cat = 't:' . $cat; + + $cat = preg_replace('/[,\'\"]/', "", $cat); + + if (Config::get(Config::DB_TYPE) == "mysql") { + $cat = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $cat); + } + + if (mb_strlen($cat) > 250) + $cat = mb_substr($cat, 0, 250); + + return $cat; + }, $tmp); + + // remove empty values + $tmp = array_filter($tmp, 'strlen'); + + asort($tmp); + + return array_unique($tmp); + } +} diff --git a/classes/FeedItem_RSS.php b/classes/FeedItem_RSS.php new file mode 100644 index 000000000..b5710ef4f --- /dev/null +++ b/classes/FeedItem_RSS.php @@ -0,0 +1,169 @@ +elem->getElementsByTagName("guid")->item(0); + + if ($id) { + return clean($id->nodeValue); + } else { + return clean($this->get_link()); + } + } + + /** + * @return int|false a timestamp on success, false otherwise + */ + function get_date() { + $pubDate = $this->elem->getElementsByTagName("pubDate")->item(0); + + if ($pubDate) { + return strtotime($pubDate->nodeValue ?? ''); + } + + $date = $this->xpath->query("dc:date", $this->elem)->item(0); + + if ($date) { + return strtotime($date->nodeValue ?? ''); + } + + // consistent with strtotime failing to parse + return false; + } + + function get_link(): string { + $links = $this->xpath->query("atom:link", $this->elem); + + foreach ($links as $link) { + if ($link && $link->hasAttribute("href") && + (!$link->hasAttribute("rel") + || $link->getAttribute("rel") == "alternate" + || $link->getAttribute("rel") == "standout")) { + + return clean(trim($link->getAttribute("href"))); + } + } + + /** @var DOMElement|null */ + $link = $this->elem->getElementsByTagName("guid")->item(0); + + if ($link && $link->hasAttributes() && $link->getAttribute("isPermaLink") == "true") { + return clean(trim($link->nodeValue)); + } + + $link = $this->elem->getElementsByTagName("link")->item(0); + + if ($link) { + return clean(trim($link->nodeValue)); + } + + return ''; + } + + function get_title(): string { + $title = $this->xpath->query("title", $this->elem)->item(0); + + if ($title) { + return clean(trim($title->nodeValue)); + } + + // if the document has a default namespace then querying for + // title would fail because of reasons so let's try the old way + $title = $this->elem->getElementsByTagName("title")->item(0); + + if ($title) { + return clean(trim($title->nodeValue)); + } + + return ''; + } + + 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) { + $resultA = $this->subtree_or_text($contentA); + $resultB = $this->subtree_or_text($contentB); + + return mb_strlen($resultA) > mb_strlen($resultB) ? $resultA : $resultB; + } + + if ($contentA) { + return $this->subtree_or_text($contentA); + } + + if ($contentB) { + return $this->subtree_or_text($contentB); + } + + return ''; + } + + function get_description(): string { + $summary = $this->elem->getElementsByTagName("description")->item(0); + + if ($summary) { + return $summary->nodeValue; + } + + return ''; + } + + /** + * @return array + */ + function get_categories(): array { + $categories = $this->elem->getElementsByTagName("category"); + $cats = []; + + foreach ($categories as $cat) { + array_push($cats, $cat->nodeValue); + } + + $categories = $this->xpath->query("dc:subject", $this->elem); + + foreach ($categories as $cat) { + array_push($cats, $cat->nodeValue); + } + + return $this->normalize_categories($cats); + } + + /** + * @return array + */ + function get_enclosures(): array { + $enclosures = $this->elem->getElementsByTagName("enclosure"); + + $encs = array(); + + foreach ($enclosures as $enclosure) { + $enc = new FeedEnclosure(); + + $enc->type = clean($enclosure->getAttribute("type")); + $enc->link = clean($enclosure->getAttribute("url")); + $enc->length = clean($enclosure->getAttribute("length")); + $enc->height = clean($enclosure->getAttribute("height")); + $enc->width = clean($enclosure->getAttribute("width")); + + array_push($encs, $enc); + } + + array_push($encs, ...parent::get_enclosures()); + + return $encs; + } + + function get_language(): string { + $languages = $this->doc->getElementsByTagName('language'); + + if (count($languages) == 0) { + return ""; + } + + return clean($languages[0]->textContent); + } +} diff --git a/classes/FeedParser.php b/classes/FeedParser.php new file mode 100644 index 000000000..4b9c63f56 --- /dev/null +++ b/classes/FeedParser.php @@ -0,0 +1,245 @@ + */ + private $libxml_errors = []; + + /** @var array */ + 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(string $data) { + libxml_use_internal_errors(true); + libxml_clear_errors(); + $this->doc = new DOMDocument(); + $this->doc->loadXML($data); + + mb_substitute_character("none"); + + $error = libxml_get_last_error(); + + if ($error) { + foreach (libxml_get_errors() as $error) { + if ($error->level == LIBXML_ERR_FATAL) { + // currently only the first error is reported + $this->error ??= Errors::format_libxml_error($error); + $this->libxml_errors[] = Errors::format_libxml_error($error); + } + } + } + libxml_clear_errors(); + } + + function init() : void { + $root = $this->doc->firstChild; + $xpath = new DOMXPath($this->doc); + $xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom'); + $xpath->registerNamespace('atom03', 'http://purl.org/atom/ns#'); + $xpath->registerNamespace('media', 'http://search.yahoo.com/mrss/'); + $xpath->registerNamespace('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'); + $xpath->registerNamespace('slash', 'http://purl.org/rss/1.0/modules/slash/'); + $xpath->registerNamespace('dc', 'http://purl.org/dc/elements/1.1/'); + $xpath->registerNamespace('content', 'http://purl.org/rss/1.0/modules/content/'); + $xpath->registerNamespace('thread', 'http://purl.org/syndication/thread/1.0'); + + $this->xpath = $xpath; + + $root_list = $xpath->query("(//atom03:feed|//atom:feed|//channel|//rdf:rdf|//rdf:RDF)"); + + if (!empty($root_list) && $root_list->length > 0) { + + /** @var DOMElement|null $root */ + $root = $root_list->item(0); + + if ($root) { + switch (mb_strtolower($root->tagName)) { + case "rdf:rdf": + $this->type = $this::FEED_RDF; + break; + case "channel": + $this->type = $this::FEED_RSS; + break; + case "feed": + case "atom:feed": + $this->type = $this::FEED_ATOM; + break; + default: + $this->error ??= "Unknown/unsupported feed type"; + return; + } + } + + switch ($this->type) { + case $this::FEED_ATOM: + + $title = $xpath->query("//atom:feed/atom:title")->item(0); + + if (!$title) + $title = $xpath->query("//atom03:feed/atom03:title")->item(0); + + + if ($title) { + $this->title = $title->nodeValue; + } + + $link = $xpath->query("//atom:feed/atom:link[not(@rel)]")->item(0); + + if (!$link) + $link = $xpath->query("//atom:feed/atom:link[@rel='alternate']")->item(0); + + if (!$link) + $link = $xpath->query("//atom03:feed/atom03:link[not(@rel)]")->item(0); + + 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"); + } + + $articles = $xpath->query("//atom:entry"); + + if (empty($articles) || $articles->length == 0) + $articles = $xpath->query("//atom03:entry"); + + foreach ($articles as $article) { + array_push($this->items, new FeedItem_Atom($article, $this->doc, $this->xpath)); + } + + break; + case $this::FEED_RSS: + $title = $xpath->query("//channel/title")->item(0); + + if ($title) { + $this->title = $title->nodeValue; + } + + /** @var DOMElement|null $link */ + $link = $xpath->query("//channel/link")->item(0); + + if ($link) { + if ($link->getAttribute("href")) + $this->link = $link->getAttribute("href"); + else if ($link->nodeValue) + $this->link = $link->nodeValue; + } + + $articles = $xpath->query("//channel/item"); + + foreach ($articles as $article) { + array_push($this->items, new FeedItem_RSS($article, $this->doc, $this->xpath)); + } + + break; + case $this::FEED_RDF: + $xpath->registerNamespace('rssfake', 'http://purl.org/rss/1.0/'); + + $title = $xpath->query("//rssfake:channel/rssfake:title")->item(0); + + if ($title) { + $this->title = $title->nodeValue; + } + + $link = $xpath->query("//rssfake:channel/rssfake:link")->item(0); + + if ($link) { + $this->link = $link->nodeValue; + } + + $articles = $xpath->query("//rssfake:item"); + + foreach ($articles as $article) { + array_push($this->items, new FeedItem_RSS($article, $this->doc, $this->xpath)); + } + + break; + + } + + if ($this->title) $this->title = trim($this->title); + if ($this->link) $this->link = trim($this->link); + + } else { + $this->error ??= "Unknown/unsupported feed type"; + 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() : string { + return UConverter::transcode($this->error ?? '', 'UTF-8', 'UTF-8'); + } + + /** @return array - WARNING: may return invalid unicode data */ + function errors() : array { + return $this->libxml_errors; + } + + function get_link() : string { + return clean($this->link ?? ''); + } + + function get_title() : string { + return clean($this->title ?? ''); + } + + /** @return array */ + function get_items() : array { + return $this->items; + } + + /** @return array */ + function get_links(string $rel) : array { + $rv = array(); + + switch ($this->type) { + case $this::FEED_ATOM: + $links = $this->xpath->query("//atom:feed/atom:link"); + + foreach ($links as $link) { + if (!$rel || $link->hasAttribute('rel') && $link->getAttribute('rel') == $rel) { + array_push($rv, clean(trim($link->getAttribute('href')))); + } + } + break; + case $this::FEED_RSS: + $links = $this->xpath->query("//atom:link"); + + foreach ($links as $link) { + if (!$rel || $link->hasAttribute('rel') && $link->getAttribute('rel') == $rel) { + array_push($rv, clean(trim($link->getAttribute('href')))); + } + } + break; + } + + return $rv; + } +} diff --git a/classes/Feeds.php b/classes/Feeds.php new file mode 100644 index 000000000..a97ac221f --- /dev/null +++ b/classes/Feeds.php @@ -0,0 +1,2507 @@ +, 1: int, 2: int, 3: bool, 4: array} $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 = false, ? string $order_by = ''): array { + + $disable_cache = false; + + $span = Tracer::start(__METHOD__); + $span->setAttribute('func.args', json_encode(func_get_args())); + + $reply = []; + $rgba_cache = []; + $topmost_article_ids = []; + + if (!$offset) $offset = 0; + if ($method == "undefined") $method = ""; + + $method_split = explode(":", $method); + + if ($method == "ForceUpdate" && $feed > 0 && is_numeric($feed)) { + $sth = $this->pdo->prepare("UPDATE ttrss_feeds + SET last_updated = '1970-01-01', last_update_started = '1970-01-01' + WHERE id = ?"); + $sth->execute([$feed]); + } + + if ($method_split[0] == "MarkAllReadGR") { + $this->_catchup($method_split[1], false); + } + + // FIXME: might break tag display? + + if (is_numeric($feed) && $feed > 0 && !$cat_view) { + $sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE id = ? LIMIT 1"); + $sth->execute([$feed]); + + if (!$sth->fetch()) { + $reply['content'] = "
".__('Feed not found.')."
"; + } + } + + $search = $_REQUEST["query"] ?? ""; + $search_language = $_REQUEST["search_language"] ?? ""; // PGSQL only + + if ($search) { + $disable_cache = true; + } + + $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)); + + if ($handler) { + $options = array( + "limit" => $limit, + "view_mode" => $view_mode, + "cat_view" => $cat_view, + "search" => $search, + "override_order" => $override_order, + "offset" => $offset, + "owner_uid" => $_SESSION["uid"], + "filter" => false, + "since_id" => 0, + "include_children" => $include_children, + "order_by" => $order_by); + + $qfh_ret = $handler->get_headlines(PluginHost::feed_to_pfeed_id($feed), + $options); + } + + } else { + + $params = array( + "feed" => $feed, + "limit" => $limit, + "view_mode" => $view_mode, + "cat_view" => $cat_view, + "search" => $search, + "search_language" => $search_language, + "override_order" => $override_order, + "offset" => $offset, + "include_children" => $include_children, + "check_first_id" => $check_first_id, + "skip_first_id_check" => $skip_first_id_check, + "order_by" => $order_by + ); + + $qfh_ret = $this->_get_headlines($params); + } + + $vfeed_group_enabled = get_pref(Prefs::VFEED_GROUP_BY_FEED) && + !(in_array($feed, self::NEVER_GROUP_FEEDS) && !$cat_view); + + $result = $qfh_ret[0]; // this could be either a PDO query result or a -1 if first id changed + $feed_title = $qfh_ret[1]; + $feed_site_url = $qfh_ret[2]; + $last_error = $qfh_ret[3]; + $last_updated = strpos($qfh_ret[4] ?? "", '1970-') === false ? + TimeHelper::make_local_datetime($qfh_ret[4], false) : __("Never"); + $highlight_words = $qfh_ret[5]; + $reply['first_id'] = $qfh_ret[6]; + $reply['is_vfeed'] = $qfh_ret[7]; + $query_error_override = $qfh_ret[8]; + + $reply['search_query'] = [$search, $search_language]; + $reply['vfeed_group_enabled'] = $vfeed_group_enabled; + + $span->addEvent('plugin_menu_items'); + + $plugin_menu_items = ""; + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2, + function ($result) use (&$plugin_menu_items) { + $plugin_menu_items .= $result; + }, + $feed, $cat_view); + + $plugin_buttons = ""; + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HEADLINE_TOOLBAR_BUTTON, + function ($result) use (&$plugin_buttons) { + $plugin_buttons .= $result; + }, + $feed, $cat_view); + + $reply['toolbar'] = [ + 'site_url' => $feed_site_url, + 'title' => strip_tags($feed_title), + 'error' => $last_error, + 'last_updated' => $last_updated, + 'plugin_menu_items' => $plugin_menu_items, + 'plugin_buttons' => $plugin_buttons, + ]; + + $reply['content'] = []; + + if ($offset == 0) + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HEADLINES_BEFORE, + function ($result) use (&$reply) { + $reply['content'] .= $result; + }, + $feed, $cat_view, $qfh_ret); + + $span->addEvent('articles'); + + $headlines_count = 0; + + if ($result instanceof PDOStatement) { + while ($line = $result->fetch(PDO::FETCH_ASSOC)) { + $span->addEvent('article: ' . $line['id']); + + ++$headlines_count; + + if (!get_pref(Prefs::SHOW_CONTENT_PREVIEW)) { + $line["content_preview"] = ""; + } else { + $line["content_preview"] = "— " . truncate_string(strip_tags($line["content"]), 250); + + $max_excerpt_length = 250; + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES, + function ($result) use (&$line) { + $line = $result; + }, + $line, $max_excerpt_length); + } + + $id = $line["id"]; + + // frontend doesn't expect pdo returning booleans as strings on mysql + if (Config::get(Config::DB_TYPE) == "mysql") { + foreach (["unread", "marked", "published"] as $k) { + if (is_integer($line[$k])) { + $line[$k] = $line[$k] === 1; + } else { + $line[$k] = $line[$k] === "1"; + } + } + } + + // normalize archived feed + if ($line['feed_id'] === null) { + $line['feed_id'] = Feeds::FEED_ARCHIVED; + $line["feed_title"] = __("Archived articles"); + } + + $feed_id = $line["feed_id"]; + + if ($line["num_labels"] > 0) { + $label_cache = $line["label_cache"]; + $labels = false; + + if ($label_cache) { + $label_cache = json_decode($label_cache, true); + + if ($label_cache) { + if ($label_cache["no-labels"] ?? 0 == 1) + $labels = []; + else + $labels = $label_cache; + } + } else { + $labels = Article::_get_labels($id); + } + + $line["labels"] = $labels; + } else { + $line["labels"] = []; + } + + if (count($topmost_article_ids) < 3) { + array_push($topmost_article_ids, $id); + } + + $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, $plugin) use (&$line, &$button_doc) { + if ($result && $button_doc->loadXML($result)) { + + /** @var DOMElement|null $child */ + $child = $button_doc->firstChild; + + if ($child) { + do { + /** @var DOMElement|null $child */ + $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, $plugin) use (&$line, &$button_doc) { + if ($result && $button_doc->loadXML($result)) { + + /** @var DOMElement|null $child */ + $child = $button_doc->firstChild; + + if ($child) { + do { + /** @var DOMElement|null $child */ + $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); + + $line["content"] = Sanitizer::sanitize($line["content"], + $line['hide_images'], null, $line["site_url"], $highlight_words, $line["id"]); + + if (!get_pref(Prefs::CDM_EXPANDED)) { + $line["cdm_excerpt"] = " + remove_circle"; + + if (get_pref(Prefs::SHOW_CONTENT_PREVIEW)) { + $line["cdm_excerpt"] .= "" . $line["content_preview"] . ""; + } + } + + if ($line["num_enclosures"] > 0) { + $line["enclosures"] = Article::_format_enclosures($id, + sql_bool_to_bool($line["always_display_enclosures"]), + $line["content"], + sql_bool_to_bool($line["hide_images"])); + } else { + $line["enclosures"] = [ 'formatted' => '', 'entries' => [] ]; + } + + $line["updated_long"] = TimeHelper::make_local_datetime($line["updated"],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)); + + if ($line["tag_cache"]) + $tags = explode(",", $line["tag_cache"]); + else + $tags = []; + + $line["tags"] = $tags; + + //$line["tags"] = Article::_get_tags($line["id"], false, $line["tag_cache"]); + + $line['has_icon'] = self::_has_icon($feed_id); + + //setting feed headline background color, needs to change text color based on dark/light + $fav_color = $line['favicon_avg_color'] ?? false; + + $span->addEvent("colors"); + + require_once "colors.php"; + + if (!isset($rgba_cache[$feed_id])) { + if ($fav_color && $fav_color != 'fail') { + $rgba_cache[$feed_id] = \Colors\_color_unpack($fav_color); + } else { + $rgba_cache[$feed_id] = \Colors\_color_unpack($this->_color_of($line['feed_title'])); + } + } + + if (isset($rgba_cache[$feed_id])) { + $line['feed_bg_color'] = 'rgba(' . implode(",", $rgba_cache[$feed_id]) . ',0.3)'; + } + + $span->addEvent("HOOK_RENDER_ARTICLE_CDM"); + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_CDM, + function ($result, $plugin) use (&$line) { + $line = $result; + }, + $line); + + $line['content'] = DiskCache::rewrite_urls($line['content']); + + /* we don't need those */ + + foreach (["date_entered", "guid", "last_published", "last_marked", "tag_cache", "favicon_avg_color", + "uuid", "label_cache", "yyiw", "num_enclosures"] as $k) + unset($line[$k]); + + array_push($reply['content'], $line); + } + } + + if (!$headlines_count) { + + if ($result instanceof PDOStatement) { + + if ($query_error_override) { + $message = $query_error_override; + } else { + switch ($view_mode) { + case "unread": + $message = __("No unread articles found to display."); + break; + case "updated": + $message = __("No updated articles found to display."); + break; + case "marked": + $message = __("No starred articles found to display."); + break; + default: + if ($feed < LABEL_BASE_INDEX) { + $message = __("No articles found to display. You can assign articles to labels manually from article header context menu (applies to all selected articles) or use a filter."); + } else { + $message = __("No articles found to display."); + } + } + } + + if (!$offset && $message) { + $reply['content'] = "
$message"; + + $reply['content'] .= "

"; + + $sth = $this->pdo->prepare("SELECT " . SUBSTRING_FOR_DATE . "(MAX(last_updated), 1, 19) AS last_updated FROM ttrss_feeds + WHERE owner_uid = ?"); + $sth->execute([$_SESSION['uid']]); + $row = $sth->fetch(); + + $last_updated = TimeHelper::make_local_datetime($row["last_updated"], false); + + $reply['content'] .= sprintf(__("Feeds last updated at %s"), $last_updated); + + $num_errors = ORM::for_table('ttrss_feeds') + ->where_not_equal('last_error', '') + ->where('owner_uid', $_SESSION['uid']) + ->where_gte('update_interval', 0) + ->count('id'); + + if ($num_errors > 0) { + $reply['content'] .= "
"; + $reply['content'] .= "" . + __('Some feeds have update errors (click for details)') . ""; + } + $reply['content'] .= "

"; + + } + } else if (is_numeric($result) && $result == -1) { + $reply['first_id_changed'] = true; + } + } + + $span->end(); + + return array($topmost_article_ids, $headlines_count, $feed, $disable_cache, $reply); + } + + 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']]); + + print json_encode(array("message" => "UPDATE_COUNTERS")); + } + + function view(): void { + $reply = array(); + + $feed = $_REQUEST["feed"]; + $method = $_REQUEST["m"] ?? ""; + $view_mode = $_REQUEST["view_mode"] ?? ""; + $limit = 30; + $cat_view = self::_param_to_bool($_REQUEST["cat"] ?? false); + $next_unread_feed = $_REQUEST["nuf"] ?? 0; + $offset = (int) ($_REQUEST["skip"] ?? 0); + $order_by = $_REQUEST["order_by"] ?? ""; + $check_first_id = $_REQUEST["fid"] ?? 0; + + if (is_numeric($feed)) $feed = (int) $feed; + + if ($feed == Feeds::FEED_DASHBOARD) { + print json_encode($this->_generate_dashboard_feed()); + return; + } + + $sth = false; + if ($feed < LABEL_BASE_INDEX) { + + $label_feed = Labels::feed_to_label_id($feed); + + $sth = $this->pdo->prepare("SELECT id FROM ttrss_labels2 WHERE + id = ? AND owner_uid = ?"); + $sth->execute([$label_feed, $_SESSION['uid']]); + + } else if (!$cat_view && is_numeric($feed) && $feed > 0) { + + $sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE + id = ? AND owner_uid = ?"); + $sth->execute([$feed, $_SESSION['uid']]); + + } else if ($cat_view && is_numeric($feed) && $feed > 0) { + + $sth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories WHERE + id = ? AND owner_uid = ?"); + + $sth->execute([$feed, $_SESSION['uid']]); + } + + if ($sth && !$sth->fetch()) { + print json_encode($this->_generate_error_feed(__("Feed not found."))); + return; + } + + set_pref(Prefs::_DEFAULT_VIEW_MODE, $view_mode); + set_pref(Prefs::_DEFAULT_VIEW_ORDER_BY, $order_by); + + /* bump login timestamp if needed */ + if (time() - $_SESSION["last_login_update"] > 3600) { + $user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]); + $user->last_login = Db::NOW(); + $user->save(); + + $_SESSION["last_login_update"] = time(); + } + + if (!$cat_view && is_numeric($feed) && $feed > 0) { + $sth = $this->pdo->prepare("UPDATE ttrss_feeds SET last_viewed = NOW() + WHERE id = ? AND owner_uid = ?"); + $sth->execute([$feed, $_SESSION['uid']]); + } + + $reply['headlines'] = []; + + list($override_order, $skip_first_id_check) = self::_order_to_override_query($order_by); + + $ret = $this->_format_headlines_list($feed, $method, + $view_mode, $limit, $cat_view, $offset, + $override_order, true, $check_first_id, $skip_first_id_check, $order_by); + + $headlines_count = $ret[1]; + $disable_cache = $ret[3]; + $reply['headlines'] = $ret[4]; + + if (!$next_unread_feed) + $reply['headlines']['id'] = $feed; + else + $reply['headlines']['id'] = $next_unread_feed; + + $reply['headlines']['is_cat'] = $cat_view; + + $reply['headlines-info'] = ["count" => (int) $headlines_count, + "disable_cache" => (bool) $disable_cache]; + + // this is parsed by handleRpcJson() on first viewfeed() to set cdm expanded, etc + $reply['runtime-info'] = RPC::_make_runtime_info(); + + print json_encode($reply); + } + + /** + * @return array> + */ + private function _generate_dashboard_feed(): array { + $reply = array(); + + $reply['headlines']['id'] = Feeds::FEED_DASHBOARD; + $reply['headlines']['is_cat'] = false; + + $reply['headlines']['toolbar'] = ''; + + $reply['headlines']['content'] = "
".__('No feed selected.'); + + $reply['headlines']['content'] .= "

"; + + $sth = $this->pdo->prepare("SELECT ".SUBSTRING_FOR_DATE."(MAX(last_updated), 1, 19) AS last_updated FROM ttrss_feeds + WHERE owner_uid = ?"); + $sth->execute([$_SESSION['uid']]); + $row = $sth->fetch(); + + $last_updated = TimeHelper::make_local_datetime($row["last_updated"], false); + + $reply['headlines']['content'] .= sprintf(__("Feeds last updated at %s"), $last_updated); + + $num_errors = ORM::for_table('ttrss_feeds') + ->where_not_equal('last_error', '') + ->where('owner_uid', $_SESSION['uid']) + ->where_gte('update_interval', 0) + ->count('id'); + + if ($num_errors > 0) { + $reply['headlines']['content'] .= "
"; + $reply['headlines']['content'] .= "". + __('Some feeds have update errors (click for details)').""; + } + $reply['headlines']['content'] .= "

"; + + $reply['headlines-info'] = array("count" => 0, + "unread" => 0, + "disable_cache" => true); + + return $reply; + } + + /** + * @return array + */ + private function _generate_error_feed(string $error): array { + $reply = array(); + + $reply['headlines']['id'] = Feeds::FEED_ERROR; + $reply['headlines']['is_cat'] = false; + + $reply['headlines']['toolbar'] = ''; + $reply['headlines']['content'] = "
". $error . "
"; + + $reply['headlines-info'] = array("count" => 0, + "unread" => 0, + "disable_cache" => true); + + return $reply; + } + + function subscribeToFeed(): void { + print json_encode([ + "cat_select" => \Controls\select_feeds_cats("cat") + ]); + } + + 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, + "all_languages" => Pref_Feeds::get_ts_languages(), + "default_language" => get_pref(Prefs::DEFAULT_SEARCH_LANGUAGE) + ]); + } + + 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"] : Debug::LOG_VERBOSE; + + if (!in_array($xdebug, Debug::ALL_LOG_LEVELS)) { + $xdebug = Debug::LOG_VERBOSE; + } + + Debug::set_enabled(true); + Debug::set_loglevel((int)Debug::map_loglevel($xdebug)); + + $feed_id = (int)$_REQUEST["feed_id"]; + $do_update = ($_REQUEST["action"] ?? "") == "do_update"; + $csrf_token = $_POST["csrf_token"]; + + $sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE id = ? AND owner_uid = ?"); + $sth->execute([$feed_id, $_SESSION['uid']]); + + if (!$sth->fetch()) { + print "Access denied."; + return; + } + ?> + + + + Feed Debugger + + + + + + + + + + + +
+

Feed Debugger: _get_title($feed_id) ?>

+
+
+ + + + + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ +
+
+
+ + + $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']; + + $pdo = Db::pdo(); + + if (is_array($search) && $search[0]) { + $search_qpart = ""; + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_SEARCH, + function ($result) use (&$search_qpart, &$search_words) { + if (!empty($result)) { + list($search_qpart, $search_words) = $result; + return true; + } + }, + $search[0]); + + // fall back in case of no plugins + if (empty($search_qpart)) { + list($search_qpart, $search_words) = self::_search_to_sql($search[0], $search[1], $owner_uid); + } + } else { + $search_qpart = "true"; + } + + // TODO: all this interval stuff needs some generic generator function + + switch ($mode) { + case "1day": + if (Config::get(Config::DB_TYPE) == "pgsql") { + $date_qpart = "date_entered < NOW() - INTERVAL '1 day' "; + } else { + $date_qpart = "date_entered < DATE_SUB(NOW(), INTERVAL 1 DAY) "; + } + break; + case "1week": + if (Config::get(Config::DB_TYPE) == "pgsql") { + $date_qpart = "date_entered < NOW() - INTERVAL '1 week' "; + } else { + $date_qpart = "date_entered < DATE_SUB(NOW(), INTERVAL 1 WEEK) "; + } + break; + case "2week": + if (Config::get(Config::DB_TYPE) == "pgsql") { + $date_qpart = "date_entered < NOW() - INTERVAL '2 week' "; + } else { + $date_qpart = "date_entered < DATE_SUB(NOW(), INTERVAL 2 WEEK) "; + } + break; + default: + $date_qpart = "true"; + } + + if (is_numeric($feed_id_or_tag_name)) { + $feed_id = (int) $feed_id_or_tag_name; + + if ($cat_view) { + + if ($feed_id >= 0) { + + if ($feed_id == Feeds::CATEGORY_UNCATEGORIZED) { + $cat_qpart = "cat_id IS NULL"; + } else { + $children = self::_get_child_cats($feed_id, $owner_uid); + array_push($children, $feed_id); + $children = array_map("intval", $children); + + $children = join(",", $children); + + $cat_qpart = "cat_id IN ($children)"; + } + + $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 IN + (SELECT id FROM ttrss_feeds WHERE $cat_qpart) AND $date_qpart AND $search_qpart) as tmp)"); + $sth->execute([$owner_uid]); + + } else if ($feed_id == Feeds::CATEGORY_LABELS) { + + $sth = $pdo->prepare("UPDATE ttrss_user_entries + SET unread = false,last_read = NOW() WHERE (SELECT COUNT(*) + FROM ttrss_user_labels2, ttrss_entries WHERE article_id = ref_id AND id = ref_id AND $date_qpart AND $search_qpart) > 0 + AND unread = true AND owner_uid = ?"); + $sth->execute([$owner_uid]); + } + + } 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_id]); + + } else if ($feed_id < 0 && $feed_id > LABEL_BASE_INDEX) { // special, like starred + + if ($feed_id == Feeds::FEED_STARRED) { + $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 marked = true AND $date_qpart AND $search_qpart) as tmp)"); + $sth->execute([$owner_uid]); + } + + if ($feed_id == Feeds::FEED_PUBLISHED) { + $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 published = true AND $date_qpart AND $search_qpart) as tmp)"); + $sth->execute([$owner_uid]); + } + + if ($feed_id == Feeds::FEED_FRESH) { + + $intl = (int) get_pref(Prefs::FRESH_ARTICLE_MAX_AGE); + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $match_part = "date_entered > NOW() - INTERVAL '$intl hour' "; + } else { + $match_part = "date_entered > DATE_SUB(NOW(), + INTERVAL $intl HOUR) "; + } + + $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 score >= 0 AND unread = true AND $date_qpart AND $match_part AND $search_qpart) as tmp)"); + $sth->execute([$owner_uid]); + } + + if ($feed_id == Feeds::FEED_ALL) { + $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 $date_qpart AND $search_qpart) as tmp)"); + $sth->execute([$owner_uid]); + } + } else if ($feed_id < LABEL_BASE_INDEX) { // label + + $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 + (SELECT id FROM + (SELECT DISTINCT ttrss_entries.id FROM ttrss_entries, ttrss_user_entries, ttrss_user_labels2 WHERE ref_id = id + 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([$tag_name, $owner_uid]); + } + } + + /** + * @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 { + $span = OpenTelemetry\API\Trace\Span::getCurrent(); + + $span->addEvent(__METHOD__ . ": $feed ($is_cat)"); + + $n_feed = (int) $feed; + $need_entries = false; + + $pdo = Db::pdo(); + + if (!$owner_uid) $owner_uid = $_SESSION["uid"]; + + if ($unread_only) { + $unread_qpart = "unread = true"; + } else { + $unread_qpart = "true"; + } + + $match_part = ""; + + if ($is_cat) { + return self::_get_cat_unread($n_feed, $owner_uid); + } else if(is_numeric($feed) && $feed < PLUGIN_FEED_BASE_INDEX && $feed > LABEL_BASE_INDEX) { // virtual Feed + $feed_id = PluginHost::feed_to_pfeed_id($feed); + $handler = PluginHost::getInstance()->get_feed_handler($feed_id); + if (implements_interface($handler, 'IVirtualFeed')) { + /** @var IVirtualFeed $handler */ + //$span->end(); + return $handler->get_unread($feed_id); + } else { + //$span->end(); + return 0; + } + } else if ($n_feed == Feeds::FEED_RECENTLY_READ) { + //$span->end(); + return 0; + // tags + } else if ($feed != "0" && $n_feed == 0) { + + $sth = $pdo->prepare("SELECT SUM((SELECT COUNT(int_id) + FROM ttrss_user_entries,ttrss_entries WHERE int_id = post_int_id + AND ref_id = id AND $unread_qpart)) AS count FROM ttrss_tags + WHERE owner_uid = ? AND tag_name = ?"); + + $sth->execute([$owner_uid, $feed]); + $row = $sth->fetch(); + + // Handle 'SUM()' returning null if there are no results + //$span->end(); + return $row["count"] ?? 0; + + } else if ($n_feed == Feeds::FEED_STARRED) { + $match_part = "marked = true"; + } else if ($n_feed == Feeds::FEED_PUBLISHED) { + $match_part = "published = true"; + } else if ($n_feed == Feeds::FEED_FRESH) { + $match_part = "unread = true AND score >= 0"; + + $intl = (int) get_pref(Prefs::FRESH_ARTICLE_MAX_AGE, $owner_uid); + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $match_part .= " AND date_entered > NOW() - INTERVAL '$intl hour' "; + } else { + $match_part .= " AND date_entered > DATE_SUB(NOW(), INTERVAL $intl HOUR) "; + } + + $need_entries = true; + + } else if ($n_feed == Feeds::FEED_ALL) { + $match_part = "true"; + } else if ($n_feed >= 0) { + + if ($n_feed != Feeds::FEED_ARCHIVED) { + $match_part = sprintf("feed_id = %d", $n_feed); + } else { + $match_part = "feed_id IS NULL"; + } + + } else if ($feed < LABEL_BASE_INDEX) { + + $label_id = Labels::feed_to_label_id($feed); + + //$span->end(); + return self::_get_label_unread($label_id, $owner_uid); + } + + if ($match_part) { + + if ($need_entries) { + $from_qpart = "ttrss_user_entries,ttrss_entries"; + $from_where = "ttrss_entries.id = ttrss_user_entries.ref_id AND"; + } else { + $from_qpart = "ttrss_user_entries"; + $from_where = ""; + } + + $sth = $pdo->prepare("SELECT count(int_id) AS unread + FROM $from_qpart WHERE + $unread_qpart AND $from_where ($match_part) AND ttrss_user_entries.owner_uid = ?"); + $sth->execute([$owner_uid]); + $row = $sth->fetch(); + + //$span->end(); + return $row["unread"]; + + } else { + + $sth = $pdo->prepare("SELECT COUNT(post_int_id) AS unread + FROM ttrss_tags,ttrss_user_entries,ttrss_entries + WHERE tag_name = ? AND post_int_id = int_id AND ref_id = ttrss_entries.id + AND $unread_qpart AND ttrss_tags.owner_uid = ,"); + + $sth->execute([$feed, $owner_uid]); + $row = $sth->fetch(); + + //$span->end(); + return $row["unread"]; + } + } + + function add(): void { + $feed = clean($_REQUEST['feed']); + $cat = (int) clean($_REQUEST['cat'] ?? ''); + $need_auth = isset($_REQUEST['need_auth']); + $login = $need_auth ? clean($_REQUEST['login']) : ''; + $pass = $need_auth ? clean($_REQUEST['pass']) : ''; + + $rc = Feeds::_subscribe($feed, $cat, $login, $pass); + + print json_encode(array("result" => $rc)); + } + + /** + * @return array (code => Status code, message => error message if available) + * + * 0 - OK, Feed already exists + * 1 - OK, Feed added + * 2 - Invalid URL + * 3 - URL content is HTML, no feeds available + * 4 - URL content is HTML which contains multiple feeds. + * Here you should call extractfeedurls in rpc-backend + * to get all possible feeds. + * 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(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(); + + $url = UrlHelper::validate($url); + + 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, + function ($result) use (&$contents) { + $contents = $result; + }, + $contents, $url, $auth_login, $auth_pass); + + if (empty($contents)) { + if (preg_match("/cloudflare\.com/", UrlHelper::$fetch_last_error_content)) { + UrlHelper::$fetch_last_error .= " (feed behind Cloudflare)"; + } + + return array("code" => 5, "message" => UrlHelper::$fetch_last_error); + } + + if (mb_strpos(UrlHelper::$fetch_last_content_type, "html") !== false && self::_is_html($contents)) { + $feedUrls = self::_get_feeds_from_html($url, $contents); + + if (count($feedUrls) == 0) { + return array("code" => 3); + } else if (count($feedUrls) > 1) { + return array("code" => 4, "feeds" => $feedUrls); + } + //use feed url as new URL + $url = key($feedUrls); + } + + $feed = ORM::for_table('ttrss_feeds') + ->where('feed_url', $url) + ->where('owner_uid', $_SESSION['uid']) + ->find_one(); + + if ($feed) { + return ["code" => 0, "feed_id" => $feed->id]; + } else { + $feed = ORM::for_table('ttrss_feeds')->create(); + + $feed->set([ + 'owner_uid' => $_SESSION['uid'], + 'feed_url' => $url, + 'title' => "[Unknown]", + 'cat_id' => $cat_id ? $cat_id : null, + 'auth_login' => (string)$auth_login, + 'auth_pass' => (string)$auth_pass, + 'update_method' => 0, + 'auth_pass_encrypted' => false, + ]); + + if ($feed->save()) { + RSSUtils::update_basic_info($feed->id); + return ["code" => 1, "feed_id" => (int) $feed->id]; + } + + return ["code" => 7]; + } + } + + static function _get_icon_file(int $feed_id): string { + $favicon_cache = DiskCache::instance('feed-icons'); + + return $favicon_cache->get_full_path((string)$feed_id); + } + + static function _get_icon_url(int $feed_id, string $fallback_url = "") : string { + if (self::_has_icon($feed_id)) { + $icon_url = Config::get_self_url() . "/public.php?" . http_build_query([ + 'op' => 'feed_icon', + 'id' => $feed_id, + ]); + + return $icon_url; + } + + return $fallback_url; + } + + static function _has_icon(int $feed_id): bool { + $favicon_cache = DiskCache::instance('feed-icons'); + + return $favicon_cache->exists((string)$feed_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 Feeds::FEED_ARCHIVED: + return "archive"; + case Feeds::FEED_STARRED: + return "star"; + case Feeds::FEED_PUBLISHED: + return "rss_feed"; + case Feeds::FEED_FRESH: + return "whatshot"; + case Feeds::FEED_ALL: + return "inbox"; + case Feeds::FEED_RECENTLY_READ: + return "restore"; + default: + if ($id < LABEL_BASE_INDEX) { + return "label"; + } else { + return self::_get_icon_url($id); + } + } + } + + /** + * @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) + ->where('feed_url', $feed_url) + ->find_one(); + + if ($feed) { + return $feed->id; + } else { + return false; + } + } + + /** + * $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); + } else if ($id == Feeds::FEED_STARRED) { + return __("Starred articles"); + } else if ($id == Feeds::FEED_PUBLISHED) { + return __("Published articles"); + } else if ($id == Feeds::FEED_FRESH) { + return __("Fresh articles"); + } else if ($id == Feeds::FEED_ALL) { + return __("All articles"); + } else if ($id === Feeds::FEED_ARCHIVED) { + return __("Archived articles"); + } else if ($id == Feeds::FEED_RECENTLY_READ) { + return __("Recently read"); + } else if ($id < LABEL_BASE_INDEX) { + + $label_id = Labels::feed_to_label_id($id); + + $sth = $pdo->prepare("SELECT caption FROM ttrss_labels2 WHERE id = ?"); + $sth->execute([$label_id]); + + if ($row = $sth->fetch()) { + return $row["caption"]; + } else { + return "Unknown label ($label_id)"; + } + + } else if (is_numeric($id) && $id > 0) { + + $sth = $pdo->prepare("SELECT title FROM ttrss_feeds WHERE id = ?"); + $sth->execute([$id]); + + if ($row = $sth->fetch()) { + return $row["title"]; + } else { + return "Unknown feed ($id)"; + } + + } else { + return "$id"; + } + } + + // only real cats + static function _get_cat_marked(int $cat, int $owner_uid = 0): int { + + if (!$owner_uid) $owner_uid = $_SESSION["uid"]; + + $pdo = Db::pdo(); + + if ($cat >= 0) { + + $sth = $pdo->prepare("SELECT SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS marked + 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)) + AND owner_uid = :uid) + AND owner_uid = :uid"); + + $sth->execute(["cat" => $cat ? $cat : null, "uid" => $owner_uid]); + + if ($row = $sth->fetch()) { + return (int) $row["marked"]; + } + } + return 0; + } + + static function _get_cat_unread(int $cat, int $owner_uid = 0): int { + + if (!$owner_uid) $owner_uid = $_SESSION["uid"]; + + $pdo = Db::pdo(); + + if ($cat >= 0) { + + $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)) + AND owner_uid = :uid) + AND owner_uid = :uid"); + + $sth->execute(["cat" => $cat ? $cat : null, "uid" => $owner_uid]); + + if ($row = $sth->fetch()) { + return (int) $row["unread"]; + } + } else if ($cat == Feeds::CATEGORY_SPECIAL) { + return 0; + } else if ($cat == Feeds::CATEGORY_LABELS) { + + $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]); + + 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): int { + if (!$owner_uid) $owner_uid = $_SESSION["uid"]; + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT id FROM ttrss_feed_categories WHERE parent_cat = ? + AND owner_uid = ?"); + $sth->execute([$cat, $owner_uid]); + + $unread = 0; + + while ($line = $sth->fetch()) { + $unread += self::_get_cat_unread($line["id"], $owner_uid); + $unread += self::_get_cat_children_unread($line["id"], $owner_uid); + } + + return $unread; + } + + static function _get_global_unread(int $user_id = 0): int { + + if (!$user_id) $user_id = $_SESSION["uid"]; + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count + FROM ttrss_user_entries ue + WHERE ue.owner_uid = ?"); + + $sth->execute([$user_id]); + $row = $sth->fetch(); + + // 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): string { + switch ($cat_id) { + case Feeds::CATEGORY_UNCATEGORIZED: + return __("Uncategorized"); + case Feeds::CATEGORY_SPECIAL: + return __("Special"); + case Feeds::CATEGORY_LABELS: + return __("Labels"); + default: + $cat = ORM::for_table('ttrss_feed_categories') + ->find_one($cat_id); + + if ($cat) { + return $cat->title; + } else { + return "UNKNOWN"; + } + } + } + + private static function _get_label_unread(int $label_id, ?int $owner_uid = null): int { + if (!$owner_uid) $owner_uid = $_SESSION["uid"]; + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT COUNT(ref_id) AS unread FROM ttrss_user_entries, ttrss_user_labels2 + WHERE owner_uid = ? AND unread = true AND label_id = ? AND article_id = ref_id"); + + $sth->execute([$owner_uid, $label_id]); + + if ($row = $sth->fetch()) { + return $row["unread"]; + } else { + return 0; + } + } + + /** + * @param array $params + * @return array $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 { + + $span = Tracer::start(__METHOD__); + $span->setAttribute('func.args', json_encode(func_get_args())); + + $pdo = Db::pdo(); + + // WARNING: due to highly dynamic nature of this query its going to quote parameters + // right before adding them to SQL part + + $feed = $params["feed"]; + $limit = isset($params["limit"]) ? $params["limit"] : 30; + $view_mode = $params["view_mode"]; + $cat_view = isset($params["cat_view"]) ? $params["cat_view"] : false; + $search = isset($params["search"]) ? $params["search"] : false; + $search_language = isset($params["search_language"]) ? $params["search_language"] : ""; + $override_order = isset($params["override_order"]) ? $params["override_order"] : false; + $offset = isset($params["offset"]) ? $params["offset"] : 0; + $owner_uid = isset($params["owner_uid"]) ? $params["owner_uid"] : $_SESSION["uid"]; + $since_id = isset($params["since_id"]) ? $params["since_id"] : 0; + $include_children = isset($params["include_children"]) ? $params["include_children"] : false; + $ignore_vfeed_group = isset($params["ignore_vfeed_group"]) ? $params["ignore_vfeed_group"] : false; + $override_strategy = isset($params["override_strategy"]) ? $params["override_strategy"] : false; + $override_vfeed = isset($params["override_vfeed"]) ? $params["override_vfeed"] : false; + $start_ts = isset($params["start_ts"]) ? $params["start_ts"] : false; + $check_first_id = isset($params["check_first_id"]) ? $params["check_first_id"] : false; + $skip_first_id_check = isset($params["skip_first_id_check"]) ? $params["skip_first_id_check"] : false; + //$order_by = isset($params["order_by"]) ? $params["order_by"] : false; + + $ext_tables_part = ""; + $limit_query_part = ""; + $query_error_override = ""; + + $search_words = []; + + if ($search) { + $search_query_part = ""; + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_SEARCH, + function ($result) use (&$search_query_part, &$search_words) { + if (!empty($result)) { + list($search_query_part, $search_words) = $result; + return true; + } + }, + $search); + + // fall back in case of no plugins + if (!$search_query_part) { + list($search_query_part, $search_words) = self::_search_to_sql($search, $search_language, $owner_uid); + } + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $test_sth = $pdo->prepare("select $search_query_part + FROM ttrss_entries, ttrss_user_entries WHERE id = ref_id limit 1"); + + try { + $test_sth->execute(); + } catch (PDOException $e) { + // looks like tsquery syntax is invalid + $search_query_part = "false"; + + $query_error_override = T_sprintf("Incorrect search syntax: %s.", implode(" ", $search_words)); + } + } + + $search_query_part .= " AND "; + } else { + $search_query_part = ""; + } + + if ($since_id) { + $since_id_part = "ttrss_entries.id > ".$pdo->quote($since_id)." AND "; + } else { + $since_id_part = ""; + } + + $view_query_part = ""; + + if ($view_mode == "adaptive") { + if ($search) { + $view_query_part = " "; + } else if ($feed != -1) { + // not Feeds::FEED_STARRED or Feeds::CATEGORY_SPECIAL + + $unread = Feeds::_get_counters($feed, $cat_view, true); + + if ($cat_view && $feed > 0 && $include_children) + $unread += self::_get_cat_children_unread($feed); + + if ($unread > 0) { + $view_query_part = " unread = true AND "; + } + } + } + + if ($view_mode == "marked") { + $view_query_part = " marked = true AND "; + } + + if ($view_mode == "has_note") { + $view_query_part = " (note IS NOT NULL AND note != '') AND "; + } + + if ($view_mode == "published") { + $view_query_part = " published = true AND "; + } + + if ($view_mode == "unread" && $feed != Feeds::FEED_RECENTLY_READ) { + $view_query_part = " unread = true AND "; + } + + if ($limit > 0) { + $limit_query_part = "LIMIT " . (int)$limit; + } + + $allow_archived = false; + + $vfeed_query_part = ""; + + /* tags */ + if (!is_numeric($feed)) { + $query_strategy_part = "true"; + $vfeed_query_part = "(SELECT title FROM ttrss_feeds WHERE + id = feed_id) as feed_title,"; + } else if ($feed > 0) { + + if ($cat_view) { + + if ($feed > 0) { + if ($include_children) { + # sub-cats + $subcats = self::_get_child_cats($feed, $owner_uid); + array_push($subcats, $feed); + $subcats = array_map("intval", $subcats); + + $query_strategy_part = "cat_id IN (". + implode(",", $subcats).")"; + + } else { + $query_strategy_part = "cat_id = " . $pdo->quote((string)$feed); + } + + } else { + $query_strategy_part = "cat_id IS NULL"; + } + + $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; + + } else { + $query_strategy_part = "feed_id = " . $pdo->quote((string)$feed); + } + } else if ($feed == Feeds::FEED_ARCHIVED && !$cat_view) { // archive virtual feed + $query_strategy_part = "feed_id IS NULL"; + $allow_archived = true; + } else if ($feed == Feeds::CATEGORY_UNCATEGORIZED && $cat_view) { // uncategorized + $query_strategy_part = "cat_id IS NULL AND feed_id IS NOT NULL"; + $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; + } else if ($feed == -1) { // starred virtual feed, Feeds::FEED_STARRED or Feeds::CATEGORY_SPECIAL + $query_strategy_part = "marked = true"; + $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; + $allow_archived = true; + + if (!$override_order) { + $override_order = "last_marked DESC, date_entered DESC, updated DESC"; + } + + } else if ($feed == -2) { // published virtual feed (Feeds::FEED_PUBLISHED) OR labels category (Feeds::CATEGORY_LABELS) + + if (!$cat_view) { + $query_strategy_part = "published = true"; + $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; + $allow_archived = true; + + if (!$override_order) { + $override_order = "last_published DESC, date_entered DESC, updated DESC"; + } + + } else { + $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; + + $ext_tables_part = "ttrss_labels2,ttrss_user_labels2,"; + + $query_strategy_part = "ttrss_labels2.id = ttrss_user_labels2.label_id AND + ttrss_user_labels2.article_id = ref_id"; + + } + } else if ($feed == Feeds::FEED_RECENTLY_READ) { // recently read + $query_strategy_part = "unread = false AND last_read IS NOT NULL"; + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $query_strategy_part .= " AND last_read > NOW() - INTERVAL '1 DAY' "; + } else { + $query_strategy_part .= " AND last_read > DATE_SUB(NOW(), INTERVAL 1 DAY) "; + } + + $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; + $allow_archived = true; + $ignore_vfeed_group = true; + + if (!$override_order) $override_order = "last_read DESC"; + + } else if ($feed == Feeds::FEED_FRESH) { // fresh virtual feed + $query_strategy_part = "unread = true AND score >= 0"; + + $intl = (int) get_pref(Prefs::FRESH_ARTICLE_MAX_AGE, $owner_uid); + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $query_strategy_part .= " AND date_entered > NOW() - INTERVAL '$intl hour' "; + } else { + $query_strategy_part .= " AND date_entered > DATE_SUB(NOW(), INTERVAL $intl HOUR) "; + } + + $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; + } else if ($feed == Feeds::FEED_ALL) { // all articles virtual feed + $allow_archived = true; + $query_strategy_part = "true"; + $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; + } else if ($feed <= LABEL_BASE_INDEX) { // labels + $label_id = Labels::feed_to_label_id($feed); + + $query_strategy_part = "label_id = $label_id AND + ttrss_labels2.id = ttrss_user_labels2.label_id AND + ttrss_user_labels2.article_id = ref_id"; + + $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; + $ext_tables_part = "ttrss_labels2,ttrss_user_labels2,"; + $allow_archived = true; + + } else { + $query_strategy_part = "true"; + } + + $order_by = "score DESC, date_entered DESC, updated DESC"; + + if ($override_order) { + $order_by = $override_order; + } + + if ($override_strategy) { + $query_strategy_part = $override_strategy; + } + + if ($override_vfeed) { + $vfeed_query_part = $override_vfeed; + } + + $feed_title = ""; + $feed_site_url = ""; + $last_error = ""; + $last_updated = ""; + + if ($search) { + $feed_title = T_sprintf("Search results: %s", $search); + } else { + if ($cat_view) { + $feed_title = self::_get_cat_title($feed); + } else { + if (is_numeric($feed) && $feed > 0) { + $ssth = $pdo->prepare("SELECT title,site_url,last_error,last_updated + FROM ttrss_feeds WHERE id = ? AND owner_uid = ?"); + $ssth->execute([$feed, $owner_uid]); + $row = $ssth->fetch(); + + $feed_title = $row["title"]; + $feed_site_url = $row["site_url"]; + $last_error = $row["last_error"]; + $last_updated = $row["last_updated"]; + } else { + $feed_title = self::_get_title($feed); + } + } + } + + $content_query_part = "content, "; + + if ($limit_query_part) { + $offset_query_part = "OFFSET " . (int)$offset; + } else { + $offset_query_part = ""; + } + + if ($start_ts) { + $start_ts_formatted = date("Y/m/d H:i:s", strtotime($start_ts)); + $start_ts_query_part = "date_entered >= '$start_ts_formatted' AND"; + } else { + $start_ts_query_part = ""; + } + + $first_id = 0; + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $yyiw_qpart = "to_char(date_entered, 'IYYY-IW') AS yyiw"; + } else { + $yyiw_qpart = "date_format(date_entered, '%Y-%u') AS yyiw"; + } + + if (is_numeric($feed)) { + // proper override_order applied above + if ($vfeed_query_part && !$ignore_vfeed_group && get_pref(Prefs::VFEED_GROUP_BY_FEED, $owner_uid)) { + + if (!(in_array($feed, self::NEVER_GROUP_BY_DATE) && !$cat_view)) { + $yyiw_desc = $order_by == "date_reverse" ? "" : "desc"; + $yyiw_order_qpart = "yyiw $yyiw_desc, "; + } else { + $yyiw_order_qpart = ""; + } + + if (!$override_order) { + $order_by = "$yyiw_order_qpart ttrss_feeds.title, $order_by"; + } else { + $order_by = "$yyiw_order_qpart ttrss_feeds.title, $override_order"; + } + } + + if (!$allow_archived) { + $from_qpart = "{$ext_tables_part}ttrss_entries LEFT JOIN ttrss_user_entries ON (ref_id = ttrss_entries.id), ttrss_feeds"; + $feed_check_qpart = "ttrss_user_entries.feed_id = ttrss_feeds.id AND"; + + } else { + $from_qpart = "{$ext_tables_part}ttrss_entries LEFT JOIN ttrss_user_entries ON (ref_id = ttrss_entries.id) + LEFT JOIN ttrss_feeds ON (feed_id = ttrss_feeds.id)"; + $feed_check_qpart = ""; + } + + if ($vfeed_query_part) $vfeed_query_part .= "favicon_avg_color,"; + + $first_id_query_strategy_part = $query_strategy_part; + + if ($feed == Feeds::FEED_FRESH) + $first_id_query_strategy_part = "true"; + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $sanity_interval_qpart = "date_entered >= NOW() - INTERVAL '1 hour' AND"; + + $distinct_columns = str_replace("desc", "", strtolower($order_by)); + $distinct_qpart = "DISTINCT ON (id, $distinct_columns)"; + } else { + $sanity_interval_qpart = "date_entered >= DATE_SUB(NOW(), INTERVAL 1 hour) AND"; + $distinct_qpart = "DISTINCT"; //fallback + } + + // except for Labels category + if (get_pref(Prefs::HEADLINES_NO_DISTINCT, $owner_uid) && !($feed == Feeds::CATEGORY_LABELS && $cat_view)) { + $distinct_qpart = ""; + } + + if (!$search && !$skip_first_id_check) { + // if previous topmost article id changed that means our current pagination is no longer valid + $query = "SELECT + ttrss_entries.id, + date_entered, + $yyiw_qpart, + guid, + ttrss_entries.title, + ttrss_feeds.title, + updated, + score, + marked, + published, + last_marked, + last_published, + last_read + FROM + $from_qpart + WHERE + $feed_check_qpart + ttrss_user_entries.owner_uid = ".$pdo->quote($owner_uid)." AND + $search_query_part + $start_ts_query_part + $since_id_part + $sanity_interval_qpart + $first_id_query_strategy_part ORDER BY $order_by LIMIT 1"; + + if (!empty($_REQUEST["debug"])) { + print "\n*** FIRST ID QUERY ***\n$query\n"; + } + + $res = $pdo->query($query); + + if (!empty($res) && $row = $res->fetch()) { + $first_id = (int)$row["id"]; + + if ($offset > 0 && $first_id && $check_first_id && $first_id != $check_first_id) { + return array(-1, $feed_title, $feed_site_url, $last_error, $last_updated, $search_words, $first_id, $vfeed_query_part != "", $query_error_override); + } + } + } + + $query = "SELECT $distinct_qpart + ttrss_entries.id AS id, + date_entered, + $yyiw_qpart, + guid, + ttrss_entries.title, + updated, + label_cache, + tag_cache, + always_display_enclosures, + site_url, + note, + num_comments, + comments, + int_id, + uuid, + lang, + hide_images, + unread,feed_id,marked,published,link,last_read, + last_marked, last_published, + $vfeed_query_part + $content_query_part + author,score, + (SELECT count(label_id) FROM ttrss_user_labels2 WHERE article_id = ttrss_entries.id) AS num_labels, + (SELECT count(id) FROM ttrss_enclosures WHERE post_id = ttrss_entries.id) AS num_enclosures + FROM + $from_qpart + WHERE + $feed_check_qpart + ttrss_user_entries.owner_uid = ".$pdo->quote($owner_uid)." AND + $search_query_part + $start_ts_query_part + $view_query_part + $since_id_part + $query_strategy_part ORDER BY $order_by + $limit_query_part $offset_query_part"; + + //if ($_REQUEST["debug"]) print $query; + + if (!empty($_REQUEST["debug"])) { + print "\n*** HEADLINES QUERY ***\n$query\n"; + } + + $res = $pdo->query($query); + + } else { + // browsing by tag + + if (get_pref(Prefs::HEADLINES_NO_DISTINCT, $owner_uid)) { + $distinct_qpart = ""; + } else { + if (Config::get(Config::DB_TYPE) == "pgsql") { + $distinct_columns = str_replace("desc", "", strtolower($order_by)); + $distinct_qpart = "DISTINCT ON (id, $distinct_columns)"; + } else { + $distinct_qpart = "DISTINCT"; //fallback + } + } + + $query = "SELECT $distinct_qpart + ttrss_entries.id AS id, + date_entered, + $yyiw_qpart, + guid, + ttrss_entries.title, + updated, + label_cache, + tag_cache, + always_display_enclosures, + site_url, + note, + num_comments, + comments, + int_id, + uuid, + lang, + hide_images, + unread,feed_id,marked,published,link,last_read, + last_marked, last_published, + $since_id_part + $vfeed_query_part + $content_query_part + author, score, + (SELECT count(label_id) FROM ttrss_user_labels2 WHERE article_id = ttrss_entries.id) AS num_labels, + (SELECT count(id) FROM ttrss_enclosures WHERE post_id = ttrss_entries.id) AS num_enclosures + FROM ttrss_entries, + ttrss_user_entries LEFT JOIN ttrss_feeds ON (ttrss_feeds.id = ttrss_user_entries.feed_id), + ttrss_tags + WHERE + ref_id = ttrss_entries.id AND + ttrss_user_entries.owner_uid = ".$pdo->quote($owner_uid)." AND + post_int_id = int_id AND + tag_name = ".$pdo->quote($feed)." AND + $view_query_part + $search_query_part + $start_ts_query_part + $query_strategy_part ORDER BY $order_by + $limit_query_part $offset_query_part"; + + //if ($_REQUEST["debug"]) print $query; + + if (!empty($_REQUEST["debug"])) { + print "\n*** TAGS QUERY ***\n$query\n"; + } + + $res = $pdo->query($query); + } + + $span->end(); + + return array($res, $feed_title, $feed_site_url, $last_error, $last_updated, $search_words, $first_id, $vfeed_query_part != "", $query_error_override); + } + + /** + * @return array + */ + static function _get_parent_cats(int $cat, int $owner_uid): array { + $rv = array(); + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT parent_cat FROM ttrss_feed_categories + WHERE id = ? AND parent_cat IS NOT NULL AND owner_uid = ?"); + $sth->execute([$cat, $owner_uid]); + + while ($line = $sth->fetch()) { + $cat = (int) $line["parent_cat"]; + array_push($rv, $cat, ...self::_get_parent_cats($cat, $owner_uid)); + } + + return $rv; + } + + /** + * @return array + */ + static function _get_child_cats(int $cat, int $owner_uid): array { + $rv = array(); + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT id FROM ttrss_feed_categories + WHERE parent_cat = ? AND owner_uid = ?"); + $sth->execute([$cat, $owner_uid]); + + while ($line = $sth->fetch()) { + array_push($rv, $line["id"], ...self::_get_child_cats($line["id"], $owner_uid)); + } + + return $rv; + } + + /** + * @param array $feeds + * @return array + */ + static function _cats_of(array $feeds, int $owner_uid, bool $with_parents = false): array { + if (count($feeds) == 0) + return []; + + $pdo = Db::pdo(); + + $feeds_qmarks = arr_qmarks($feeds); + + $sth = $pdo->prepare("SELECT DISTINCT cat_id, fc.parent_cat FROM ttrss_feeds f LEFT JOIN ttrss_feed_categories fc + ON (fc.id = f.cat_id) + WHERE f.owner_uid = ? AND f.id IN ($feeds_qmarks)"); + $sth->execute([$owner_uid, ...$feeds]); + + $rv = []; + + if ($row = $sth->fetch()) { + $cat_id = (int) $row["cat_id"]; + $rv[] = $cat_id; + array_push($rv, (int)$row["cat_id"]); + + if ($with_parents && $row["parent_cat"]) { + array_push($rv, ...self::_get_parent_cats($cat_id, $owner_uid)); + } + } + + $rv = array_unique($rv); + + return $rv; + } + + // returns Uncategorized as 0 + static function _cat_of(int $feed) : int { + $feed = ORM::for_table('ttrss_feeds')->find_one($feed); + + if ($feed) { + return (int)$feed->cat_id; + } else { + return -1; + } + } + + 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; + + for ($i = 0; $i < strlen($name); $i++) { + $sum += ord($name[$i]); + } + + $sum %= count($colormap); + + return $colormap[$sum]; + } + + /** + * @return array 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 = []; + + $doc = new DOMDocument(); + if (@$doc->loadHTML($content)) { + $xpath = new DOMXPath($doc); + $entries = $xpath->query('/html/head/link[@rel="alternate" and '. + '(contains(@type,"rss") or contains(@type,"atom"))]|/html/head/link[@rel="feed"]'); + + foreach ($entries as $entry) { + if ($entry->hasAttribute('href')) { + $title = $entry->getAttribute('title'); + if ($title == '') { + $title = $entry->getAttribute('type'); + } + $feedUrl = UrlHelper::rewrite_relative($baseUrl, $entry->getAttribute('href')); + $feedUrls[$feedUrl] = $title; + } + } + } + return $feedUrls; + } + + static function _is_html(string $content): bool { + return preg_match("/where('owner_uid', $owner_uid) + ->find_one($id); + + if ($cat) + $cat->delete(); + } + + 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) + ->where('parent_cat', $parent_cat) + ->where('title', $title) + ->find_one(); + + if (!$cat) { + $cat = ORM::for_table('ttrss_feed_categories')->create(); + + $cat->set([ + 'owner_uid' => $owner_uid, + 'parent_cat' => $parent_cat, + 'order_id' => $order_id, + 'title' => $title, + ]); + + return $cat->save(); + } + + return false; + } + + static function _clear_access_keys(int $owner_uid): void { + $key = ORM::for_table('ttrss_access_keys') + ->where('owner_uid', $owner_uid) + ->delete_many(); + } + + /** + * @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) + ->where('is_cat', $is_cat) + ->delete_many(); + + return self::_get_access_key($feed_id, $is_cat, $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) + ->where('is_cat', $is_cat) + ->find_one(); + + if ($key) { + return $key->access_key; + } + + $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(); + + if ($key->save()) { + return $key->access_key; + } + + return null; + } + + static function _purge(int $feed_id, int $purge_interval): ?int { + + if (!$purge_interval) $purge_interval = self::_get_purge_interval($feed_id); + + $pdo = Db::pdo(); + + $owner_uid = false; + $rows_deleted = 0; + + $sth = $pdo->prepare("SELECT owner_uid FROM ttrss_feeds WHERE id = ?"); + $sth->execute([$feed_id]); + + if ($row = $sth->fetch()) { + $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); + $purge_unread = true; + $purge_interval = Config::get(Config::FORCE_ARTICLE_PURGE); + } else { + $purge_unread = get_pref(Prefs::PURGE_UNREAD_ARTICLES, $owner_uid); + } + + $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); + + if ($purge_interval <= 0) { + Debug::log("purge_feed: purging disabled for this feed, nothing to do.", Debug::LOG_VERBOSE); + return null; + } + + if (!$purge_unread) + $query_limit = " unread = false AND "; + else + $query_limit = ""; + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $sth = $pdo->prepare("DELETE FROM ttrss_user_entries + USING ttrss_entries + WHERE ttrss_entries.id = ref_id AND + marked = false AND + feed_id = ? AND + $query_limit + ttrss_entries.date_updated < NOW() - INTERVAL '$purge_interval days'"); + $sth->execute([$feed_id]); + + } else { + $sth = $pdo->prepare("DELETE FROM ttrss_user_entries + USING ttrss_user_entries, ttrss_entries + WHERE ttrss_entries.id = ref_id AND + marked = false AND + feed_id = ? AND + $query_limit + ttrss_entries.date_updated < DATE_SUB(NOW(), INTERVAL $purge_interval DAY)"); + $sth->execute([$feed_id]); + + } + + $rows_deleted = $sth->rowCount(); + + 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); + } + + return $rows_deleted; + } + + private static function _get_purge_interval(int $feed_id): int { + $feed = ORM::for_table('ttrss_feeds')->find_one($feed_id); + + if ($feed) { + if ($feed->purge_interval != 0) + return $feed->purge_interval; + else + return get_pref(Prefs::PURGE_OLD_DAYS, $feed->owner_uid); + } else { + return -1; + } + } + + /** + * @return array{0: string, 1: array} [$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(); + $search_query_leftover = array(); + + $pdo = Db::pdo(); + + if ($search_language) + $search_language = $pdo->quote(mb_strtolower($search_language)); + else + $search_language = $pdo->quote(mb_strtolower(get_pref(Prefs::DEFAULT_SEARCH_LANGUAGE, $owner_uid))); + + foreach ($keywords as $k) { + if (strpos($k, "-") === 0) { + $k = substr($k, 1); + $not = "NOT"; + } else { + $not = ""; + } + + $commandpair = explode(":", mb_strtolower($k), 2); + + switch ($commandpair[0]) { + case "title": + if ($commandpair[1]) { + array_push($query_keywords, "($not (LOWER(ttrss_entries.title) LIKE ". + $pdo->quote('%' . mb_strtolower($commandpair[1]) . '%') ."))"); + } else { + array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER('%$k%') + OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); + array_push($search_words, $k); + } + break; + case "author": + if ($commandpair[1]) { + array_push($query_keywords, "($not (LOWER(author) LIKE ". + $pdo->quote('%' . mb_strtolower($commandpair[1]) . '%')."))"); + } else { + array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER('%$k%') + OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); + array_push($search_words, $k); + } + break; + case "note": + if ($commandpair[1]) { + if ($commandpair[1] == "true") + array_push($query_keywords, "($not (note IS NOT NULL AND note != ''))"); + else if ($commandpair[1] == "false") + array_push($query_keywords, "($not (note IS NULL OR note = ''))"); + else + array_push($query_keywords, "($not (LOWER(note) LIKE ". + $pdo->quote('%' . mb_strtolower($commandpair[1]) . '%')."))"); + } else { + array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER(".$pdo->quote("%$k%").") + OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); + if (!$not) array_push($search_words, $k); + } + break; + case "star": + + if ($commandpair[1]) { + if ($commandpair[1] == "true") + array_push($query_keywords, "($not (marked = true))"); + else + array_push($query_keywords, "($not (marked = false))"); + } else { + array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER(".$pdo->quote("%$k%").") + OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); + if (!$not) array_push($search_words, $k); + } + break; + case "pub": + if ($commandpair[1]) { + if ($commandpair[1] == "true") + array_push($query_keywords, "($not (published = true))"); + else + array_push($query_keywords, "($not (published = false))"); + + } else { + array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER('%$k%') + OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); + if (!$not) array_push($search_words, $k); + } + break; + case "label": + if ($commandpair[1]) { + $label_id = Labels::find_id($commandpair[1], $_SESSION["uid"]); + + if ($label_id) { + array_push($query_keywords, "($not + (ttrss_entries.id IN ( + SELECT article_id FROM ttrss_user_labels2 WHERE + label_id = $label_id)))"); + } else { + array_push($query_keywords, "(false)"); + } + } else { + array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER(".$pdo->quote("%$k%").") + OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); + if (!$not) array_push($search_words, $k); + } + break; + case "unread": + if ($commandpair[1]) { + if ($commandpair[1] == "true") + array_push($query_keywords, "($not (unread = true))"); + else + array_push($query_keywords, "($not (unread = false))"); + + } else { + array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER(".$pdo->quote("%$k%").") + OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); + if (!$not) array_push($search_words, $k); + } + break; + default: + if (strpos($k, "@") === 0) { + + $user_tz_string = get_pref(Prefs::USER_TIMEZONE, $_SESSION['uid']); + $orig_ts = strtotime(substr($k, 1)); + $k = date("Y-m-d", TimeHelper::convert_timestamp($orig_ts, $user_tz_string, 'UTC')); + + //$k = date("Y-m-d", strtotime(substr($k, 1))); + + array_push($query_keywords, "(".SUBSTRING_FOR_DATE."(updated,1,LENGTH('$k')) $not = '$k')"); + } else { + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $k = mb_strtolower($k); + array_push($search_query_leftover, $not ? "!$k" : $k); + } else { + $k = mb_strtolower($k); + array_push($search_query_leftover, $not ? "-$k" : $k); + + //array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER(".$pdo->quote("%$k%").") + // OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); + } + + if (!$not) array_push($search_words, $k); + } + } + } + + if (count($search_query_leftover) > 0) { + + if (Config::get(Config::DB_TYPE) == "pgsql") { + + // if there's no joiners consider this a "simple" search and + // concatenate everything with &, otherwise don't try to mess with tsquery syntax + if (preg_match("/[&|]/", implode(" " , $search_query_leftover))) { + $tsquery = $pdo->quote(implode(" ", $search_query_leftover)); + } else { + $tsquery = $pdo->quote(implode(" & ", $search_query_leftover)); + } + + array_push($query_keywords, + "(tsvector_combined @@ to_tsquery($search_language, $tsquery))"); + } else { + $ft_query = $pdo->quote(implode(" ", $search_query_leftover)); + + array_push($query_keywords, + "MATCH (ttrss_entries.title, ttrss_entries.content) AGAINST ($ft_query IN BOOLEAN MODE)"); + } + } + + if (count($query_keywords) > 0) + $search_query_part = implode("AND ", $query_keywords); + else + $search_query_part = "false"; + + return array($search_query_part, $search_words); + } + + /** + * @return array{0: string, 1: bool} + */ + static function _order_to_override_query(string $order): array { + $query = ""; + $skip_first_id = false; + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE, + function ($result) use (&$query, &$skip_first_id) { + list ($query, $skip_first_id) = $result; + + // run until first hard match + return !empty($query); + }, + $order); + + if (is_string($query) && $query !== "") { + return [$query, $skip_first_id]; + } + + switch ($order) { + case "title": + $query = "ttrss_entries.title, date_entered, updated"; + break; + case "date_reverse": + $query = "updated"; + $skip_first_id = true; + break; + case "feed_dates": + $query = "updated DESC"; + break; + } + + return [$query, $skip_first_id]; + } + +} + diff --git a/classes/Handler.php b/classes/Handler.php new file mode 100644 index 000000000..5b54570d8 --- /dev/null +++ b/classes/Handler.php @@ -0,0 +1,35 @@ + */ + protected array $args; + + /** + * @param array $args + */ + function __construct(array $args) { + $this->pdo = Db::pdo(); + $this->args = $args; + } + + function csrf_ignore(string $method): bool { + return false; + } + + function before(string $method): bool { + return true; + } + + 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 new file mode 100644 index 000000000..533cb3630 --- /dev/null +++ b/classes/Handler_Administrative.php @@ -0,0 +1,11 @@ += UserHelper::ACCESS_LEVEL_ADMIN) { + return true; + } + } + return false; + } +} diff --git a/classes/Handler_Protected.php b/classes/Handler_Protected.php new file mode 100644 index 000000000..a15fc0956 --- /dev/null +++ b/classes/Handler_Protected.php @@ -0,0 +1,7 @@ + $owner_uid, + "feed" => $feed, + "limit" => $limit, + "view_mode" => $view_mode, + "cat_view" => $is_cat, + "search" => $search, + "override_order" => $override_order, + "include_children" => true, + "ignore_vfeed_group" => true, + "offset" => $offset, + "start_ts" => $start_ts + ); + + if (!$is_cat && is_numeric($feed) && $feed < PLUGIN_FEED_BASE_INDEX && $feed > LABEL_BASE_INDEX) { + + $user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $owner_uid); + + $tmppluginhost = new PluginHost(); + $tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL); + $tmppluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $owner_uid); + //$tmppluginhost->load_data(); + + $handler = $tmppluginhost->get_feed_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 { + $qfh_ret = Feeds::_get_headlines($params); + } + + $result = $qfh_ret[0]; + $feed_title = htmlspecialchars($qfh_ret[1]); + $feed_site_url = $qfh_ret[2]; + /* $last_error = $qfh_ret[3]; */ + + $feed_self_url = Config::get_self_url() . + "/public.php?op=rss&id=$feed&key=" . + Feeds::_get_access_key($feed, false, $owner_uid); + + if (!$feed_site_url) $feed_site_url = Config::get_self_url(); + + if ($format == 'atom') { + $tpl = new Templator(); + + $tpl->readTemplateFromFile("generated_feed.txt"); + + $tpl->setVariable('FEED_TITLE', $feed_title, true); + $tpl->setVariable('VERSION', Config::get_version(), true); + $tpl->setVariable('FEED_URL', htmlspecialchars($feed_self_url), true); + + $tpl->setVariable('SELF_URL', htmlspecialchars(Config::get_self_url()), true); + while ($line = $result->fetch()) { + + $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, $max_excerpt_length); + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_EXPORT_FEED, + function ($result) use (&$line) { + $line = $result; + }, + $line, $feed, $is_cat, $owner_uid); + + $tpl->setVariable('ARTICLE_ID', + htmlspecialchars($orig_guid ? $line['link'] : + $this->_make_article_tag_uri($line['id'], $line['date_entered'])), true); + $tpl->setVariable('ARTICLE_LINK', htmlspecialchars($line['link']), true); + $tpl->setVariable('ARTICLE_TITLE', htmlspecialchars($line['title']), true); + $tpl->setVariable('ARTICLE_EXCERPT', $line["content_preview"], true); + + $content = Sanitizer::sanitize($line["content"], false, $owner_uid, + $feed_site_url, null, $line["id"]); + + $content = DiskCache::rewrite_urls($content); + + if ($line['note']) { + $content = "
Article note: " . $line['note'] . "
" . + $content; + $tpl->setVariable('ARTICLE_NOTE', htmlspecialchars($line['note']), true); + } + + $tpl->setVariable('ARTICLE_CONTENT', $content, true); + + $tpl->setVariable('ARTICLE_UPDATED_ATOM', + date('c', strtotime($line["updated"] ?? '')), true); + $tpl->setVariable('ARTICLE_UPDATED_RFC822', + date(DATE_RFC822, strtotime($line["updated"] ?? '')), true); + + $tpl->setVariable('ARTICLE_AUTHOR', htmlspecialchars($line['author']), true); + + $tpl->setVariable('ARTICLE_SOURCE_LINK', htmlspecialchars($line['site_url'] ? $line["site_url"] : Config::get_self_url()), true); + $tpl->setVariable('ARTICLE_SOURCE_TITLE', htmlspecialchars($line['feed_title'] ?? $feed_title), true); + + foreach ($line["tags"] as $tag) { + $tpl->setVariable('ARTICLE_CATEGORY', htmlspecialchars($tag), true); + $tpl->addBlock('category'); + } + + $enclosures = Article::_get_enclosures($line["id"]); + + if (count($enclosures) > 0) { + foreach ($enclosures as $e) { + $type = htmlspecialchars($e['content_type']); + $url = htmlspecialchars($e['content_url']); + $length = $e['duration'] ? $e['duration'] : 1; + + $tpl->setVariable('ARTICLE_ENCLOSURE_URL', $url, true); + $tpl->setVariable('ARTICLE_ENCLOSURE_TYPE', $type, true); + $tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', $length, true); + + $tpl->addBlock('enclosure'); + } + } else { + $tpl->setVariable('ARTICLE_ENCLOSURE_URL', "", true); + $tpl->setVariable('ARTICLE_ENCLOSURE_TYPE', "", true); + $tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', "", true); + } + + list ($og_image, $og_stream) = Article::_get_image($enclosures, $line['content'], $feed_site_url, $line); + + $tpl->setVariable('ARTICLE_OG_IMAGE', $og_image, true); + + $tpl->addBlock('entry'); + } + + $tmp = ""; + + $tpl->addBlock('feed'); + $tpl->generateOutputToString($tmp); + + if (empty($_REQUEST["noxml"])) { + header("Content-Type: text/xml; charset=utf-8"); + } else { + header("Content-Type: text/plain; charset=utf-8"); + } + + print $tmp; + } else if ($format == 'json') { + + $feed = array(); + + $feed['title'] = $feed_title; + $feed['feed_url'] = $feed_self_url; + $feed['self_url'] = Config::get_self_url(); + $feed['articles'] = []; + + while ($line = $result->fetch()) { + + $line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...')); + $line["tags"] = Article::_get_tags($line["id"], $owner_uid); + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES, + function ($result) use (&$line) { + $line = $result; + }, + $line); + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_EXPORT_FEED, + function ($result) use (&$line) { + $line = $result; + }, + $line, $feed, $is_cat, $owner_uid); + + $article = array(); + + $article['id'] = $line['link']; + $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, null, $line["id"]); + $article['updated'] = date('c', strtotime($line["updated"] ?? '')); + + if (!empty($line['note'])) $article['note'] = $line['note']; + if (!empty($line['author'])) $article['author'] = $line['author']; + + $article['source'] = [ + 'link' => $line['site_url'] ? $line["site_url"] : Config::get_self_url(), + 'title' => $line['feed_title'] ?? $feed_title + ]; + + if (count($line["tags"]) > 0) { + $article['tags'] = array(); + + foreach ($line["tags"] as $tag) { + array_push($article['tags'], $tag); + } + } + + $enclosures = Article::_get_enclosures($line["id"]); + + if (count($enclosures) > 0) { + $article['enclosures'] = array(); + + foreach ($enclosures as $e) { + $type = $e['content_type']; + $url = $e['content_url']; + $length = $e['duration']; + + array_push($article['enclosures'], array("url" => $url, "type" => $type, "length" => $length)); + } + } + + array_push($feed['articles'], $article); + } + + header("Content-Type: text/json; charset=utf-8"); + print json_encode($feed); + + } else { + header("Content-Type: text/plain; charset=utf-8"); + print "Unknown format: $format."; + } + } + + function getUnread(): void { + $login = clean($_REQUEST["login"]); + $fresh = clean($_REQUEST["fresh"] ?? "0") == "1"; + + $uid = UserHelper::find_user_by_login($login); + + if ($uid) { + print Feeds::_get_global_unread($uid); + + if ($fresh) { + print ";"; + print Feeds::_get_counters(Feeds::FEED_FRESH, false, true, $uid); + } + } else { + print "-1;User not found"; + } + } + + function getProfiles(): void { + $login = clean($_REQUEST["login"]); + $rv = []; + + if ($login) { + $profiles = ORM::for_table('ttrss_settings_profiles') + ->table_alias('p') + ->select_many('title' , 'p.id') + ->join('ttrss_users', ['owner_uid', '=', 'u.id'], 'u') + ->where_raw('LOWER(login) = LOWER(?)', [$login]) + ->order_by_asc('title') + ->find_many(); + + $rv = [ [ "value" => 0, "label" => __("Default profile") ] ]; + + foreach ($profiles as $profile) { + array_push($rv, [ "label" => $profile->title, "value" => $profile->id ]); + } + } + + print json_encode($rv); + } + + function logout(): void { + if (validate_csrf($_POST["csrf_token"])) { + + $login = $_SESSION["name"]; + $user_id = $_SESSION["uid"]; + + UserHelper::logout(); + + $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 = Config::get_self_url() . "/index.php"; + + header("Location: " . $redirect_url); + } else { + header("Content-Type: text/json"); + print Errors::to_json(Errors::E_UNAUTHORIZED); + } + } + + function rss(): void { + $feed = clean($_REQUEST["id"]); + $key = clean($_REQUEST["key"]); + $is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false); + $limit = (int)clean($_REQUEST["limit"] ?? 0); + $offset = (int)clean($_REQUEST["offset"] ?? 0); + + $search = clean($_REQUEST["q"] ?? ""); + $view_mode = clean($_REQUEST["view-mode"] ?? ""); + $order = clean($_REQUEST["order"] ?? ""); + $start_ts = clean($_REQUEST["ts"] ?? ""); + + $format = clean($_REQUEST['format'] ?? "atom"); + $orig_guid = clean($_REQUEST["orig_guid"] ?? ""); + + if (Config::get(Config::SINGLE_USER_MODE)) { + UserHelper::authenticate("admin", null); + } + + if ($key) { + $access_key = ORM::for_table('ttrss_access_keys') + ->select('owner_uid') + ->where(['access_key' => $key, 'feed_id' => $feed]) + ->find_one(); + + if ($access_key) { + $this->generate_syndicated_feed($access_key->owner_uid, $feed, $is_cat, $limit, + $offset, $search, $view_mode, $format, $order, $orig_guid, $start_ts); + return; + } + } + + header('HTTP/1.1 403 Forbidden'); + } + + function updateTask(): void { + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK); + } + + function housekeepingTask(): void { + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); + } + + function globalUpdateFeeds(): void { + RPC::updaterandomfeed_real(); + + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK); + } + + function login(): void { + if (!Config::get(Config::SINGLE_USER_MODE)) { + + $login = clean($_POST["login"]); + $password = clean($_POST["password"]); + $remember_me = clean($_POST["remember_me"] ?? false); + $safe_mode = checkbox_to_sql_bool($_POST["safe_mode"] ?? false); + + if (session_status() != PHP_SESSION_ACTIVE) { + if ($remember_me) { + session_set_cookie_params(Config::get(Config::SESSION_COOKIE_LIFETIME)); + } else { + session_set_cookie_params(0); + } + } + + if (UserHelper::authenticate($login, $password)) { + $_POST["password"] = ""; + + $_SESSION["ref_schema_version"] = Config::get_schema_version(); + $_SESSION["bw_limit"] = !!clean($_POST["bw_limit"] ?? false); + $_SESSION["safe_mode"] = $safe_mode; + + if (!empty($_POST["profile"])) { + $profile = (int) clean($_POST["profile"]); + + $profile_obj = ORM::for_table('ttrss_settings_profiles') + ->where(['id' => $profile, 'owner_uid' => $_SESSION['uid']]) + ->find_one(); + + $_SESSION["profile"] = $profile_obj ? $profile : null; + } + } else { + + // start an empty session to deliver login error message + if (session_status() != PHP_SESSION_ACTIVE) + session_start(); + + $_SESSION["login_error_msg"] ??= __("Incorrect username or password"); + } + + $return = clean($_REQUEST['return'] ?? ''); + + if ($return && mb_strpos($return, Config::get_self_url()) === 0) { + header("Location: $return"); + } else { + header("Location: " . Config::get_self_url()); + } + } + } + + function index(): void { + header("Content-Type: text/plain"); + print Errors::to_json(Errors::E_UNKNOWN_METHOD); + } + + function forgotpass(): void { + startup_gettext(); + session_start(); + + $hash = clean($_REQUEST["hash"] ?? ''); + + header('Content-Type: text/html; charset=utf-8'); + ?> + + + + Tiny Tiny RSS + + + + + + + +
+ + + ".__("Password recovery").""; + print "
"; + + $method = clean($_POST['method'] ?? ''); + + if ($hash) { + $login = clean($_REQUEST["login"]); + + if ($login) { + $user = ORM::for_table('ttrss_users') + ->select_many('id', 'resetpass_token') + ->where_raw('LOWER(login) = LOWER(?)', [$login]) + ->find_one(); + + if ($user) { + list($timestamp, $resetpass_token) = explode(":", $user->resetpass_token); + + if ($timestamp && $resetpass_token && + $timestamp >= time() - 15*60*60 && + $resetpass_token === $hash) { + $user->resetpass_token = null; + $user->save(); + + UserHelper::reset_password($user->id, true); + + print "

"."Completed."."

"; + + } else { + print_error("Some of the information provided is missing or incorrect."); + } + } else { + print_error("Some of the information provided is missing or incorrect."); + } + } else { + print_error("Some of the information provided is missing or incorrect."); + } + + print "".__("Return to Tiny Tiny RSS").""; + + } else if (!$method) { + print_notice(__("You will need to provide valid account name and email. Password reset link will be sent to your email address.")); + + print "
+ + + +
+ + +
+ +
+ + +
"; + + $_SESSION["pwdreset:testvalue1"] = rand(1,10); + $_SESSION["pwdreset:testvalue2"] = rand(1,10); + + print "
+ + +
+ +
+
+ + ".__("Return to Tiny Tiny RSS")." +
+ +
"; + } else if ($method == 'do') { + $login = clean($_POST["login"]); + $email = clean($_POST["email"]); + $test = clean($_POST["test"]); + + if ($test != ($_SESSION["pwdreset:testvalue1"] + $_SESSION["pwdreset:testvalue2"]) || !$email || !$login) { + print_error(__('Some of the required form parameters are missing or incorrect.')); + + print "
+ + +
"; + } else { + // prevent submitting this form multiple times + $_SESSION["pwdreset:testvalue1"] = rand(1, 1000); + $_SESSION["pwdreset:testvalue2"] = rand(1, 1000); + + $user = ORM::for_table('ttrss_users') + ->select('id') + ->where_raw('LOWER(login) = LOWER(?)', [$login]) + ->where('email', $email) + ->find_one(); + + if ($user) { + print_notice("Password reset instructions are being sent to your email address."); + + $resetpass_token = sha1(get_random_bytes(128)); + $resetpass_link = Config::get_self_url() . "/public.php?op=forgotpass&hash=" . $resetpass_token . + "&login=" . urlencode($login); + + $tpl = new Templator(); + + $tpl->readTemplateFromFile("resetpass_link_template.txt"); + + $tpl->setVariable('LOGIN', $login); + $tpl->setVariable('RESETPASS_LINK', $resetpass_link); + $tpl->setVariable('TTRSS_HOST', Config::get_self_url()); + + $tpl->addBlock('message'); + + $message = ""; + + $tpl->generateOutputToString($message); + + $mailer = new Mailer(); + + $rc = $mailer->mail(["to_name" => $login, + "to_address" => $email, + "subject" => __("[tt-rss] Password reset request"), + "message" => $message]); + + if (!$rc) print_error($mailer->error()); + + $user->resetpass_token = time() . ":" . $resetpass_token; + $user->save(); + + print "".__("Return to Tiny Tiny RSS").""; + } else { + print_error(__("Sorry, login and email combination not found.")); + + print "
+ + +
"; + } + } + } + + print "
"; + print "
"; + print ""; + print ""; + } + + function dbupdate(): void { + startup_gettext(); + + if (!Config::get(Config::SINGLE_USER_MODE) && ($_SESSION["access_level"] ?? 0) < 10) { + $_SESSION["login_error_msg"] = __("Your access level is insufficient to run this script."); + $this->_render_login_form(); + exit; + } + + ?> + + + + Tiny Tiny RSS: Database Updater + + + + + + + + + + + + + + + + +
+

+ +
+ + is_migration_needed()) { + ?> + +

+ +
migrate();
+							Debug::set_loglevel(Debug::LOG_NORMAL);
+							Debug::set_enabled(false);
+						?>
+ + + + +
+ + "return confirmDbUpdate()"]) ?> +
+ + + + + + + + + + + is_migration_needed()) { + + ?> +

+ + + +
+ + "return confirmDbUpdate()"]) ?> +
+ + + + + + + + + +
+
+ + + exists($filename)) { + $cache->send($filename); + } else { + header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); + echo "File not found."; + } + } + + function feed_icon() : void { + $id = (int)$_REQUEST['id']; + $cache = DiskCache::instance('feed-icons'); + + if ($cache->exists((string)$id)) { + $cache->send((string)$id); + } else { + header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); + echo "File not found."; + } + } + + private function _make_article_tag_uri(int $id, string $timestamp): string { + + $timestamp = date("Y-m-d", strtotime($timestamp)); + + return "tag:" . parse_url(Config::get_self_url(), PHP_URL_HOST) . ",$timestamp:/$id"; + } + + // 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(): void { + $host = new PluginHost(); + + $plugin_name = basename(clean($_REQUEST["plugin"])); + $method = clean($_REQUEST["pmethod"]); + + $host->load($plugin_name, PluginHost::KIND_ALL, 0); + //$host->load_data(); + + $plugin = $host->get_plugin($plugin_name); + + if ($plugin) { + if (method_exists($plugin, $method)) { + if ($plugin->is_public_method($method)) { + $plugin->$method(); + } else { + user_error("PluginHandler[PUBLIC]: Requested private method '$method' of plugin '$plugin_name'.", E_USER_WARNING); + header("Content-Type: text/json"); + print Errors::to_json(Errors::E_UNAUTHORIZED); + } + } else { + user_error("PluginHandler[PUBLIC]: Requested unknown method '$method' of plugin '$plugin_name'.", E_USER_WARNING); + header("Content-Type: text/json"); + print Errors::to_json(Errors::E_UNKNOWN_METHOD); + } + } else { + user_error("PluginHandler[PUBLIC]: Requested method '$method' of unknown plugin '$plugin_name'.", E_USER_WARNING); + header("Content-Type: text/json"); + print Errors::to_json(Errors::E_UNKNOWN_PLUGIN, ['plugin' => $plugin_name]); + } + } + + static function _render_login_form(string $return_to = ""): void { + header('Cache-Control: public'); + + if ($return_to) + $_REQUEST['return'] = $return_to; + + require_once "login_form.php"; + exit; + } + +} +?> diff --git a/classes/IAuthModule.php b/classes/IAuthModule.php new file mode 100644 index 000000000..dbf8c5587 --- /dev/null +++ b/classes/IAuthModule.php @@ -0,0 +1,18 @@ +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/ICatchall.php b/classes/ICatchall.php new file mode 100644 index 000000000..29954d35a --- /dev/null +++ b/classes/ICatchall.php @@ -0,0 +1,4 @@ + $options + * @return array + */ + function get_headlines(int $feed_id, array $options) : array; +} diff --git a/classes/Labels.php b/classes/Labels.php new file mode 100644 index 000000000..026e6621f --- /dev/null +++ b/classes/Labels.php @@ -0,0 +1,233 @@ +prepare("SELECT id FROM ttrss_labels2 WHERE LOWER(caption) = LOWER(?) + AND owner_uid = ? LIMIT 1"); + $sth->execute([$label, $owner_uid]); + + if ($row = $sth->fetch()) { + return $row['id']; + } else { + return 0; + } + } + + static function find_caption(int $label, int $owner_uid): string { + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT caption FROM ttrss_labels2 WHERE id = ? + AND owner_uid = ? LIMIT 1"); + $sth->execute([$label, $owner_uid]); + + if ($row = $sth->fetch()) { + return $row['caption']; + } else { + return ""; + } + } + + /** + * @return array> + */ + static function get_as_hash(int $owner_uid): array { + $rv = []; + $labels = Labels::get_all($owner_uid); + + foreach ($labels as $i => $label) { + $rv[(int)$label["id"]] = $labels[$i]; + } + + return $rv; + } + + /** + * @return array> An array of label detail arrays + */ + static function get_all(int $owner_uid) { + $rv = array(); + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT id, fg_color, bg_color, caption FROM ttrss_labels2 + WHERE owner_uid = ? ORDER BY caption"); + $sth->execute([$owner_uid]); + + while ($line = $sth->fetch(PDO::FETCH_ASSOC)) { + array_push($rv, $line); + } + + return $rv; + } + + /** + * @param array{'no-labels': 1}|array> $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) + self::clear_cache($id); + + if (!$labels) + $labels = Article::_get_labels($id); + + $labels = json_encode($labels); + + $sth = $pdo->prepare("UPDATE ttrss_user_entries SET + label_cache = ? WHERE ref_id = ? AND owner_uid = ?"); + $sth->execute([$labels, $id, $owner_uid]); + + } + + static function clear_cache(int $id): void { + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("UPDATE ttrss_user_entries SET + label_cache = '' WHERE ref_id = ?"); + $sth->execute([$id]); + + } + + static function remove_article(int $id, string $label, int $owner_uid): void { + + $label_id = self::find_id($label, $owner_uid); + + if (!$label_id) return; + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("DELETE FROM ttrss_user_labels2 + WHERE + label_id = ? AND + article_id = ?"); + + $sth->execute([$label_id, $id]); + + self::clear_cache($id); + } + + static function add_article(int $id, string $label, int $owner_uid): void { + + $label_id = self::find_id($label, $owner_uid); + + if (!$label_id) return; + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT + article_id FROM ttrss_labels2, ttrss_user_labels2 + WHERE + label_id = id AND + label_id = ? AND + article_id = ? AND owner_uid = ? + LIMIT 1"); + + $sth->execute([$label_id, $id, $owner_uid]); + + if (!$sth->fetch()) { + $sth = $pdo->prepare("INSERT INTO ttrss_user_labels2 + (label_id, article_id) VALUES (?, ?)"); + + $sth->execute([$label_id, $id]); + } + + self::clear_cache($id); + + } + + static function remove(int $id, int $owner_uid): void { + if (!$owner_uid) $owner_uid = $_SESSION["uid"]; + + $pdo = Db::pdo(); + $tr_in_progress = false; + + try { + $pdo->beginTransaction(); + } catch (Exception $e) { + $tr_in_progress = true; + } + + $sth = $pdo->prepare("SELECT caption FROM ttrss_labels2 + WHERE id = ?"); + $sth->execute([$id]); + + $row = $sth->fetch(); + $caption = $row['caption']; + + $sth = $pdo->prepare("DELETE FROM ttrss_labels2 WHERE id = ? + AND owner_uid = ?"); + $sth->execute([$id, $owner_uid]); + + if ($sth->rowCount() != 0 && $caption) { + + /* Remove access key for the label */ + + $ext_id = LABEL_BASE_INDEX - 1 - $id; + + $sth = $pdo->prepare("DELETE FROM ttrss_access_keys WHERE + feed_id = ? AND owner_uid = ?"); + $sth->execute([$ext_id, $owner_uid]); + + /* Remove cached data */ + + $sth = $pdo->prepare("UPDATE ttrss_user_entries SET label_cache = '' + WHERE owner_uid = ?"); + $sth->execute([$owner_uid]); + + } + + if (!$tr_in_progress) $pdo->commit(); + } + + /** + * @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']; + + $pdo = Db::pdo(); + + $tr_in_progress = false; + + try { + $pdo->beginTransaction(); + } catch (Exception $e) { + $tr_in_progress = true; + } + + $sth = $pdo->prepare("SELECT id FROM ttrss_labels2 + WHERE LOWER(caption) = LOWER(?) AND owner_uid = ?"); + $sth->execute([$caption, $owner_uid]); + + if (!$sth->fetch()) { + $sth = $pdo->prepare("INSERT INTO ttrss_labels2 + (caption,owner_uid,fg_color,bg_color) VALUES (?, ?, ?, ?)"); + + $sth->execute([$caption, $owner_uid, $fg_color, $bg_color]); + + $result = $sth->rowCount(); + } else { + $result = false; + } + + if (!$tr_in_progress) $pdo->commit(); + + return $result; + } +} diff --git a/classes/Logger.php b/classes/Logger.php new file mode 100644 index 000000000..ef6173a42 --- /dev/null +++ b/classes/Logger.php @@ -0,0 +1,89 @@ + 'E_ERROR', + 2 => 'E_WARNING', + 4 => 'E_PARSE', + 8 => 'E_NOTICE', + 16 => 'E_CORE_ERROR', + 32 => 'E_CORE_WARNING', + 64 => 'E_COMPILE_ERROR', + 128 => 'E_COMPILE_WARNING', + 256 => 'E_USER_ERROR', + 512 => 'E_USER_WARNING', + 1024 => 'E_USER_NOTICE', + 2048 => 'E_STRICT', + 4096 => 'E_RECOVERABLE_ERROR', + 8192 => 'E_DEPRECATED', + 16384 => 'E_USER_DEPRECATED', + 32767 => 'E_ALL']; + + 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(int $errno, string $errstr, string $file, int $line, string $context): bool { + //if ($errno == E_NOTICE) return false; + + if ($this->adapter) + return $this->adapter->log_error($errno, $errstr, $file, $line, $context); + else + return false; + } + + 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, string $context = ""): bool { + if ($this->adapter) + return $this->adapter->log_error($errno, $errstr, '', 0, $context); + else + return user_error($errstr, $errno); + } + + private function __clone() { + // + } + + function __construct() { + switch (Config::get(Config::LOG_DESTINATION)) { + case self::LOG_DEST_SQL: + $this->adapter = new Logger_SQL(); + break; + case self::LOG_DEST_SYSLOG: + $this->adapter = new Logger_Syslog(); + break; + case self::LOG_DEST_STDOUT: + $this->adapter = new Logger_Stdout(); + break; + default: + $this->adapter = null; + } + + if ($this->adapter && !implements_interface($this->adapter, "Logger_Adapter")) + user_error("Adapter for LOG_DESTINATION: " . Config::LOG_DESTINATION . " does not implement required interface.", E_USER_ERROR); + } + + private static function get_instance() : Logger { + if (self::$instance == null) + self::$instance = new self(); + + return self::$instance; + } + + static function get() : Logger { + user_error("Please don't use Logger::get(), call Logger::log(...) instead.", E_USER_DEPRECATED); + return self::get_instance(); + } +} diff --git a/classes/Logger_Adapter.php b/classes/Logger_Adapter.php new file mode 100644 index 000000000..b0287b5fa --- /dev/null +++ b/classes/Logger_Adapter.php @@ -0,0 +1,4 @@ + 117) { + + // limit context length, DOMDocument dumps entire XML in here sometimes, which may be huge + $context = mb_substr($context, 0, 8192); + + $server_params = [ + "Real IP" => "HTTP_X_REAL_IP", + "Forwarded For" => "HTTP_X_FORWARDED_FOR", + "Forwarded Protocol" => "HTTP_X_FORWARDED_PROTO", + "Remote IP" => "REMOTE_ADDR", + "Request URI" => "REQUEST_URI", + "User agent" => "HTTP_USER_AGENT", + ]; + + foreach ($server_params as $n => $p) { + if (isset($_SERVER[$p])) + $context .= "\n$n: " . $_SERVER[$p]; + } + + // passed error message may contain invalid unicode characters, failing to insert an error here + // would break the execution entirely by generating an actual fatal error instead of a E_WARNING etc + $errstr = UConverter::transcode($errstr, 'UTF-8', 'UTF-8'); + $context = UConverter::transcode($context, 'UTF-8', 'UTF-8'); + + // can't use $_SESSION["uid"] ?? null because what if its, for example, false? or zero? + // this would cause a PDOException on insert below + $owner_uid = !empty($_SESSION["uid"]) ? $_SESSION["uid"] : null; + + $entry = ORM::for_table('ttrss_error_log', get_class($this))->create(); + + $entry->set([ + 'errno' => $errno, + 'errstr' => $errstr, + 'filename' => $file, + 'lineno' => (int)$line, + 'context' => $context, + 'owner_uid' => $owner_uid, + 'created_at' => Db::NOW(), + ]); + + return $entry->save(); + } + + return false; + } + +} diff --git a/classes/Logger_Stdout.php b/classes/Logger_Stdout.php new file mode 100644 index 000000000..b15649028 --- /dev/null +++ b/classes/Logger_Stdout.php @@ -0,0 +1,31 @@ + $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"]; + $subject = $params["subject"]; + $message = $params["message"]; + $message_html = $params["message_html"] ?? ""; + $from_name = $params["from_name"] ?? Config::get(Config::SMTP_FROM_NAME); + $from_address = $params["from_address"] ?? Config::get(Config::SMTP_FROM_ADDRESS); + $additional_headers = $params["headers"] ?? []; + + $from_combined = $from_name ? "$from_name <$from_address>" : $from_address; + $to_combined = $to_name ? "$to_name <$to_address>" : $to_address; + + if (Config::get(Config::LOG_SENT_MAIL)) + Logger::log(E_USER_NOTICE, "Sending mail from $from_combined to $to_combined [$subject]: $message"); + + // HOOK_SEND_MAIL plugin instructions: + // 1. return 1 or true if mail is handled + // 2. return -1 if there's been a fatal error and no further action is allowed + // 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); + + if ($rc == 1) + return $rc; + + if ($rc == -1) + return 0; + + ++$hooks_tried; + } + + $headers = [ "From: $from_combined", "Content-Type: text/plain; charset=UTF-8" ]; + + $rc = mail($to_combined, $subject, $message, implode("\r\n", [...$headers, ...$additional_headers])); + + if (!$rc) { + $this->set_error(error_get_last()['message'] ?? T_sprintf("Unknown error while sending mail. Hooks tried: %d.", $hooks_tried)); + } + + return $rc; + } + + function set_error(string $message): void { + $this->last_error = $message; + user_error("Error sending mail: $message", E_USER_WARNING); + } + + function error(): string { + return $this->last_error; + } +} diff --git a/classes/OPML.php b/classes/OPML.php new file mode 100644 index 000000000..b9f5f2eab --- /dev/null +++ b/classes/OPML.php @@ -0,0 +1,703 @@ +opml_export($output_name, $owner_uid, false, $include_settings); + + return $rc; + } + + function import(): void { + $owner_uid = $_SESSION["uid"]; + + header('Content-Type: text/html; charset=utf-8'); + + print " + + ".stylesheet_tag("themes/light.css")." + ".__("OPML Utility")." + + + +

".__('OPML Utility')."

"; + + Feeds::_add_cat("Imported feeds", $owner_uid); + + $this->opml_notice(__("Importing OPML...")); + + $this->opml_import($owner_uid); + + print "
+ +
"; + + print "
"; + } + + // Export + + 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 = '')"; + else + $hide_qpart = "true"; + + $out = ""; + + $ttrss_specific_qpart = ""; + + if ($cat_id) { + $sth = $this->pdo->prepare("SELECT title,order_id + FROM ttrss_feed_categories WHERE id = ? + AND owner_uid = ?"); + $sth->execute([$cat_id, $owner_uid]); + $row = $sth->fetch(); + $cat_title = htmlspecialchars($row['title']); + + if ($include_settings) { + $order_id = (int)$row["order_id"]; + $ttrss_specific_qpart = "ttrssSortOrder=\"$order_id\""; + } + } else { + $cat_title = ""; + } + + if ($cat_title) $out .= "\n"; + + $sth = $this->pdo->prepare("SELECT id,title + FROM ttrss_feed_categories WHERE + (parent_cat = :cat OR (:cat = 0 AND parent_cat IS NULL)) AND + owner_uid = :uid ORDER BY order_id, title"); + + $sth->execute([':cat' => $cat_id, ':uid' => $owner_uid]); + + while ($line = $sth->fetch()) { + $out .= $this->opml_export_category($owner_uid, $line["id"], $hide_private_feeds, $include_settings); + } + + $fsth = $this->pdo->prepare("select title, feed_url, site_url, update_interval, order_id, purge_interval + FROM ttrss_feeds WHERE + (cat_id = :cat OR (:cat = 0 AND cat_id IS NULL)) AND owner_uid = :uid AND $hide_qpart + ORDER BY order_id, title"); + + $fsth->execute([':cat' => $cat_id, ':uid' => $owner_uid]); + + while ($fline = $fsth->fetch()) { + $title = htmlspecialchars($fline["title"]); + $url = htmlspecialchars($fline["feed_url"]); + $site_url = htmlspecialchars($fline["site_url"]); + + if ($include_settings) { + $update_interval = (int)$fline["update_interval"]; + $order_id = (int)$fline["order_id"]; + $purge_interval = (int)$fline["purge_interval"]; + + $ttrss_specific_qpart = "ttrssSortOrder=\"$order_id\" ttrssPurgeInterval=\"$purge_interval\" ttrssUpdateInterval=\"$update_interval\""; + } else { + $ttrss_specific_qpart = ""; + } + + if ($site_url) { + $html_url_qpart = "htmlUrl=\"$site_url\""; + } else { + $html_url_qpart = ""; + } + + $out .= "\n"; + } + + if ($cat_title) $out .= "\n"; + + return $out; + } + + /** + * @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) + if (!isset($_REQUEST["debug"])) { + header("Content-type: application/xml+opml"); + header("Content-Disposition: attachment; filename=$filename"); + } else { + header("Content-type: text/xml"); + } + + $out = ""; + + $out .= ""; + $out .= " + " . date("r", time()) . " + Tiny Tiny RSS Feed Export + "; + $out .= ""; + + $out .= $this->opml_export_category($owner_uid, 0, $hide_private_feeds, $include_settings); + + # export tt-rss settings + + if ($include_settings) { + $out .= ""; + + $sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2 WHERE + profile IS NULL AND owner_uid = ? ORDER BY pref_name"); + $sth->execute([$owner_uid]); + + while ($line = $sth->fetch()) { + $name = $line["pref_name"]; + $value = htmlspecialchars($line["value"]); + + $out .= ""; + } + + $out .= ""; + + $out .= ""; + + $sth = $this->pdo->prepare("SELECT * FROM ttrss_labels2 WHERE + owner_uid = ?"); + $sth->execute([$owner_uid]); + + while ($line = $sth->fetch()) { + $name = htmlspecialchars($line['caption']); + $fg_color = htmlspecialchars($line['fg_color']); + $bg_color = htmlspecialchars($line['bg_color']); + + $out .= ""; + + } + + $out .= ""; + + $out .= ""; + + $sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2 + WHERE owner_uid = ? ORDER BY id"); + $sth->execute([$owner_uid]); + + while ($line = $sth->fetch(PDO::FETCH_ASSOC)) { + $line["rules"] = array(); + $line["actions"] = array(); + + $tmph = $this->pdo->prepare("SELECT * FROM ttrss_filters2_rules + WHERE filter_id = ?"); + $tmph->execute([$line['id']]); + + while ($tmp_line = $tmph->fetch(PDO::FETCH_ASSOC)) { + unset($tmp_line["id"]); + unset($tmp_line["filter_id"]); + + $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"]); + } + + unset($tmp_line["feed_id"]); + unset($tmp_line["cat_id"]); + + array_push($line["rules"], $tmp_line); + } + + $tmph = $this->pdo->prepare("SELECT * FROM ttrss_filters2_actions + WHERE filter_id = ?"); + $tmph->execute([$line['id']]); + + while ($tmp_line = $tmph->fetch(PDO::FETCH_ASSOC)) { + unset($tmp_line["id"]); + unset($tmp_line["filter_id"]); + + array_push($line["actions"], $tmp_line); + } + + unset($line["id"]); + unset($line["owner_uid"]); + $filter = json_encode($line); + + $out .= ""; + + } + + + $out .= ""; + } + + $out .= ""; + + // Format output. + $doc = new DOMDocument(); + $doc->formatOutput = true; + $doc->preserveWhiteSpace = false; + $doc->loadXML($out); + + $xpath = new DOMXPath($doc); + $outlines = $xpath->query("//outline[@title]"); + + // cleanup empty categories + foreach ($outlines as $node) { + if ($node->getElementsByTagName('outline')->length == 0) + $node->parentNode->removeChild($node); + } + + $res = $doc->saveXML(); + +/* // saveXML uses a two-space indent. Change to tabs. + $res = preg_replace_callback('/^(?: )+/mu', + create_function( + '$matches', + 'return str_repeat("\t", intval(strlen($matches[0])/2));'), + $res); */ + + if ($file_output) + return file_put_contents($filename, $res) > 0; + + print $res; + return true; + } + + // Import + + 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); + if (!$feed_title) $feed_title = mb_substr($attrs->getNamedItem('title')->nodeValue, 0, 250); + + $feed_url = $attrs->getNamedItem('xmlUrl')->nodeValue; + if (!$feed_url) $feed_url = $attrs->getNamedItem('xmlURL')->nodeValue; + + $site_url = mb_substr($attrs->getNamedItem('htmlUrl')->nodeValue, 0, 250); + + if ($feed_url) { + $sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE + feed_url = ? AND owner_uid = ?"); + $sth->execute([$feed_url, $owner_uid]); + + if (!$feed_title) $feed_title = '[Unknown]'; + + 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), $nest); + + if (!$cat_id) $cat_id = null; + + $update_interval = (int) $attrs->getNamedItem('ttrssUpdateInterval')->nodeValue; + if (!$update_interval) $update_interval = 0; + + $order_id = (int) $attrs->getNamedItem('ttrssSortOrder')->nodeValue; + if (!$order_id) $order_id = 0; + + $purge_interval = (int) $attrs->getNamedItem('ttrssPurgeInterval')->nodeValue; + if (!$purge_interval) $purge_interval = 0; + + $sth = $this->pdo->prepare("INSERT INTO ttrss_feeds + (title, feed_url, owner_uid, cat_id, site_url, order_id, update_interval, purge_interval) VALUES + (?, ?, ?, ?, ?, ?, ?, ?)"); + + $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), $nest); + } + } + } + + private function opml_import_label(DOMNode $node, int $owner_uid, int $nest): void { + $attrs = $node->attributes; + $label_name = $attrs->getNamedItem('label-name')->nodeValue; + + if ($label_name) { + $fg_color = $attrs->getNamedItem('label-fg-color')->nodeValue; + $bg_color = $attrs->getNamedItem('label-bg-color')->nodeValue; + + 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)), $nest); + } + } + } + + private function opml_import_preference(DOMNode $node, int $owner_uid, int $nest): void { + $attrs = $node->attributes; + $pref_name = $attrs->getNamedItem('pref-name')->nodeValue; + + if ($pref_name) { + $pref_value = $attrs->getNamedItem('value')->nodeValue; + + $this->opml_notice(T_sprintf("Setting preference key %s to %s", + $pref_name, $pref_value), $nest); + + set_pref($pref_name, $pref_value, $owner_uid); + } + } + + private function opml_import_filter(DOMNode $node, int $owner_uid, int $nest): void { + $attrs = $node->attributes; + + $filter_type = $attrs->getNamedItem('filter-type')->nodeValue; + + if ($filter_type == '2') { + $filter = json_decode($node->nodeValue, true); + + if ($filter) { + $match_any_rule = bool_to_sql_bool($filter["match_any_rule"]); + $enabled = bool_to_sql_bool($filter["enabled"]); + $inverse = bool_to_sql_bool($filter["inverse"]); + $title = $filter["title"]; + + //print "F: $title, $inverse, $enabled, $match_any_rule"; + + $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, $owner_uid]); + + $sth = $this->pdo->prepare("SELECT MAX(id) AS id FROM ttrss_filters2 WHERE + owner_uid = ?"); + $sth->execute([$owner_uid]); + + $row = $sth->fetch(); + $filter_id = $row['id']; + + if ($filter_id) { + $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"] ?? false) { + + $match_on = []; + + foreach ($rule["match"] as $match) { + list ($name, $is_cat, $is_id) = $match; + + if ($is_id) { + array_push($match_on, ($is_cat ? "CAT:" : "") . $name); + } else { + + $match_id = Feeds::_find_by_title($name, $is_cat, $owner_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']; + + array_push($match_on, $match_id); + } + } 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()) { + $match_id = $row['id']; + + 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); + + $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]); + + } else { + + $match_id = Feeds::_find_by_title($rule['feed'] ?? "", $rule['cat_filter'], $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']]); + + if ($row = $tsth->fetch()) { + $feed_id = $row['id']; + } + } else { + $tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories + 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"]); + + $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]); + } + } + + foreach ($filter["actions"] as $action) { + + $action_id = (int)$action["action_id"]; + $action_param = $action["action_param"]; + + $usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_actions + (filter_id,action_id,action_param) + VALUES + (?, ?, ?)"); + $usth->execute([$filter_id, $action_id, $action_param]); + } + } + } + } + } + + 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); + + if (!$cat_title) + $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, $owner_uid, $parent_id); + + if ($cat_id === 0) { + $order_id = (int) $root_node->attributes->getNamedItem('ttrssSortOrder')->nodeValue; + + 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 { + $cat_id = 0; + } + + $outlines = $root_node->childNodes; + + } else { + $xpath = new DOMXPath($doc); + $outlines = $xpath->query("//opml/body/outline"); + + $cat_id = 0; + $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")), $nest); + + foreach ($outlines as $node) { + if ($node->hasAttributes() && strtolower($node->tagName) == "outline") { + $attrs = $node->attributes; + $node_cat_title = $attrs->getNamedItem('text') ? $attrs->getNamedItem('text')->nodeValue : false; + + if (!$node_cat_title) + $node_cat_title = $attrs->getNamedItem('title') ? $attrs->getNamedItem('title')->nodeValue : false; + + $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, $nest+1); + } else { + + if (!$cat_id) { + $dst_cat_id = $default_cat_id; + } else { + $dst_cat_id = $cat_id; + } + + switch ($cat_title) { + case "tt-rss-prefs": + $this->opml_import_preference($node, $owner_uid, $nest+1); + break; + case "tt-rss-labels": + $this->opml_import_label($node, $owner_uid, $nest+1); + break; + case "tt-rss-filters": + $this->opml_import_filter($node, $owner_uid, $nest+1); + break; + default: + $this->opml_import_feed($node, $dst_cat_id, $owner_uid, $nest+1); + } + } + } + } + } + + /** $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 (!$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'); + + $result = move_uploaded_file($_FILES['opml_file']['tmp_name'], + $tmp_file); + + if (!$result) { + print_error(__("Unable to move uploaded file.")); + return false; + } + } else { + print_error(__('Error: please upload OPML file.')); + return false; + } + } else { + $tmp_file = $filename; + } + + if (!is_readable($tmp_file)) { + $this->opml_notice(T_sprintf("Error: file is not readable: %s", $filename)); + return false; + } + + $loaded = false; + + $doc = new DOMDocument(); + + if (version_compare(PHP_VERSION, '8.0.0', '<')) { + libxml_disable_entity_loader(false); + } + + $loaded = $doc->load($tmp_file); + + if (version_compare(PHP_VERSION, '8.0.0', '<')) { + libxml_disable_entity_loader(true); + } + + // only remove temporary i.e. HTTP uploaded files + if (!$filename) + unlink($tmp_file); + + if ($loaded) { + // 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 { + $this->opml_notice(__('Error while parsing document.')); + return false; + } + + return true; + } + + 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 . "
"; + } + } + + 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' => $owner_uid]); + + if ($row = $sth->fetch()) { + return $row['id']; + } else { + return 0; + } + } + +} diff --git a/classes/Plugin.php b/classes/Plugin.php new file mode 100644 index 000000000..d941a1616 --- /dev/null +++ b/classes/Plugin.php @@ -0,0 +1,709 @@ + */ + abstract function about(); + // return array(1.0, "plugin", "No description", "No author", false); + + function __construct() { + $this->pdo = Db::pdo(); + } + + /** @return array */ + function flags() { + /* associative array, possible keys: + needs_curl = boolean + */ + 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_login_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); + + 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 $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 $article + * @return array + * @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 $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 $hotkeys + * @return array + * @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 $article + * @return array + * @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 $article + * @return array + * @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 $allowed_elements + * @param array $disallowed_attributes + * @param int $article_id + * @return DOMDocument|array> + * @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|null, 'headline': array|null} $params + * @return array + * @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> $hotkeys + * @return array> + * @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 $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 $row + * @param int $excerpt_length + * @return array + * @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> - 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> $enclosures + * @param int $article_id + * @param bool $always_display_enclosures + * @param string $article_content + * @param bool $hide_images + * @return string|array>> ($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 $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 $entry + * @param int $article_id + * @param array $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 $article + * @param string $action + * @return array ($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 $line + * @param int $feed + * @param bool $is_cat + * @param int $owner_uid + * @return array ($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 $entry + * @param int $id article id + * @param array{'formatted': string, 'entries': array>} $rv + * @return array ($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 $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 $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 $article + * @param array $matched_filters + * @param array $matched_rules + * @param array $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 $enclosures + * @param string $content + * @param string $site_url + * @param array $article + * @return string|array + * @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 + * @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 -- (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 (
"; + + } else if ($pref_name == Prefs::USER_CSS_THEME) { + + $theme_files = array_map("basename", [ + ...glob("themes/*.php") ?: [], + ...glob("themes/*.css") ?: [], + ...glob("themes.local/*.css") ?: [], + ]); + + asort($theme_files); + + $themes = [ "" => __("default") ]; + + foreach ($theme_files as $file) { + $themes[$file] = basename($file, ".css"); + } + ?> + + + "Helpers.Prefs.customizeCSS()"]) ?> + "alt-info", "onclick" => "window.open(\"https://tt-rss.org/wiki/Themes\")"]) ?> + + $is_disabled], "CB_$pref_name"); + + if ($pref_name == Prefs::DIGEST_ENABLE) { + print \Controls\button_tag(\Controls\icon("info") . " " . __('Preview'), '', + ['onclick' => 'Helpers.Digest.preview()', 'style' => 'margin-left : 10px']); + } + + } 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 == Prefs::PURGE_OLD_DAYS && Config::get(Config::FORCE_ARTICLE_PURGE) != 0) { + $attributes = ["disabled" => true, "required" => true]; + $value = Config::get(Config::FORCE_ARTICLE_PURGE); + } else { + $attributes = ["required" => true]; + } + + if ($type_hint == Config::T_INT) + print \Controls\number_spinner_tag($pref_name, $value, $attributes); + else + print \Controls\input_tag($pref_name, $value, "text", $attributes); + + } else if ($pref_name == Prefs::SSL_CERT_SERIAL) { + + print \Controls\input_tag($pref_name, $value, "text", ["readonly" => true], "SSL_CERT_SERIAL"); + + $cert_serial = htmlspecialchars(self::_get_ssl_certificate_id()); + $has_serial = ($cert_serial) ? true : false; + + print \Controls\button_tag(\Controls\icon("security") . " " . __('Register'), "", [ + "disabled" => !$has_serial, + "onclick" => "dijit.byId('SSL_CERT_SERIAL').attr('value', '$cert_serial')"]); + + print \Controls\button_tag(\Controls\icon("clear") . " " . __('Clear'), "", [ + "class" => "alt-danger", + "onclick" => "dijit.byId('SSL_CERT_SERIAL').attr('value', '')"]); + + print \Controls\button_tag(\Controls\icon("help") . " " . __("More info..."), "", [ + "class" => "alt-info", + "onclick" => "window.open('https://tt-rss.org/wiki/SSL%20Certificate%20Authentication')"]); + + } else if ($pref_name == Prefs::DIGEST_PREFERRED_TIME) { + print ""; + $item['help_text'] .= ". " . T_sprintf("Current server time: %s", date("H:i")); + } else { + $regexp = ($type_hint == Config::T_INT) ? 'regexp="^\d*$"' : ''; + + print ""; + } + + if ($item['help_text']) + print "
"; + + print ""; + } + } + } + print \Controls\hidden_tag("boolean_prefs", htmlspecialchars(join(",", $listed_boolean_prefs))); + } + + private function index_prefs(): void { + ?> +
+ + + + + +
+
+ index_prefs_list() ?> + run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefPrefsPrefsInside") ?> +
+
+ +
+ +
+
+ +
+
+
+ + + + + + run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefPrefsPrefsOutside") ?> +
+
+
+ load_all($tmppluginhost::KIND_ALL, $_SESSION["uid"], true); + + $rv = []; + + foreach ($tmppluginhost->get_plugins() as $name => $plugin) { + $about = $plugin->about(); + $is_local = $tmppluginhost->is_local($plugin); + $version = htmlspecialchars($this->_get_plugin_version($plugin)); + + array_push($rv, [ + "name" => $name, + "is_local" => $is_local, + "system_enabled" => in_array($name, $system_enabled), + "user_enabled" => in_array($name, $user_enabled), + "has_data" => count($tmppluginhost->get_all($plugin)) > 0, + "is_system" => (bool)($about[3] ?? false), + "version" => $version, + "author" => $about[2] ?? "", + "description" => $about[1] ?? "", + "more_info" => $about[4] ?? "", + ]); + } + + usort($rv, function($a, $b) { return strcmp($a["name"], $b["name"]); }); + + print json_encode(['plugins' => $rv, 'is_admin' => $_SESSION['access_level'] >= UserHelper::ACCESS_LEVEL_ADMIN]); + } + + function index_plugins(): void { + ?> +
+ + + + +
+
+
+ + +
+ +
+ +
+
+
+
+
+
+ +
+ + + + + +
    +
  • +
+ +
+
+ + + + "alt-primary", + "onclick" => "Helpers.Plugins.enableSelected()"]) ?> + + __("Reload"), "onclick" => "Helpers.Plugins.reload()"]) ?> + + = UserHelper::ACCESS_LEVEL_ADMIN) { ?> + + + + + + + + + +
+
+
+ +
+
+ + +
+
+ index_prefs() ?> +
+
+ index_plugins() ?> +
+ run_hooks(PluginHost::HOOK_PREFS_TAB, "prefPrefs") ?> +
+ render($otpurl); + } + + return null; + } + + 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"; + } else { + print "ERROR:".__("Incorrect one time password"); + } + } else { + print "ERROR:".__("Incorrect password"); + } + } + + 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)) { + + $sth = $this->pdo->prepare("SELECT email, login FROM ttrss_users WHERE id = ?"); + $sth->execute([$_SESSION['uid']]); + + if ($row = $sth->fetch()) { + $mailer = new Mailer(); + + $tpl = new Templator(); + + $tpl->readTemplateFromFile("otp_disabled_template.txt"); + + $tpl->setVariable('LOGIN', $row["login"]); + $tpl->setVariable('TTRSS_HOST', Config::get_self_url()); + + $tpl->addBlock('message'); + + $tpl->generateOutputToString($message); + + $mailer->mail(["to_name" => $row["login"], + "to_address" => $row["email"], + "subject" => "[tt-rss] OTP change notification", + "message" => $message]); + } + + UserHelper::disable_otp($_SESSION["uid"]); + + print "OK"; + } else { + print "ERROR: ".__("Incorrect password"); + } + + } + + function setplugins(): void { + $plugins = array_filter($_REQUEST["plugins"] ?? [], 'clean'); + + set_pref(Prefs::_ENABLED_PLUGINS, implode(",", $plugins)); + } + + 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]); + } + + $ref = new ReflectionClass(get_class($plugin)); + + $plugin_dir = dirname($ref->getFileName()); + + if (basename($plugin_dir) == "plugins") { + return ""; + } + + 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 ""; + } + + /** + * @return array + */ + 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) { + if (is_dir("$dir/.git")) { + $plugin_name = basename($dir); + + array_push($rv, ["plugin" => $plugin_name, "rv" => self::_plugin_needs_update($root_dir, $plugin_name)]); + } + } + + $rv = array_values(array_filter($rv, fn($item) => $item["rv"]["need_update"])); + + return $rv; + } + + /** + * @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; + + if (is_dir($plugin_dir) && is_dir("$plugin_dir/.git")) { + $pipes = []; + + $descriptorspec = [ + //0 => ["pipe", "r"], // STDIN + 1 => ["pipe", "w"], // STDOUT + 2 => ["pipe", "w"], // STDERR + ]; + + $proc = proc_open("git fetch -q origin -a && git log HEAD..origin/master --oneline", $descriptorspec, $pipes, $plugin_dir); + + if (is_resource($proc)) { + $rv = [ + "stdout" => stream_get_contents($pipes[1]), + "stderr" => stream_get_contents($pipes[2]), + "git_status" => proc_close($proc), + ]; + $rv["need_update"] = !empty($rv["stdout"]); + } + } + + return $rv; + } + + + /** + * @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 = []; + + if (is_dir($plugin_dir) && is_dir("$plugin_dir/.git")) { + $pipes = []; + + $descriptorspec = [ + //0 => ["pipe", "r"], // STDIN + 1 => ["pipe", "w"], // STDOUT + 2 => ["pipe", "w"], // STDERR + ]; + + $proc = proc_open("git fetch origin -a && git log HEAD..origin/master --oneline && git pull --ff-only origin master", $descriptorspec, $pipes, $plugin_dir); + + if (is_resource($proc)) { + $rv["stdout"] = stream_get_contents($pipes[1]); + $rv["stderr"] = stream_get_contents($pipes[2]); + $rv["git_status"] = proc_close($proc); + } + } + + return $rv; + } + + // https://gist.github.com/mindplay-dk/a4aad91f5a4f1283a5e2#gistcomment-2036828 + 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. + } elseif (is_file($dir) || is_link($dir)) { + return unlink($dir); // Delete file/link. + } + + // Delete all children. + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $fileinfo) { + $action = $fileinfo->isDir() ? 'rmdir' : 'unlink'; + if (!$action($fileinfo->getRealPath())) { + return false; // Abort due to the failure. + } + } + + return $keep_root ? true : rmdir($dir); + } + + // https://stackoverflow.com/questions/7153000/get-class-name-from-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++) { + if (isset($tokens[$i][0]) && $tokens[$i][0] == T_CLASS) { + for ($j = $i+1; $j < count($tokens); $j++) { + if (isset($tokens[$j][1]) && $tokens[$j][1] != " ") { + return $tokens[$j][1]; + } + } + } + } + + return ""; + } + + function uninstallPlugin(): void { + if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN) { + $plugin_name = basename(clean($_REQUEST['plugin'])); + $status = 0; + + $plugin_dir = dirname(dirname(__DIR__)) . "/plugins.local/$plugin_name"; + + if (is_dir($plugin_dir)) { + $status = $this->_recursive_rmdir($plugin_dir); + } + + print json_encode(['status' => $status]); + } + } + + 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"; + + $work_dir = "$plugin_dir/plugin-installer"; + + $rv = [ ]; + + if (is_dir($work_dir) || mkdir($work_dir)) { + foreach ($all_plugins as $plugin) { + if ($plugin['name'] == $plugin_name) { + + $tmp_dir = tempnam($work_dir, $plugin_name); + + if (file_exists($tmp_dir)) { + unlink($tmp_dir); + + $pipes = []; + + $descriptorspec = [ + 1 => ["pipe", "w"], // STDOUT + 2 => ["pipe", "w"], // STDERR + ]; + + $proc = proc_open("git clone " . escapeshellarg($plugin['clone_url']) . " " . $tmp_dir, + $descriptorspec, $pipes, sys_get_temp_dir()); + + $status = 0; + + if (is_resource($proc)) { + $rv["stdout"] = stream_get_contents($pipes[1]); + $rv["stderr"] = stream_get_contents($pipes[2]); + $status = proc_close($proc); + $rv["git_status"] = $status; + + // yeah I know about mysterious RC = -1 + if (file_exists("$tmp_dir/init.php")) { + $class_name = strtolower(basename($this->_get_class_name_from_file("$tmp_dir/init.php"))); + + if ($class_name) { + $dst_dir = "$plugin_dir/$class_name"; + + if (is_dir($dst_dir)) { + $rv['result'] = self::PI_RES_ALREADY_INSTALLED; + } else { + if (rename($tmp_dir, "$plugin_dir/$class_name")) { + $rv['result'] = self::PI_RES_SUCCESS; + } + } + } else { + $rv['result'] = self::PI_ERR_NO_CLASS; + } + } else { + $rv['result'] = self::PI_ERR_NO_INIT_PHP; + } + + } else { + $rv['result'] = self::PI_ERR_EXEC_FAILED; + } + } else { + $rv['result'] = self::PI_ERR_NO_TEMPDIR; + } + + // cleanup after failure + if ($tmp_dir && is_dir($tmp_dir)) { + $this->_recursive_rmdir($tmp_dir); + } + + break; + } + } + + if (empty($rv['result'])) + $rv['result'] = self::PI_ERR_PLUGIN_NOT_FOUND; + + } else { + $rv["result"] = self::PI_ERR_NO_WORKDIR; + } + + print json_encode($rv); + } + } + + /** + * @return array, '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(): void { + if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN) { + print json_encode($this->_get_available_plugins()); + } else { + print "[]"; + } + } + + 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/ + + $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(): 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 ($plugins) { + foreach ($plugins as $plugin_name) { + array_push($rv, ["plugin" => $plugin_name, "rv" => $this->_update_plugin($root_dir, $plugin_name)]); + } + } else { + $plugin_dirs = array_filter(glob("$root_dir/plugins.local/*"), "is_dir"); + + foreach ($plugin_dirs as $dir) { + if (is_dir("$dir/.git")) { + $plugin_name = basename($dir); + + $test = self::_plugin_needs_update($root_dir, $plugin_name); + + if (!empty($test["stdout"])) + array_push($rv, ["plugin" => $plugin_name, "rv" => $this->_update_plugin($root_dir, $plugin_name)]); + } + } + } + + print json_encode($rv); + } + } + + function clearplugindata(): void { + $name = clean($_REQUEST["name"]); + + PluginHost::getInstance()->clear_data(PluginHost::getInstance()->get_plugin($name)); + } + + function customizeCSS(): void { + $value = get_pref(Prefs::USER_STYLESHEET); + $value = str_replace("
", "\n", $value); + + print json_encode(["value" => $value]); + } + + function activateprofile(): void { + $id = (int) ($_REQUEST['id'] ?? 0); + + $profile = ORM::for_table('ttrss_settings_profiles') + ->where('owner_uid', $_SESSION['uid']) + ->find_one($id); + + if ($profile) { + $_SESSION["profile"] = $id; + } else { + $_SESSION["profile"] = null; + } + } + + function cloneprofile(): void { + $old_profile = $_REQUEST["old_profile"] ?? 0; + $new_title = clean($_REQUEST["new_title"]); + + if ($old_profile && $new_title) { + $new_profile = ORM::for_table('ttrss_settings_profiles')->create(); + $new_profile->title = $new_title; + $new_profile->owner_uid = $_SESSION['uid']; + + if ($new_profile->save()) { + $sth = $this->pdo->prepare("INSERT INTO ttrss_user_prefs + (owner_uid, pref_name, profile, value) + SELECT + :uid, + pref_name, + :new_profile, + value + FROM ttrss_user_prefs + WHERE owner_uid = :uid AND profile = :old_profile"); + + $sth->execute([ + "uid" => $_SESSION["uid"], + "new_profile" => $new_profile->id, + "old_profile" => $old_profile, + ]); + } + } + } + + function remprofiles(): void { + $ids = $_REQUEST["ids"] ?? []; + + ORM::for_table('ttrss_settings_profiles') + ->where('owner_uid', $_SESSION['uid']) + ->where_in('id', $ids) + ->where_not_equal('id', $_SESSION['profile'] ?? 0) + ->delete_many(); + } + + function addprofile(): void { + $title = clean($_REQUEST["title"]); + + if ($title) { + $profile = ORM::for_table('ttrss_settings_profiles') + ->where('owner_uid', $_SESSION['uid']) + ->where('title', $title) + ->find_one(); + + if (!$profile) { + $profile = ORM::for_table('ttrss_settings_profiles')->create(); + + $profile->title = $title; + $profile->owner_uid = $_SESSION['uid']; + $profile->save(); + } + } + } + + function saveprofile(): void { + $id = (int)$_REQUEST["id"]; + $title = clean($_REQUEST["value"]); + + if ($title && $id) { + $profile = ORM::for_table('ttrss_settings_profiles') + ->where('owner_uid', $_SESSION['uid']) + ->find_one($id); + + if ($profile) { + $profile->title = $title; + $profile->save(); + } + } + } + + // TODO: this maybe needs to be unified with Public::getProfiles() + function getProfiles(): void { + $rv = []; + + $profiles = ORM::for_table('ttrss_settings_profiles') + ->where('owner_uid', $_SESSION['uid']) + ->order_by_expr('title') + ->find_many(); + + array_push($rv, ["title" => __("Default profile"), + "id" => 0, + "initialized" => true, + "active" => empty($_SESSION["profile"]) + ]); + + foreach ($profiles as $profile) { + $profile['active'] = ($_SESSION["profile"] ?? 0) == $profile->id; + + $num_settings = ORM::for_table('ttrss_user_prefs') + ->where('profile', $profile->id) + ->count(); + + $profile['initialized'] = $num_settings > 0; + + array_push($rv, $profile->as_array()); + }; + + print json_encode($rv); + } + + 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(string $pref_name): string { + if (isset($this->pref_help[$pref_name][1])) { + return $this->pref_help[$pref_name][1]; + } + return ""; + } + + private function appPasswordList(): void { + ?> +
+
+ +
+
+
+
+
+
+ +
+ + + + + + + + where('owner_uid', $_SESSION['uid']) + ->order_by_asc('title') + ->find_many(); + + foreach ($passwords as $pass) { ?> + '> + + + + + + +
+ + + + + + + +
+
+ where('owner_uid', $_SESSION['uid']) + ->where_in('id', $_REQUEST['ids'] ?? []) + ->delete_many(); + + $this->appPasswordList(); + } + + function generateAppPassword(): void { + $title = clean($_REQUEST['title']); + $new_password = make_password(16); + $new_salt = UserHelper::get_salt(); + $new_password_hash = UserHelper::hash_password($new_password, $new_salt, UserHelper::HASH_ALGOS[0]); + + print_warning(T_sprintf("Generated password %s for %s. Please remember it for future reference.", $new_password, $title)); + + $password = ORM::for_table('ttrss_app_passwords')->create(); + + $password->title = $title; + $password->owner_uid = $_SESSION['uid']; + $password->pwd_hash = "$new_password_hash:$new_salt"; + $password->service = Auth_Base::AUTH_SERVICE_API; + $password->created = Db::NOW(); + + $password->save(); + + $this->appPasswordList(); + } + + function previewDigest(): void { + print json_encode(Digest::prepare_headlines_digest($_SESSION["uid"], 1, 16)); + } + + 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"] . + $_SERVER["REDIRECT_SSL_CLIENT_V_END"] . + $_SERVER["REDIRECT_SSL_CLIENT_S_DN"]); + } + if ($_SERVER["SSL_CLIENT_M_SERIAL"] ?? false) { + return sha1($_SERVER["SSL_CLIENT_M_SERIAL"] . + $_SERVER["SSL_CLIENT_V_START"] . + $_SERVER["SSL_CLIENT_V_END"] . + $_SERVER["SSL_CLIENT_S_DN"]); + } + return ""; + } + + 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 new file mode 100644 index 000000000..e85c1134d --- /dev/null +++ b/classes/Pref_System.php @@ -0,0 +1,225 @@ +pdo->query("DELETE FROM ttrss_error_log"); + } + + function sendTestEmail(): void { + $mail_address = clean($_REQUEST["mail_address"]); + + $mailer = new Mailer(); + + $rc = $mailer->mail(["to_name" => "", + "to_address" => $mail_address, + "subject" => __("Test message from tt-rss"), + "message" => ("This message confirms that tt-rss can send outgoing mail.") + ]); + + print json_encode(['rc' => $rc, 'error' => $mailer->error()]); + } + + function getphpinfo(): void { + ob_start(); + phpinfo(); + $info = ob_get_contents(); + ob_end_clean(); + + print preg_replace( '%^.*(.*).*$%ms','$1', (string)$info); + } + + private function _log_viewer(int $page, int $severity): void { + $errno_values = []; + + switch ($severity) { + case E_USER_ERROR: + $errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR ]; + break; + case E_USER_WARNING: + $errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR, E_WARNING, E_USER_WARNING, E_DEPRECATED, E_USER_DEPRECATED ]; + break; + } + + if (count($errno_values) > 0) { + $errno_qmarks = arr_qmarks($errno_values); + $errno_filter_qpart = "errno IN ($errno_qmarks)"; + } else { + $errno_filter_qpart = "true"; + } + + $offset = self::LOG_PAGE_LIMIT * $page; + + $sth = $this->pdo->prepare("SELECT + COUNT(id) AS total_pages + FROM + ttrss_error_log + WHERE + $errno_filter_qpart"); + + $sth->execute($errno_values); + + if ($res = $sth->fetch()) { + $total_pages = (int)($res["total_pages"] / self::LOG_PAGE_LIMIT); + } else { + $total_pages = 0; + } + + ?> +
+
+ + + + + + + + + + + +
+ + + __("Errors"), + E_USER_WARNING => __("Warnings"), + E_USER_NOTICE => __("Everything") + ], ["onchange"=> "Helpers.EventLog.refresh()"], "severity") ?> +
+
+ +
+ + + + + + + + + + + + pdo->prepare("SELECT + errno, errstr, filename, lineno, created_at, login, context + FROM + ttrss_error_log LEFT JOIN ttrss_users ON (owner_uid = ttrss_users.id) + WHERE + $errno_filter_qpart + ORDER BY + ttrss_error_log.id DESC + LIMIT ". self::LOG_PAGE_LIMIT ." OFFSET $offset"); + + $sth->execute($errno_values); + + while ($line = $sth->fetch()) { + foreach ($line as $k => $v) { $line[$k] = htmlspecialchars($v ?? ''); } + ?> + + + + + + + + +
+ + + +
+
+
+ +
+ +
'> + _log_viewer($page, $severity); + ?> +
+ +
'> +
+ +
+ + + + + +
+ + 1]) ?> + + +
+
+
+
+
'> + + +
+ + run_hooks(PluginHost::HOOK_PREFS_TAB, "prefSystem") ?> +
+ select_expr("id,login,access_level,email,full_name,otp_enabled") + ->find_one((int)$_REQUEST["id"]) + ->as_array(); + + global $access_level_names; + + if ($user) { + print json_encode([ + "user" => $user, + "access_level_names" => $access_level_names + ]); + } + } + + function userdetails(): void { + $id = (int) clean($_REQUEST["id"]); + + $sth = $this->pdo->prepare("SELECT login, + ".SUBSTRING_FOR_DATE."(last_login,1,16) AS last_login, + access_level, + (SELECT COUNT(int_id) FROM ttrss_user_entries + WHERE owner_uid = id) AS stored_articles, + ".SUBSTRING_FOR_DATE."(created,1,16) AS created + FROM ttrss_users + WHERE id = ?"); + $sth->execute([$id]); + + if ($row = $sth->fetch()) { + + $last_login = TimeHelper::make_local_datetime( + $row["last_login"], true); + + $created = TimeHelper::make_local_datetime( + $row["created"], true); + + $stored_articles = $row["stored_articles"]; + + $sth = $this->pdo->prepare("SELECT COUNT(id) as num_feeds FROM ttrss_feeds + WHERE owner_uid = ?"); + $sth->execute([$id]); + $row = $sth->fetch(); + + $num_feeds = $row["num_feeds"]; + + ?> + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + pdo->prepare("SELECT id,title,site_url FROM ttrss_feeds + WHERE owner_uid = ? ORDER BY title"); + $sth->execute([$id]); + ?> + +
    + fetch()) { ?> +
  • + + + + + "> + + +
  • + +
+ + find_one($id); + + if ($user) { + $login = clean($_REQUEST["login"]); + + if ($id == 1) $login = "admin"; + if (!$login) return; + + $user->login = mb_strtolower($login); + $user->access_level = (int) clean($_REQUEST["access_level"]); + $user->email = clean($_REQUEST["email"]); + $user->otp_enabled = checkbox_to_sql_bool($_REQUEST["otp_enabled"] ?? ""); + + // force new OTP secret when next enabled + if (Config::get_schema_version() >= 143 && !$user->otp_enabled) { + $user->otp_secret = null; + } + + $user->save(); + } + + if ($password) { + UserHelper::reset_password($id, false, $password); + } + } + + function remove(): void { + $ids = explode(",", clean($_REQUEST["ids"])); + + foreach ($ids as $id) { + if ($id != $_SESSION["uid"] && $id != 1) { + $sth = $this->pdo->prepare("DELETE FROM ttrss_tags WHERE owner_uid = ?"); + $sth->execute([$id]); + + $sth = $this->pdo->prepare("DELETE FROM ttrss_feeds WHERE owner_uid = ?"); + $sth->execute([$id]); + + $sth = $this->pdo->prepare("DELETE FROM ttrss_users WHERE id = ?"); + $sth->execute([$id]); + } + } + } + + function add(): void { + $login = clean($_REQUEST["login"]); + + if (!$login) return; // no blank usernames + + if (!UserHelper::find_user_by_login($login)) { + + $new_password = make_password(); + + $user = ORM::for_table('ttrss_users')->create(); + + $user->salt = UserHelper::get_salt(); + $user->login = mb_strtolower($login); + $user->pwd_hash = UserHelper::hash_password($new_password, $user->salt); + $user->access_level = 0; + $user->created = Db::NOW(); + $user->save(); + + if (!is_null(UserHelper::find_user_by_login($login))) { + print T_sprintf("Added user %s with password %s", + $login, $new_password); + } else { + print T_sprintf("Could not create user %s", $login); + } + } else { + print T_sprintf("User %s already exists.", $login); + } + } + + function resetPass(): void { + UserHelper::reset_password(clean($_REQUEST["id"])); + } + + function index(): void { + + global $access_level_names; + + $user_search = clean($_REQUEST["search"] ?? ""); + + if (array_key_exists("search", $_REQUEST)) { + $_SESSION["prefs_user_search"] = $user_search; + } else { + $user_search = ($_SESSION["prefs_user_search"] ?? ""); + } + + $sort = clean($_REQUEST["sort"] ?? ""); + + if (!$sort || $sort == "undefined") { + $sort = "login"; + } + + if (!in_array($sort, ["login", "access_level", "created", "num_feeds", "created", "last_login"])) + $sort = "login"; + + if ($sort != "login") $sort = "$sort DESC"; + ?> + +
+
+
+ +
+ + +
+ +
+ +
+
+
+
+
+ + + + + + + + run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefUsersToolbar") ?> + +
+
+
+ + + + + + + + + + + + + table_alias('u') + ->left_outer_join("ttrss_feeds", ["owner_uid", "=", "u.id"], 'f') + ->select_expr('u.*,COUNT(f.id) AS num_feeds') + ->where_like("login", $user_search ? "%$user_search%" : "%") + ->order_by_expr($sort) + ->group_by_expr('u.id') + ->find_many(); + + foreach ($users as $user) { ?> + + + + + + + + + + + +
+ + + person + +
+
+ run_hooks(PluginHost::HOOK_PREFS_TAB, "prefUsers") ?> +
+ [ 60, Config::T_INT ], + Prefs::DEFAULT_UPDATE_INTERVAL => [ 30, Config::T_INT ], + //Prefs::DEFAULT_ARTICLE_LIMIT => [ 30, Config::T_INT ], + //Prefs::ALLOW_DUPLICATE_POSTS => [ false, Config::T_BOOL ], + Prefs::ENABLE_FEED_CATS => [ true, Config::T_BOOL ], + Prefs::SHOW_CONTENT_PREVIEW => [ true, Config::T_BOOL ], + Prefs::SHORT_DATE_FORMAT => [ "M d, G:i", Config::T_STRING ], + Prefs::LONG_DATE_FORMAT => [ "D, M d Y - G:i", Config::T_STRING ], + Prefs::COMBINED_DISPLAY_MODE => [ true, Config::T_BOOL ], + Prefs::HIDE_READ_FEEDS => [ false, Config::T_BOOL ], + Prefs::ON_CATCHUP_SHOW_NEXT_FEED => [ false, Config::T_BOOL ], + Prefs::FEEDS_SORT_BY_UNREAD => [ false, Config::T_BOOL ], + Prefs::REVERSE_HEADLINES => [ false, Config::T_BOOL ], + Prefs::DIGEST_ENABLE => [ false, Config::T_BOOL ], + Prefs::CONFIRM_FEED_CATCHUP => [ true, Config::T_BOOL ], + Prefs::CDM_AUTO_CATCHUP => [ false, Config::T_BOOL ], + Prefs::_DEFAULT_VIEW_MODE => [ "adaptive", Config::T_STRING ], + Prefs::_DEFAULT_VIEW_LIMIT => [ 30, Config::T_INT ], + //Prefs::_PREFS_ACTIVE_TAB => [ "", Config::T_STRING ], + //Prefs::STRIP_UNSAFE_TAGS => [ true, Config::T_BOOL ], + Prefs::BLACKLISTED_TAGS => [ 'main, generic, misc, uncategorized, blog, blogroll, general, news', Config::T_STRING ], + Prefs::FRESH_ARTICLE_MAX_AGE => [ 24, Config::T_INT ], + Prefs::DIGEST_CATCHUP => [ false, Config::T_BOOL ], + Prefs::CDM_EXPANDED => [ true, Config::T_BOOL ], + Prefs::PURGE_UNREAD_ARTICLES => [ true, Config::T_BOOL ], + Prefs::HIDE_READ_SHOWS_SPECIAL => [ true, Config::T_BOOL ], + Prefs::VFEED_GROUP_BY_FEED => [ false, Config::T_BOOL ], + Prefs::STRIP_IMAGES => [ false, Config::T_BOOL ], + Prefs::_DEFAULT_VIEW_ORDER_BY => [ "default", Config::T_STRING ], + Prefs::ENABLE_API_ACCESS => [ false, Config::T_BOOL ], + //Prefs::_COLLAPSED_SPECIAL => [ false, Config::T_BOOL ], + //Prefs::_COLLAPSED_LABELS => [ false, Config::T_BOOL ], + //Prefs::_COLLAPSED_UNCAT => [ false, Config::T_BOOL ], + //Prefs::_COLLAPSED_FEEDLIST => [ false, Config::T_BOOL ], + //Prefs::_MOBILE_ENABLE_CATS => [ false, Config::T_BOOL ], + //Prefs::_MOBILE_SHOW_IMAGES => [ false, Config::T_BOOL ], + //Prefs::_MOBILE_HIDE_READ => [ false, Config::T_BOOL ], + //Prefs::_MOBILE_SORT_FEEDS_UNREAD => [ false, Config::T_BOOL ], + //Prefs::_MOBILE_BROWSE_CATS => [ true, Config::T_BOOL ], + //Prefs::_THEME_ID => [ 0, Config::T_BOOL ], + Prefs::USER_TIMEZONE => [ "Automatic", Config::T_STRING ], + Prefs::USER_STYLESHEET => [ "", Config::T_STRING ], + //Prefs::SORT_HEADLINES_BY_FEED_DATE => [ false, Config::T_BOOL ], + Prefs::SSL_CERT_SERIAL => [ "", Config::T_STRING ], + Prefs::DIGEST_PREFERRED_TIME => [ "00:00", Config::T_STRING ], + //Prefs::_PREFS_SHOW_EMPTY_CATS => [ false, Config::T_BOOL ], + Prefs::_DEFAULT_INCLUDE_CHILDREN => [ false, Config::T_BOOL ], + //Prefs::AUTO_ASSIGN_LABELS => [ false, Config::T_BOOL ], + Prefs::_ENABLED_PLUGINS => [ "", Config::T_STRING ], + //Prefs::_MOBILE_REVERSE_HEADLINES => [ false, Config::T_BOOL ], + Prefs::USER_CSS_THEME => [ "" , Config::T_STRING ], + Prefs::USER_LANGUAGE => [ "" , Config::T_STRING ], + Prefs::DEFAULT_SEARCH_LANGUAGE => [ "" , Config::T_STRING ], + Prefs::_PREFS_MIGRATED => [ false, Config::T_BOOL ], + Prefs::HEADLINES_NO_DISTINCT => [ false, Config::T_BOOL ], + Prefs::DEBUG_HEADLINE_IDS => [ false, Config::T_BOOL ], + Prefs::DISABLE_CONDITIONAL_COUNTERS => [ false, Config::T_BOOL ], + Prefs::WIDESCREEN_MODE => [ false, Config::T_BOOL ], + Prefs::CDM_ENABLE_GRID => [ false, Config::T_BOOL ], + ]; + + const _PROFILE_BLACKLIST = [ + //Prefs::ALLOW_DUPLICATE_POSTS, + Prefs::PURGE_OLD_DAYS, + Prefs::PURGE_UNREAD_ARTICLES, + Prefs::DIGEST_ENABLE, + Prefs::DIGEST_CATCHUP, + Prefs::BLACKLISTED_TAGS, + Prefs::ENABLE_API_ACCESS, + //Prefs::UPDATE_POST_ON_CHECKSUM_CHANGE, + Prefs::DEFAULT_UPDATE_INTERVAL, + Prefs::USER_TIMEZONE, + //Prefs::SORT_HEADLINES_BY_FEED_DATE, + Prefs::SSL_CERT_SERIAL, + Prefs::DIGEST_PREFERRED_TIME, + Prefs::_PREFS_MIGRATED + ]; + + /** @var Prefs|null */ + private static $instance; + + /** @var array */ + private $cache = []; + + /** @var PDO */ + private $pdo; + + public static function get_instance() : Prefs { + if (self::$instance == null) + self::$instance = new self(); + + return self::$instance; + } + + 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]; + else + return null; + } + + function __construct() { + $this->pdo = Db::pdo(); + + if (!empty($_SESSION["uid"])) { + $owner_uid = (int) $_SESSION["uid"]; + $profile_id = $_SESSION["profile"] ?? null; + + $this->cache_all($owner_uid, $profile_id); + $this->migrate($owner_uid, $profile_id); + }; + } + + private function __clone() { + // + } + + /** + * @return array> + */ + static function get_all(int $owner_uid, int $profile_id = null) { + return self::get_instance()->_get_all($owner_uid, $profile_id); + } + + /** + * @return array> + */ + private function _get_all(int $owner_uid, int $profile_id = null) { + $rv = []; + + $ref = new ReflectionClass(get_class($this)); + + foreach ($ref->getConstants() as $const => $cvalue) { + if (isset($this::_DEFAULTS[$const])) { + list ($def_val, $type_hint) = $this::_DEFAULTS[$const]; + + array_push($rv, [ + "pref_name" => $const, + "value" => $this->_get($const, $owner_uid, $profile_id), + "type_hint" => $type_hint, + ]); + } + } + + return $rv; + } + + private function cache_all(int $owner_uid, ?int $profile_id): void { + if (!$profile_id) $profile_id = null; + + // fill cache with defaults + $ref = new ReflectionClass(get_class($this)); + foreach ($ref->getConstants() as $const => $cvalue) { + if (isset($this::_DEFAULTS[$const])) { + list ($def_val, $type_hint) = $this::_DEFAULTS[$const]; + + $this->_set_cache($const, $def_val, $owner_uid, $profile_id); + } + } + + if (Config::get_schema_version() >= 141) { + // fill in any overrides from the database + $sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2 + WHERE owner_uid = :uid AND + (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); + + $sth->execute(["uid" => $owner_uid, "profile" => $profile_id]); + + while ($row = $sth->fetch()) { + $this->_set_cache($row["pref_name"], $row["value"], $owner_uid, $profile_id); + } + } + } + + /** + * @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); + } + + /** + * @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; + + list ($def_val, $type_hint) = self::_DEFAULTS[$pref_name]; + + $cached_value = $this->_get_cache($pref_name, $owner_uid, $profile_id); + + if ($this->_is_cached($pref_name, $owner_uid, $profile_id)) { + $cached_value = $this->_get_cache($pref_name, $owner_uid, $profile_id); + return Config::cast_to($cached_value, $type_hint); + } else if (Config::get_schema_version() >= 141) { + $sth = $this->pdo->prepare("SELECT value FROM ttrss_user_prefs2 + WHERE pref_name = :name AND owner_uid = :uid AND + (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); + + $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name ]); + + if ($row = $sth->fetch(PDO::FETCH_ASSOC)) { + $this->_set_cache($pref_name, $row["value"], $owner_uid, $profile_id); + + return Config::cast_to($row["value"], $type_hint); + } else { + $this->_set_cache($pref_name, $def_val, $owner_uid, $profile_id); + + return $def_val; + } + } else { + return Config::cast_to($def_val, $type_hint); + + } + } else { + user_error("Attempt to get invalid preference key: $pref_name (UID: $owner_uid, profile: $profile_id)", E_USER_WARNING); + } + + return 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]); + } + + /** + * @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])) + return $this->cache[$cache_key]; + + return 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; + } + + /** + * @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); + } + + /** + * @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)) + return false; + + if (isset(self::_DEFAULTS[$pref_name])) { + list ($def_val, $type_hint) = self::_DEFAULTS[$pref_name]; + + if ($strip_tags) + $value = trim(strip_tags($value)); + + $value = Config::cast_to($value, $type_hint); + + if ($value == $this->_get($pref_name, $owner_uid, $profile_id)) + return false; + + $this->_set_cache($pref_name, $value, $owner_uid, $profile_id); + + $sth = $this->pdo->prepare("SELECT COUNT(pref_name) AS count FROM ttrss_user_prefs2 + WHERE pref_name = :name AND owner_uid = :uid AND + (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); + $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name ]); + + if ($row = $sth->fetch()) { + if ($row["count"] == 0) { + $sth = $this->pdo->prepare("INSERT INTO ttrss_user_prefs2 + (pref_name, value, owner_uid, profile) + VALUES + (:name, :value, :uid, :profile)"); + + return $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name, "value" => $value ]); + + } else { + $sth = $this->pdo->prepare("UPDATE ttrss_user_prefs2 + SET value = :value + WHERE pref_name = :name AND owner_uid = :uid AND + (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); + + return $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name, "value" => $value ]); + } + } + } else { + user_error("Attempt to set invalid preference key: $pref_name (UID: $owner_uid, profile: $profile_id)", E_USER_WARNING); + } + + return false; + } + + function migrate(int $owner_uid, ?int $profile_id): void { + if (Config::get_schema_version() < 141) + return; + + if (!$profile_id) $profile_id = null; + + if (!$this->_get(Prefs::_PREFS_MIGRATED, $owner_uid, $profile_id)) { + + $in_nested_tr = false; + + try { + $this->pdo->beginTransaction(); + } catch (PDOException $e) { + $in_nested_tr = true; + } + + $sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs + WHERE owner_uid = :uid AND + (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); + $sth->execute(["uid" => $owner_uid, "profile" => $profile_id]); + + while ($row = $sth->fetch(PDO::FETCH_ASSOC)) { + if (isset(self::_DEFAULTS[$row["pref_name"]])) { + list ($def_val, $type_hint) = self::_DEFAULTS[$row["pref_name"]]; + + $user_val = Config::cast_to($row["value"], $type_hint); + + if ($user_val != $def_val) { + $this->_set($row["pref_name"], $user_val, $owner_uid, $profile_id); + } + } + } + + $this->_set(Prefs::_PREFS_MIGRATED, "1", $owner_uid, $profile_id); + + if (!$in_nested_tr) + $this->pdo->commit(); + + Logger::log(E_USER_NOTICE, sprintf("Migrated preferences of user %d (profile %d)", $owner_uid, $profile_id)); + } + } + + 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 + WHERE owner_uid = :uid AND pref_name != :mig_key AND + (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); + + $sth->execute(["uid" => $owner_uid, "mig_key" => self::_PREFS_MIGRATED, "profile" => $profile_id]); + } +} diff --git a/classes/RPC.php b/classes/RPC.php new file mode 100644 index 000000000..e21671d78 --- /dev/null +++ b/classes/RPC.php @@ -0,0 +1,846 @@ + + */ + private function _translations_as_array(): array { + + global $text_domains; + + $rv = []; + + foreach (array_keys($text_domains) as $domain) { + + /** @var gettext_reader $l10n */ + $l10n = _get_reader($domain); + + for ($i = 0; $i < $l10n->total; $i++) { + if (isset($l10n->table_originals[$i * 2 + 2]) && $orig = $l10n->get_original_string($i)) { + if(strpos($orig, "\000") !== false) { // Plural forms + $key = explode(chr(0), $orig); + + $rv[$key[0]] = _ngettext($key[0], $key[1], 1); // Singular + $rv[$key[1]] = _ngettext($key[0], $key[1], 2); // Plural + } else { + $translation = _dgettext($domain,$orig); + $rv[$orig] = $translation; + } + } + } + } + + return $rv; + } + + + function togglepref(): void { + $key = clean($_REQUEST["key"]); + set_pref($key, !get_pref($key)); + $value = get_pref($key); + + print json_encode(array("param" =>$key, "value" => $value)); + } + + function setpref(): void { + // set_pref escapes input, so no need to double escape it here + $key = clean($_REQUEST['key']); + $value = $_REQUEST['value']; + + set_pref($key, $value, $_SESSION["uid"], $key != 'USER_STYLESHEET'); + + print json_encode(array("param" =>$key, "value" => $value)); + } + + function mark(): void { + $mark = clean($_REQUEST["mark"]); + $id = clean($_REQUEST["id"]); + + $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET marked = ?, + last_marked = NOW() + WHERE ref_id = ? AND owner_uid = ?"); + + $sth->execute([$mark, $id, $_SESSION['uid']]); + + print json_encode(array("message" => "UPDATE_COUNTERS")); + } + + function delete(): void { + $ids = explode(",", clean($_REQUEST["ids"])); + $ids_qmarks = arr_qmarks($ids); + + $sth = $this->pdo->prepare("DELETE FROM ttrss_user_entries + WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); + $sth->execute([...$ids, $_SESSION['uid']]); + + Article::_purge_orphans(); + + print json_encode(array("message" => "UPDATE_COUNTERS")); + } + + function publ(): void { + $pub = clean($_REQUEST["pub"]); + $id = clean($_REQUEST["id"]); + + $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET + published = ?, last_published = NOW() + WHERE ref_id = ? AND owner_uid = ?"); + + $sth->execute([$pub, $id, $_SESSION['uid']]); + + print json_encode(array("message" => "UPDATE_COUNTERS")); + } + + function getRuntimeInfo(): void { + $reply = [ + 'runtime-info' => $this->_make_runtime_info() + ]; + + print json_encode($reply); + } + + function getAllCounters(): void { + $span = Tracer::start(__METHOD__); + + @$seq = (int) $_REQUEST['seq']; + + $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 + // we need null because it means "return all counters"; [] would return nothing + if ($feed_id_count == -1) + $feed_ids = null; + else + $feed_ids = array_map("intval", clean($_REQUEST["feed_ids"] ?? [])); + + if ($label_id_count == -1) + $label_ids = null; + else + $label_ids = array_map("intval", clean($_REQUEST["label_ids"] ?? [])); + + $counters = is_array($feed_ids) && !get_pref(Prefs::DISABLE_CONDITIONAL_COUNTERS) ? + Counters::get_conditional($feed_ids, $label_ids) : Counters::get_all(); + + $reply = [ + 'counters' => $counters, + 'seq' => $seq + ]; + + $span->end(); + print json_encode($reply); + } + + /* GET["cmode"] = 0 - mark as read, 1 - as unread, 2 - toggle */ + function catchupSelected(): void { + $ids = array_map("intval", clean($_REQUEST["ids"] ?? [])); + $cmode = (int)clean($_REQUEST["cmode"]); + + if (count($ids) > 0) + Article::_catchup_by_id($ids, $cmode); + + print json_encode(["message" => "UPDATE_COUNTERS", + "labels" => Article::_labels_of($ids), + "feeds" => Article::_feeds_of($ids)]); + } + + function markSelected(): void { + $ids = array_map("intval", clean($_REQUEST["ids"] ?? [])); + $cmode = (int)clean($_REQUEST["cmode"]); + + if (count($ids) > 0) + $this->markArticlesById($ids, $cmode); + + print json_encode(["message" => "UPDATE_COUNTERS", + "labels" => Article::_labels_of($ids), + "feeds" => Article::_feeds_of($ids)]); + } + + function publishSelected(): void { + $ids = array_map("intval", clean($_REQUEST["ids"] ?? [])); + $cmode = (int)clean($_REQUEST["cmode"]); + + if (count($ids) > 0) + $this->publishArticlesById($ids, $cmode); + + print json_encode(["message" => "UPDATE_COUNTERS", + "labels" => Article::_labels_of($ids), + "feeds" => Article::_feeds_of($ids)]); + } + + function sanityCheck(): void { + $span = Tracer::start(__METHOD__); + + $_SESSION["hasSandbox"] = self::_param_to_bool($_REQUEST["hasSandbox"] ?? false); + $_SESSION["clientTzOffset"] = clean($_REQUEST["clientTzOffset"]); + + $client_location = $_REQUEST["clientLocation"]; + + $error = Errors::E_SUCCESS; + $error_params = []; + + $client_scheme = parse_url($client_location, PHP_URL_SCHEME); + $server_scheme = parse_url(Config::get_self_url(), PHP_URL_SCHEME); + + if (Config::is_migration_needed()) { + $error = Errors::E_SCHEMA_MISMATCH; + } else if ($client_scheme != $server_scheme) { + $error = Errors::E_URL_SCHEME_MISMATCH; + $error_params["client_scheme"] = $client_scheme; + $error_params["server_scheme"] = $server_scheme; + $error_params["self_url_path"] = Config::get_self_url(); + } + + if ($error == Errors::E_SUCCESS) { + $reply = []; + + $reply['init-params'] = $this->_make_init_params(); + $reply['runtime-info'] = $this->_make_runtime_info(); + $reply['translations'] = $this->_translations_as_array(); + + print json_encode($reply); + } else { + print Errors::to_json($error, $error_params); + } + + $span->end(); + } + + /*function completeLabels() { + $search = clean($_REQUEST["search"]); + + $sth = $this->pdo->prepare("SELECT DISTINCT caption FROM + ttrss_labels2 + WHERE owner_uid = ? AND + LOWER(caption) LIKE LOWER(?) ORDER BY caption + LIMIT 5"); + $sth->execute([$_SESSION['uid'], "%$search%"]); + + print "
    "; + while ($line = $sth->fetch()) { + print "
  • " . $line["caption"] . "
  • "; + } + print "
"; + }*/ + + function catchupFeed(): void { + $feed_id = clean($_REQUEST['feed_id']); + $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, 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()]); + + //print json_encode(array("message" => "UPDATE_COUNTERS")); + } + + function setWidescreen(): void { + $wide = (int) clean($_REQUEST["wide"]); + + set_pref(Prefs::WIDESCREEN_MODE, $wide); + + print json_encode(["wide" => $wide]); + } + + static function updaterandomfeed_real(): void { + $span = Tracer::start(__METHOD__); + + $default_interval = (int) Prefs::get_default(Prefs::DEFAULT_UPDATE_INTERVAL); + + // Test if the feed need a update (update interval exceded). + if (Config::get(Config::DB_TYPE) == "pgsql") { + $update_limit_qpart = "AND (( + update_interval = 0 + AND (p.value IS NULL OR p.value != '-1') + AND last_updated < NOW() - CAST((COALESCE(p.value, '$default_interval') || ' minutes') AS INTERVAL) + ) OR ( + update_interval > 0 + AND last_updated < NOW() - CAST((update_interval || ' minutes') AS INTERVAL) + ) OR ( + update_interval >= 0 + AND (p.value IS NULL OR p.value != '-1') + AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) + ))"; + } else { + $update_limit_qpart = "AND (( + update_interval = 0 + AND (p.value IS NULL OR p.value != '-1') + AND last_updated < DATE_SUB(NOW(), INTERVAL CONVERT(COALESCE(p.value, '$default_interval'), SIGNED INTEGER) MINUTE) + ) OR ( + update_interval > 0 + AND last_updated < DATE_SUB(NOW(), INTERVAL update_interval MINUTE) + ) OR ( + update_interval >= 0 + AND (p.value IS NULL OR p.value != '-1') + AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) + ))"; + } + + // Test if feed is currently being updated by another process. + if (Config::get(Config::DB_TYPE) == "pgsql") { + $updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < NOW() - INTERVAL '5 minutes')"; + } else { + $updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < DATE_SUB(NOW(), INTERVAL 5 MINUTE))"; + } + + $random_qpart = Db::sql_random_function(); + + $pdo = Db::pdo(); + + // we could be invoked from public.php with no active session + if (!empty($_SESSION["uid"])) { + $owner_check_qpart = "AND f.owner_uid = ".$pdo->quote($_SESSION["uid"]); + } else { + $owner_check_qpart = ""; + } + + $query = "SELECT f.feed_url,f.id + 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 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 + ORDER BY $random_qpart LIMIT 30"; + + $res = $pdo->query($query); + + $num_updated = 0; + + $tstart = time(); + + while ($line = $res->fetch()) { + $feed_id = $line["id"]; + + if (time() - $tstart < ini_get("max_execution_time") * 0.7) { + RSSUtils::update_rss_feed($feed_id, true); + ++$num_updated; + } else { + break; + } + } + + // Purge orphans and cleanup tags + Article::_purge_orphans(); + //cleanup_tags(14, 50000); + + if ($num_updated > 0) { + print json_encode(array("message" => "UPDATE_COUNTERS", + "num_updated" => $num_updated)); + } else { + print json_encode(array("message" => "NOTHING_TO_UPDATE")); + } + + $span->end(); + } + + function updaterandomfeed(): void { + self::updaterandomfeed_real(); + } + + /** + * @param array $ids + */ + private function markArticlesById(array $ids, int $cmode): void { + + $ids_qmarks = arr_qmarks($ids); + + 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 == 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 = ?"); + } else { + $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET + marked = NOT marked,last_marked = NOW() + WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); + } + + $sth->execute([...$ids, $_SESSION['uid']]); + } + + /** + * @param array $ids + */ + private function publishArticlesById(array $ids, int $cmode): void { + + $ids_qmarks = arr_qmarks($ids); + + 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 == 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 = ?"); + } else { + $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET + published = NOT published,last_published = NOW() + WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); + } + + $sth->execute([...$ids, $_SESSION['uid']]); + } + + function log(): void { + $span = Tracer::start(__METHOD__); + + $msg = clean($_REQUEST['msg'] ?? ""); + $file = basename(clean($_REQUEST['file'] ?? "")); + $line = (int) clean($_REQUEST['line'] ?? 0); + $context = clean($_REQUEST['context'] ?? ""); + + if ($msg) { + Logger::log_error(E_USER_WARNING, + $msg, 'client-js:' . $file, $line, $context); + + echo json_encode(array("message" => "HOST_ERROR_LOGGED")); + } + + $span->end(); + } + + function checkforupdates(): void { + $span = Tracer::start(__METHOD__); + + $rv = ["changeset" => [], "plugins" => []]; + + $version = Config::get_version(false); + + $git_timestamp = $version["timestamp"] ?? false; + $git_commit = $version["commit"] ?? false; + + 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) { + $content = json_decode($content, true); + + if ($content && isset($content["changeset"])) { + if ($git_timestamp < (int)$content["changeset"]["timestamp"] && + $git_commit != $content["changeset"]["id"]) { + + $rv["changeset"] = $content["changeset"]; + } + } + } + + $rv["plugins"] = Pref_Prefs::_get_updated_plugins(); + } + + $span->end(); + + print json_encode($rv); + } + + /** + * @return array + */ + private function _make_init_params(): array { + $span = Tracer::start(__METHOD__); + + $params = array(); + + foreach ([Prefs::ON_CATCHUP_SHOW_NEXT_FEED, Prefs::HIDE_READ_FEEDS, + Prefs::ENABLE_FEED_CATS, Prefs::FEEDS_SORT_BY_UNREAD, + Prefs::CONFIRM_FEED_CATCHUP, Prefs::CDM_AUTO_CATCHUP, + Prefs::FRESH_ARTICLE_MAX_AGE, Prefs::HIDE_READ_SHOWS_SPECIAL, + Prefs::COMBINED_DISPLAY_MODE, Prefs::DEBUG_HEADLINE_IDS, Prefs::CDM_ENABLE_GRID] as $param) { + + $params[strtolower($param)] = (int) get_pref($param); + } + + $params["safe_mode"] = !empty($_SESSION["safe_mode"]); + $params["check_for_updates"] = Config::get(Config::CHECK_FOR_UPDATES); + $params["icons_url"] = Config::get_self_url() . '/public.php'; + $params["cookie_lifetime"] = Config::get(Config::SESSION_COOKIE_LIFETIME); + $params["default_view_mode"] = get_pref(Prefs::_DEFAULT_VIEW_MODE); + $params["default_view_limit"] = (int) get_pref(Prefs::_DEFAULT_VIEW_LIMIT); + $params["default_view_order_by"] = get_pref(Prefs::_DEFAULT_VIEW_ORDER_BY); + $params["bw_limit"] = (int) ($_SESSION["bw_limit"] ?? false); + $params["is_default_pw"] = UserHelper::is_default_password(); + $params["label_base_index"] = LABEL_BASE_INDEX; + + $theme = get_pref(Prefs::USER_CSS_THEME); + $params["theme"] = theme_exists($theme) ? $theme : ""; + + $params["plugins"] = implode(", ", PluginHost::getInstance()->get_plugin_names()); + + $params["php_platform"] = PHP_OS; + $params["php_version"] = PHP_VERSION; + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM + ttrss_feeds WHERE owner_uid = ?"); + $sth->execute([$_SESSION['uid']]); + $row = $sth->fetch(); + + $max_feed_id = $row["mid"]; + $num_feeds = $row["nf"]; + + $params["self_url_prefix"] = Config::get_self_url(); + $params["max_feed_id"] = (int) $max_feed_id; + $params["num_feeds"] = (int) $num_feeds; + $params["hotkeys"] = $this->get_hotkeys_map(); + $params["widescreen"] = (int) get_pref(Prefs::WIDESCREEN_MODE); + $params['simple_update'] = Config::get(Config::SIMPLE_UPDATE_MODE); + $params["icon_indicator_white"] = $this->image_to_base64("images/indicator_white.gif"); + $params["icon_oval"] = $this->image_to_base64("images/oval.svg"); + $params["icon_three_dots"] = $this->image_to_base64("images/three-dots.svg"); + $params["icon_blank"] = $this->image_to_base64("images/blank_icon.gif"); + $params["labels"] = Labels::get_all($_SESSION["uid"]); + + $span->end(); + + return $params; + } + + private function image_to_base64(string $filename): string { + if (file_exists($filename)) { + $ext = pathinfo($filename, PATHINFO_EXTENSION); + + if ($ext == "svg") $ext = "svg+xml"; + + return "data:image/$ext;base64," . base64_encode((string)file_get_contents($filename)); + } else { + return ""; + } + } + + /** + * @return array + */ + static function _make_runtime_info(): array { + $span = Tracer::start(__METHOD__); + + $data = array(); + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM + ttrss_feeds WHERE owner_uid = ?"); + $sth->execute([$_SESSION['uid']]); + $row = $sth->fetch(); + + $max_feed_id = $row['mid']; + $num_feeds = $row['nf']; + + $data["max_feed_id"] = (int) $max_feed_id; + $data["num_feeds"] = (int) $num_feeds; + $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'] >= UserHelper::ACCESS_LEVEL_ADMIN) { + if (Config::get(Config::DB_TYPE) == 'pgsql') { + $log_interval = "created_at > NOW() - interval '1 hour'"; + } else { + $log_interval = "created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)"; + } + + $sth = $pdo->prepare("SELECT COUNT(id) AS cid + FROM ttrss_error_log + 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(); + + if ($row = $sth->fetch()) { + $data['recent_log_events'] = $row['cid']; + } + } + + if (file_exists(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.lock")) { + + $data['daemon_is_running'] = (int) file_is_locked("update_daemon.lock"); + + if (time() - ($_SESSION["daemon_stamp_check"] ?? 0) > 30) { + + $stamp = (int) @file_get_contents(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.stamp"); + + if ($stamp) { + $stamp_delta = time() - $stamp; + + if ($stamp_delta > 1800) { + $stamp_check = 0; + } else { + $stamp_check = 1; + $_SESSION["daemon_stamp_check"] = time(); + } + + $data['daemon_stamp_ok'] = $stamp_check; + + $stamp_fmt = date("Y.m.d, G:i", $stamp); + + $data['daemon_stamp'] = $stamp_fmt; + } + } + } + + $span->end(); + + return $data; + } + + /** + * @return array> + */ + static function get_hotkeys_info(): array { + $hotkeys = array( + __("Navigation") => array( + "next_feed" => __("Open next feed"), + "next_unread_feed" => __("Open next unread feed"), + "prev_feed" => __("Open previous feed"), + "prev_unread_feed" => __("Open previous unread feed"), + "next_article_or_scroll" => __("Open next article (in combined mode, scroll down)"), + "prev_article_or_scroll" => __("Open previous article (in combined mode, scroll up)"), + "next_headlines_page" => __("Scroll headlines by one page down"), + "prev_headlines_page" => __("Scroll headlines by one page up"), + "next_article_noscroll" => __("Open next article"), + "prev_article_noscroll" => __("Open previous article"), + "next_article_noexpand" => __("Move to next article (don't expand)"), + "prev_article_noexpand" => __("Move to previous article (don't expand)"), + "search_dialog" => __("Show search dialog"), + "cancel_search" => __("Cancel active search")), + __("Article") => array( + "toggle_mark" => __("Toggle starred"), + "toggle_publ" => __("Toggle published"), + "toggle_unread" => __("Toggle unread"), + "edit_tags" => __("Edit tags"), + "open_in_new_window" => __("Open in new window"), + "catchup_below" => __("Mark below as read"), + "catchup_above" => __("Mark above as read"), + "article_scroll_down" => __("Scroll down"), + "article_scroll_up" => __("Scroll up"), + "article_page_down" => __("Scroll down page"), + "article_page_up" => __("Scroll up page"), + "select_article_cursor" => __("Select article under cursor"), + "email_article" => __("Email article"), + "close_article" => __("Close/collapse article"), + "toggle_expand" => __("Toggle article expansion (combined mode)"), + "toggle_widescreen" => __("Toggle widescreen mode"), + "toggle_full_text" => __("Toggle full article text via Readability")), + __("Article selection") => array( + "select_all" => __("Select all articles"), + "select_unread" => __("Select unread"), + "select_marked" => __("Select starred"), + "select_published" => __("Select published"), + "select_invert" => __("Invert selection"), + "select_none" => __("Deselect everything")), + __("Feed") => array( + "feed_refresh" => __("Refresh current feed"), + "feed_unhide_read" => __("Un/hide read feeds"), + "feed_subscribe" => __("Subscribe to feed"), + "feed_edit" => __("Edit feed"), + "feed_catchup" => __("Mark as read"), + "feed_reverse" => __("Reverse headlines"), + "feed_toggle_vgroup" => __("Toggle headline grouping"), + "feed_toggle_grid" => __("Toggle grid view"), + "feed_debug_update" => __("Debug feed update"), + "feed_debug_viewfeed" => __("Debug viewfeed()"), + "catchup_all" => __("Mark all feeds as read"), + "cat_toggle_collapse" => __("Un/collapse current category"), + "toggle_cdm_expanded" => __("Toggle auto expand in combined mode"), + "toggle_combined_mode" => __("Toggle combined mode")), + __("Go to") => array( + "goto_all" => __("All articles"), + "goto_fresh" => __("Fresh"), + "goto_marked" => __("Starred"), + "goto_published" => __("Published"), + "goto_read" => __("Recently read"), + "goto_prefs" => __("Preferences")), + __("Other") => array( + "create_label" => __("Create label"), + "create_filter" => __("Create filter"), + "collapse_sidebar" => __("Un/collapse sidebar"), + "help_dialog" => __("Show help dialog")) + ); + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HOTKEY_INFO, + function ($result) use (&$hotkeys) { + $hotkeys = $result; + }, + $hotkeys); + + return $hotkeys; + } + + /** + * {3} - 3 panel mode only + * {C} - combined mode only + * + * @return array{0: array, 1: array} $prefixes, $hotkeys + */ + static function get_hotkeys_map() { + $hotkeys = array( + "k" => "next_feed", + "K" => "next_unread_feed", + "j" => "prev_feed", + "J" => "prev_unread_feed", + "n" => "next_article_noscroll", + "p" => "prev_article_noscroll", + "N" => "article_page_down", + "P" => "article_page_up", + "*(33)|Shift+PgUp" => "article_page_up", + "*(34)|Shift+PgDn" => "article_page_down", + "{3}(38)|Up" => "prev_article_or_scroll", + "{3}(40)|Down" => "next_article_or_scroll", + "*(38)|Shift+Up" => "article_scroll_up", + "*(40)|Shift+Down" => "article_scroll_down", + "^(38)|Ctrl+Up" => "prev_article_noscroll", + "^(40)|Ctrl+Down" => "next_article_noscroll", + "/" => "search_dialog", + "\\" => "cancel_search", + "s" => "toggle_mark", + "S" => "toggle_publ", + "u" => "toggle_unread", + "T" => "edit_tags", + "o" => "open_in_new_window", + "c p" => "catchup_below", + "c n" => "catchup_above", + "a W" => "toggle_widescreen", + "a e" => "toggle_full_text", + "e" => "email_article", + "a q" => "close_article", + "a s" => "article_span_grid", + "a a" => "select_all", + "a u" => "select_unread", + "a U" => "select_marked", + "a p" => "select_published", + "a i" => "select_invert", + "a n" => "select_none", + "f r" => "feed_refresh", + "f a" => "feed_unhide_read", + "f s" => "feed_subscribe", + "f e" => "feed_edit", + "f q" => "feed_catchup", + "f x" => "feed_reverse", + "f g" => "feed_toggle_vgroup", + "f G" => "feed_toggle_grid", + "f D" => "feed_debug_update", + "f %" => "feed_debug_viewfeed", + "f C" => "toggle_combined_mode", + "f c" => "toggle_cdm_expanded", + "Q" => "catchup_all", + "x" => "cat_toggle_collapse", + "g a" => "goto_all", + "g f" => "goto_fresh", + "g s" => "goto_marked", + "g p" => "goto_published", + "g r" => "goto_read", + "g P" => "goto_prefs", + "r" => "select_article_cursor", + "c l" => "create_label", + "c f" => "create_filter", + "c s" => "collapse_sidebar", + "?" => "help_dialog", + ); + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HOTKEY_MAP, + function ($result) use (&$hotkeys) { + $hotkeys = $result; + }, + $hotkeys); + + $prefixes = array(); + + foreach (array_keys($hotkeys) as $hotkey) { + $pair = explode(" ", (string)$hotkey, 2); + + if (count($pair) > 1 && !in_array($pair[0], $prefixes)) { + array_push($prefixes, $pair[0]); + } + } + + return array($prefixes, $hotkeys); + } + + function hotkeyHelp(): void { + $info = self::get_hotkeys_info(); + $imap = self::get_hotkeys_map(); + $omap = []; + + foreach ($imap[1] as $sequence => $action) { + $omap[$action] ??= []; + $omap[$action][] = $sequence; + } + + ?> +
    + $hotkeys) { + ?> +
  • + $description) { + + if (!empty($omap[$action])) { + foreach ($omap[$action] as $sequence) { + if (strpos($sequence, "|") !== false) { + $sequence = substr($sequence, + strpos($sequence, "|")+1, + strlen($sequence)); + } else { + $keys = explode(" ", $sequence); + + for ($i = 0; $i < count($keys); $i++) { + if (strlen($keys[$i]) > 1) { + $tmp = ''; + foreach (str_split($keys[$i]) as $c) { + switch ($c) { + case '*': + $tmp .= __('Shift') . '+'; + break; + case '^': + $tmp .= __('Ctrl') . '+'; + break; + default: + $tmp .= $c; + } + } + $keys[$i] = $tmp; + } + } + $sequence = join(" ", $keys); + } + + ?> +
  • +
    +
    +
  • + +
+
+ +
+ $article + */ + static function calculate_article_hash(array $article, PluginHost $pluginhost): string { + $tmp = ""; + + $ignored_fields = [ "feed", "guid", "guid_hashed", "owner_uid", "force_catchup" ]; + + foreach ($article as $k => $v) { + if (in_array($k, $ignored_fields)) + continue; + + if ($k != "feed" && isset($v)) { + $x = strip_tags( + is_array($v) ? implode(",", array_keys($v)) : $v); + + $tmp .= sha1("$k:" . sha1($x)); + } + } + + return sha1(implode(",", $pluginhost->get_plugin_names()) . $tmp); + } + + // Strips utf8mb4 characters (i.e. emoji) for mysql + static function strip_utf8mb4(string $str): string { + return preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $str); + } + + static function cleanup_feed_browser(): void { + $pdo = Db::pdo(); + $pdo->query("DELETE FROM ttrss_feedbrowser_cache"); + } + + static function cleanup_feed_icons(): void { + $pdo = Db::pdo(); + $sth = $pdo->prepare("SELECT id FROM ttrss_feeds WHERE id = ?"); + + $cache = DiskCache::instance('feed-icons'); + + if ($cache->is_writable()) { + $dh = opendir($cache->get_full_path("")); + + if ($dh) { + while (($icon = readdir($dh)) !== false) { + if (preg_match('/^[0-9]{1,}$/', $icon) && $cache->get_mtime($icon) < time() - 86400 * Config::get(Config::CACHE_MAX_DAYS)) { + + $sth->execute([(int)$icon]); + + if ($sth->fetch()) { + $cache->put($icon, $cache->get($icon)); + } else { + $icon_path = $cache->get_full_path($icon); + + Debug::log("Removing orphaned feed icon: $icon_path"); + unlink($icon_path); + } + } + } + + closedir($dh); + } + } + } + + /** + * @param array $options + */ + static function update_daemon_common(int $limit = 0, array $options = []): int { + $span = Tracer::start(__METHOD__); + + if (!$limit) $limit = Config::get(Config::DAEMON_FEED_LIMIT); + + if (Config::get_schema_version() != Config::SCHEMA_VERSION) { + die("Schema version is wrong, please upgrade the database.\n"); + } + + $pdo = Db::pdo(); + + if (!Config::get(Config::SINGLE_USER_MODE) && Config::get(Config::DAEMON_UPDATE_LOGIN_LIMIT) > 0) { + $login_limit = (int) Config::get(Config::DAEMON_UPDATE_LOGIN_LIMIT); + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $login_thresh_qpart = "AND last_login >= NOW() - INTERVAL '$login_limit days'"; + } else { + $login_thresh_qpart = "AND last_login >= DATE_SUB(NOW(), INTERVAL $login_limit DAY)"; + } + } else { + $login_thresh_qpart = ""; + } + + $default_interval = (int) Prefs::get_default(Prefs::DEFAULT_UPDATE_INTERVAL); + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $update_limit_qpart = "AND (( + update_interval = 0 + AND (p.value IS NULL OR p.value != '-1') + AND last_updated < NOW() - CAST((COALESCE(p.value, '$default_interval') || ' minutes') AS INTERVAL) + ) OR ( + update_interval > 0 + AND last_updated < NOW() - CAST((update_interval || ' minutes') AS INTERVAL) + ) OR ( + update_interval >= 0 + AND (p.value IS NULL OR p.value != '-1') + AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) + ))"; + } else { + $update_limit_qpart = "AND (( + update_interval = 0 + AND (p.value IS NULL OR p.value != '-1') + AND last_updated < DATE_SUB(NOW(), INTERVAL CONVERT(COALESCE(p.value, '$default_interval'), SIGNED INTEGER) MINUTE) + ) OR ( + update_interval > 0 + AND last_updated < DATE_SUB(NOW(), INTERVAL update_interval MINUTE) + ) OR ( + update_interval >= 0 + AND (p.value IS NULL OR p.value != '-1') + AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) + ))"; + } + + // Test if feed is currently being updated by another process. + if (Config::get(Config::DB_TYPE) == "pgsql") { + $updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < NOW() - INTERVAL '10 minutes')"; + } else { + $updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < DATE_SUB(NOW(), INTERVAL 10 MINUTE))"; + } + + $query_limit = $limit ? sprintf("LIMIT %d", $limit) : ""; + + // Update the least recently updated feeds first + $query_order = "ORDER BY last_updated"; + + if (Config::get(Config::DB_TYPE) == "pgsql") + $query_order .= " NULLS FIRST"; + + $query = "SELECT f.feed_url, f.last_updated + 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 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 + $query_order $query_limit"; + + //print "$query\n"; + + $res = $pdo->query($query); + + $feeds_to_update = array(); + while ($line = $res->fetch()) { + array_push($feeds_to_update, $line['feed_url']); + } + + Debug::log(sprintf("Scheduled %d feeds to update...", count($feeds_to_update))); + + // Update last_update_started before actually starting the batch + // in order to minimize collision risk for parallel daemon tasks + if (count($feeds_to_update) > 0) { + $feeds_qmarks = arr_qmarks($feeds_to_update); + + $tmph = $pdo->prepare("UPDATE ttrss_feeds SET last_update_started = NOW() + WHERE feed_url IN ($feeds_qmarks)"); + $tmph->execute($feeds_to_update); + } + + $nf = 0; + $bstarted = microtime(true); + + $batch_owners = []; + + $user_query = "SELECT f.id, + last_updated, + f.owner_uid, + u.login AS owner, + f.title + 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 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 + ORDER BY f.id $query_limit"; + + //print "$user_query\n"; + + // since we have feed xml cached, we can deal with other feeds with the same url + $usth = $pdo->prepare($user_query); + + foreach ($feeds_to_update as $feed) { + Debug::log("Base feed: $feed"); + + $usth->execute(["feed" => $feed]); + + if ($tline = $usth->fetch()) { + Debug::log(sprintf("=> %s (ID: %d, U: %s [%d]), last updated: %s", $tline["title"], $tline["id"], + $tline["owner"], $tline["owner_uid"], + $tline["last_updated"] ? $tline["last_updated"] : "never")); + + if (!in_array($tline["owner_uid"], $batch_owners)) + array_push($batch_owners, $tline["owner_uid"]); + + $fstarted = microtime(true); + + $quiet = (isset($options["quiet"])) ? "--quiet" : ""; + $log = function_exists("flock") && isset($options['log']) ? '--log '.$options['log'] : ''; + $log_level = isset($options['log-level']) ? '--log-level '.$options['log-level'] : ''; + + /* shared hosting may have this disabled and it's not strictly required */ + if (self::function_enabled('passthru')) { + $exit_code = 0; + + passthru(Config::get(Config::PHP_EXECUTABLE) . " update.php --update-feed " . $tline["id"] . " --pidlock feed-" . $tline["id"] . " $quiet $log $log_level", $exit_code); + + Debug::log(sprintf("<= %.4f (sec) exit code: %d", microtime(true) - $fstarted, $exit_code)); + + // -1 can be caused by a SIGCHLD handler which daemon master process installs (not every setup, apparently) + if ($exit_code != 0 && $exit_code != -1) { + $festh = $pdo->prepare("SELECT last_error FROM ttrss_feeds WHERE id = ?"); + $festh->execute([$tline["id"]]); + + if ($ferow = $festh->fetch()) { + $error_message = $ferow["last_error"]; + } else { + $error_message = "N/A"; + } + + Debug::log("!! Last error: $error_message"); + + Logger::log(E_USER_NOTICE, + sprintf("Update process for feed %d (%s, owner UID: %d) failed with exit code: %d (%s).", + $tline["id"], clean($tline["title"]), $tline["owner_uid"], $exit_code, clean($error_message))); + + $combined_error_message = sprintf("Update process failed with exit code: %d (%s)", + $exit_code, clean($error_message)); + + # mark failed feed as having an update error (unless it is already marked) + $fusth = $pdo->prepare("UPDATE ttrss_feeds SET last_error = ? WHERE id = ? AND last_error = ''"); + $fusth->execute([$combined_error_message, $tline["id"]]); + } + + } else { + try { + if (!self::update_rss_feed($tline["id"], true)) { + Logger::log(E_USER_NOTICE, + sprintf("Update request for feed %d (%s, owner UID: %d) failed: %s.", + $tline["id"], clean($tline["title"]), $tline["owner_uid"], clean(UrlHelper::$fetch_last_error))); + } + + Debug::log(sprintf("<= %.4f (sec) (not using a separate process)", microtime(true) - $fstarted)); + + } catch (PDOException $e) { + Logger::log_error(E_USER_WARNING, $e->getMessage(), $e->getFile(), $e->getLine(), $e->getTraceAsString()); + + try { + $pdo->rollback(); + } catch (PDOException $e) { + // it doesn't matter if there wasn't actually anything to rollback, PDO Exception can be + // thrown outside of an active transaction during feed update + } + } + } + + ++$nf; + } + } + + if ($nf > 0) { + Debug::log(sprintf("Processed %d feeds in %.4f (sec), %.4f (sec/feed avg)", $nf, + microtime(true) - $bstarted, (microtime(true) - $bstarted) / $nf)); + } + + foreach ($batch_owners as $owner_uid) { + Debug::log("Running housekeeping tasks for user $owner_uid..."); + + self::housekeeping_user($owner_uid); + } + + // Send feed digests by email if needed. + Digest::send_headlines_digests(); + + $span->end(); + + return $nf; + } + + /** this is used when subscribing */ + 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); + + if ($feed) { + $pluginhost = new PluginHost(); + $user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $feed->owner_uid); + + $pluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL); + $pluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $feed->owner_uid); + //$pluginhost->load_data(); + + $basic_info = []; + + $pluginhost->run_hooks_callback(PluginHost::HOOK_FEED_BASIC_INFO, function ($result) use (&$basic_info) { + $basic_info = $result; + }, $basic_info, $feed->feed_url, $feed->owner_uid, $feed_id, $feed->auth_login, $feed->auth_pass); + + if (!$basic_info) { + $feed_data = UrlHelper::fetch([ + 'url' => $feed->feed_url, + 'login' => $feed->auth_login, + 'pass' => $feed->auth_pass, + 'timeout' => Config::get(Config::FEED_FETCH_TIMEOUT), + ]); + + $feed_data = trim($feed_data); + + if ($feed_data) { + $rss = new FeedParser($feed_data); + $rss->init(); + + if (!$rss->error()) { + $basic_info = [ + 'title' => mb_substr(clean($rss->get_title()), 0, 199), + 'site_url' => mb_substr(UrlHelper::rewrite_relative($feed->feed_url, clean($rss->get_link())), 0, 245), + ]; + } else { + Debug::log(sprintf("unable to parse feed for basic info: %s", $rss->error()), Debug::LOG_VERBOSE); + } + } else { + Debug::log(sprintf("unable to fetch feed for basic info: %s [%s]", UrlHelper::$fetch_last_error, UrlHelper::$fetch_last_error_code), Debug::LOG_VERBOSE); + } + } + + if ($basic_info && is_array($basic_info)) { + if (!empty($basic_info['title']) && (!$feed->title || $feed->title == '[Unknown]')) { + $feed->title = $basic_info['title']; + } + + if (!empty($basic_info['site_url']) && $feed->site_url != $basic_info['site_url']) { + $feed->site_url = $basic_info['site_url']; + } + + $feed->save(); + } + } + } + + static function update_rss_feed(int $feed, bool $no_cache = false, bool $html_output = false) : bool { + + $span = Tracer::start(__METHOD__); + $span->setAttribute('func.args', json_encode(func_get_args())); + + Debug::enable_html($html_output); + Debug::log("start", Debug::LOG_VERBOSE); + + $pdo = Db::pdo(); + + /** @var DiskCache $cache */ + $cache = DiskCache::instance('feeds'); + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $favicon_interval_qpart = "favicon_last_checked < NOW() - INTERVAL '12 hour'"; + } else { + $favicon_interval_qpart = "favicon_last_checked < DATE_SUB(NOW(), INTERVAL 12 HOUR)"; + } + + $feed_obj = ORM::for_table('ttrss_feeds') + ->select_expr("ttrss_feeds.*, + ".SUBSTRING_FOR_DATE."(last_unconditional, 1, 19) AS last_unconditional, + (favicon_is_custom IS NOT TRUE AND + (favicon_last_checked IS NULL OR $favicon_interval_qpart)) AS favicon_needs_check") + ->find_one($feed); + + if ($feed_obj) { + $feed_obj->last_update_started = Db::NOW(); + $feed_obj->save(); + + $feed_language = mb_strtolower($feed_obj->feed_language); + + 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"); + $span->end(); + return false; + } + } else { + // this would indicate database corruption of some kind + Debug::log("error: owner not found for feed: $feed"); + $span->end(); + return false; + } + + } else { + Debug::log("error: feeds table record not found for feed: $feed"); + $span->end(); + return false; + } + + // feed was batch-subscribed or something, we need to get basic info + // this is not optimal currently as it fetches stuff separately TODO: optimize + if ($feed_obj->title == "[Unknown]" || empty($feed_obj->title) || empty($feed_obj->site_url)) { + Debug::log("setting basic feed info for $feed..."); + self::update_basic_info($feed); + } + + $date_feed_processed = date('Y-m-d H:i'); + + $cache_filename = sha1($feed_obj->feed_url) . ".xml"; + + $pluginhost = new PluginHost(); + $user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $feed_obj->owner_uid); + + $pluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL); + $pluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $feed_obj->owner_uid); + + $rss_hash = false; + + $force_refetch = isset($_REQUEST["force_refetch"]); + $dump_feed_xml = isset($_REQUEST["dump_feed_xml"]); + $feed_data = ""; + + Debug::log("running HOOK_FETCH_FEED handlers...", Debug::LOG_VERBOSE); + + $start_ts = microtime(true); + $last_article_timestamp = 0; + + $hff_owner_uid = $feed_obj->owner_uid; + $hff_feed_url = $feed_obj->feed_url; + + $pluginhost->chain_hooks_callback(PluginHost::HOOK_FETCH_FEED, + function ($result, $plugin) use (&$feed_data, $start_ts) { + $feed_data = $result; + Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), Debug::LOG_VERBOSE); + }, + $feed_data, $hff_feed_url, $hff_owner_uid, $feed, $last_article_timestamp, $auth_login, $auth_pass); + + if ($feed_data) { + Debug::log("feed data has been modified by a plugin.", Debug::LOG_VERBOSE); + } else { + Debug::log("feed data has not been modified by a plugin.", Debug::LOG_VERBOSE); + } + + // try cache + if (!$feed_data && + $cache->exists($cache_filename) && + !$feed_obj->auth_login && !$feed_obj->auth_pass && + $cache->get_mtime($cache_filename) > time() - 30) { + + Debug::log("using local cache: {$cache_filename}.", Debug::LOG_VERBOSE); + + $feed_data = $cache->get($cache_filename); + + if ($feed_data) { + $rss_hash = sha1($feed_data); + } + + } else { + Debug::log("local cache will not be used for this feed", Debug::LOG_VERBOSE); + } + + // fetch feed from source + if (!$feed_data) { + Debug::log("last unconditional update request: {$feed_obj->last_unconditional}", Debug::LOG_VERBOSE); + + if (ini_get("open_basedir") && function_exists("curl_init")) { + Debug::log("not using CURL due to open_basedir restrictions", Debug::LOG_VERBOSE); + } + + if (time() - strtotime($feed_obj->last_unconditional ?? "") > Config::get(Config::MAX_CONDITIONAL_INTERVAL)) { + Debug::log("maximum allowed interval for conditional requests exceeded, forcing refetch", Debug::LOG_VERBOSE); + + $force_refetch = true; + } else { + Debug::log("stored last modified for conditional request: {$feed_obj->last_modified}", Debug::LOG_VERBOSE); + } + + Debug::log("fetching {$feed_obj->feed_url} (force_refetch: $force_refetch)...", Debug::LOG_VERBOSE); + + $feed_data = UrlHelper::fetch([ + "url" => $feed_obj->feed_url, + "login" => $feed_obj->auth_login, + "pass" => $feed_obj->auth_pass, + "timeout" => $no_cache ? Config::get(Config::FEED_FETCH_NO_CACHE_TIMEOUT) : Config::get(Config::FEED_FETCH_TIMEOUT), + "last_modified" => $force_refetch ? "" : $feed_obj->last_modified + ]); + + $feed_data = trim($feed_data); + + Debug::log("fetch done.", Debug::LOG_VERBOSE); + Debug::log(sprintf("effective URL (after redirects): %s (IP: %s) ", UrlHelper::$fetch_effective_url, UrlHelper::$fetch_effective_ip_addr), Debug::LOG_VERBOSE); + Debug::log("server last modified: " . UrlHelper::$fetch_last_modified, Debug::LOG_VERBOSE); + + if ($feed_data && UrlHelper::$fetch_last_modified != $feed_obj->last_modified) { + $feed_obj->last_modified = substr(UrlHelper::$fetch_last_modified, 0, 245); + $feed_obj->save(); + } + + // cache vanilla feed data for re-use + if ($feed_data && !$feed_obj->auth_pass && !$feed_obj->auth_login && $cache->is_writable()) { + $new_rss_hash = sha1($feed_data); + + if ($new_rss_hash != $rss_hash) { + Debug::log("saving to local cache: $cache_filename", Debug::LOG_VERBOSE); + $cache->put($cache_filename, $feed_data); + } + } + } + + if (!$feed_data) { + Debug::log(sprintf("unable to fetch: %s [%s]", UrlHelper::$fetch_last_error, UrlHelper::$fetch_last_error_code), Debug::LOG_VERBOSE); + + // If-Modified-Since + if (UrlHelper::$fetch_last_error_code == 304) { + Debug::log("source claims data not modified (304), nothing to do.", Debug::LOG_VERBOSE); + $error_message = ""; + + $feed_obj->set([ + 'last_error' => '', + 'last_successful_update' => Db::NOW(), + 'last_updated' => Db::NOW(), + ]); + + $feed_obj->save(); + + } else if (UrlHelper::$fetch_last_error_code == 429) { + + // randomize interval using Config::HTTP_429_THROTTLE_INTERVAL as a base value (1-2x) + $http_429_throttle_interval = rand(Config::get(Config::HTTP_429_THROTTLE_INTERVAL), + Config::get(Config::HTTP_429_THROTTLE_INTERVAL)*2); + + $error_message = UrlHelper::$fetch_last_error; + + Debug::log("source claims we're requesting too often (429), throttling updates for $http_429_throttle_interval seconds.", + Debug::LOG_VERBOSE); + + $feed_obj->set([ + 'last_error' => $error_message . " (updates throttled for $http_429_throttle_interval seconds.)", + 'last_successful_update' => Db::NOW($http_429_throttle_interval), + 'last_updated' => Db::NOW($http_429_throttle_interval), + ]); + + $feed_obj->save(); + } else { + $error_message = UrlHelper::$fetch_last_error; + + $feed_obj->set([ + 'last_error' => $error_message, + 'last_updated' => Db::NOW(), + ]); + + $feed_obj->save(); + } + + $span->end(); + return $error_message == ""; + } + + Debug::log("running HOOK_FEED_FETCHED handlers...", Debug::LOG_VERBOSE); + $feed_data_checksum = md5($feed_data); + + // because chain_hooks_callback() accepts variables by value + $pff_owner_uid = $feed_obj->owner_uid; + $pff_feed_url = $feed_obj->feed_url; + + if ($dump_feed_xml) { + Debug::log("feed data before hooks:", Debug::LOG_VERBOSE); + + Debug::log(Debug::SEPARATOR, Debug::LOG_VERBOSE); + print("" . htmlspecialchars($feed_data). "\n"); + Debug::log(Debug::SEPARATOR, Debug::LOG_VERBOSE); + } + + $start_ts = microtime(true); + $pluginhost->chain_hooks_callback(PluginHost::HOOK_FEED_FETCHED, + function ($result, $plugin) use (&$feed_data, $start_ts) { + $feed_data = $result; + Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), Debug::LOG_VERBOSE); + }, + $feed_data, $pff_feed_url, $pff_owner_uid, $feed); + + if (md5($feed_data) != $feed_data_checksum) { + Debug::log("feed data has been modified by a plugin.", Debug::LOG_VERBOSE); + } else { + Debug::log("feed data has not been modified by a plugin.", Debug::LOG_VERBOSE); + } + + if ($dump_feed_xml) { + Debug::log("feed data after hooks:", Debug::LOG_VERBOSE); + + Debug::log(Debug::SEPARATOR, Debug::LOG_VERBOSE); + print("" . htmlspecialchars($feed_data). "\n"); + Debug::log(Debug::SEPARATOR, Debug::LOG_VERBOSE); + } + + $rss = new FeedParser($feed_data); + $rss->init(); + + if (!$rss->error()) { + + Debug::log("running HOOK_FEED_PARSED handlers...", Debug::LOG_VERBOSE); + + // We use local pluginhost here because we need to load different per-user feed plugins + + $start_ts = microtime(true); + $pluginhost->chain_hooks_callback(PluginHost::HOOK_FEED_PARSED, + function($result, $plugin) use ($start_ts) { + Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), Debug::LOG_VERBOSE); + }, + $rss, $feed); + + Debug::log("language: $feed_language", Debug::LOG_VERBOSE); + Debug::log("processing feed data...", Debug::LOG_VERBOSE); + + // this is a fallback, in case RSSUtils::update_basic_info() fails. + // TODO: is this necessary? remove unless it is. + if (empty($feed_obj->site_url)) { + $feed_obj->site_url = mb_substr(UrlHelper::rewrite_relative($feed_obj->feed_url, clean($rss->get_link())), 0, 245); + $feed_obj->save(); + } + + Debug::log("site_url: {$feed_obj->site_url}", Debug::LOG_VERBOSE); + Debug::log("feed_title: {$rss->get_title()}", Debug::LOG_VERBOSE); + + Debug::log('favicon: needs check: ' . ($feed_obj->favicon_needs_check ? 'true' : 'false') + . ', is custom: ' . ($feed_obj->favicon_is_custom ? 'true' : 'false') + . ", avg color: {$feed_obj->favicon_avg_color}", + Debug::LOG_VERBOSE); + + if ($feed_obj->favicon_needs_check || $force_refetch + || ($feed_obj->favicon_is_custom && !$feed_obj->favicon_avg_color)) { + + // restrict update attempts to once per 12h + $feed_obj->favicon_last_checked = Db::NOW(); + $feed_obj->save(); + + $favicon_cache = DiskCache::instance('feed-icons'); + + $favicon_modified = $favicon_cache->exists($feed) ? $favicon_cache->get_mtime($feed) : -1; + + // don't try to redownload custom favicons + if (!$feed_obj->favicon_is_custom) { + Debug::log("favicon: trying to update favicon...", Debug::LOG_VERBOSE); + self::update_favicon($feed_obj->site_url, $feed); + + if (!$favicon_cache->exists($feed) || $favicon_cache->get_mtime($feed) > $favicon_modified) { + $feed_obj->favicon_avg_color = null; + $feed_obj->save(); + } + } + + /* terrible hack: if we crash on floicon shit here, we won't check + * the icon avgcolor again (unless icon got updated) */ + if (file_exists($favicon_cache->get_full_path($feed)) && function_exists("imagecreatefromstring") && empty($feed_obj->favicon_avg_color)) { + require_once "colors.php"; + + Debug::log("favicon: trying to calculate average color...", Debug::LOG_VERBOSE); + + $feed_obj->favicon_avg_color = 'fail'; + $feed_obj->save(); + + $calculated_avg_color = \Colors\calculate_avg_color($favicon_cache->get_full_path($feed)); + if ($calculated_avg_color) { + $feed_obj->favicon_avg_color = $calculated_avg_color; + $feed_obj->save(); + } + + Debug::log("favicon: calculated avg color: {$calculated_avg_color}, setting avg color: {$feed_obj->favicon_avg_color}", Debug::LOG_VERBOSE); + + } else if ($feed_obj->favicon_avg_color == 'fail') { + Debug::log("floicon failed on $feed or a suitable avg color couldn't be determined, not trying to recalculate avg color", Debug::LOG_VERBOSE); + } + } + + Debug::log("loading filters & labels...", Debug::LOG_VERBOSE); + + $filters = self::load_filters($feed, $feed_obj->owner_uid); + + if (Debug::get_loglevel() >= Debug::LOG_EXTENDED) { + print_r($filters); + } + + Debug::log("" . count($filters) . " filters loaded.", Debug::LOG_VERBOSE); + + $items = $rss->get_items(); + + if (!is_array($items)) { + Debug::log("no articles found.", Debug::LOG_VERBOSE); + + $feed_obj->set([ + 'last_updated' => Db::NOW(), + 'last_unconditional' => Db::NOW(), + 'last_error' => '', + ]); + + $feed_obj->save(); + $span->end(); + return true; // no articles + } + + Debug::log("processing articles...", Debug::LOG_VERBOSE); + + $tstart = time(); + + foreach ($items as $item) { + $a_span = Tracer::start('article'); + + $pdo->beginTransaction(); + + Debug::log(Debug::SEPARATOR, Debug::LOG_VERBOSE); + + if (Debug::get_loglevel() >= 3) { + print_r($item); + } + + if (ini_get("max_execution_time") > 0 && time() - $tstart >= ((float)ini_get("max_execution_time") * 0.7)) { + Debug::log("looks like there's too many articles to process at once, breaking out.", Debug::LOG_VERBOSE); + $pdo->commit(); + break; + } + + $entry_guid = strip_tags($item->get_id()); + if (!$entry_guid) $entry_guid = strip_tags($item->get_link()); + if (!$entry_guid) $entry_guid = self::make_guid_from_title($item->get_title()); + + if (!$entry_guid) { + $pdo->commit(); + continue; + } + + $entry_guid_hashed_compat = 'SHA1:' . sha1("{$feed_obj->owner_uid},$entry_guid"); + $entry_guid_hashed = json_encode(["ver" => 2, "uid" => $feed_obj->owner_uid, "hash" => 'SHA1:' . sha1($entry_guid)]); + $entry_guid = "$feed_obj->owner_uid,$entry_guid"; + + Debug::log("guid $entry_guid (hash: $entry_guid_hashed compat: $entry_guid_hashed_compat)", Debug::LOG_VERBOSE); + + $entry_timestamp = (int)$item->get_date(); + + Debug::log(sprintf("orig date: %s (%s)", $item->get_date(), date("Y-m-d H:i:s", $item->get_date())), + Debug::LOG_VERBOSE); + + $entry_title = strip_tags($item->get_title()); + + $entry_link = UrlHelper::rewrite_relative($feed_obj->site_url, clean($item->get_link()), "a", "href"); + + $entry_language = mb_substr(trim($item->get_language()), 0, 2); + + Debug::log("title $entry_title", Debug::LOG_VERBOSE); + Debug::log("link $entry_link", Debug::LOG_VERBOSE); + Debug::log("language $entry_language", Debug::LOG_VERBOSE); + + if (!$entry_title) $entry_title = date("Y-m-d H:i:s", $entry_timestamp);; + + $entry_content = $item->get_content(); + if (!$entry_content) $entry_content = $item->get_description(); + + if (Debug::get_loglevel() >= 3) { + print "content: "; + print htmlspecialchars($entry_content); + print "\n"; + } + + $entry_comments = mb_substr(strip_tags($item->get_comments_url()), 0, 245); + $num_comments = $item->get_comments_count(); + + $entry_author = strip_tags($item->get_author()); + $entry_guid = mb_substr($entry_guid, 0, 245); + + Debug::log("author $entry_author", Debug::LOG_VERBOSE); + Debug::log("looking for tags...", Debug::LOG_VERBOSE); + + $entry_tags = $item->get_categories(); + Debug::log("tags found: " . join(", ", $entry_tags), Debug::LOG_VERBOSE); + + Debug::log("done collecting data.", Debug::LOG_VERBOSE); + + $sth = $pdo->prepare("SELECT id, content_hash, lang FROM ttrss_entries + WHERE guid IN (?, ?, ?)"); + $sth->execute([$entry_guid, $entry_guid_hashed, $entry_guid_hashed_compat]); + + if ($row = $sth->fetch()) { + $base_entry_id = $row["id"]; + $entry_stored_hash = $row["content_hash"]; + $article_labels = Article::_get_labels($base_entry_id, $feed_obj->owner_uid); + + $existing_tags = Article::_get_tags($base_entry_id, $feed_obj->owner_uid); + $entry_tags = array_unique([...$entry_tags, ...$existing_tags]); + } else { + $base_entry_id = false; + $entry_stored_hash = ""; + $article_labels = array(); + } + + Debug::log("looking for enclosures...", Debug::LOG_VERBOSE); + + // enclosures + + $enclosures = array(); + + $encs = $item->get_enclosures(); + + if (is_array($encs)) { + foreach ($encs as $e) { + + $pluginhost->chain_hooks_callback(PluginHost::HOOK_ENCLOSURE_IMPORTED, + function ($result) use (&$e) { + $e = $result; + }, + $e, $feed); + + // TODO: Just use FeedEnclosure (and modify it to cover whatever justified this)? + $e_item = array( + UrlHelper::rewrite_relative($feed_obj->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" + if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET) != "UTF8MB4") { + for ($i = 0; $i < count($e_item); $i++) { + if (is_string($e_item[$i])) { + $e_item[$i] = self::strip_utf8mb4($e_item[$i]); + } + } + } + + array_push($enclosures, $e_item); + } + } + + $article = array("owner_uid" => $feed_obj->owner_uid, // read only + "guid" => $entry_guid, // read only + "guid_hashed" => $entry_guid_hashed, // read only + "title" => $entry_title, + "content" => $entry_content, + "link" => $entry_link, + "labels" => $article_labels, // current limitation: can add labels to article, can't remove them + "tags" => $entry_tags, + "author" => $entry_author, + "force_catchup" => false, // ugly hack for the time being + "score_modifier" => 0, // no previous value, plugin should recalculate score modifier based on content if needed + "language" => $entry_language, + "timestamp" => $entry_timestamp, + "num_comments" => $num_comments, + "enclosures" => $enclosures, + "feed" => array("id" => $feed, + "fetch_url" => $feed_obj->feed_url, + "site_url" => $feed_obj->site_url, + "cache_images" => $feed_obj->cache_images) + ); + + $entry_plugin_data = ""; + $entry_current_hash = self::calculate_article_hash($article, $pluginhost); + + Debug::log("article hash: $entry_current_hash [stored=$entry_stored_hash]", Debug::LOG_VERBOSE); + + if ($entry_current_hash == $entry_stored_hash && !isset($_REQUEST["force_rehash"])) { + Debug::log("stored article seems up to date [IID: $base_entry_id], updating timestamp only.", Debug::LOG_VERBOSE); + + // we keep encountering the entry in feeds, so we need to + // update date_updated column so that we don't get horrible + // 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(); + + continue; + } + + Debug::log("hash differs, running HOOK_ARTICLE_FILTER handlers...", Debug::LOG_VERBOSE); + + $start_ts = microtime(true); + + $pluginhost->chain_hooks_callback(PluginHost::HOOK_ARTICLE_FILTER, + function ($result, $plugin) use (&$article, &$entry_plugin_data, $start_ts) { + $article = $result; + + $entry_plugin_data .= mb_strtolower(get_class($plugin)) . ","; + + Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), + Debug::LOG_VERBOSE); + }, + $article); + + if (Debug::get_loglevel() >= 3) { + print "processed content: "; + print htmlspecialchars($article["content"]); + print "\n"; + } + + Debug::log("plugin data: {$entry_plugin_data}", Debug::LOG_VERBOSE); + + // Workaround: 4-byte unicode requires utf8mb4 in MySQL. See https://tt-rss.org/forum/viewtopic.php?f=1&t=3377&p=20077#p20077 + if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET) != "UTF8MB4") { + foreach ($article as $k => $v) { + // i guess we'll have to take the risk of 4byte unicode labels & tags here + if (is_string($article[$k])) { + $article[$k] = self::strip_utf8mb4($v); + } + } + } + + /* Collect article tags here so we could filter by them: */ + + $matched_rules = []; + $matched_filters = []; + + $article_filters = self::get_article_filters($filters, $article["title"], + $article["content"], $article["link"], $article["author"], + $article["tags"], $matched_rules, $matched_filters); + + // $article_filters should be renamed to something like $filter_actions; actual filter objects are in $matched_filters + $pluginhost->run_hooks(PluginHost::HOOK_FILTER_TRIGGERED, + $feed, $feed_obj->owner_uid, $article, $matched_filters, $matched_rules, $article_filters); + + $matched_filter_ids = array_map(fn(array $f) => $f['id'], $matched_filters); + + 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) + ->find_many(); + + foreach ($filter_objs as $filter_obj) { + $filter_obj->set('last_triggered', Db::NOW()); + $filter_obj->save(); + } + } + + if (Debug::get_loglevel() >= Debug::LOG_EXTENDED) { + Debug::log("matched filters: ", Debug::LOG_VERBOSE); + + if (count($matched_filters) != 0) { + print_r($matched_filters); + } + + Debug::log("matched filter rules: ", Debug::LOG_VERBOSE); + + if (count($matched_rules) != 0) { + print_r($matched_rules); + } + + Debug::log("filter actions: ", Debug::LOG_VERBOSE); + + if (count($article_filters) != 0) { + print_r($article_filters); + } + } + + $plugin_filter_names = self::find_article_filters($article_filters, "plugin"); + $plugin_filter_actions = $pluginhost->get_filter_actions(); + + if (count($plugin_filter_names) > 0) { + Debug::log("applying plugin filter actions...", Debug::LOG_VERBOSE); + + foreach ($plugin_filter_names as $pfn) { + list($pfclass,$pfaction) = explode(":", $pfn["param"]); + + if (isset($plugin_filter_actions[$pfclass])) { + $plugin = $pluginhost->get_plugin($pfclass); + + Debug::log("... $pfclass: $pfaction", Debug::LOG_VERBOSE); + + if ($plugin) { + $start = microtime(true); + $article = $plugin->hook_article_filter_action($article, $pfaction); + + Debug::log(sprintf("=== %.4f (sec)", microtime(true) - $start), Debug::LOG_VERBOSE); + } else { + Debug::log("??? $pfclass: plugin object not found.", Debug::LOG_VERBOSE); + } + } else { + Debug::log("??? $pfclass: filter plugin not registered.", Debug::LOG_VERBOSE); + } + } + } + + $entry_tags = $article["tags"]; + $entry_title = strip_tags($article["title"]); + $entry_author = mb_substr(strip_tags($article["author"]), 0, 245); + $entry_link = strip_tags($article["link"]); + $entry_content = $article["content"]; // escaped below + $entry_force_catchup = $article["force_catchup"]; + $article_labels = $article["labels"]; + $entry_score_modifier = (int) $article["score_modifier"]; + $entry_language = $article["language"]; + $entry_timestamp = $article["timestamp"]; + $num_comments = $article["num_comments"]; + $enclosures = $article["enclosures"]; + + if ($entry_timestamp == -1 || !$entry_timestamp || $entry_timestamp > time()) { + $entry_timestamp = time(); + } + + $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); + + if (Debug::get_loglevel() >= Debug::LOG_EXTENDED) { + Debug::log("article labels:", Debug::LOG_VERBOSE); + + if (count($article_labels) != 0) { + print_r($article_labels); + } + } + + Debug::log("force catchup: $entry_force_catchup", Debug::LOG_VERBOSE); + + if ($feed_obj->cache_images) + self::cache_media($entry_content, $feed_obj->site_url); + + $csth = $pdo->prepare("SELECT id FROM ttrss_entries + WHERE guid IN (?, ?, ?)"); + $csth->execute([$entry_guid, $entry_guid_hashed, $entry_guid_hashed_compat]); + + if (!$row = $csth->fetch()) { + + Debug::log("base guid [$entry_guid or $entry_guid_hashed] not found, creating...", Debug::LOG_VERBOSE); + + // base post entry does not exist, create it + + $usth = $pdo->prepare( + "INSERT INTO ttrss_entries + (title, + guid, + link, + updated, + content, + content_hash, + no_orig_date, + date_updated, + date_entered, + comments, + num_comments, + plugin_data, + lang, + author) + VALUES + (?, ?, ?, ?, ?, ?, + false, + NOW(), + ?, ?, ?, ?, ?, ?)"); + + $usth->execute([$entry_title, + $entry_guid_hashed, + $entry_link, + $entry_timestamp_fmt, + "$entry_content", + $entry_current_hash, + $date_feed_processed, + $entry_comments, + (int)$num_comments, + $entry_plugin_data, + "$entry_language", + "$entry_author"]); + + } + + $csth->execute([$entry_guid, $entry_guid_hashed, $entry_guid_hashed_compat]); + + $entry_ref_id = 0; + $entry_int_id = 0; + + if ($row = $csth->fetch()) { + + Debug::log("base guid found, checking for user record", Debug::LOG_VERBOSE); + + $ref_id = $row['id']; + $entry_ref_id = $ref_id; + + if (self::find_article_filter($article_filters, "filter")) { + Debug::log("article is filtered out, nothing to do.", Debug::LOG_VERBOSE); + $pdo->commit(); + continue; + } + + $score = self::calculate_article_score($article_filters) + $entry_score_modifier; + + Debug::log("initial score: $score [including plugin modifier: $entry_score_modifier]", Debug::LOG_VERBOSE); + + // check for user post link to main table + + $sth = $pdo->prepare("SELECT ref_id, int_id FROM ttrss_user_entries WHERE + ref_id = ? AND owner_uid = ?"); + $sth->execute([$ref_id, $feed_obj->owner_uid]); + + // okay it doesn't exist - create user entry + if ($row = $sth->fetch()) { + $entry_ref_id = $row["ref_id"]; + $entry_int_id = $row["int_id"]; + + Debug::log("user record FOUND: RID: $entry_ref_id, IID: $entry_int_id", Debug::LOG_VERBOSE); + } else { + + Debug::log("user record not found, creating...", Debug::LOG_VERBOSE); + + if ($score >= -500 && !self::find_article_filter($article_filters, 'catchup') && !$entry_force_catchup) { + $unread = 1; + $last_read_qpart = null; + } else { + $unread = 0; + $last_read_qpart = date("Y-m-d H:i"); // we can't use NOW() here because it gets quoted + } + + if (self::find_article_filter($article_filters, 'mark') || $score > 1000) { + $marked = 1; + } else { + $marked = 0; + } + + if (self::find_article_filter($article_filters, 'publish')) { + $published = 1; + } else { + $published = 0; + } + + $last_marked = ($marked == 1) ? 'NOW()' : 'NULL'; + $last_published = ($published == 1) ? 'NOW()' : 'NULL'; + + $sth = $pdo->prepare( + "INSERT INTO ttrss_user_entries + (ref_id, owner_uid, feed_id, unread, last_read, marked, + published, score, tag_cache, label_cache, uuid, + last_marked, last_published) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', '', '', ".$last_marked.", ".$last_published.")"); + + $sth->execute([$ref_id, $feed_obj->owner_uid, $feed, $unread, $last_read_qpart, $marked, + $published, $score]); + + $sth = $pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE + ref_id = ? AND owner_uid = ? AND + feed_id = ? LIMIT 1"); + + $sth->execute([$ref_id, $feed_obj->owner_uid, $feed]); + + if ($row = $sth->fetch()) + $entry_int_id = $row['int_id']; + } + + Debug::log("resulting RID: $entry_ref_id, IID: $entry_int_id", Debug::LOG_VERBOSE); + + if (Config::get(Config::DB_TYPE) == "pgsql") + $tsvector_qpart = "tsvector_combined = to_tsvector(:ts_lang, :ts_content),"; + else + $tsvector_qpart = ""; + + $sth = $pdo->prepare("UPDATE ttrss_entries + SET title = :title, + $tsvector_qpart + content = :content, + content_hash = :content_hash, + updated = :updated, + date_updated = NOW(), + num_comments = :num_comments, + plugin_data = :plugin_data, + author = :author, + lang = :lang + WHERE id = :id"); + + $params = [":title" => $entry_title, + ":content" => "$entry_content", + ":content_hash" => $entry_current_hash, + ":updated" => $entry_timestamp_fmt, + ":num_comments" => (int)$num_comments, + ":plugin_data" => $entry_plugin_data, + ":author" => "$entry_author", + ":lang" => $entry_language, + ":id" => $ref_id]; + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $params[":ts_lang"] = $feed_language; + $params[":ts_content"] = mb_substr(strip_tags($entry_title) . " " . \Soundasleep\Html2Text::convert($entry_content), 0, 900000); + } + + $sth->execute($params); + + // update aux data + $sth = $pdo->prepare("UPDATE ttrss_user_entries + SET score = ? WHERE ref_id = ?"); + $sth->execute([$score, $ref_id]); + + if ($feed_obj->mark_unread_on_update && + !$entry_force_catchup && + !self::find_article_filter($article_filters, 'catchup')) { + + Debug::log("article updated, marking unread as requested.", Debug::LOG_VERBOSE); + + $sth = $pdo->prepare("UPDATE ttrss_user_entries + SET last_read = null, unread = true WHERE ref_id = ?"); + $sth->execute([$ref_id]); + } else { + Debug::log("article updated, but we're forbidden to mark it unread.", Debug::LOG_VERBOSE); + } + } + + Debug::log("assigning labels [other]...", Debug::LOG_VERBOSE); + + foreach ($article_labels as $label) { + Labels::add_article($entry_ref_id, $label[1], $feed_obj->owner_uid); + } + + Debug::log("assigning labels [filters]...", Debug::LOG_VERBOSE); + + self::assign_article_to_label_filters($entry_ref_id, $article_filters, + $feed_obj->owner_uid, $article_labels); + + if ($feed_obj->cache_images) + self::cache_enclosures($enclosures, $feed_obj->site_url); + + if (Debug::get_loglevel() >= Debug::LOG_EXTENDED) { + Debug::log("article enclosures:", Debug::LOG_VERBOSE); + print_r($enclosures); + } + + $esth = $pdo->prepare("SELECT id FROM ttrss_enclosures + WHERE content_url = ? AND content_type = ? AND post_id = ?"); + + $usth = $pdo->prepare("INSERT INTO ttrss_enclosures + (content_url, content_type, title, duration, post_id, width, height) VALUES + (?, ?, ?, ?, ?, ?, ?)"); + + foreach ($enclosures as $enc) { + $enc_url = $enc[0]; + $enc_type = $enc[1]; + $enc_dur = (int)$enc[2]; + $enc_title = $enc[3]; + $enc_width = intval($enc[4]); + $enc_height = intval($enc[5]); + + $esth->execute([$enc_url, $enc_type, $entry_ref_id]); + + if (!$esth->fetch()) { + $usth->execute([$enc_url, $enc_type, (string)$enc_title, $enc_dur, $entry_ref_id, $enc_width, $enc_height]); + } + } + + // 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 = [...$entry_tags, ...FeedItem_Common::normalize_categories(explode(",", $f["param"]))]; + } + } + + // 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 + $entry_tags = FeedItem_Common::normalize_categories( + array_diff($entry_tags, + FeedItem_Common::normalize_categories(explode(",", + get_pref(Prefs::BLACKLISTED_TAGS, $feed_obj->owner_uid))))); + + 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 + WHERE tag_name = ? AND post_int_id = ? AND + owner_uid = ? LIMIT 1"); + + $usth = $pdo->prepare("INSERT INTO ttrss_tags + (owner_uid,tag_name,post_int_id) + VALUES (?, ?, ?)"); + + foreach ($entry_tags as $tag) { + $tsth->execute([$tag, $entry_int_id, $feed_obj->owner_uid]); + + if (!$tsth->fetch()) { + $usth->execute([$feed_obj->owner_uid, $tag, $entry_int_id]); + } + } + + /* update the cache */ + + $tsth = $pdo->prepare("UPDATE ttrss_user_entries + SET tag_cache = ? WHERE ref_id = ? + AND owner_uid = ?"); + + $tsth->execute([ + join(",", $entry_tags), + $entry_ref_id, + $feed_obj->owner_uid + ]); + } + + Debug::log("article processed.", Debug::LOG_VERBOSE); + + $pdo->commit(); + $a_span->end(); + } + + Debug::log(Debug::SEPARATOR, Debug::LOG_VERBOSE); + + Debug::log("purging feed...", Debug::LOG_VERBOSE); + + Feeds::_purge($feed, 0); + + $feed_obj->set([ + 'last_updated' => Db::NOW(), + 'last_unconditional' => Db::NOW(), + 'last_successful_update' => Db::NOW(), + 'last_error' => '', + ]); + + $feed_obj->save(); + + } else { + + $error_msg = mb_substr($rss->error(), 0, 245); + + Debug::log("fetch error: $error_msg", Debug::LOG_VERBOSE); + + if (count($rss->errors()) > 1) { + foreach ($rss->errors() as $error) { + Debug::log("+ $error", Debug::LOG_VERBOSE); + } + } + + $feed_obj->set([ + 'last_updated' => Db::NOW(), + 'last_unconditional' => Db::NOW(), + 'last_error' => $error_msg, + ]); + + $feed_obj->save(); + + unset($rss); + + Debug::log("update failed.", Debug::LOG_VERBOSE); + $span->end(); + return false; + } + + Debug::log("update done.", Debug::LOG_VERBOSE); + $span->end(); + return true; + } + + /** + * TODO: move to DiskCache? + * + * @param array> $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 = DiskCache::instance("images"); + + if ($cache->is_writable()) { + foreach ($enclosures as $enc) { + + if (preg_match("/(image|audio|video)/", $enc[1])) { + $src = UrlHelper::rewrite_relative($site_url, $enc[0]); + + $local_filename = sha1($src); + + Debug::log("cache_enclosures: downloading: $src to $local_filename", Debug::LOG_VERBOSE); + + if (!$cache->exists($local_filename)) { + $file_content = UrlHelper::fetch(array("url" => $src, + "http_referrer" => $src, + "max_size" => Config::get(Config::MAX_CACHE_FILE_SIZE))); + + if ($file_content) { + $cache->put($local_filename, $file_content); + } else { + Debug::log("cache_enclosures: failed with ".UrlHelper::$fetch_last_error_code.": ".UrlHelper::$fetch_last_error); + } + } + } + } + } + } + + /* TODO: move to DiskCache? */ + 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); + + if (!$cache->exists($local_filename)) { + Debug::log("cache_media: downloading: $url to $local_filename", Debug::LOG_VERBOSE); + + $file_content = UrlHelper::fetch(array("url" => $url, + "http_referrer" => $url, + "max_size" => Config::get(Config::MAX_CACHE_FILE_SIZE))); + + if ($file_content) { + $cache->put($local_filename, $file_content); + } else { + Debug::log("cache_media: failed with ".UrlHelper::$fetch_last_error_code.": ".UrlHelper::$fetch_last_error); + } + } + } + + /* TODO: move to DiskCache? */ + static function cache_media(string $html, string $site_url): void { + $cache = DiskCache::instance("images"); + + if ($html && $cache->is_writable()) { + $doc = new DOMDocument(); + if (@$doc->loadHTML($html)) { + $xpath = new DOMXPath($doc); + + $entries = $xpath->query('(//img[@src]|//source[@src|@srcset]|//video[@poster|@src])'); + + foreach ($entries as $entry) { + foreach (array('src', 'poster') as $attr) { + if ($entry->hasAttribute($attr) && strpos($entry->getAttribute($attr), "data:") !== 0) { + self::cache_media_url($cache, $entry->getAttribute($attr), $site_url); + } + } + + if ($entry->hasAttribute("srcset")) { + $matches = self::decode_srcset($entry->getAttribute('srcset')); + + for ($i = 0; $i < count($matches); $i++) { + self::cache_media_url($cache, $matches[$i]["url"], $site_url); + } + } + } + } + } + } + + static function expire_error_log(): void { + Debug::log("Removing old error log entries..."); + + $pdo = Db::pdo(); + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $pdo->query("DELETE FROM ttrss_error_log + WHERE created_at < NOW() - INTERVAL '7 days'"); + } else { + $pdo->query("DELETE FROM ttrss_error_log + WHERE created_at < DATE_SUB(NOW(), INTERVAL 7 DAY)"); + } + } + + /** + * @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(): void { + Debug::log("Removing old lock files...", Debug::LOG_VERBOSE); + + $num_deleted = 0; + + if (is_writable(Config::get(Config::LOCK_DIRECTORY))) { + $files = glob(Config::get(Config::LOCK_DIRECTORY) . "/*.lock"); + + if ($files) { + foreach ($files as $file) { + if (!file_is_locked(basename($file)) && time() - filemtime($file) > 86400*2) { + unlink($file); + ++$num_deleted; + } + } + } + } + + Debug::log("Removed $num_deleted old lock files."); + } + + /** + * Source: http://www.php.net/manual/en/function.parse-url.php#104527 + * Returns the url query as associative array + * + * @param string query + * @return array params + */ + /* static function convertUrlQuery($query) { + $queryParts = explode('&', $query); + + $params = array(); + + foreach ($queryParts as $param) { + $item = explode('=', $param); + $params[$item[0]] = $item[1]; + } + + return $params; + } */ + + /** + * @param array> $filters + * @param array $tags + * @param array>|null $matched_rules + * @param array>|null $matched_filters + * + * @return array> 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 { + $span = Tracer::start(__METHOD__); + + $matches = array(); + + foreach ($filters as $filter) { + $match_any_rule = $filter["match_any_rule"] ?? false; + $inverse = $filter["inverse"] ?? false; + $filter_match = false; + $last_processed_rule = false; + $regexp_matches = []; + + foreach ($filter["rules"] as $rule) { + $match = false; + $reg_exp = str_replace('/', '\/', (string)$rule["reg_exp"]); + $reg_exp = str_replace("\n", "", $reg_exp); // reg_exp may be formatted with CRs now because of textarea, we need to strip those + $rule_inverse = $rule["inverse"] ?? false; + $last_processed_rule = $rule; + + if (empty($reg_exp)) + continue; + + switch ($rule["type"]) { + case "title": + $match = @preg_match("/$reg_exp/iu", $title, $regexp_matches); + break; + case "content": + // we don't need to deal with multiline regexps + $content = (string)preg_replace("/[\r\n\t]/", "", $content); + + $match = @preg_match("/$reg_exp/iu", $content, $regexp_matches); + break; + case "both": + // we don't need to deal with multiline regexps + $content = (string)preg_replace("/[\r\n\t]/", "", $content); + + $match = (@preg_match("/$reg_exp/iu", $title, $regexp_matches) || @preg_match("/$reg_exp/iu", $content, $regexp_matches)); + break; + case "link": + $match = @preg_match("/$reg_exp/iu", $link, $regexp_matches); + break; + case "author": + $match = @preg_match("/$reg_exp/iu", $author, $regexp_matches); + break; + case "tag": + if (count($tags) == 0) + array_push($tags, ''); // allow matching if there are no tags + + foreach ($tags as $tag) { + if (@preg_match("/$reg_exp/iu", $tag, $regexp_matches)) { + $match = true; + break; + } + } + break; + } + + if ($rule_inverse) $match = !$match; + + if ($match_any_rule) { + if ($match) { + $filter_match = true; + break; + } + } else { + $filter_match = $match; + if (!$match) { + break; + } + } + } + + if ($inverse) $filter_match = !$filter_match; + + if ($filter_match) { + $last_processed_rule["regexp_matches"] = $regexp_matches; + + if (is_array($matched_rules)) array_push($matched_rules, $last_processed_rule); + if (is_array($matched_filters)) array_push($matched_filters, $filter); + + foreach ($filter["actions"] AS $action) { + array_push($matches, $action); + + // if Stop action encountered, perform no further processing + if (isset($action["type"]) && $action["type"] == "stop") return $matches; + } + } + } + + $span->end(); + + return $matches; + } + + /** + * @param array> $filters An array of filter action arrays with keys "type" and "param" + * + * @return array|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 null; + } + + /** + * @param array> $filters An array of filter action arrays with keys "type" and "param" + * + * @return array> 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) { + if ($f["type"] == $filter_name) { + array_push($results, $f); + }; + } + return $results; + } + + /** + * @param array> $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) { + if ($f["type"] == "score") { + $score += $f["param"]; + }; + } + return $score; + } + + /** + * @param array> $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; + } + } + + return false; + } + + /** + * @param array> $filters An array of filter action arrays with keys "type" and "param" + * @param array> $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"])) { + Labels::add_article($id, $f["param"], $owner_uid); + } + } + } + } + + 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(): void { + $pdo = Db::pdo(); + + $pdo->query("DELETE FROM ttrss_counters_cache"); + $pdo->query("DELETE FROM ttrss_cat_counters_cache"); + } + + static function disable_failed_feeds(): void { + if (Config::get(Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT) > 0) { + + $pdo = Db::pdo(); + + $pdo->beginTransaction(); + + $days = Config::get(Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT); + + if (Config::get(Config::DB_TYPE) == "pgsql") { + $interval_query = "last_successful_update < NOW() - INTERVAL '$days days' AND last_updated > NOW() - INTERVAL '1 days'"; + } else /* if (Config::get(Config::DB_TYPE) == "mysql") */ { + $interval_query = "last_successful_update < DATE_SUB(NOW(), INTERVAL $days DAY) AND last_updated > DATE_SUB(NOW(), INTERVAL 1 DAY)"; + } + + $sth = $pdo->prepare("SELECT id, title, owner_uid + FROM ttrss_feeds + WHERE update_interval != -1 AND last_successful_update IS NOT NULL AND $interval_query"); + + $sth->execute(); + + while ($row = $sth->fetch()) { + Logger::log(E_USER_NOTICE, + sprintf("Auto disabling feed %d (%s, UID: %d) because it failed to update for %d days.", + $row["id"], clean($row["title"]), $row["owner_uid"], Config::get(Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT))); + + Debug::log(sprintf("Auto-disabling feed %d (%s) (failed to update for %d days).", $row["id"], + clean($row["title"]), Config::get(Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT))); + } + + $sth = $pdo->prepare("UPDATE ttrss_feeds SET update_interval = -1 WHERE + update_interval != -1 AND last_successful_update IS NOT NULL AND $interval_query"); + $sth->execute(); + + $pdo->commit(); + } + } + + static function housekeeping_user(int $owner_uid): void { + $tmph = new PluginHost(); + + UserHelper::load_user_plugins($owner_uid, $tmph); + + $tmph->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); + } + + /** migrates favicons from legacy storage in feed-icons/ to cache/feed-icons/using new naming (sans .ico suffix) */ + static function migrate_feed_icons() : void { + $old_dir = Config::get(Config::ICONS_DIR); + $new_dir = Config::get(Config::CACHE_DIR) . '/feed-icons'; + + $dh = opendir($old_dir); + + $cache = DiskCache::instance('feed-icons'); + + if ($dh) { + while (($old_filename = readdir($dh)) !== false) { + if (strpos($old_filename, ".ico") !== false) { + $new_filename = str_replace(".ico", "", $old_filename); + $old_full_path = "$old_dir/$old_filename"; + + if (is_file($old_full_path) && $cache->put($new_filename, file_get_contents($old_full_path))) { + unlink($old_full_path); + } + } + } + + closedir($dh); + } + } + + static function housekeeping_common(): void { + $cache = DiskCache::instance(""); + $cache->expire_all(); + + self::migrate_feed_icons(); + self::expire_lock_files(); + self::expire_error_log(); + self::expire_feed_archive(); + self::cleanup_feed_browser(); + self::cleanup_feed_icons(); + self::disable_failed_feeds(); + + Article::_purge_orphans(); + self::cleanup_counters_cache(); + + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); + } + + /** + * @return false|string + */ + static function update_favicon(string $site_url, int $feed) { + $favicon_urls = self::get_favicon_urls($site_url); + + if (count($favicon_urls) == 0) { + Debug::log("favicon: couldn't find any favicon URLs for $site_url", Debug::LOG_VERBOSE); + return false; + } + + // i guess we'll have to go through all of them until something looks valid... + foreach ($favicon_urls as $favicon_url) { + + // Limiting to "image" type misses those served with text/plain + $contents = UrlHelper::fetch([ + 'url' => $favicon_url, + 'max_size' => Config::get(Config::MAX_FAVICON_FILE_SIZE), + //'type' => 'image', + ]); + + if (!$contents) { + Debug::log("favicon: fetching $favicon_url failed, skipping...", Debug::LOG_VERBOSE); + break; + } + + // TODO: we could use mime_conent_type() here instead of below hacks but we'll need to + // save every favicon to disk and go from there. + // also, if SVG is allowed in the future, we'll need to specifically forbid 'image/svg+xml'. + + // Crude image type matching. + // Patterns gleaned from the file(1) source code. + if (preg_match('/^\x00\x00\x01\x00/', $contents)) { + // 0 string \000\000\001\000 MS Windows icon resource + //error_log("update_favicon: favicon_url=$favicon_url isa MS Windows icon resource"); + } + elseif (preg_match('/^GIF8/', $contents)) { + // 0 string GIF8 GIF image data + //error_log("update_favicon: favicon_url=$favicon_url isa GIF image"); + } + elseif (preg_match('/^\x89PNG\x0d\x0a\x1a\x0a/', $contents)) { + // 0 string \x89PNG\x0d\x0a\x1a\x0a PNG image data + //error_log("update_favicon: favicon_url=$favicon_url isa PNG image"); + } + elseif (preg_match('/^\xff\xd8/', $contents)) { + // 0 beshort 0xffd8 JPEG image data + //error_log("update_favicon: favicon_url=$favicon_url isa JPG image"); + } + elseif (preg_match('/^BM/', $contents)) { + // 0 string BM PC bitmap (OS2, Windows BMP files) + //error_log("update_favicon, favicon_url=$favicon_url isa BMP image"); + } + else { + //error_log("update_favicon: favicon_url=$favicon_url isa UNKNOWN type"); + Debug::log("favicon $favicon_url type is unknown, skipping...", Debug::LOG_VERBOSE); + break; + } + + $favicon_cache = DiskCache::instance('feed-icons'); + + if ($favicon_cache->is_writable()) { + Debug::log("favicon: $favicon_url looks valid, saving to cache", Debug::LOG_VERBOSE); + + // we deal with this manually + if (!$favicon_cache->exists(".no-auto-expiry")) + $favicon_cache->put(".no-auto-expiry", ""); + + return $favicon_cache->put((string)$feed, $contents); + } else { + Debug::log("favicon: $favicon_url skipping, local cache is not writable", Debug::LOG_VERBOSE); + } + } + + return false; + } + + static function is_gzipped(string $feed_data): bool { + return strpos(substr($feed_data, 0, 3), + "\x1f" . "\x8b" . "\x08", 0) === 0; + } + + /** + * @return array> 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(); + + $feed_id = (int) $feed_id; + $cat_id = Feeds::_cat_of($feed_id); + + if (!$cat_id) + $null_cat_qpart = "cat_id IS NULL OR"; + else + $null_cat_qpart = ""; + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT * FROM ttrss_filters2 WHERE + owner_uid = ? AND enabled = true ORDER BY order_id, title"); + $sth->execute([$owner_uid]); + + $check_cats = [...Feeds::_get_parent_cats($cat_id, $owner_uid), $cat_id]; + + $check_cats_str = join(",", $check_cats); + $check_cats_fullids = array_map(fn(int $a) => "CAT:$a", $check_cats); + + while ($line = $sth->fetch()) { + $filter_id = $line["id"]; + + $match_any_rule = sql_bool_to_bool($line["match_any_rule"]); + + $sth2 = $pdo->prepare("SELECT + r.reg_exp, r.inverse, r.feed_id, r.cat_id, r.cat_filter, r.match_on, t.name AS type_name + FROM ttrss_filters2_rules AS r, + ttrss_filter_types AS t + WHERE + (match_on IS NOT NULL OR + (($null_cat_qpart (cat_id IS NULL AND cat_filter = false) OR cat_id IN ($check_cats_str)) AND + (feed_id IS NULL OR feed_id = ?))) AND + filter_type = t.id AND filter_id = ?"); + $sth2->execute([$feed_id, $filter_id]); + + $rules = array(); + $actions = array(); + + while ($rule_line = $sth2->fetch()) { + # print_r($rule_line); + + if ($rule_line["match_on"]) { + $match_on = json_decode($rule_line["match_on"], true); + + if (in_array("0", $match_on) || in_array($feed_id, $match_on) || count(array_intersect($check_cats_fullids, $match_on)) > 0) { + + $rule = array(); + $rule["reg_exp"] = $rule_line["reg_exp"]; + $rule["type"] = $rule_line["type_name"]; + $rule["inverse"] = sql_bool_to_bool($rule_line["inverse"]); + + array_push($rules, $rule); + } else if (!$match_any_rule) { + // this filter contains a rule that doesn't match to this feed/category combination + // thus filter has to be rejected + + $rules = []; + break; + } + + } else { + + $rule = array(); + $rule["reg_exp"] = $rule_line["reg_exp"]; + $rule["type"] = $rule_line["type_name"]; + $rule["inverse"] = sql_bool_to_bool($rule_line["inverse"]); + + array_push($rules, $rule); + } + } + + if (count($rules) > 0) { + $sth2 = $pdo->prepare("SELECT a.action_param,t.name AS type_name + FROM ttrss_filters2_actions AS a, + ttrss_filter_actions AS t + WHERE + action_id = t.id AND filter_id = ?"); + $sth2->execute([$filter_id]); + + while ($action_line = $sth2->fetch()) { + # print_r($action_line); + + $action = array(); + $action["type"] = $action_line["type_name"]; + $action["param"] = $action_line["action_param"]; + + array_push($actions, $action); + } + } + + $filter = []; + $filter["id"] = $filter_id; + $filter["match_any_rule"] = sql_bool_to_bool($line["match_any_rule"]); + $filter["inverse"] = sql_bool_to_bool($line["inverse"]); + $filter["rules"] = $rules; + $filter["actions"] = $actions; + + if (count($rules) > 0 && count($actions) > 0) { + array_push($filters, $filter); + } + } + + return $filters; + } + + /** + * Returns first determined favicon URL for a feed. + * @param string $url A feed or page URL + * @access public + * @return false|string The favicon URL string, or false if none was found. + */ + static function get_favicon_url(string $url) { + + $favicon_urls = self::get_favicon_urls($url); + + if (count($favicon_urls) > 0) + return $favicon_urls[0]; + else + return false; + } + + /** + * Try to determine all favicon URLs for a feed. + * adapted from wordpress favicon plugin by Jeff Minard (http://thecodepro.com/) + * http://dev.wp-plugins.org/file/favatars/trunk/favatars.php + * + * @param string $url A feed or page URL + * @access public + * @return array List of all determined favicon URLs or an empty array + */ + static function get_favicon_urls(string $url) : array { + + $favicon_urls = []; + + if ($html = @UrlHelper::fetch($url)) { + + $doc = new DOMDocument(); + if (@$doc->loadHTML($html)) { + $xpath = new DOMXPath($doc); + + $base = $xpath->query('/html/head/base[@href]'); + foreach ($base as $b) { + $url = UrlHelper::rewrite_relative($url, $b->getAttribute("href")); + break; + } + + $entries = $xpath->query('/html/head/link[@rel="shortcut icon" or @rel="icon" or @rel="alternate icon"]'); + if (count($entries) > 0) { + foreach ($entries as $entry) { + $favicon_url = UrlHelper::rewrite_relative($url, $entry->getAttribute("href")); + + if ($favicon_url) + array_push($favicon_urls, $favicon_url); + + } + } + } + } + + if (count($favicon_urls) == 0) { + $favicon_url = UrlHelper::rewrite_relative($url, "/favicon.ico"); + + if ($favicon_url) + array_push($favicon_urls, $favicon_url); + } + + return $favicon_urls; + } + + /** + * @see https://community.tt-rss.org/t/problem-with-img-srcset/3519 + * + * @return array> An array of srcset subitem arrays with keys "url" and "size" + */ + static function decode_srcset(string $srcset): array { + $matches = []; + + preg_match_all( + '/(?:\A|,)\s*(?P(?!,)\S+(?\s\d+w|\s\d+(?:\.\d+)?(?:[eE][+-]?\d+)?x|)\s*(?=,|\Z)/', + $srcset, $matches, PREG_SET_ORDER + ); + + foreach ($matches as $m) { + array_push($matches, [ + "url" => trim($m["url"]), + "size" => trim($m["size"]) + ]); + } + + return $matches; + } + + /** + * @param array> $matches An array of srcset subitem arrays with keys "url" and "size" + */ + static function encode_srcset(array $matches): string { + $tokens = []; + + foreach ($matches as $m) { + array_push($tokens, trim($m["url"]) . " " . trim($m["size"])); + } + + return implode(",", $tokens); + } + + 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 new file mode 100644 index 000000000..a7bea9e5f --- /dev/null +++ b/classes/Sanitizer.php @@ -0,0 +1,238 @@ + $allowed_elements + * @param array $disallowed_attributes + */ + private static function strip_harmful_tags(DOMDocument $doc, array $allowed_elements, $disallowed_attributes): DOMDocument { + $xpath = new DOMXPath($doc); + $entries = $xpath->query('//*'); + + foreach ($entries as $entry) { + if (!in_array($entry->nodeName, $allowed_elements)) { + $entry->parentNode->removeChild($entry); + } + + if ($entry->hasAttributes()) { + $attrs_to_remove = array(); + + foreach ($entry->attributes as $attr) { + + if (strpos($attr->nodeName, 'on') === 0) { + array_push($attrs_to_remove, $attr); + } + + if (strpos($attr->nodeName, "data-") === 0) { + array_push($attrs_to_remove, $attr); + } + + if ($attr->nodeName == 'href' && stripos($attr->value, 'javascript:') === 0) { + array_push($attrs_to_remove, $attr); + } + + if (in_array($attr->nodeName, $disallowed_attributes)) { + array_push($attrs_to_remove, $attr); + } + } + + foreach ($attrs_to_remove as $attr) { + $entry->removeAttributeNode($attr); + } + } + } + + return $doc; + } + + public static function iframe_whitelisted(DOMElement $entry): bool { + $src = parse_url($entry->getAttribute("src"), PHP_URL_HOST); + + if (!empty($src)) + return PluginHost::getInstance()->run_hooks_until(PluginHost::HOOK_IFRAME_WHITELISTED, true, $src); + + return false; + } + + private static function is_prefix_https(): bool { + return parse_url(Config::get_self_url(), PHP_URL_SCHEME) == 'https'; + } + + /** + * @param array|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) { + $span = OpenTelemetry\API\Trace\Span::getCurrent(); + $span->addEvent("Sanitizer::sanitize"); + + if (!$owner && isset($_SESSION["uid"])) + $owner = $_SESSION["uid"]; + + $res = trim($str); if (!$res) return ''; + + $doc = new DOMDocument(); + $doc->loadHTML('' . $res); + $xpath = new DOMXPath($doc); + + // is it a good idea to possibly rewrite urls to our own prefix? + // $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]|//video[@poster])'); + + foreach ($entries as $entry) { + + if ($entry->hasAttribute('href')) { + $entry->setAttribute('href', + UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('href'), $entry->tagName, "href")); + + $entry->setAttribute('rel', 'noopener noreferrer'); + $entry->setAttribute("target", "_blank"); + } + + if ($entry->hasAttribute('src')) { + $entry->setAttribute('src', + UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('src'), $entry->tagName, "src")); + } + + if ($entry->nodeName == 'img') { + $entry->setAttribute('referrerpolicy', 'no-referrer'); + $entry->setAttribute('loading', 'lazy'); + } + + if ($entry->hasAttribute('srcset')) { + $matches = RSSUtils::decode_srcset($entry->getAttribute('srcset')); + + for ($i = 0; $i < count($matches); $i++) { + $matches[$i]["url"] = UrlHelper::rewrite_relative($rewrite_base_url, $matches[$i]["url"]); + } + + $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)) { + + $p = $doc->createElement('p'); + + $a = $doc->createElement('a'); + $a->setAttribute('href', $entry->getAttribute('src')); + + $a->appendChild(new DOMText($entry->getAttribute('src'))); + $a->setAttribute('target', '_blank'); + $a->setAttribute('rel', 'noopener noreferrer'); + + $p->appendChild($a); + + if ($entry->nodeName == 'source') { + + if ($entry->parentNode && $entry->parentNode->parentNode) + $entry->parentNode->parentNode->replaceChild($p, $entry->parentNode); + + } else if ($entry->nodeName == 'img') { + if ($entry->parentNode) + $entry->parentNode->replaceChild($p, $entry); + } + } + } + + $entries = $xpath->query('//iframe'); + foreach ($entries as $entry) { + if (!self::iframe_whitelisted($entry)) { + $entry->setAttribute('sandbox', 'allow-scripts'); + } else { + if (self::is_prefix_https()) { + $entry->setAttribute("src", + str_replace("http://", "https://", + $entry->getAttribute("src"))); + } + } + } + + $allowed_elements = array('a', 'abbr', 'address', 'acronym', 'audio', 'article', 'aside', + 'b', 'bdi', 'bdo', 'big', 'blockquote', 'body', 'br', + 'caption', 'cite', 'center', 'code', 'col', 'colgroup', + 'data', 'dd', 'del', 'details', 'description', 'dfn', 'div', 'dl', 'font', + 'dt', 'em', 'footer', 'figure', 'figcaption', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'html', 'i', + 'img', 'ins', 'kbd', 'li', 'main', 'mark', 'nav', 'noscript', + 'ol', 'p', 'picture', 'pre', 'q', 'ruby', 'rp', 'rt', 's', 'samp', 'section', + 'small', 'source', 'span', 'strike', 'strong', 'sub', 'summary', + 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', + 'tr', 'track', 'tt', 'u', 'ul', 'var', 'wbr', 'video', 'xml:namespace' ); + + if ($_SESSION['hasSandbox'] ?? false) $allowed_elements[] = 'iframe'; + + $disallowed_attributes = array('id', 'style', 'class', 'width', 'height', 'allow'); + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_SANITIZE, + function ($result) use (&$doc, &$allowed_elements, &$disallowed_attributes) { + if (is_array($result)) { + $doc = $result[0]; + $allowed_elements = $result[1]; + $disallowed_attributes = $result[2]; + } else { + $doc = $result; + } + }, + $doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id); + + $doc->removeChild($doc->firstChild); //remove doctype + $doc = self::strip_harmful_tags($doc, $allowed_elements, $disallowed_attributes); + + $entries = $xpath->query('//iframe'); + foreach ($entries as $entry) { + $div = $doc->createElement('div'); + $div->setAttribute('class', 'embed-responsive'); + $entry->parentNode->replaceChild($div, $entry); + $div->appendChild($entry); + } + + if (is_array($highlight_words)) { + foreach ($highlight_words as $word) { + + // http://stackoverflow.com/questions/4081372/highlight-keywords-in-a-paragraph + + $elements = $xpath->query("//*/text()"); + + foreach ($elements as $child) { + + $fragment = $doc->createDocumentFragment(); + $text = $child->textContent; + + while (($pos = mb_stripos($text, $word)) !== false) { + $fragment->appendChild(new DOMText(mb_substr($text, 0, (int)$pos))); + $word = mb_substr($text, (int)$pos, mb_strlen($word)); + $highlight = $doc->createElement('span'); + $highlight->appendChild(new DOMText($word)); + $highlight->setAttribute('class', 'highlight'); + $fragment->appendChild($highlight); + $text = mb_substr($text, $pos + mb_strlen($word)); + } + + if (!empty($text)) $fragment->appendChild(new DOMText($text)); + + $child->parentNode->replaceChild($fragment, $child); + } + } + } + + $res = $doc->saveHTML(); + + /* strip everything outside of ... */ + + $res_frag = array(); + if (preg_match('/(.*)<\/body>/is', $res, $res_frag)) { + return $res_frag[1]; + } else { + return $res; + } + } + +} diff --git a/classes/Templator.php b/classes/Templator.php new file mode 100644 index 000000000..b682f8b82 --- /dev/null +++ b/classes/Templator.php @@ -0,0 +1,21 @@ +getOffset($dt); + } else { + $tz_offset = (int) -($_SESSION["clientTzOffset"] ?? 0); + } + + $user_timestamp = $dt->format('U') + $tz_offset; + + if (!$no_smart_dt) { + return self::smart_date_time($user_timestamp, + $tz_offset, $owner_uid, $eta_min); + } else { + if ($long) + $format = get_pref(Prefs::LONG_DATE_FORMAT, $owner_uid); + else + $format = get_pref(Prefs::SHORT_DATE_FORMAT, $owner_uid); + + return date($format, $user_timestamp); + } + } + + static function convert_timestamp(int $timestamp, string $source_tz, string $dest_tz): int { + + try { + $source_tz = new DateTimeZone($source_tz); + } catch (Exception $e) { + $source_tz = new DateTimeZone('UTC'); + } + + try { + $dest_tz = new DateTimeZone($dest_tz); + } catch (Exception $e) { + $dest_tz = new DateTimeZone('UTC'); + } + + $dt = new DateTime(date('Y-m-d H:i:s', $timestamp), $source_tz); + + return (int)$dt->format('U') + $dest_tz->getOffset($dt); + } + +} diff --git a/classes/Tracer.php b/classes/Tracer.php new file mode 100644 index 000000000..7163adb90 --- /dev/null +++ b/classes/Tracer.php @@ -0,0 +1,216 @@ +create($OPENTELEMETRY_ENDPOINT, 'application/x-protobuf'); + $exporter = new SpanExporter($transport); + + $resource = ResourceInfoFactory::emptyResource()->merge( + ResourceInfo::create(Attributes::create( + [ResourceAttributes::SERVICE_NAME => Config::get(Config::OPENTELEMETRY_SERVICE)] + ), ResourceAttributes::SCHEMA_URL), + ); + + $this->tracerProvider = TracerProvider::builder() + ->addSpanProcessor(new SimpleSpanProcessor($exporter)) + ->setResource($resource) + ->setSampler(new ParentBased(new AlwaysOnSampler())) + ->build(); + + $this->tracer = $this->tracerProvider->getTracer('io.opentelemetry.contrib.php'); + + $context = TraceContextPropagator::getInstance()->extract(getallheaders()); + + $span = $this->tracer->spanBuilder($_SESSION['name'] ?? 'not logged in') + ->setParent($context) + ->setSpanKind(SpanKind::KIND_SERVER) + ->setAttribute('php.request', json_encode($_REQUEST)) + ->setAttribute('php.server', json_encode($_SERVER)) + ->setAttribute('php.session', json_encode($_SESSION ?? [])) + ->startSpan(); + + $scope = $span->activate(); + + register_shutdown_function(function() use ($span, $scope) { + $span->end(); + $scope->detach(); + $this->tracerProvider->shutdown(); + }); + } + } + + /** + * @param string $name + * @return OpenTelemetry\API\Trace\SpanInterface + */ + private function _start(string $name) { + if ($this->tracer != null) { + $span = $this->tracer + ->spanBuilder($name) + ->setSpanKind(SpanKind::KIND_SERVER) + ->startSpan(); + + $span->activate(); + } else { + $span = new DummySpanInterface(); + } + + return $span; + } + + /** + * @param string $name + * @return OpenTelemetry\API\Trace\SpanInterface + */ + public static function start(string $name) { + return self::get_instance()->_start($name); + } + + public static function get_instance() : Tracer { + if (self::$instance == null) + self::$instance = new self(); + + return self::$instance; + } +} diff --git a/classes/UrlHelper.php b/classes/UrlHelper.php new file mode 100644 index 000000000..dbbde55e6 --- /dev/null +++ b/classes/UrlHelper.php @@ -0,0 +1,656 @@ + [ "magnet" ], + ]; + + static string $fetch_last_error; + static int $fetch_last_error_code; + static string $fetch_last_error_content; + static string $fetch_last_content_type; + static string $fetch_last_modified; + static string $fetch_effective_url; + static string $fetch_effective_ip_addr; + static bool $fetch_curl_used; + + /** + * @param array $parts + */ + static function build_url(array $parts): string { + $tmp = $parts['scheme'] . "://" . $parts['host']; + + if (isset($parts['path'])) $tmp .= $parts['path']; + if (isset($parts['query'])) $tmp .= '?' . $parts['query']; + if (isset($parts['fragment'])) $tmp .= '#' . $parts['fragment']; + + return $tmp; + } + + /** + * Converts a (possibly) relative URL to a absolute one, using provided base URL. + * Provides some exceptions for additional schemes like data: if called with owning element/attribute. + * + * @param string $base_url Base URL (i.e. from where the document is) + * @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 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 = "", + string $content_type = "") { + + $rel_parts = parse_url($rel_url); + + if (!$rel_url) return $base_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); + + // protocol-relative URL (rare but they exist) + } else if (strpos($rel_url, "//") === 0) { + return self::validate("https:" . $rel_url); + // allow some extra schemes for A href + } else if (in_array($rel_parts["scheme"] ?? "", self::EXTRA_HREF_SCHEMES, true) && + $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" && + preg_match('%^image/(webp|gif|jpg|png|svg);base64,%', $rel_parts["path"]) && + $owner_element == "img" && + $owner_attribute == "src") { + return $rel_url; + } else { + $base_parts = parse_url($base_url); + + $rel_parts['host'] = $base_parts['host'] ?? ""; + $rel_parts['scheme'] = $base_parts['scheme'] ?? ""; + + if ($rel_parts['path'] ?? "") { + + // we append dirname() of base path to relative URL path as per RFC 3986 section 5.2.2 + $base_path = with_trailing_slash(dirname($base_parts['path'] ?? "")); + + // 1. absolute relative path (/test.html) = no-op, proceed as is + + // 2. dotslash relative URI (./test.html) - strip "./", append base path + if (strpos($rel_parts['path'], './') === 0) { + $rel_parts['path'] = $base_path . substr($rel_parts['path'], 2); + // 3. anything else relative (test.html) - append dirname() of base path + } else if (strpos($rel_parts['path'], '/') !== 0) { + $rel_parts['path'] = $base_path . $rel_parts['path']; + } + + //$rel_parts['path'] = str_replace("/./", "/", $rel_parts['path']); + //$rel_parts['path'] = str_replace("//", "/", $rel_parts['path']); + } + + return self::validate(self::build_url($rel_parts)); + } + } + + /** 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); + + # fix protocol-relative URLs + if (strpos($url, "//") === 0) + $url = "https:" . $url; + + $tokens = parse_url($url); + + // this isn't really necessary because filter_var(... FILTER_VALIDATE_URL) requires host and scheme + // as per https://php.watch/versions/7.3/filter-var-flag-deprecation but it might save time + if (empty($tokens['host'])) + return false; + + if (!in_array(strtolower($tokens['scheme']), ['http', 'https'])) + return false; + + //convert IDNA hostname to punycode if possible + if (function_exists("idn_to_ascii")) { + if (mb_detect_encoding($tokens['host']) != 'ASCII') { + if (defined('IDNA_NONTRANSITIONAL_TO_ASCII') && defined('INTL_IDNA_VARIANT_UTS46')) { + $tokens['host'] = idn_to_ascii($tokens['host'], IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46); + } else { + $tokens['host'] = idn_to_ascii($tokens['host']); + } + + // if `idn_to_ascii` failed + if ($tokens['host'] === false) { + return false; + } + } + } + + // separate set of tokens with urlencoded 'path' because filter_var() rightfully fails on non-latin characters + // (used for validation only, we actually request the original URL, in case of urlencode breaking it) + $tokens_filter_var = $tokens; + + if ($tokens['path'] ?? false) { + $tokens_filter_var['path'] = implode("/", + array_map("rawurlencode", + array_map("rawurldecode", + explode("/", $tokens['path'])))); + } + + $url = self::build_url($tokens); + $url_filter_var = self::build_url($tokens_filter_var); + + if (filter_var($url_filter_var, FILTER_VALIDATE_URL) === false) + return false; + + if ($extended_filtering) { + if (!in_array($tokens['port'] ?? '', [80, 443, ''])) + return false; + + if (strtolower($tokens['host']) == 'localhost' || $tokens['host'] == '::1' || strpos($tokens['host'], '127.') === 0) + return false; + } + + return $url; + } + + /** + * @return false|string + */ + static function resolve_redirects(string $url, int $timeout, int $nest = 0) { + $span = Tracer::start(__METHOD__); + $span->setAttribute('func.args', json_encode(func_get_args())); + + // too many redirects + if ($nest > 10) { + $span->setAttribute('error', 'too many redirects'); + $span->end(); + return false; + } + + $context_options = array( + 'http' => array( + 'header' => array( + 'Connection: close' + ), + 'method' => 'HEAD', + 'timeout' => $timeout, + 'protocol_version'=> 1.1) + ); + + if (Config::get(Config::HTTP_PROXY)) { + $context_options['http']['request_fulluri'] = true; + $context_options['http']['proxy'] = Config::get(Config::HTTP_PROXY); + } + + $context = stream_context_create($context_options); + + // PHP 8 changed the second param from int to bool, but we still support PHP >= 7.4.0 + // @phpstan-ignore-next-line + $headers = get_headers($url, 0, $context); + + if (is_array($headers)) { + $headers = array_reverse($headers); // last one is the correct one + + foreach($headers as $header) { + if (stripos($header, 'Location:') === 0) { + $url = self::rewrite_relative($url, trim(substr($header, strlen('Location:')))); + + return self::resolve_redirects($url, $timeout, $nest + 1); + } + } + + $span->end(); + return $url; + } + + $span->setAttribute('error', 'request failed'); + $span->end(); + // request failed? + return false; + } + + /** + * @param array|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 = ""; + self::$fetch_last_error_code = -1; + self::$fetch_last_error_content = ""; + self::$fetch_last_content_type = ""; + self::$fetch_curl_used = false; + self::$fetch_last_modified = ""; + self::$fetch_effective_url = ""; + self::$fetch_effective_ip_addr = ""; + + if (!is_array($options)) { + + // falling back on compatibility shim + $option_names = [ "url", "type", "login", "pass", "post_query", "timeout", "last_modified", "useragent" ]; + $tmp = []; + + for ($i = 0; $i < func_num_args(); $i++) { + $tmp[$option_names[$i]] = func_get_arg($i); + } + + $options = $tmp; + + /*$options = array( + "url" => func_get_arg(0), + "type" => @func_get_arg(1), + "login" => @func_get_arg(2), + "pass" => @func_get_arg(3), + "post_query" => @func_get_arg(4), + "timeout" => @func_get_arg(5), + "timestamp" => @func_get_arg(6), + "useragent" => @func_get_arg(7) + ); */ + } + $url = $options["url"]; + + $span = Tracer::start(__METHOD__); + $span->setAttribute('func.args', json_encode(func_get_args())); + + $type = isset($options["type"]) ? $options["type"] : false; + $login = isset($options["login"]) ? $options["login"] : false; + $pass = isset($options["pass"]) ? $options["pass"] : false; + $post_query = isset($options["post_query"]) ? $options["post_query"] : false; + $timeout = isset($options["timeout"]) ? $options["timeout"] : false; + $last_modified = isset($options["last_modified"]) ? $options["last_modified"] : ""; + $useragent = isset($options["useragent"]) ? $options["useragent"] : false; + $followlocation = isset($options["followlocation"]) ? $options["followlocation"] : true; + $max_size = isset($options["max_size"]) ? $options["max_size"] : Config::get(Config::MAX_DOWNLOAD_FILE_SIZE); // in bytes + $http_accept = isset($options["http_accept"]) ? $options["http_accept"] : false; + $http_referrer = isset($options["http_referrer"]) ? $options["http_referrer"] : false; + + $url = ltrim($url, ' '); + $url = str_replace(' ', '%20', $url); + + Debug::log("[UrlHelper] fetching: $url", Debug::LOG_EXTENDED); + + $url = self::validate($url, true); + + if (!$url) { + self::$fetch_last_error = "Requested URL failed extended validation."; + + $span->setAttribute('error', self::$fetch_last_error); + $span->end(); + return false; + } + + $url_host = parse_url($url, PHP_URL_HOST); + $ip_addr = gethostbyname($url_host); + + if (!$ip_addr || strpos($ip_addr, "127.") === 0) { + self::$fetch_last_error = "URL hostname failed to resolve or resolved to a loopback address ($ip_addr)"; + + $span->setAttribute('error', self::$fetch_last_error); + $span->end(); + return false; + } + + if (function_exists('curl_init') && !ini_get("open_basedir")) { + + self::$fetch_curl_used = true; + + $ch = curl_init($url); + + if (!$ch) { + self::$fetch_last_error = "curl_init() failed"; + $span->setAttribute('error', self::$fetch_last_error); + $span->end(); + return false; + } + + $curl_http_headers = []; + + if ($last_modified && !$post_query) + array_push($curl_http_headers, "If-Modified-Since: $last_modified"); + + if ($http_accept) + array_push($curl_http_headers, "Accept: " . $http_accept); + + if (count($curl_http_headers) > 0) + curl_setopt($ch, CURLOPT_HTTPHEADER, $curl_http_headers); + + 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, $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_BASIC); + 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); + + if ($max_size) { + curl_setopt($ch, CURLOPT_NOPROGRESS, false); + curl_setopt($ch, CURLOPT_BUFFERSIZE, 16384); // needed to get 5 arguments in progress function? + + // holy shit closures in php + // download & upload are *expected* sizes respectively, could be zero + curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function($curl_handle, $download_size, $downloaded, $upload_size, $uploaded) use(&$max_size, $url) { + //Debug::log("[curl progressfunction] $downloaded $max_size", Debug::$LOG_EXTENDED); + + if ($downloaded > $max_size) { + Debug::log("[UrlHelper] fetch error: curl reached max size of $max_size bytes downloading $url, aborting.", Debug::LOG_VERBOSE); + return 1; + } + + return 0; + }); + + } + + if (Config::get(Config::HTTP_PROXY)) { + curl_setopt($ch, CURLOPT_PROXY, Config::get(Config::HTTP_PROXY)); + } + + if ($post_query) { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $post_query); + } + + if ($login && $pass) + curl_setopt($ch, CURLOPT_USERPWD, "$login:$pass"); + + $ret = @curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + // CURLAUTH_BASIC didn't work, let's retry with CURLAUTH_ANY in case it's actually something + // unusual like NTLM... + if ($http_code == 403 && $login && $pass) { + curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_ANY); + + $ret = @curl_exec($ch); + } + + if (curl_errno($ch) === 23 || curl_errno($ch) === 61) { + curl_setopt($ch, CURLOPT_ENCODING, 'none'); + $ret = @curl_exec($ch); + } + + $headers_length = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $headers = explode("\r\n", substr($ret, 0, $headers_length)); + $contents = substr($ret, $headers_length); + + foreach ($headers as $header) { + if (strstr($header, ": ") !== false) { + list ($key, $value) = explode(": ", $header); + + if (strtolower($key) == "last-modified") { + self::$fetch_last_modified = $value; + } + } + + if (substr(strtolower($header), 0, 7) == 'http/1.') { + self::$fetch_last_error_code = (int) substr($header, 9, 3); + self::$fetch_last_error = $header; + } + } + + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + self::$fetch_last_content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + + self::$fetch_effective_url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); + + if (!self::validate(self::$fetch_effective_url, true)) { + self::$fetch_last_error = "URL received after redirection failed extended validation."; + + $span->setAttribute('error', self::$fetch_last_error); + $span->end(); + return false; + } + + self::$fetch_effective_ip_addr = gethostbyname(parse_url(self::$fetch_effective_url, PHP_URL_HOST)); + + if (!self::$fetch_effective_ip_addr || strpos(self::$fetch_effective_ip_addr, "127.") === 0) { + self::$fetch_last_error = "URL hostname received after redirection failed to resolve or resolved to a loopback address (".self::$fetch_effective_ip_addr.")"; + + $span->setAttribute('error', self::$fetch_last_error); + $span->end(); + return false; + } + + self::$fetch_last_error_code = $http_code; + + if ($http_code != 200 || $type && strpos(self::$fetch_last_content_type, "$type") === false) { + + 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; + curl_close($ch); + + $span->setAttribute('error', self::$fetch_last_error); + $span->end(); + return false; + } + + if (!$contents) { + if (curl_errno($ch) === 0) { + self::$fetch_last_error = 'Successful response, but no content was received.'; + } else { + self::$fetch_last_error = curl_errno($ch) . " " . curl_error($ch); + } + curl_close($ch); + + $span->setAttribute('error', self::$fetch_last_error); + $span->end(); + return false; + } + + curl_close($ch); + + $is_gzipped = RSSUtils::is_gzipped($contents); + + if ($is_gzipped && is_string($contents)) { + $tmp = @gzdecode($contents); + + if ($tmp) $contents = $tmp; + } + + $span->end(); + + return $contents; + } else { + + self::$fetch_curl_used = false; + + if ($login && $pass){ + $url_parts = array(); + + preg_match("/(^[^:]*):\/\/(.*)/", $url, $url_parts); + + $pass = urlencode($pass); + + if ($url_parts[1] && $url_parts[2]) { + $url = $url_parts[1] . "://$login:$pass@" . $url_parts[2]; + } + } + + // TODO: should this support POST requests or not? idk + + $context_options = array( + 'http' => array( + 'header' => array( + 'Connection: close' + ), + 'method' => 'GET', + 'ignore_errors' => true, + 'timeout' => $timeout ? $timeout : Config::get(Config::FILE_FETCH_TIMEOUT), + 'protocol_version'=> 1.1) + ); + + if (!$post_query && $last_modified) + array_push($context_options['http']['header'], "If-Modified-Since: $last_modified"); + + if ($http_accept) + array_push($context_options['http']['header'], "Accept: $http_accept"); + + if ($http_referrer) + array_push($context_options['http']['header'], "Referer: $http_referrer"); + + if (Config::get(Config::HTTP_PROXY)) { + $context_options['http']['request_fulluri'] = true; + $context_options['http']['proxy'] = Config::get(Config::HTTP_PROXY); + } + + $context = stream_context_create($context_options); + + $old_error = error_get_last(); + + self::$fetch_effective_url = self::resolve_redirects($url, $timeout ? $timeout : Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT)); + + if (!self::validate(self::$fetch_effective_url, true)) { + self::$fetch_last_error = "URL received after redirection failed extended validation."; + + $span->setAttribute('error', self::$fetch_last_error); + $span->end(); + return false; + } + + self::$fetch_effective_ip_addr = gethostbyname(parse_url(self::$fetch_effective_url, PHP_URL_HOST)); + + if (!self::$fetch_effective_ip_addr || strpos(self::$fetch_effective_ip_addr, "127.") === 0) { + self::$fetch_last_error = "URL hostname received after redirection failed to resolve or resolved to a loopback address (".self::$fetch_effective_ip_addr.")"; + + $span->setAttribute('error', self::$fetch_last_error); + $span->end(); + return false; + } + + $data = @file_get_contents($url, false, $context); + + if ($data === false) { + self::$fetch_last_error = "'file_get_contents' failed."; + + $span->setAttribute('error', self::$fetch_last_error); + $span->end(); + return false; + } + + foreach ($http_response_header as $header) { + if (strstr($header, ": ") !== false) { + list ($key, $value) = explode(": ", $header); + + $key = strtolower($key); + + if ($key == 'content-type') { + self::$fetch_last_content_type = $value; + // don't abort here b/c there might be more than one + // e.g. if we were being redirected -- last one is the right one + } else if ($key == 'last-modified') { + self::$fetch_last_modified = $value; + } else if ($key == 'location') { + self::$fetch_effective_url = $value; + } + } + + if (substr(strtolower($header), 0, 7) == 'http/1.') { + self::$fetch_last_error_code = (int) substr($header, 9, 3); + self::$fetch_last_error = $header; + } + } + + if (self::$fetch_last_error_code != 200) { + $error = error_get_last(); + + if (($error['message'] ?? '') != ($old_error['message'] ?? '')) { + self::$fetch_last_error .= "; " . $error["message"]; + } + + self::$fetch_last_error_content = $data; + + $span->setAttribute('error', self::$fetch_last_error); + $span->end(); + return false; + } + + if ($data) { + $is_gzipped = RSSUtils::is_gzipped($data); + + if ($is_gzipped) { + $tmp = @gzdecode($data); + + if ($tmp) $data = $tmp; + } + + $span->end(); + return $data; + } else { + self::$fetch_last_error = 'Successful response, but no content was received.'; + + $span->setAttribute('error', self::$fetch_last_error); + $span->end(); + return false; + } + } + } + + /** + * @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 = [ + "/\/\/www\.youtube-nocookie\.com\/v\/([\w-]+)/", + "/\/\/www\.youtube-nocookie\.com\/embed\/([\w-]+)/", + "/\/\/www\.youtube-nocookie\.com\/watch?v=([\w-]+)/", + "/\/\/youtu.be\/([\w-]+)/", + ]; + + foreach ($regexps as $re) { + $matches = []; + + if (preg_match($re, $url, $matches)) { + return $matches[1]; + } + } + + return false; + } + + +} diff --git a/classes/UserHelper.php b/classes/UserHelper.php new file mode 100644 index 000000000..4d9f30548 --- /dev/null +++ b/classes/UserHelper.php @@ -0,0 +1,520 @@ +chain_hooks_callback(PluginHost::HOOK_AUTH_USER, + function ($result, $plugin) use (&$user_id, &$auth_module) { + if ($result) { + $user_id = (int)$result; + $auth_module = strtolower(get_class($plugin)); + return true; + } + }, + $login, $password, $service); + + if ($user_id && !$check_only) { + + if (session_status() != PHP_SESSION_ACTIVE) + session_start(); + + session_regenerate_id(true); + + $user = ORM::for_table('ttrss_users')->find_one($user_id); + + if ($user && $user->access_level != self::ACCESS_LEVEL_DISABLED) { + self::set_session_for_user($user_id); + $_SESSION["auth_module"] = $auth_module; + $_SESSION["name"] = $user->login; + $_SESSION["access_level"] = $user->access_level; + $_SESSION["pwd_hash"] = $user->pwd_hash; + + $user->last_login = Db::NOW(); + $user->save(); + + return true; + } + + return false; + } + + if ($login && $password && !$user_id && !$check_only) + Logger::log(E_USER_WARNING, "Failed login attempt for $login (service: $service) from " . UserHelper::get_user_ip()); + + return false; + + } else { + self::set_session_for_user(1); + $_SESSION["name"] = "admin"; + $_SESSION["access_level"] = self::ACCESS_LEVEL_ADMIN; + + $_SESSION["hide_hello"] = true; + $_SESSION["hide_logout"] = true; + + $_SESSION["auth_module"] = false; + + return true; + } + } + + static function set_session_for_user(int $owner_uid): void { + $_SESSION["uid"] = $owner_uid; + $_SESSION["last_login_update"] = time(); + $_SESSION["ip_address"] = UserHelper::get_user_ip(); + + if (empty($_SESSION["csrf_token"])) + $_SESSION["csrf_token"] = bin2hex(get_random_bytes(16)); + + if (Config::get_schema_version() >= 120) { + $_SESSION["language"] = get_pref(Prefs::USER_LANGUAGE, $owner_uid); + } + } + + static function load_user_plugins(int $owner_uid, PluginHost $pluginhost = null): void { + + if (!$pluginhost) $pluginhost = PluginHost::getInstance(); + + if ($owner_uid && Config::get_schema_version() >= 100 && empty($_SESSION["safe_mode"])) { + $plugins = get_pref(Prefs::_ENABLED_PLUGINS, $owner_uid); + + $pluginhost->load((string)$plugins, PluginHost::KIND_USER, $owner_uid); + + /*if (get_schema_version() > 100) { + $pluginhost->load_data(); + }*/ + } + } + + static function login_sequence(): void { + $pdo = Db::pdo(); + + if (Config::get(Config::SINGLE_USER_MODE)) { + if (session_status() != PHP_SESSION_ACTIVE) + session_start(); + + self::authenticate("admin", null); + startup_gettext(); + self::load_user_plugins($_SESSION["uid"]); + } else { + if (!\Sessions\validate_session()) + $_SESSION["uid"] = null; + + if (empty($_SESSION["uid"])) { + + if (Config::get(Config::AUTH_AUTO_LOGIN) && self::authenticate(null, null)) { + $_SESSION["ref_schema_version"] = Config::get_schema_version(); + } else { + self::authenticate(null, null, true); + } + + if (empty($_SESSION["uid"])) { + UserHelper::logout(); + + Handler_Public::_render_login_form(); + exit; + } + + } else { + /* bump login timestamp */ + $user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]); + $user->last_login = Db::NOW(); + $user->save(); + + $_SESSION["last_login_update"] = time(); + } + + if ($_SESSION["uid"]) { + startup_gettext(); + self::load_user_plugins($_SESSION["uid"]); + } + } + } + + static function print_user_stylesheet(): void { + $value = get_pref(Prefs::USER_STYLESHEET); + + if ($value) { + print ""; + } + + } + + static function get_user_ip(): ?string { + foreach (["HTTP_X_REAL_IP", "REMOTE_ADDR"] as $hdr) { + if (isset($_SERVER[$hdr])) + return $_SERVER[$hdr]; + } + + return null; + } + + static function get_login_by_id(int $id): ?string { + $user = ORM::for_table('ttrss_users') + ->find_one($id); + + if ($user) + return $user->login; + else + return null; + } + + static function find_user_by_login(string $login): ?int { + $user = ORM::for_table('ttrss_users') + ->where('login', $login) + ->find_one(); + + if ($user) + return $user->id; + else + return null; + } + + static function logout(): void { + if (session_status() === PHP_SESSION_ACTIVE) + session_destroy(); + + if (isset($_COOKIE[session_name()])) { + setcookie(session_name(), '', time()-42000, '/'); + + } + session_commit(); + } + + static function get_salt(): string { + return substr(bin2hex(get_random_bytes(125)), 0, 250); + } + + /** TODO: this should invoke UserHelper::user_modify() */ + static function reset_password(int $uid, bool $format_output = false, string $new_password = ""): void { + + $user = ORM::for_table('ttrss_users')->find_one($uid); + $message = ""; + + if ($user) { + + $login = $user->login; + + $new_salt = self::get_salt(); + $tmp_user_pwd = $new_password ? $new_password : make_password(); + + $pwd_hash = self::hash_password($tmp_user_pwd, $new_salt, self::HASH_ALGOS[0]); + + $user->pwd_hash = $pwd_hash; + $user->salt = $new_salt; + $user->save(); + + $message = T_sprintf("Changed password of user %s to %s", "$login", "$tmp_user_pwd"); + } else { + $message = __("User not found"); + } + + if ($format_output) + print_notice($message); + else + print $message; + } + + static function check_otp(int $owner_uid, int $otp_check) : bool { + $otp = TOTP::create(self::get_otp_secret($owner_uid, true)); + + return $otp->now() == $otp_check; + } + + static function disable_otp(int $owner_uid) : bool { + $user = ORM::for_table('ttrss_users')->find_one($owner_uid); + + if ($user) { + $user->otp_enabled = false; + + // force new OTP secret when next enabled + if (Config::get_schema_version() >= 143) { + $user->otp_secret = null; + } + + $user->save(); + + return true; + } else { + return false; + } + } + + static function enable_otp(int $owner_uid, int $otp_check) : bool { + $secret = self::get_otp_secret($owner_uid); + + if ($secret) { + $otp = TOTP::create($secret); + $user = ORM::for_table('ttrss_users')->find_one($owner_uid); + + if ($otp->now() == $otp_check && $user) { + + $user->otp_enabled = true; + $user->save(); + + return true; + } + } + return false; + } + + + static function is_otp_enabled(int $owner_uid) : bool { + $user = ORM::for_table('ttrss_users')->find_one($owner_uid); + + if ($user) { + return $user->otp_enabled; + } else { + return 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) { + + $salt_based_secret = mb_substr(sha1($user->salt), 0, 12); + + if (Config::get_schema_version() >= 143) { + $secret = $user->otp_secret; + + if (empty($secret)) { + + /* migrate secret if OTP is already enabled, otherwise make a new one */ + if ($user->otp_enabled) { + $user->otp_secret = $salt_based_secret; + } else { + $user->otp_secret = bin2hex(get_random_bytes(10)); + } + + $user->save(); + + $secret = $user->otp_secret; + } + } else { + $secret = $salt_based_secret; + } + + if (!$user->otp_enabled || $show_if_enabled) { + return \ParagonIE\ConstantTime\Base32::encodeUpperUnpadded($secret); + } + } + + return null; + } + + /** + * @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only + * @return bool + * @throws PDOException + * @throws Exception + */ + static function is_default_password(?int $owner_uid = null): bool { + return self::user_has_password($owner_uid, 'password'); + } + + /** + * @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) { + case self::HASH_ALGO_SHA1: + $pass_hash = sha1($pass); + break; + case self::HASH_ALGO_SHA1X: + $pass_hash = sha1("$salt:$pass"); + break; + case self::HASH_ALGO_MODE2: + case self::HASH_ALGO_SSHA256: + $pass_hash = hash('sha256', $salt . $pass); + break; + case self::HASH_ALGO_SSHA512: + $pass_hash = hash('sha512', $salt . $pass); + break; + default: + user_error("hash_password: unknown hash algo: $algo", E_USER_ERROR); + } + + if ($pass_hash) + return "$algo:$pass_hash"; + else + return false; + } + + /** + * @param string $login Login for new user (case-insensitive) + * @param string $password Password for new user (may not be blank) + * @param UserHelper::ACCESS_LEVEL_* $access_level Access level for new user + * @return bool true if user has been created + */ + static function user_add(string $login, string $password, int $access_level) : bool { + $login = clean($login); + + if ($login && + $password && + !self::find_user_by_login($login) && + self::map_access_level((int)$access_level) != self::ACCESS_LEVEL_KEEP_CURRENT) { + + $user = ORM::for_table('ttrss_users')->create(); + + $user->salt = self::get_salt(); + $user->login = mb_strtolower($login); + $user->pwd_hash = self::hash_password($password, $user->salt); + $user->access_level = $access_level; + $user->created = Db::NOW(); + + return $user->save(); + } + + return false; + } + + /** + * @param int $uid User ID to modify + * @param string $new_password set password to this value if its not blank + * @param UserHelper::ACCESS_LEVEL_* $access_level set user access level to this value if it is set (default ACCESS_LEVEL_KEEP_CURRENT) + * @return bool true if user record has been saved + * + * NOTE: $access_level is of mixed type because of intellephense + */ + static function user_modify(int $uid, string $new_password = '', $access_level = self::ACCESS_LEVEL_KEEP_CURRENT) : bool { + $user = ORM::for_table('ttrss_users')->find_one($uid); + + if ($user) { + if ($new_password != '') { + $new_salt = self::get_salt(); + $pwd_hash = self::hash_password($new_password, $new_salt, self::HASH_ALGOS[0]); + + $user->pwd_hash = $pwd_hash; + $user->salt = $new_salt; + } + + if ($access_level != self::ACCESS_LEVEL_KEEP_CURRENT) { + $user->access_level = (int)$access_level; + } + + return $user->save(); + } + + return false; + } + + /** + * @param int $uid user ID to delete (this won't delete built-in admin user with UID 1) + * @return bool true if user has been deleted + */ + static function user_delete(int $uid) : bool { + if ($uid != 1) { + + $user = ORM::for_table('ttrss_users')->find_one($uid); + + if ($user) { + // TODO: is it still necessary to split those queries? + + ORM::for_table('ttrss_tags') + ->where('owner_uid', $uid) + ->delete_many(); + + ORM::for_table('ttrss_feeds') + ->where('owner_uid', $uid) + ->delete_many(); + + return $user->delete(); + } + } + + return false; + } + + /** + * @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only + * @param string $password password to compare hash against + * @return bool + */ + static function user_has_password(?int $owner_uid, string $password) : bool { + if ($owner_uid) { + $authenticator = new Auth_Internal(); + + return $authenticator->check_password($owner_uid, $password); + } else { + /** @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 && + method_exists($authenticator, "check_password") && + $authenticator->check_password($_SESSION["uid"], $password)) { + + return true; + } + } + + return false; + } + +} diff --git a/classes/api.php b/classes/api.php deleted file mode 100755 index 3a3ae0e63..000000000 --- a/classes/api.php +++ /dev/null @@ -1,967 +0,0 @@ - $reply - */ - private function _wrap(int $status, array $reply): bool { - print json_encode([ - "seq" => $this->seq, - "status" => $status, - "content" => $reply - ]); - return true; - } - - function before(string $method): bool { - if (parent::before($method)) { - header("Content-Type: text/json"); - - if (empty($_SESSION["uid"]) && $method != "login" && $method != "isloggedin") { - $this->_wrap(self::STATUS_ERR, array("error" => self::E_NOT_LOGGED_IN)); - return false; - } - - if (!empty($_SESSION["uid"]) && $method != "logout" && !get_pref(Prefs::ENABLE_API_ACCESS)) { - $this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED)); - return false; - } - - $this->seq = (int) clean($_REQUEST['seq'] ?? 0); - - return true; - } - return false; - } - - function getVersion(): bool { - $rv = array("version" => Config::get_version()); - return $this->_wrap(self::STATUS_OK, $rv); - } - - function getApiLevel(): bool { - $rv = array("level" => self::API_LEVEL); - return $this->_wrap(self::STATUS_OK, $rv); - } - - function login(): bool { - - if (session_status() == PHP_SESSION_ACTIVE) { - session_destroy(); - } - - session_start(); - - $login = clean($_REQUEST["user"]); - $password = clean($_REQUEST["password"]); - - if (Config::get(Config::SINGLE_USER_MODE)) $login = "admin"; - - if ($uid = UserHelper::find_user_by_login($login)) { - if (get_pref(Prefs::ENABLE_API_ACCESS, $uid)) { - if (UserHelper::authenticate($login, $password, false, Auth_Base::AUTH_SERVICE_API)) { - - // needed for _get_config() - UserHelper::load_user_plugins($_SESSION['uid']); - - return $this->_wrap(self::STATUS_OK, array("session_id" => session_id(), - "config" => $this->_get_config(), - "api_level" => self::API_LEVEL)); - } else { - return $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR)); - } - } else { - return $this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED)); - } - } - return $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR)); - } - - function logout(): bool { - UserHelper::logout(); - return $this->_wrap(self::STATUS_OK, array("status" => "OK")); - } - - function isLoggedIn(): bool { - return $this->_wrap(self::STATUS_OK, array("status" => (bool)($_SESSION["uid"] ?? ''))); - } - - function getUnread(): bool { - $feed_id = clean($_REQUEST["feed_id"] ?? ""); - $is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false); - - if ($feed_id) { - return $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_counters($feed_id, $is_cat, true))); - } else { - return $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_global_unread())); - } - } - - /* Method added for ttrss-reader for Android */ - function getCounters(): bool { - return $this->_wrap(self::STATUS_OK, Counters::get_all()); - } - - 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($_REQUEST["include_nested"] ?? false); - - $feeds = $this->_api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested); - - return $this->_wrap(self::STATUS_OK, $feeds); - } - - 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 - - $categories = ORM::for_table('ttrss_feed_categories') - ->select_many('id', 'title', 'order_id') - ->select_many_expr([ - 'num_feeds' => '(SELECT COUNT(id) FROM ttrss_feeds WHERE ttrss_feed_categories.id IS NOT NULL AND cat_id = ttrss_feed_categories.id)', - 'num_cats' => '(SELECT COUNT(id) FROM ttrss_feed_categories AS c2 WHERE c2.parent_cat = ttrss_feed_categories.id)', - ]) - ->where('owner_uid', $_SESSION['uid']); - - if ($enable_nested) { - $categories->where_null('parent_cat'); - } - - $cats = []; - - foreach ($categories->find_many() as $category) { - if ($include_empty || $category->num_feeds > 0 || $category->num_cats > 0) { - $unread = Feeds::_get_counters($category->id, true, true); - - if ($enable_nested) - $unread += Feeds::_get_cat_children_unread($category->id); - - if ($unread || !$unread_only) { - array_push($cats, [ - 'id' => (int) $category->id, - 'title' => $category->title, - 'unread' => (int) $unread, - 'order_id' => (int) $category->order_id, - ]); - } - } - } - - foreach ([Feeds::CATEGORY_LABELS, Feeds::CATEGORY_SPECIAL, Feeds::CATEGORY_UNCATEGORIZED] as $cat_id) { - if ($include_empty || !$this->_is_cat_empty($cat_id)) { - $unread = Feeds::_get_counters($cat_id, true, true); - - if ($unread || !$unread_only) { - array_push($cats, [ - 'id' => $cat_id, - 'title' => Feeds::_get_cat_title($cat_id), - 'unread' => (int) $unread, - ]); - } - } - } - - return $this->_wrap(self::STATUS_OK, $cats); - } - - 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($_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($_REQUEST["include_attachments"] ?? false); - $since_id = (int)clean($_REQUEST["since_id"] ?? 0); - $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($_REQUEST["include_header"] ?? false); - - $_SESSION['hasSandbox'] = $has_sandbox; - - list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query(clean($_REQUEST["order_by"] ?? "")); - - /* do not rely on params below */ - - $search = clean($_REQUEST["search"] ?? ""); - - list($headlines, $headlines_header) = $this->_api_get_headlines($feed_id, $limit, $offset, - $filter, $is_cat, $show_excerpt, $show_content, $view_mode, $override_order, - $include_attachments, $since_id, $search, - $include_nested, $sanitize_content, $force_update, $excerpt_length, $check_first_id, $skip_first_id_check); - - if ($include_header) { - return $this->_wrap(self::STATUS_OK, array($headlines_header, $headlines)); - } else { - return $this->_wrap(self::STATUS_OK, $headlines); - } - } else { - return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); - } - } - - function updateArticle(): bool { - $article_ids = explode(",", clean($_REQUEST["article_ids"])); - $mode = (int) clean($_REQUEST["mode"]); - $data = clean($_REQUEST["data"] ?? ""); - $field_raw = (int)clean($_REQUEST["field"]); - - $field = ""; - $set_to = ""; - $additional_fields = ""; - - switch ($field_raw) { - case 0: - $field = "marked"; - $additional_fields = ",last_marked = NOW()"; - break; - case 1: - $field = "published"; - $additional_fields = ",last_published = NOW()"; - break; - case 2: - $field = "unread"; - $additional_fields = ",last_read = NOW()"; - break; - case 3: - $field = "note"; - break; - case 4: - $field = "score"; - break; - }; - - switch ($mode) { - case 1: - $set_to = "true"; - break; - case 0: - $set_to = "false"; - break; - case 2: - $set_to = "NOT $field"; - break; - } - - if ($field == "note") $set_to = $this->pdo->quote($data); - if ($field == "score") $set_to = (int) $data; - - if ($field && $set_to && count($article_ids) > 0) { - - $article_qmarks = arr_qmarks($article_ids); - - $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET - $field = $set_to $additional_fields - WHERE ref_id IN ($article_qmarks) AND owner_uid = ?"); - $sth->execute([...$article_ids, $_SESSION['uid']]); - - $num_updated = $sth->rowCount(); - - return $this->_wrap(self::STATUS_OK, array("status" => "OK", - "updated" => $num_updated)); - - } else { - return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); - } - } - - 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') - ->select_many('e.id', 'e.guid', 'e.title', 'e.link', 'e.author', 'e.content', 'e.lang', 'e.comments', - 'ue.feed_id', 'ue.int_id', 'ue.marked', 'ue.unread', 'ue.published', 'ue.score', 'ue.note') - ->select_many_expr([ - 'updated' => SUBSTRING_FOR_DATE.'(updated,1,16)', - 'feed_title' => '(SELECT title FROM ttrss_feeds WHERE id = ue.feed_id)', - 'site_url' => '(SELECT site_url FROM ttrss_feeds WHERE id = ue.feed_id)', - 'hide_images' => '(SELECT hide_images FROM ttrss_feeds WHERE id = feed_id)', - ]) - ->join('ttrss_user_entries', [ 'ue.ref_id', '=', 'e.id'], 'ue') - ->where_in('e.id', array_map('intval', $article_ids)) - ->where('ue.owner_uid', $_SESSION['uid']) - ->find_many(); - - $articles = []; - - foreach ($entries as $entry) { - $article = [ - 'id' => $entry->id, - 'guid' => $entry->guid, - 'title' => $entry->title, - 'link' => $entry->link, - 'labels' => Article::_get_labels($entry->id), - 'unread' => self::_param_to_bool($entry->unread), - 'marked' => self::_param_to_bool($entry->marked), - 'published' => self::_param_to_bool($entry->published), - 'comments' => $entry->comments, - 'author' => $entry->author, - 'updated' => (int) strtotime($entry->updated ?? ''), - 'feed_id' => $entry->feed_id, - 'attachments' => Article::_get_enclosures($entry->id), - 'score' => (int) $entry->score, - 'feed_title' => $entry->feed_title, - 'note' => $entry->note, - 'lang' => $entry->lang, - ]; - - if ($sanitize_content) { - $article['content'] = Sanitizer::sanitize( - $entry->content, - self::_param_to_bool($entry->hide_images), - null, $entry->site_url, null, $entry->id); - } else { - $article['content'] = $entry->content; - } - - $hook_object = ['article' => &$article]; - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API, - function ($result) use (&$article) { - $article = $result; - }, - $hook_object); - - $article['content'] = DiskCache::rewrite_urls($article['content']); - - array_push($articles, $article); - } - - return $this->_wrap(self::STATUS_OK, $articles); - } else { - return $this->_wrap(self::STATUS_ERR, ['error' => self::E_INCORRECT_USAGE]); - } - } - - /** - * @return array|bool|int|string> - */ - private function _get_config(): array { - $config = [ - "icons_dir" => Config::get(Config::ICONS_DIR), - "icons_url" => Config::get(Config::ICONS_URL) - ]; - - $config["daemon_is_running"] = file_is_locked("update_daemon.lock"); - $config["custom_sort_types"] = $this->_get_custom_sort_types(); - - $config["num_feeds"] = ORM::for_table('ttrss_feeds') - ->where('owner_uid', $_SESSION['uid']) - ->count(); - - return $config; - } - - function getConfig(): bool { - $config = $this->_get_config(); - - return $this->_wrap(self::STATUS_OK, $config); - } - - function updateFeed(): bool { - $feed_id = (int) clean($_REQUEST["feed_id"]); - - if (!ini_get("open_basedir")) { - RSSUtils::update_rss_feed($feed_id); - } - - return $this->_wrap(self::STATUS_OK, array("status" => "OK")); - } - - function catchupFeed(): bool { - $feed_id = clean($_REQUEST["feed_id"]); - $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); - - return $this->_wrap(self::STATUS_OK, array("status" => "OK")); - } - - function getPref(): bool { - $pref_name = clean($_REQUEST["pref_name"]); - - return $this->_wrap(self::STATUS_OK, array("value" => get_pref($pref_name))); - } - - function getLabels(): bool { - $article_id = (int)clean($_REQUEST['article_id'] ?? -1); - - $rv = []; - - $labels = ORM::for_table('ttrss_labels2') - ->where('owner_uid', $_SESSION['uid']) - ->order_by_asc('caption') - ->find_many(); - - if ($article_id) - $article_labels = Article::_get_labels($article_id); - else - $article_labels = []; - - foreach ($labels as $label) { - $checked = false; - foreach ($article_labels as $al) { - if (Labels::feed_to_label_id($al[0]) == $label->id) { - $checked = true; - break; - } - } - - array_push($rv, [ - 'id' => (int) Labels::label_to_feed_id($label->id), - 'caption' => $label->caption, - 'fg_color' => $label->fg_color, - 'bg_color' => $label->bg_color, - 'checked' => $checked, - ]); - } - - return $this->_wrap(self::STATUS_OK, $rv); - } - - function setArticleLabel(): bool { - - $article_ids = explode(",", clean($_REQUEST["article_ids"])); - $label_id = (int) clean($_REQUEST['label_id']); - $assign = self::_param_to_bool(clean($_REQUEST['assign'])); - - $label = Labels::find_caption(Labels::feed_to_label_id($label_id), $_SESSION["uid"]); - - $num_updated = 0; - - if ($label) { - - foreach ($article_ids as $id) { - - if ($assign) - Labels::add_article((int)$id, $label, $_SESSION["uid"]); - else - Labels::remove_article((int)$id, $label, $_SESSION["uid"]); - - ++$num_updated; - - } - } - - return $this->_wrap(self::STATUS_OK, array("status" => "OK", - "updated" => $num_updated)); - - } - - function index(string $method): bool { - $plugin = PluginHost::getInstance()->get_api_method(strtolower($method)); - - if ($plugin && method_exists($plugin, $method)) { - $reply = $plugin->$method(); - - return $this->_wrap($reply[0], $reply[1]); - - } else { - return $this->_wrap(self::STATUS_ERR, array("error" => self::E_UNKNOWN_METHOD, "method" => $method)); - } - } - - function shareToPublished(): bool { - $title = clean($_REQUEST["title"]); - $url = clean($_REQUEST["url"]); - $sanitize_content = self::_param_to_bool($_REQUEST["sanitize"] ?? true); - - if ($sanitize_content) - $content = clean($_REQUEST["content"]); - else - $content = $_REQUEST["content"]; - - if (Article::_create_published_article($title, $url, $content, "", $_SESSION["uid"])) { - return $this->_wrap(self::STATUS_OK, array("status" => 'OK')); - } else { - return $this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED)); - } - } - - /** - * @return array - */ - private static function _api_get_feeds(int $cat_id, bool $unread_only, int $limit, int $offset, bool $include_nested = false): array { - $feeds = []; - - /* Labels */ - - /* API only: -4 (Feeds::CATEGORY_ALL) All feeds, including virtual feeds */ - if ($cat_id == Feeds::CATEGORY_ALL || $cat_id == Feeds::CATEGORY_LABELS) { - $counters = Counters::get_labels(); - - foreach (array_values($counters) as $cv) { - $unread = $cv['counter']; - - if ($unread || !$unread_only) { - $row = [ - 'id' => (int) $cv['id'], - 'title' => $cv['description'], - 'unread' => $cv['counter'], - 'cat_id' => Feeds::CATEGORY_LABELS, - ]; - - array_push($feeds, $row); - } - } - } - - /* Virtual feeds */ - - $vfeeds = PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL); - - if (is_array($vfeeds)) { - foreach ($vfeeds as $feed) { - if (!implements_interface($feed['sender'], 'IVirtualFeed')) - continue; - - /** @var IVirtualFeed $feed['sender'] */ - $unread = $feed['sender']->get_unread($feed['id']); - - if ($unread || !$unread_only) { - $row = [ - 'id' => PluginHost::pfeed_to_feed_id($feed['id']), - 'title' => $feed['title'], - 'unread' => $unread, - 'cat_id' => Feeds::CATEGORY_SPECIAL, - ]; - - array_push($feeds, $row); - } - } - } - - if ($cat_id == Feeds::CATEGORY_ALL || $cat_id == Feeds::CATEGORY_SPECIAL) { - foreach ([Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED, Feeds::FEED_FRESH, - Feeds::FEED_ALL, Feeds::FEED_RECENTLY_READ, Feeds::FEED_ARCHIVED] as $i) { - $unread = Feeds::_get_counters($i, false, true); - - if ($unread || !$unread_only) { - $title = Feeds::_get_title($i); - - $row = [ - 'id' => $i, - 'title' => $title, - 'unread' => $unread, - 'cat_id' => Feeds::CATEGORY_SPECIAL, - ]; - - array_push($feeds, $row); - } - } - } - - /* Child cats */ - - if ($include_nested && $cat_id) { - $categories = ORM::for_table('ttrss_feed_categories') - ->where(['parent_cat' => $cat_id, 'owner_uid' => $_SESSION['uid']]) - ->order_by_asc('order_id') - ->order_by_asc('title') - ->find_many(); - - foreach ($categories as $category) { - $unread = Feeds::_get_counters($category->id, true, true) + - Feeds::_get_cat_children_unread($category->id); - - if ($unread || !$unread_only) { - $row = [ - 'id' => (int) $category->id, - 'title' => $category->title, - 'unread' => $unread, - 'is_cat' => true, - 'order_id' => (int) $category->order_id, - ]; - array_push($feeds, $row); - } - } - } - - /* Real feeds */ - - /* API only: -3 (Feeds::CATEGORY_ALL_EXCEPT_VIRTUAL) All feeds, excluding virtual feeds (e.g. Labels and such) */ - $feeds_obj = ORM::for_table('ttrss_feeds') - ->select_many('id', 'feed_url', 'cat_id', 'title', 'order_id') - ->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'); - - if ($limit) $feeds_obj->limit($limit); - if ($offset) $feeds_obj->offset($offset); - - if ($cat_id != Feeds::CATEGORY_ALL_EXCEPT_VIRTUAL && $cat_id != Feeds::CATEGORY_ALL) { - $feeds_obj->where_raw('(cat_id = ? OR (? = 0 AND cat_id IS NULL))', [$cat_id, $cat_id]); - } - - foreach ($feeds_obj->find_many() as $feed) { - $unread = Feeds::_get_counters($feed->id, false, true); - $has_icon = Feeds::_has_icon($feed->id); - - if ($unread || !$unread_only) { - $row = [ - 'feed_url' => $feed->feed_url, - 'title' => $feed->title, - 'id' => (int) $feed->id, - 'unread' => (int) $unread, - 'has_icon' => $has_icon, - 'cat_id' => (int) $feed->cat_id, - 'last_updated' => (int) strtotime($feed->last_updated ?? ''), - 'order_id' => (int) $feed->order_id, - ]; - - array_push($feeds, $row); - } - } - - return $feeds; - } - - /** - * @param string|int $feed_id - * @return array{0: array>, 1: array} $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') - ->select_many('id', 'cache_images') - ->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated') - ->find_one($feed_id); - - if ($feed) { - $last_updated = strtotime($feed->last_updated ?? ''); - $cache_images = self::_param_to_bool($feed->cache_images); - - if (!$cache_images && time() - $last_updated > 120) { - RSSUtils::update_rss_feed($feed_id, true); - } else { - $feed->last_updated = '1970-01-01'; - $feed->last_update_started = '1970-01-01'; - $feed->save(); - } - } - } - - $params = array( - "feed" => $feed_id, - "limit" => $limit, - "view_mode" => $view_mode, - "cat_view" => $is_cat, - "search" => $search, - "override_order" => $order, - "offset" => $offset, - "since_id" => $since_id, - "include_children" => $include_nested, - "check_first_id" => $check_first_id, - "skip_first_id_check" => $skip_first_id_check - ); - - $qfh_ret = []; - - if (!$is_cat && is_numeric($feed_id) && $feed_id < PLUGIN_FEED_BASE_INDEX && $feed_id > LABEL_BASE_INDEX) { - $pfeed_id = PluginHost::feed_to_pfeed_id($feed_id); - - /** @var IVirtualFeed|false $handler */ - $handler = PluginHost::getInstance()->get_feed_handler($pfeed_id); - - if ($handler) { - $params = array( - "feed" => $feed_id, - "limit" => $limit, - "view_mode" => $view_mode, - "cat_view" => $is_cat, - "search" => $search, - "override_order" => $order, - "offset" => $offset, - "since_id" => 0, - "include_children" => $include_nested, - "check_first_id" => $check_first_id, - "skip_first_id_check" => $skip_first_id_check - ); - - $qfh_ret = $handler->get_headlines($pfeed_id, $params); - } - - } else { - - $params = array( - "feed" => $feed_id, - "limit" => $limit, - "view_mode" => $view_mode, - "cat_view" => $is_cat, - "search" => $search, - "override_order" => $order, - "offset" => $offset, - "since_id" => $since_id, - "include_children" => $include_nested, - "check_first_id" => $check_first_id, - "skip_first_id_check" => $skip_first_id_check - ); - - $qfh_ret = Feeds::_get_headlines($params); - } - - $result = $qfh_ret[0]; - $feed_title = $qfh_ret[1]; - $first_id = $qfh_ret[6]; - - $headlines = array(); - - $headlines_header = array( - 'id' => $feed_id, - 'first_id' => $first_id, - 'is_cat' => $is_cat); - - if (!is_numeric($result)) { - while ($line = $result->fetch()) { - $line["content_preview"] = truncate_string(strip_tags($line["content"]), $excerpt_length); - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES, - function ($result) use (&$line) { - $line = $result; - }, - $line, $excerpt_length); - - $is_updated = ($line["last_read"] == "" && - ($line["unread"] != "t" && $line["unread"] != "1")); - - $tags = explode(",", $line["tag_cache"]); - - $label_cache = $line["label_cache"]; - $labels = false; - - if ($label_cache) { - $label_cache = json_decode($label_cache, true); - - if ($label_cache) { - if (($label_cache["no-labels"] ?? 0) == 1) - $labels = []; - else - $labels = $label_cache; - } - } - - if (!is_array($labels)) $labels = Article::_get_labels($line["id"]); - - $headline_row = array( - "id" => (int)$line["id"], - "guid" => $line["guid"], - "unread" => self::_param_to_bool($line["unread"]), - "marked" => self::_param_to_bool($line["marked"]), - "published" => self::_param_to_bool($line["published"]), - "updated" => (int)strtotime($line["updated"] ?? ''), - "is_updated" => $is_updated, - "title" => $line["title"], - "link" => $line["link"], - "feed_id" => $line["feed_id"] ? $line['feed_id'] : 0, - "tags" => $tags, - ); - - $enclosures = Article::_get_enclosures($line['id']); - - if ($include_attachments) - $headline_row['attachments'] = $enclosures; - - if ($show_excerpt) - $headline_row["excerpt"] = $line["content_preview"]; - - if ($show_content) { - - if ($sanitize_content) { - $headline_row["content"] = Sanitizer::sanitize( - $line["content"], - self::_param_to_bool($line['hide_images']), - null, $line["site_url"], null, $line["id"]); - } else { - $headline_row["content"] = $line["content"]; - } - } - - // unify label output to ease parsing - if (($labels["no-labels"] ?? 0) == 1) $labels = []; - - $headline_row["labels"] = $labels; - - $headline_row["feed_title"] = isset($line["feed_title"]) ? $line["feed_title"] : $feed_title; - - $headline_row["comments_count"] = (int)$line["num_comments"]; - $headline_row["comments_link"] = $line["comments"]; - - $headline_row["always_display_attachments"] = self::_param_to_bool($line["always_display_enclosures"]); - - $headline_row["author"] = $line["author"]; - - $headline_row["score"] = (int)$line["score"]; - $headline_row["note"] = $line["note"]; - $headline_row["lang"] = $line["lang"]; - - $headline_row["site_url"] = $line["site_url"]; - - if ($show_content) { - $hook_object = ["headline" => &$headline_row]; - - list ($flavor_image, $flavor_stream, $flavor_kind) = Article::_get_image($enclosures, - $line["content"], // unsanitized - $line["site_url"] ?? "", // could be null if archived article - $headline_row); - - $headline_row["flavor_image"] = $flavor_image; - $headline_row["flavor_stream"] = $flavor_stream; - - /* optional */ - if ($flavor_kind) - $headline_row["flavor_kind"] = $flavor_kind; - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API, - function ($result) use (&$headline_row) { - $headline_row = $result; - }, - $hook_object); - - $headline_row["content"] = DiskCache::rewrite_urls($headline_row['content']); - } - - array_push($headlines, $headline_row); - } - } else if (is_numeric($result) && $result == -1) { - $headlines_header['first_id_changed'] = true; - } - - return array($headlines, $headlines_header); - } - - function unsubscribeFeed(): bool { - $feed_id = (int) clean($_REQUEST["feed_id"]); - - $feed_exists = ORM::for_table('ttrss_feeds') - ->where(['id' => $feed_id, 'owner_uid' => $_SESSION['uid']]) - ->count(); - - if ($feed_exists) { - Pref_Feeds::remove_feed($feed_id, $_SESSION['uid']); - return $this->_wrap(self::STATUS_OK, ['status' => 'OK']); - } else { - return $this->_wrap(self::STATUS_ERR, ['error' => self::E_OPERATION_FAILED]); - } - } - - function subscribeToFeed(): bool { - $feed_url = clean($_REQUEST["feed_url"]); - $category_id = (int) clean($_REQUEST["category_id"]); - $login = clean($_REQUEST["login"] ?? ""); - $password = clean($_REQUEST["password"] ?? ""); - - if ($feed_url) { - $rc = Feeds::_subscribe($feed_url, $category_id, $login, $password); - - return $this->_wrap(self::STATUS_OK, array("status" => $rc)); - } else { - return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); - } - } - - function getFeedTree(): bool { - $include_empty = self::_param_to_bool($_REQUEST['include_empty'] ?? false); - - $pf = new Pref_Feeds($_REQUEST); - - $_REQUEST['mode'] = 2; - $_REQUEST['force_show_empty'] = $include_empty; - - return $this->_wrap(self::STATUS_OK, - array("categories" => $pf->_makefeedtree())); - } - - function getFeedIcon(): bool { - $id = (int)$_REQUEST['id']; - $cache = DiskCache::instance('feed-icons'); - - if ($cache->exists((string)$id)) { - return $cache->send((string)$id) > 0; - } else { - return $this->_wrap(self::STATUS_ERR, array("error" => self::E_NOT_FOUND)); - } - } - - // only works for labels or uncategorized for the time being - private function _is_cat_empty(int $id): bool { - if ($id == Feeds::CATEGORY_LABELS) { - $label_count = ORM::for_table('ttrss_labels2') - ->where('owner_uid', $_SESSION['uid']) - ->count(); - - return $label_count == 0; - } else if ($id == Feeds::CATEGORY_UNCATEGORIZED) { - $uncategorized_count = ORM::for_table('ttrss_feeds') - ->where_null('cat_id') - ->where('owner_uid', $_SESSION['uid']) - ->count(); - - return $uncategorized_count == 0; - } - - return false; - } - - /** @return array */ - private function _get_custom_sort_types(): array { - $ret = []; - - PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP, function ($result) use (&$ret) { - foreach ($result as $sort_value => $sort_title) { - $ret[$sort_value] = $sort_title; - } - }); - - return $ret; - } -} diff --git a/classes/article.php b/classes/article.php deleted file mode 100755 index 0b446479b..000000000 --- a/classes/article.php +++ /dev/null @@ -1,730 +0,0 @@ -table_alias('e') - ->join('ttrss_user_entries', [ 'ref_id', '=', 'e.id'], 'ue') - ->where('ue.owner_uid', $_SESSION['uid']) - ->find_one((int)$_REQUEST['id']); - - if ($article) { - $article_url = UrlHelper::validate($article->link); - - if ($article_url) { - header("Location: $article_url"); - return; - } - } - - header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); - print "Article not found or has an empty URL."; - } - - 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 - - if (!$content) { - $pluginhost = new PluginHost(); - $pluginhost->load_all(PluginHost::KIND_ALL, $owner_uid); - //$pluginhost->load_data(); - - $pluginhost->run_hooks_callback(PluginHost::HOOK_GET_FULL_TEXT, - function ($result) use (&$content) { - if ($result) { - $content = $result; - return true; - } - }, - $url); - } - - $content_hash = sha1($content); - - if ($labels_str != "") { - $labels = explode(",", $labels_str); - } else { - $labels = array(); - } - - $rc = false; - - if (!$title) $title = $url; - if (!$title && !$url) return false; - - if (filter_var($url, FILTER_VALIDATE_URL) === false) return false; - - $pdo = Db::pdo(); - - $pdo->beginTransaction(); - - // only check for our user data here, others might have shared this with different content etc - $sth = $pdo->prepare("SELECT id FROM ttrss_entries, ttrss_user_entries WHERE - guid = ? AND ref_id = id AND owner_uid = ? LIMIT 1"); - $sth->execute([$guid, $owner_uid]); - - if ($row = $sth->fetch()) { - $ref_id = $row['id']; - - $sth = $pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE - ref_id = ? AND owner_uid = ? LIMIT 1"); - $sth->execute([$ref_id, $owner_uid]); - - if ($row = $sth->fetch()) { - $int_id = $row['int_id']; - - $sth = $pdo->prepare("UPDATE ttrss_entries SET - content = ?, content_hash = ? WHERE id = ?"); - $sth->execute([$content, $content_hash, $ref_id]); - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $sth = $pdo->prepare("UPDATE ttrss_entries - SET tsvector_combined = to_tsvector( :ts_content) - WHERE id = :id"); - $params = [ - ":ts_content" => mb_substr(\Soundasleep\Html2Text::convert($content), 0, 900000), - ":id" => $ref_id]; - $sth->execute($params); - } - - $sth = $pdo->prepare("UPDATE ttrss_user_entries SET published = true, - last_published = NOW() WHERE - int_id = ? AND owner_uid = ?"); - $sth->execute([$int_id, $owner_uid]); - - } else { - - $sth = $pdo->prepare("INSERT INTO ttrss_user_entries - (ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache, - last_read, note, unread, last_published) - VALUES - (?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())"); - $sth->execute([$ref_id, $owner_uid]); - } - - if (count($labels) != 0) { - foreach ($labels as $label) { - Labels::add_article($ref_id, trim($label), $owner_uid); - } - } - - $rc = true; - - } else { - $sth = $pdo->prepare("INSERT INTO ttrss_entries - (title, guid, link, updated, content, content_hash, date_entered, date_updated) - VALUES - (?, ?, ?, NOW(), ?, ?, NOW(), NOW())"); - $sth->execute([$title, $guid, $url, $content, $content_hash]); - - $sth = $pdo->prepare("SELECT id FROM ttrss_entries WHERE guid = ?"); - $sth->execute([$guid]); - - if ($row = $sth->fetch()) { - $ref_id = $row["id"]; - if (Config::get(Config::DB_TYPE) == "pgsql"){ - $sth = $pdo->prepare("UPDATE ttrss_entries - SET tsvector_combined = to_tsvector( :ts_content) - WHERE id = :id"); - $params = [ - ":ts_content" => mb_substr(\Soundasleep\Html2Text::convert($content), 0, 900000), - ":id" => $ref_id]; - $sth->execute($params); - } - $sth = $pdo->prepare("INSERT INTO ttrss_user_entries - (ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache, - last_read, note, unread, last_published) - VALUES - (?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())"); - $sth->execute([$ref_id, $owner_uid]); - - if (count($labels) != 0) { - foreach ($labels as $label) { - Labels::add_article($ref_id, trim($label), $owner_uid); - } - } - - $rc = true; - } - } - - $pdo->commit(); - - return $rc; - } - - function printArticleTags(): void { - $id = (int) clean($_REQUEST['id'] ?? 0); - - print json_encode(["id" => $id, - "tags" => self::_get_tags($id)]); - } - - function setScore(): void { - $ids = array_map("intval", clean($_REQUEST['ids'] ?? [])); - $score = (int)clean($_REQUEST['score']); - - $ids_qmarks = arr_qmarks($ids); - - $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET - score = ? WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); - - $sth->execute([$score, ...$ids, $_SESSION['uid']]); - - print json_encode(["id" => $ids, "score" => $score]); - } - - 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"] ?? ""))); - - $this->pdo->beginTransaction(); - - $sth = $this->pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE - ref_id = ? AND owner_uid = ? LIMIT 1"); - $sth->execute([$id, $_SESSION['uid']]); - - if ($row = $sth->fetch()) { - - $tags_to_cache = array(); - - $int_id = $row['int_id']; - - $dsth = $this->pdo->prepare("DELETE FROM ttrss_tags WHERE - post_int_id = ? AND owner_uid = ?"); - $dsth->execute([$int_id, $_SESSION['uid']]); - - $csth = $this->pdo->prepare("SELECT post_int_id FROM ttrss_tags - WHERE post_int_id = ? AND owner_uid = ? AND tag_name = ?"); - - $usth = $this->pdo->prepare("INSERT INTO ttrss_tags - (post_int_id, owner_uid, tag_name) - VALUES (?, ?, ?)"); - - foreach ($tags as $tag) { - $csth->execute([$int_id, $_SESSION['uid'], $tag]); - - if (!$csth->fetch()) { - $usth->execute([$int_id, $_SESSION['uid'], $tag]); - } - - array_push($tags_to_cache, $tag); - } - - /* update tag cache */ - - $tags_str = join(",", $tags_to_cache); - - $sth = $this->pdo->prepare("UPDATE ttrss_user_entries - SET tag_cache = ? WHERE ref_id = ? AND owner_uid = ?"); - $sth->execute([$tags_str, $id, $_SESSION['uid']]); - } - - $this->pdo->commit(); - - // get latest tags from the database, original $tags is sometimes JSON-encoded as a hash ({}) - ??? - print json_encode(["id" => (int)$id, "tags" => $this->_get_tags($id)]); - } - - function completeTags(): void { - $search = clean($_REQUEST["search"]); - - $sth = $this->pdo->prepare("SELECT DISTINCT tag_name FROM ttrss_tags - WHERE owner_uid = ? AND - tag_name LIKE ? ORDER BY tag_name - LIMIT 10"); - - $sth->execute([$_SESSION['uid'], "$search%"]); - - $results = []; - - while ($line = $sth->fetch()) { - array_push($results, $line["tag_name"]); - } - - print json_encode($results); - } - - function assigntolabel(): void { - $this->_label_ops(true); - } - - function removefromlabel(): void { - $this->_label_ops(false); - } - - private function _label_ops(bool $assign): void { - $reply = array(); - - $ids = array_map("intval", array_filter(explode(",", clean($_REQUEST["ids"] ?? "")), "strlen")); - $label_id = clean($_REQUEST["lid"]); - - $label = Labels::find_caption($label_id, $_SESSION["uid"]); - - $reply["labels-for"] = []; - - if ($label) { - foreach ($ids as $id) { - if ($assign) - Labels::add_article($id, $label, $_SESSION["uid"]); - else - Labels::remove_article($id, $label, $_SESSION["uid"]); - - array_push($reply["labels-for"], - ["id" => (int)$id, "labels" => $this->_get_labels($id)]); - } - } - - $reply["message"] = "UPDATE_COUNTERS"; - - print json_encode($reply); - } - - /** - * @param int $id article id - * @return array{'formatted': string, 'entries': array>} - */ - static function _format_enclosures(int $id, bool $always_display_enclosures, string $article_content, bool $hide_images = false): array { - $span = Tracer::start(__METHOD__); - - $enclosures = self::_get_enclosures($id); - $enclosures_formatted = ""; - - /*foreach ($enclosures as &$enc) { - array_push($enclosures, [ - "type" => $enc["content_type"], - "filename" => basename($enc["content_url"]), - "url" => $enc["content_url"], - "title" => $enc["title"], - "width" => (int) $enc["width"], - "height" => (int) $enc["height"] - ]); - }*/ - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_FORMAT_ENCLOSURES, - function ($result) use (&$enclosures_formatted, &$enclosures) { - if (is_array($result)) { - $enclosures_formatted = $result[0]; - $enclosures = $result[1]; - } else { - $enclosures_formatted = $result; - } - }, - $enclosures_formatted, $enclosures, $id, $always_display_enclosures, $article_content, $hide_images); - - if (!empty($enclosures_formatted)) { - $span->end(); - return [ - 'formatted' => $enclosures_formatted, - 'entries' => [] - ]; - } - - $rv = [ - 'formatted' => '', - 'entries' => [] - ]; - - $rv['can_inline'] = isset($_SESSION["uid"]) && - empty($_SESSION["bw_limit"]) && - !get_pref(Prefs::STRIP_IMAGES) && - ($always_display_enclosures || !preg_match("/chain_hooks_callback(PluginHost::HOOK_RENDER_ENCLOSURE, - function ($result) use (&$rendered_enc) { - $rendered_enc = $result; - }, - $enc, $id, $rv); - - if ($rendered_enc) { - $rv['formatted'] .= $rendered_enc; - } else { - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ENCLOSURE_ENTRY, - function ($result) use (&$enc) { - $enc = $result; - }, - $enc, $id, $rv); - - array_push($rv['entries'], $enc); - } - } - - $span->end(); - return $rv; - } - - /** - * @return array - */ - static function _get_tags(int $id, int $owner_uid = 0, ?string $tag_cache = null): array { - $span = Tracer::start(__METHOD__); - - $a_id = $id; - - if (!$owner_uid) $owner_uid = $_SESSION["uid"]; - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT DISTINCT tag_name, - owner_uid as owner FROM ttrss_tags - WHERE post_int_id = (SELECT int_id FROM ttrss_user_entries WHERE - ref_id = ? AND owner_uid = ? LIMIT 1) ORDER BY tag_name"); - - $tags = array(); - - /* check cache first */ - - 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 ($tag_cache) { - $tags = explode(",", $tag_cache); - } else { - - /* do it the hard way */ - - $sth->execute([$a_id, $owner_uid]); - - while ($tmp_line = $sth->fetch()) { - array_push($tags, $tmp_line["tag_name"]); - } - - /* update the cache */ - - $tags_str = join(",", $tags); - - $sth = $pdo->prepare("UPDATE ttrss_user_entries - SET tag_cache = ? WHERE ref_id = ? - AND owner_uid = ?"); - $sth->execute([$tags_str, $id, $owner_uid]); - } - - $span->end(); - return $tags; - } - - function getmetadatabyid(): void { - $article = ORM::for_table('ttrss_entries') - ->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue') - ->where('ue.owner_uid', $_SESSION['uid']) - ->find_one((int)$_REQUEST['id']); - - if ($article) { - echo json_encode(["link" => $article->link, "title" => $article->title]); - } else { - echo json_encode([]); - } - } - - /** - * @return array> - */ - static function _get_enclosures(int $id): array { - $encs = ORM::for_table('ttrss_enclosures') - ->where('post_id', $id) - ->find_many(); - - $rv = []; - - $cache = DiskCache::instance("images"); - - foreach ($encs as $enc) { - $cache_key = sha1($enc->content_url); - - if ($cache->exists($cache_key)) { - $enc->content_url = $cache->get_url($cache_key); - } - - array_push($rv, $enc->as_array()); - } - - return $rv; - - } - - static function _purge_orphans(): void { - - // purge orphaned posts in main content table - - if (Config::get(Config::DB_TYPE) == "mysql") - $limit_qpart = "LIMIT 5000"; - else - $limit_qpart = ""; - - $pdo = Db::pdo(); - $res = $pdo->query("DELETE FROM ttrss_entries WHERE - NOT EXISTS (SELECT ref_id FROM ttrss_user_entries WHERE ref_id = id) $limit_qpart"); - - if (Debug::enabled()) { - $rows = $res->rowCount(); - Debug::log("Purged $rows orphaned posts."); - } - } - - /** - * @param array $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"]; - - $pdo = Db::pdo(); - - $ids_qmarks = arr_qmarks($ids); - - 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 == 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 = ?"); - } else { - $sth = $pdo->prepare("UPDATE ttrss_user_entries SET - unread = false,last_read = NOW() - WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); - } - - $sth->execute([...$ids, $owner_uid]); - } - - /** - * @return array> - */ - static function _get_labels(int $id, ?int $owner_uid = null): array { - $span = Tracer::start(__METHOD__); - - $rv = array(); - - if (!$owner_uid) $owner_uid = $_SESSION["uid"]; - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT label_cache FROM - ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?"); - $sth->execute([$id, $owner_uid]); - - if ($row = $sth->fetch()) { - $label_cache = $row["label_cache"]; - - if ($label_cache) { - $tmp = json_decode($label_cache, true); - - if (empty($tmp) || ($tmp["no-labels"] ?? 0) == 1) - return $rv; - else - return $tmp; - } - } - - $sth = $pdo->prepare("SELECT DISTINCT label_id,caption,fg_color,bg_color - FROM ttrss_labels2, ttrss_user_labels2 - WHERE id = label_id - AND article_id = ? - AND owner_uid = ? - ORDER BY caption"); - $sth->execute([$id, $owner_uid]); - - while ($line = $sth->fetch()) { - $rk = array(Labels::label_to_feed_id($line["label_id"]), - $line["caption"], $line["fg_color"], - $line["bg_color"]); - array_push($rv, $rk); - } - - 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)); - - $span->end(); - - return $rv; - } - - /** - * @param array> $enclosures - * @param array $headline - * - * @return array - */ - static function _get_image(array $enclosures, string $content, string $site_url, array $headline) { - $span = Tracer::start(__METHOD__); - - $article_image = ""; - $article_stream = ""; - $article_kind = 0; - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_IMAGE, - function ($result, $plugin) use (&$article_image, &$article_stream, &$content) { - list ($article_image, $article_stream, $content) = $result; - - // run until first hard match - return !empty($article_image); - }, - $enclosures, $content, $site_url, $headline); - - if (!$article_image && !$article_stream) { - $tmpdoc = new DOMDocument(); - - if (@$tmpdoc->loadHTML('' . mb_substr($content, 0, 131070))) { - $tmpxpath = new DOMXPath($tmpdoc); - $elems = $tmpxpath->query('(//img[@src]|//video[@poster]|//iframe[contains(@src , "youtube.com/embed/")])'); - - foreach ($elems as $e) { - if ($e->nodeName == "iframe") { - $matches = []; - if ($rrr = preg_match("/\/embed\/([\w-]+)/", $e->getAttribute("src"), $matches)) { - $article_image = "https://img.youtube.com/vi/" . $matches[1] . "/hqdefault.jpg"; - $article_stream = "https://youtu.be/" . $matches[1]; - $article_kind = Article::ARTICLE_KIND_YOUTUBE; - break; - } - } else if ($e->nodeName == "video") { - $article_image = $e->getAttribute("poster"); - - /** @var DOMElement|null $src */ - $src = $tmpxpath->query("//source[@src]", $e)->item(0); - - if ($src) { - $article_stream = $src->getAttribute("src"); - $article_kind = Article::ARTICLE_KIND_VIDEO; - } - - break; - } else if ($e->nodeName == 'img') { - if (mb_strpos($e->getAttribute("src"), "data:") !== 0) { - $article_image = $e->getAttribute("src"); - } - break; - } - } - } - - if (!$article_image) - foreach ($enclosures as $enc) { - if (strpos($enc["content_type"], "image/") !== false) { - $article_image = $enc["content_url"]; - break; - } - } - - if ($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 = UrlHelper::rewrite_relative($site_url, $article_stream); - } - - $cache = DiskCache::instance("images"); - - if ($article_image && $cache->exists(sha1($article_image))) - $article_image = $cache->get_url(sha1($article_image)); - - if ($article_stream && $cache->exists(sha1($article_stream))) - $article_stream = $cache->get_url(sha1($article_stream)); - - $span->end(); - - return [$article_image, $article_stream, $article_kind]; - } - - /** - * only cached, returns label ids (not label feed ids) - * - * @param array $article_ids - * @return array - */ - static function _labels_of(array $article_ids) { - if (count($article_ids) == 0) - return []; - - $span = Tracer::start(__METHOD__); - - $entries = ORM::for_table('ttrss_entries') - ->table_alias('e') - ->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue') - ->where_in('id', $article_ids) - ->find_many(); - - $rv = []; - - foreach ($entries as $entry) { - $labels = json_decode($entry->label_cache); - - if (isset($labels) && is_array($labels)) { - foreach ($labels as $label) { - if (empty($label["no-labels"])) - array_push($rv, Labels::feed_to_label_id($label[0])); - } - } - } - - $span->end(); - - return array_unique($rv); - } - - /** - * @param array $article_ids - * @return array - */ - static function _feeds_of(array $article_ids) { - if (count($article_ids) == 0) - return []; - - $span = Tracer::start(__METHOD__); - - $entries = ORM::for_table('ttrss_entries') - ->table_alias('e') - ->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue') - ->where_in('id', $article_ids) - ->find_many(); - - $rv = []; - - foreach ($entries as $entry) { - array_push($rv, $entry->feed_id); - } - - $span->end(); - - return array_unique($rv); - } -} diff --git a/classes/auth/base.php b/classes/auth/base.php deleted file mode 100644 index d8128400d..000000000 --- a/classes/auth/base.php +++ /dev/null @@ -1,59 +0,0 @@ -pdo = Db::pdo(); - } - - 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 - * @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); - - if (!$user_id) { - - if (!$password) $password = make_password(); - - $user = ORM::for_table('ttrss_users')->create(); - - $user->salt = UserHelper::get_salt(); - $user->login = mb_strtolower($login); - $user->pwd_hash = UserHelper::hash_password($password, $user->salt); - $user->access_level = 0; - $user->created = Db::NOW(); - $user->save(); - - return UserHelper::find_user_by_login($login); - - } else { - return $user_id; - } - } - - return UserHelper::find_user_by_login($login); - } - - - /** 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/cache/adapter.php b/classes/cache/adapter.php deleted file mode 100644 index fecfc7667..000000000 --- a/classes/cache/adapter.php +++ /dev/null @@ -1,36 +0,0 @@ -get_full_path($filename)); - } - - public function get_mtime(string $filename) { - return filemtime($this->get_full_path($filename)); - } - - public function set_dir(string $dir) : void { - $this->dir = Config::get(Config::CACHE_DIR) . "/" . basename(clean($dir)); - - $this->make_dir(); - } - - public function get_dir(): string { - return $this->dir; - } - - public function make_dir(): bool { - if (!is_dir($this->dir)) { - return mkdir($this->dir); - } - return false; - } - - 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)); - else - return is_writable($this->dir); - } else { - return is_writable($this->dir); - } - } - - public function exists(string $filename): bool { - return file_exists($this->get_full_path($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(string $filename): string { - return $this->dir . "/" . basename(clean($filename)); - } - - public function get(string $filename): ?string { - if ($this->exists($filename)) - return file_get_contents($this->get_full_path($filename)); - else - return null; - } - - /** - * @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); - } - - /** - * @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; - } - - /** - * @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) { - return $this->send_local_file($this->get_full_path($filename)); - } - - public function expire_all(): void { - $dirs = array_filter(glob(Config::get(Config::CACHE_DIR) . "/*"), "is_dir"); - - foreach ($dirs as $cache_dir) { - $num_deleted = 0; - - if (is_writable($cache_dir) && !file_exists("$cache_dir/.no-auto-expiry")) { - $files = glob("$cache_dir/*"); - - if ($files) { - foreach ($files as $file) { - if (time() - filemtime($file) > 86400 * Config::get(Config::CACHE_MAX_DAYS)) { - unlink($file); - - ++$num_deleted; - } - } - } - - Debug::log("Expired $cache_dir: removed $num_deleted files."); - } - } - } - - /** - * 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 - */ - private function send_local_file(string $filename) { - if (file_exists($filename)) { - - if (is_writable($filename) && !$this->exists('.no-auto-expiry')) { - touch($filename); - } - - $tmppluginhost = new PluginHost(); - - $tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_SYSTEM); - //$tmppluginhost->load_data(); - - if ($tmppluginhost->run_hooks_until(PluginHost::HOOK_SEND_LOCAL_FILE, true, $filename)) - return true; - - return readfile($filename); - } else { - return false; - } - } - -} diff --git a/classes/config.php b/classes/config.php deleted file mode 100644 index 72d6c5106..000000000 --- a/classes/config.php +++ /dev/null @@ -1,704 +0,0 @@ - [ "pgsql", Config::T_STRING ], - Config::DB_HOST => [ "db", Config::T_STRING ], - Config::DB_USER => [ "", Config::T_STRING ], - Config::DB_NAME => [ "", Config::T_STRING ], - Config::DB_PASS => [ "", Config::T_STRING ], - Config::DB_PORT => [ "5432", Config::T_STRING ], - Config::MYSQL_CHARSET => [ "UTF8", Config::T_STRING ], - Config::SELF_URL_PATH => [ "https://example.com/tt-rss", Config::T_STRING ], - Config::SINGLE_USER_MODE => [ "", Config::T_BOOL ], - Config::SIMPLE_UPDATE_MODE => [ "", Config::T_BOOL ], - Config::PHP_EXECUTABLE => [ "/usr/bin/php", Config::T_STRING ], - Config::LOCK_DIRECTORY => [ "lock", Config::T_STRING ], - Config::CACHE_DIR => [ "cache", Config::T_STRING ], - Config::ICONS_DIR => [ "feed-icons", Config::T_STRING ], - Config::ICONS_URL => [ "feed-icons", Config::T_STRING ], - Config::AUTH_AUTO_CREATE => [ "true", Config::T_BOOL ], - Config::AUTH_AUTO_LOGIN => [ "true", Config::T_BOOL ], - Config::FORCE_ARTICLE_PURGE => [ 0, Config::T_INT ], - Config::SESSION_COOKIE_LIFETIME => [ 86400, Config::T_INT ], - Config::SMTP_FROM_NAME => [ "Tiny Tiny RSS", Config::T_STRING ], - Config::SMTP_FROM_ADDRESS => [ "noreply@localhost", Config::T_STRING ], - Config::DIGEST_SUBJECT => [ "[tt-rss] New headlines for last 24 hours", - Config::T_STRING ], - Config::CHECK_FOR_UPDATES => [ "true", Config::T_BOOL ], - Config::PLUGINS => [ "auth_internal", Config::T_STRING ], - Config::LOG_DESTINATION => [ Logger::LOG_DEST_SQL, Config::T_STRING ], - Config::LOCAL_OVERRIDE_STYLESHEET => [ "local-overrides.css", - Config::T_STRING ], - Config::LOCAL_OVERRIDE_JS => [ "local-overrides.js", - Config::T_STRING ], - Config::DAEMON_MAX_CHILD_RUNTIME => [ 1800, Config::T_INT ], - Config::DAEMON_MAX_JOBS => [ 2, Config::T_INT ], - Config::FEED_FETCH_TIMEOUT => [ 45, Config::T_INT ], - Config::FEED_FETCH_NO_CACHE_TIMEOUT => [ 15, Config::T_INT ], - Config::FILE_FETCH_TIMEOUT => [ 45, Config::T_INT ], - Config::FILE_FETCH_CONNECT_TIMEOUT => [ 15, Config::T_INT ], - Config::DAEMON_UPDATE_LOGIN_LIMIT => [ 30, Config::T_INT ], - Config::DAEMON_FEED_LIMIT => [ 500, Config::T_INT ], - Config::DAEMON_SLEEP_INTERVAL => [ 120, Config::T_INT ], - Config::MAX_CACHE_FILE_SIZE => [ 64*1024*1024, Config::T_INT ], - Config::MAX_DOWNLOAD_FILE_SIZE => [ 16*1024*1024, Config::T_INT ], - Config::MAX_FAVICON_FILE_SIZE => [ 1*1024*1024, Config::T_INT ], - Config::CACHE_MAX_DAYS => [ 7, Config::T_INT ], - Config::MAX_CONDITIONAL_INTERVAL => [ 3600*12, Config::T_INT ], - Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT => [ 30, Config::T_INT ], - Config::LOG_SENT_MAIL => [ "", Config::T_BOOL ], - Config::HTTP_PROXY => [ "", Config::T_STRING ], - Config::FORBID_PASSWORD_CHANGES => [ "", Config::T_BOOL ], - Config::SESSION_NAME => [ "ttrss_sid", Config::T_STRING ], - 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 ], - Config::HTTP_429_THROTTLE_INTERVAL => [ 3600, Config::T_INT ], - Config::OPENTELEMETRY_ENDPOINT => [ "", Config::T_STRING ], - Config::OPENTELEMETRY_SERVICE => [ "tt-rss", Config::T_STRING ], - ]; - - /** @var Config|null */ - private static $instance; - - /** @var array> */ - private $params = []; - - /** @var array */ - private $version = []; - - /** @var Db_Migrations|null $migrations */ - private $migrations; - - public static function get_instance() : Config { - if (self::$instance == null) - self::$instance = new self(); - - return self::$instance; - } - - private function __clone() { - // - } - - function __construct() { - $ref = new ReflectionClass(get_class($this)); - - foreach ($ref->getConstants() as $const => $cvalue) { - if (isset(self::_DEFAULTS[$const])) { - $override = getenv(self::_ENVVAR_PREFIX . $const); - - list ($defval, $deftype) = self::_DEFAULTS[$const]; - - $this->params[$cvalue] = [ self::cast_to($override !== false ? $override : $defval, $deftype), $deftype ]; - } - } - } - - /** 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 - */ - static function get_version(bool $as_string = true) { - return self::get_instance()->_get_version($as_string); - } - - // returns version showing (if possible) full timestamp of commit id - static function get_version_html() : string { - $version = self::get_version(false); - - return sprintf("%s", - date("Y-m-d H:i:s", ($version['timestamp'] ?? 0)), - $version['commit'] ?? '', - $version['branch'] ?? '', - $version['version']); - } - - /** - * @return array|string - */ - private function _get_version(bool $as_string = true) { - $root_dir = dirname(__DIR__); - - if (empty($this->version)) { - $this->version["status"] = -1; - - if (getenv("CI_COMMIT_SHORT_SHA") && getenv("CI_COMMIT_TIMESTAMP")) { - - $this->version["branch"] = getenv("CI_COMMIT_BRANCH"); - $this->version["timestamp"] = strtotime(getenv("CI_COMMIT_TIMESTAMP")); - $this->version["version"] = sprintf("%s-%s", date("y.m", $this->version["timestamp"]), getenv("CI_COMMIT_SHORT_SHA")); - $this->version["commit"] = getenv("CI_COMMIT_SHORT_SHA"); - $this->version["status"] = 0; - - } else if (PHP_OS === "Darwin") { - $this->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); - - if ($this->version["status"] != 0) { - 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)"; - } - } - - return $as_string ? $this->version["version"] : $this->version; - } - - /** - * @return array - */ - static function get_version_from_git(string $dir): array { - $descriptorspec = [ - 1 => ["pipe", "w"], // STDOUT - 2 => ["pipe", "w"], // STDERR - ]; - - $rv = [ - "status" => -1, - "version" => "", - "branch" => "", - "commit" => "", - "timestamp" => 0, - ]; - - $proc = proc_open("git --no-pager log --pretty=\"version-%ct-%h\" -n1 HEAD", - $descriptorspec, $pipes, $dir); - - if (is_resource($proc)) { - $stdout = trim(stream_get_contents($pipes[1])); - $stderr = trim(stream_get_contents($pipes[2])); - $status = proc_close($proc); - - $rv["status"] = $status; - - list($check, $timestamp, $commit) = explode("-", $stdout); - - if ($check == "version") { - - $rv["version"] = sprintf("%s-%s", date("y.m", (int)$timestamp), $commit); - $rv["commit"] = $commit; - $rv["timestamp"] = $timestamp; - - // proc_close() may return -1 even if command completed successfully - // so if it looks like we got valid data, we ignore it - - if ($rv["status"] == -1) - $rv["status"] = 0; - - } else { - $rv["version"] = T_sprintf("Git error [RC=%d]: %s", $status, $stderr); - } - } - - return $rv; - } - - static function get_migrations() : Db_Migrations { - return self::get_instance()->_get_migrations(); - } - - private function _get_migrations() : Db_Migrations { - if (empty($this->migrations)) { - $this->migrations = new Db_Migrations(); - $this->migrations->initialize(dirname(__DIR__) . "/sql", "ttrss_version", true, self::SCHEMA_VERSION); - } - - return $this->migrations; - } - - static function is_migration_needed() : bool { - return self::get_migrations()->is_migration_needed(); - } - - static function get_schema_version() : int { - 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: - return sql_bool_to_bool($value); - case self::T_INT: - return (int) $value; - default: - return $value; - } - } - - /** - * @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): 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): void { - $instance = self::get_instance(); - - $instance->_add($param, $default, $type_hint); - } - - /** - * @return bool|int|string - */ - static function get(string $param) { - $instance = self::get_instance(); - - return $instance->_get($param); - } - - static function is_server_https() : bool { - return (!empty($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] != 'off')) || - (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https'); - } - - /** returns fully-qualified external URL to tt-rss (no trailing slash) - * SELF_URL_PATH configuration variable is used as a fallback for the CLI SAPI - * */ - static function get_self_url(bool $always_detect = false) : string { - if (!$always_detect && php_sapi_name() == "cli") { - return self::get(Config::SELF_URL_PATH); - } else { - $proto = self::is_server_https() ? 'https' : 'http'; - - $self_url_path = $proto . '://' . $_SERVER["HTTP_HOST"] . parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH); - $self_url_path = preg_replace("/(\/api\/{1,})?(\w+\.php)?(\?.*$)?$/", "", $self_url_path); - - if (substr($self_url_path, -1) === "/") { - return substr($self_url_path, 0, -1); - } else { - return $self_url_path; - } - } - } - /* sanity check stuff */ - - /** checks for mysql tables not using InnoDB (tt-rss is incompatible with MyISAM) - * @return array> A list of entries identifying tt-rss tables with bad config - */ - private static function check_mysql_tables() { - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT engine, table_name FROM information_schema.tables WHERE - table_schema = ? AND table_name LIKE 'ttrss_%' AND engine != 'InnoDB'"); - $sth->execute([self::get(Config::DB_NAME)]); - - $bad_tables = []; - - while ($line = $sth->fetch()) { - array_push($bad_tables, $line); - } - - return $bad_tables; - } - - 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 - because it is set up in the Db constructor, which is why it's a good idea to invoke it as early as possible - - it is a bit of a hack, maybe ORM should be initialized somewhere else (functions.php?) - */ - - $pdo = Db::pdo(); - - $errors = []; - - if (strpos(self::get(Config::PLUGINS), "auth_") === false) { - array_push($errors, "Please enable at least one authentication module via PLUGINS"); - } - - /* we assume our dependencies are sane under docker, so some sanity checks are skipped. - this also allows tt-rss process to run under root if requested (I'm using this for development - under podman because of uidmapping issues with rootless containers, don't use in production -fox) */ - if (!getenv("container")) { - if (function_exists('posix_getuid') && posix_getuid() == 0) { - array_push($errors, "Please don't run this script as root."); - } - - if (version_compare(PHP_VERSION, '7.4.0', '<')) { - array_push($errors, "PHP version 7.4.0 or newer required. You're using " . PHP_VERSION . "."); - } - - if (!class_exists("UConverter")) { - array_push($errors, "PHP UConverter class is missing, it's provided by the Internationalization (intl) module."); - } - - if (!function_exists("curl_init") && !ini_get("allow_url_fopen")) { - array_push($errors, "PHP configuration option allow_url_fopen is disabled, and CURL functions are not present. Either enable allow_url_fopen or install PHP extension for CURL."); - } - - if (!function_exists("json_encode")) { - array_push($errors, "PHP support for JSON is required, but was not found."); - } - - if (!function_exists("flock")) { - array_push($errors, "PHP support for flock() function is required."); - } - - if (!class_exists("PDO")) { - array_push($errors, "PHP support for PDO is required but was not found."); - } - - if (!function_exists("mb_strlen")) { - array_push($errors, "PHP support for mbstring functions is required but was not found."); - } - - if (!function_exists("hash")) { - array_push($errors, "PHP support for hash() function is required but was not found."); - } - - if (ini_get("safe_mode")) { - array_push($errors, "PHP safe mode setting is obsolete and not supported by tt-rss."); - } - - if (!function_exists("mime_content_type")) { - array_push($errors, "PHP function mime_content_type() is missing, try enabling fileinfo module."); - } - - if (!class_exists("DOMDocument")) { - array_push($errors, "PHP support for DOMDocument is required, but was not found."); - } - } - - if (!is_writable(self::get(Config::CACHE_DIR) . "/images")) { - array_push($errors, "Image cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/images)"); - } - - if (!is_writable(self::get(Config::CACHE_DIR) . "/upload")) { - array_push($errors, "Upload cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/upload)"); - } - - if (!is_writable(self::get(Config::CACHE_DIR) . "/export")) { - array_push($errors, "Data export cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/export)"); - } - - if (!is_writable(self::get(Config::ICONS_DIR))) { - array_push($errors, "ICONS_DIR defined in config.php is not writable (chmod -R 777 ".self::get(Config::ICONS_DIR).").\n"); - } - - if (!is_writable(self::get(Config::LOCK_DIRECTORY))) { - array_push($errors, "LOCK_DIRECTORY is not writable (chmod -R 777 ".self::get(Config::LOCK_DIRECTORY).").\n"); - } - - // ttrss_users won't be there on initial startup (before migrations are done) - if (!Config::is_migration_needed() && self::get(Config::SINGLE_USER_MODE)) { - if (UserHelper::get_login_by_id(1) != "admin") { - array_push($errors, "SINGLE_USER_MODE is enabled but default admin account (ID: 1) is not found."); - } - } - - // skip check for CLI scripts so that we could install database schema if it is missing. - if (php_sapi_name() != "cli") { - if (self::get_schema_version() < 0) { - array_push($errors, "Base database schema is missing. Either load it manually or perform a migration (update.php --update-schema)"); - } - } - - if (self::get(Config::DB_TYPE) == "mysql") { - $bad_tables = self::check_mysql_tables(); - - if (count($bad_tables) > 0) { - $bad_tables_fmt = []; - - foreach ($bad_tables as $bt) { - array_push($bad_tables_fmt, sprintf("%s (%s)", $bt['table_name'], $bt['engine'])); - } - - $msg = "

The following tables use an unsupported MySQL engine: " . - implode(", ", $bad_tables_fmt) . ".

"; - - $msg .= "

The only supported engine on MySQL is InnoDB. MyISAM lacks functionality to run - tt-rss. - Please backup your data (via OPML) and re-import the schema before continuing.

-

WARNING: importing the schema would mean LOSS OF ALL YOUR DATA.

"; - - - array_push($errors, $msg); - } - } - - if (count($errors) > 0 && php_sapi_name() != "cli") { ?> - - - - Startup failed - - - - -
-

Startup failed

- -

Please fix errors indicated by the following messages:

- - - -

You might want to check tt-rss wiki or the - forums for more information. Please search the forums before creating new topic - for your question.

-
- - - - 0) { - echo "Please fix errors indicated by the following messages:\n\n"; - - foreach ($errors as $error) { - echo " * " . strip_tags($error)."\n"; - } - - echo "\nYou might want to check tt-rss wiki or the forums for more information.\n"; - echo "Please search the forums before creating new topic for your question.\n"; - - exit(1); - } - } - - private static function format_error(string $msg): string { - return "
$msg
"; - } - - static function get_override_links(): string { - $rv = ""; - - $local_css = get_theme_path(self::get(self::LOCAL_OVERRIDE_STYLESHEET)); - if ($local_css) $rv .= stylesheet_tag($local_css); - - $local_js = get_theme_path(self::get(self::LOCAL_OVERRIDE_JS)); - if ($local_js) $rv .= javascript_tag($local_js); - - 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 deleted file mode 100644 index 948e6ee1d..000000000 --- a/classes/counters.php +++ /dev/null @@ -1,362 +0,0 @@ -> - */ - static function get_all(): array { - return [ - ...self::get_global(), - ...self::get_virt(), - ...self::get_labels(), - ...self::get_feeds(), - ...self::get_cats(), - ]; - } - - /** - * @param array $feed_ids - * @param array $label_ids - * @return array> - */ - static function get_conditional(array $feed_ids = null, array $label_ids = null): array { - return [ - ...self::get_global(), - ...self::get_virt(), - ...self::get_labels($label_ids), - ...self::get_feeds($feed_ids), - ...self::get_cats(is_array($feed_ids) ? Feeds::_cats_of($feed_ids, $_SESSION["uid"], true) : null) - ]; - } - - /** - * @return array - */ - static private function get_cat_children(int $cat_id, int $owner_uid): array { - $unread = 0; - $marked = 0; - - $cats = ORM::for_table('ttrss_feed_categories') - ->where('owner_uid', $owner_uid) - ->where('parent_cat', $cat_id) - ->find_many(); - - foreach ($cats as $cat) { - list ($tmp_unread, $tmp_marked) = self::get_cat_children($cat->id, $owner_uid); - - $unread += $tmp_unread + Feeds::_get_cat_unread($cat->id, $owner_uid); - $marked += $tmp_marked + Feeds::_get_cat_marked($cat->id, $owner_uid); - } - - return [$unread, $marked]; - } - - /** - * @param array $cat_ids - * @return array> - */ - private static function get_cats(array $cat_ids = null): array { - $ret = []; - - /* Labels category */ - - $cv = array("id" => Feeds::CATEGORY_LABELS, "kind" => "cat", - "counter" => Feeds::_get_cat_unread(Feeds::CATEGORY_LABELS)); - - array_push($ret, $cv); - - $pdo = Db::pdo(); - - if (is_array($cat_ids)) { - if (count($cat_ids) == 0) - return []; - - $cat_ids_qmarks = arr_qmarks($cat_ids); - - $sth = $pdo->prepare("SELECT fc.id, - SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count, - SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked, - (SELECT COUNT(id) FROM ttrss_feed_categories fcc - WHERE fcc.parent_cat = fc.id) AS num_children - FROM ttrss_feed_categories fc - LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id) - LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id) - WHERE fc.owner_uid = ? AND fc.id IN ($cat_ids_qmarks) - GROUP BY fc.id - UNION - SELECT 0, - SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count, - SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked, - 0 - FROM ttrss_feeds f, ttrss_user_entries ue - WHERE f.cat_id IS NULL AND - ue.feed_id = f.id AND - ue.owner_uid = ?"); - - $sth->execute([$_SESSION['uid'], ...$cat_ids, $_SESSION['uid']]); - - } else { - $sth = $pdo->prepare("SELECT fc.id, - SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count, - SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked, - (SELECT COUNT(id) FROM ttrss_feed_categories fcc - WHERE fcc.parent_cat = fc.id) AS num_children - FROM ttrss_feed_categories fc - LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id) - LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id) - WHERE fc.owner_uid = :uid - GROUP BY fc.id - UNION - SELECT 0, - SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count, - SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked, - 0 - FROM ttrss_feeds f, ttrss_user_entries ue - WHERE f.cat_id IS NULL AND - ue.feed_id = f.id AND - ue.owner_uid = :uid"); - - $sth->execute(["uid" => $_SESSION['uid']]); - } - - while ($line = $sth->fetch()) { - if ($line["num_children"] > 0) { - list ($child_counter, $child_marked_counter) = self::get_cat_children($line["id"], $_SESSION["uid"]); - } else { - $child_counter = 0; - $child_marked_counter = 0; - } - - $cv = [ - "id" => (int)$line["id"], - "kind" => "cat", - "markedcounter" => (int) $line["count_marked"] + $child_marked_counter, - "counter" => (int) $line["count"] + $child_counter - ]; - - array_push($ret, $cv); - } - - return $ret; - } - - /** - * @param array $feed_ids - * @return array> - */ - private static function get_feeds(array $feed_ids = null): array { - $span = Tracer::start(__METHOD__); - - $ret = []; - - $pdo = Db::pdo(); - - if (is_array($feed_ids)) { - if (count($feed_ids) == 0) - return []; - - $feed_ids_qmarks = arr_qmarks($feed_ids); - - $sth = $pdo->prepare("SELECT f.id, - f.title, - ".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated, - f.last_error, - SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count, - SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked - FROM ttrss_feeds f, ttrss_user_entries ue - WHERE f.id = ue.feed_id AND ue.owner_uid = ? AND f.id IN ($feed_ids_qmarks) - GROUP BY f.id"); - - $sth->execute([$_SESSION['uid'], ...$feed_ids]); - } else { - $sth = $pdo->prepare("SELECT f.id, - f.title, - ".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated, - f.last_error, - SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count, - SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked - FROM ttrss_feeds f, ttrss_user_entries ue - WHERE f.id = ue.feed_id AND ue.owner_uid = :uid - GROUP BY f.id"); - - $sth->execute(["uid" => $_SESSION['uid']]); - } - - while ($line = $sth->fetch()) { - - $id = $line["id"]; - $last_updated = TimeHelper::make_local_datetime($line['last_updated'], false); - - if (Feeds::_has_icon($id)) { - $ts = filemtime(Feeds::_get_icon_file($id)); - } else { - $ts = 0; - } - - // hide default un-updated timestamp i.e. 1970-01-01 (?) -fox - if ((int)date('Y') - (int)date('Y', strtotime($line['last_updated'] ?? '')) > 2) - $last_updated = ''; - - $cv = [ - "id" => $id, - "updated" => $last_updated, - "counter" => (int) $line["count"], - "markedcounter" => (int) $line["count_marked"], - "ts" => (int) $ts - ]; - - $cv["error"] = $line["last_error"]; - $cv["title"] = truncate_string($line["title"], 30); - - array_push($ret, $cv); - - } - - $span->end(); - - return $ret; - } - - /** - * @return array> - */ - private static function get_global(): array { - $span = Tracer::start(__METHOD__); - - $ret = [ - [ - "id" => "global-unread", - "counter" => (int) Feeds::_get_global_unread() - ] - ]; - - $subcribed_feeds = ORM::for_table('ttrss_feeds') - ->where('owner_uid', $_SESSION['uid']) - ->count(); - - array_push($ret, [ - "id" => "subscribed-feeds", - "counter" => $subcribed_feeds - ]); - - $span->end(); - - return $ret; - } - - /** - * @return array> - */ - private static function get_virt(): array { - $span = Tracer::start(__METHOD__); - - $ret = []; - - foreach ([Feeds::FEED_ARCHIVED, Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED, - Feeds::FEED_FRESH, Feeds::FEED_ALL] as $feed_id) { - - $count = Feeds::_get_counters($feed_id, false, true); - - if (in_array($feed_id, [Feeds::FEED_ARCHIVED, Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED])) - $auxctr = Feeds::_get_counters($feed_id, false); - else - $auxctr = 0; - - $cv = [ - "id" => $feed_id, - "counter" => (int) $count, - "auxcounter" => (int) $auxctr - ]; - - if ($feed_id == Feeds::FEED_STARRED) - $cv["markedcounter"] = $auxctr; - - array_push($ret, $cv); - } - - $feeds = PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL); - - 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']) - ]; - - if (method_exists($feed['sender'], 'get_total')) - $cv["auxcounter"] = $feed['sender']->get_total($feed['id']); - - array_push($ret, $cv); - } - } - - $span->end(); - return $ret; - } - - /** - * @param array $label_ids - * @return array> - */ - static function get_labels(array $label_ids = null): array { - $span = Tracer::start(__METHOD__); - - $ret = []; - - $pdo = Db::pdo(); - - if (is_array($label_ids)) { - if (count($label_ids) == 0) - return []; - - $label_ids_qmarks = arr_qmarks($label_ids); - - $sth = $pdo->prepare("SELECT id, - caption, - SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread, - SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked, - COUNT(u1.unread) AS total - FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON - (ttrss_labels2.id = label_id) - LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = ? - WHERE ttrss_labels2.owner_uid = ? AND ttrss_labels2.id IN ($label_ids_qmarks) - GROUP BY ttrss_labels2.id, ttrss_labels2.caption"); - $sth->execute([$_SESSION["uid"], $_SESSION["uid"], ...$label_ids]); - } else { - $sth = $pdo->prepare("SELECT id, - caption, - SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread, - SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked, - COUNT(u1.unread) AS total - FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON - (ttrss_labels2.id = label_id) - LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = :uid - WHERE ttrss_labels2.owner_uid = :uid - GROUP BY ttrss_labels2.id, ttrss_labels2.caption"); - $sth->execute([":uid" => $_SESSION['uid']]); - } - - while ($line = $sth->fetch()) { - - $id = Labels::label_to_feed_id($line["id"]); - - $cv = [ - "id" => $id, - "counter" => (int) $line["count_unread"], - "auxcounter" => (int) $line["total"], - "markedcounter" => (int) $line["count_marked"], - "description" => $line["caption"] - ]; - - array_push($ret, $cv); - } - - $span->end(); - return $ret; - } -} diff --git a/classes/db.php b/classes/db.php deleted file mode 100755 index 4331b662e..000000000 --- a/classes/db.php +++ /dev/null @@ -1,102 +0,0 @@ - 'SET NAMES ' . Config::get(Config::MYSQL_CHARSET))); - } - } - - /** - * @param int $delta adjust generated timestamp by this value in seconds (either positive or negative) - * @return string - */ - static function NOW(int $delta = 0): string { - return date("Y-m-d H:i:s", time() + $delta); - } - - private function __clone() { - // - } - - 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)) { - $db_charset = ';charset=' . Config::get(Config::MYSQL_CHARSET); - } else { - $db_charset = ''; - } - - return Config::get(Config::DB_TYPE) . ':dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port . $db_charset; - } - - // this really shouldn't be used unless a separate PDO connection is needed - // normal usage is Db::pdo()->prepare(...) etc - public function pdo_connect() : PDO { - - try { - $pdo = new PDO(self::get_dsn(), - Config::get(Config::DB_USER), - Config::get(Config::DB_PASS)); - } catch (Exception $e) { - print "
Exception while creating PDO object:" . $e->getMessage() . "
"; - exit(101); - } - - $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - if (Config::get(Config::DB_TYPE) == "pgsql") { - - $pdo->query("set client_encoding = 'UTF-8'"); - $pdo->query("set datestyle = 'ISO, european'"); - $pdo->query("set TIME ZONE 0"); - $pdo->query("set cpu_tuple_cost = 0.5"); - - } else if (Config::get(Config::DB_TYPE) == "mysql") { - $pdo->query("SET time_zone = '+0:0'"); - - if (Config::get(Config::MYSQL_CHARSET)) { - $pdo->query("SET NAMES " . Config::get(Config::MYSQL_CHARSET)); - } - } - - return $pdo; - } - - public static function instance() : Db { - if (self::$instance == null) - self::$instance = new self(); - - return self::$instance; - } - - public static function pdo() : PDO { - if (self::$instance == null) - self::$instance = new self(); - - if (empty(self::$instance->pdo)) { - self::$instance->pdo = self::$instance->pdo_connect(); - } - - return self::$instance->pdo; - } - - public static function sql_random_function(): string { - if (Config::get(Config::DB_TYPE) == "mysql") { - return "RAND()"; - } - return "RANDOM()"; - } - -} diff --git a/classes/db/migrations.php b/classes/db/migrations.php deleted file mode 100644 index d63736987..000000000 --- a/classes/db/migrations.php +++ /dev/null @@ -1,203 +0,0 @@ -pdo = Db::pdo(); - } - - 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): void { - $this->base_path = "$root_path/" . Config::get(Config::DB_TYPE); - $this->migrations_path = $this->base_path . "/migrations"; - $this->migrations_table = $migrations_table; - $this->base_is_latest = $base_is_latest; - $this->max_version_override = $max_version_override; - } - - 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}"); - - if ($res = $sth->fetch()) { - $sth = $this->pdo->prepare("UPDATE {$this->migrations_table} SET schema_version = ?"); - } else { - $sth = $this->pdo->prepare("INSERT INTO {$this->migrations_table} (schema_version) VALUES (?)"); - } - - $sth->execute([$version]); - - $this->cached_version = $version; - } - - function get_version() : int { - if ($this->cached_version) - return $this->cached_version; - - try { - $sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}"); - - if ($res = $sth->fetch()) { - return (int) $res['schema_version']; - } else { - return -1; - } - } catch (PDOException $e) { - $this->create_migrations_table(); - - return -1; - } - } - - private function create_migrations_table(): void { - $this->pdo->query("CREATE TABLE IF NOT EXISTS {$this->migrations_table} (schema_version integer not null)"); - } - - /** - * @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); - return false; - } - - if ($version == 0) - Debug::log("Loading base database schema...", Debug::LOG_VERBOSE); - else - Debug::log("Starting migration to $version...", Debug::LOG_VERBOSE); - - $lines = $this->get_lines($version); - - if (count($lines) > 0) { - // mysql doesn't support transactions for DDL statements - if (Config::get(Config::DB_TYPE) != "mysql") - $this->pdo->beginTransaction(); - - foreach ($lines as $line) { - Debug::log($line, Debug::LOG_EXTENDED); - try { - $this->pdo->query($line); - } catch (PDOException $e) { - Debug::log("Failed on line: $line", Debug::LOG_VERBOSE); - throw $e; - } - } - - if ($version == 0 && $this->base_is_latest) - $this->set_version($this->get_max_version()); - else - $this->set_version($version); - - if (Config::get(Config::DB_TYPE) != "mysql") - $this->pdo->commit(); - - 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) { - Debug::log("Migration failed: " . $e->getMessage(), Debug::LOG_VERBOSE); - try { - $this->pdo->rollback(); - } catch (PDOException $ie) { - // - } - throw $e; - } - } - - function get_max_version() : int { - if ($this->max_version_override > 0) - return $this->max_version_override; - - if ($this->cached_max_version) - return $this->cached_max_version; - - $migrations = glob("{$this->migrations_path}/*.sql"); - - if (count($migrations) > 0) { - natsort($migrations); - - $this->cached_max_version = (int) basename(array_pop($migrations), ".sql"); - - } else { - $this->cached_max_version = 0; - } - - return $this->cached_max_version; - } - - function is_migration_needed() : bool { - return $this->get_version() != $this->get_max_version(); - } - - function migrate() : bool { - - if ($this->get_version() == -1) { - try { - $this->migrate_to(0); - } catch (PDOException $e) { - user_error("Failed to load base schema for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING); - return false; - } - } - - for ($i = $this->get_version() + 1; $i <= $this->get_max_version(); $i++) { - try { - $this->migrate_to($i); - } catch (PDOException $e) { - user_error("Failed to apply migration {$i} for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING); - return false; - //throw $e; - } - } - - return !$this->is_migration_needed(); - } - - /** - * @return array - */ - private function get_lines(int $version) : array { - if ($version > 0) - $filename = "{$this->migrations_path}/{$version}.sql"; - else - $filename = "{$this->base_path}/{$this->base_filename}"; - - if (file_exists($filename)) { - $lines = array_filter(preg_split("/[\r\n]/", file_get_contents($filename)), - fn($line) => strlen(trim($line)) > 0 && strpos($line, "--") !== 0); - - return array_filter(explode(";", implode("", $lines)), - fn($line) => strlen(trim($line)) > 0 && !in_array(strtolower($line), ["begin", "commit"])); - - } else { - user_error("Requested schema file {$filename} not found.", E_USER_ERROR); - return []; - } - } -} diff --git a/classes/db/prefs.php b/classes/db/prefs.php deleted file mode 100644 index 209ef58c1..000000000 --- a/classes/db/prefs.php +++ /dev/null @@ -1,18 +0,0 @@ -"; - - const ALL_LOG_LEVELS = [ - Debug::LOG_DISABLED, - Debug::LOG_NORMAL, - Debug::LOG_VERBOSE, - Debug::LOG_EXTENDED, - ]; - - /** - * @deprecated - */ - public static int $LOG_DISABLED = self::LOG_DISABLED; - - /** - * @deprecated - */ - public static int $LOG_NORMAL = self::LOG_NORMAL; - - /** - * @deprecated - */ - public static int $LOG_VERBOSE = self::LOG_VERBOSE; - - /** - * @deprecated - */ - public static int $LOG_EXTENDED = self::LOG_EXTENDED; - - private static bool $enabled = false; - private static bool $quiet = false; - private static ?string $logfile = null; - private static bool $enable_html = false; - - private static int $loglevel = self::LOG_NORMAL; - - public static function set_logfile(string $logfile): void { - self::$logfile = $logfile; - } - - public static function enabled(): bool { - return self::$enabled; - } - - public static function set_enabled(bool $enable): void { - self::$enabled = $enable; - } - - public static function set_quiet(bool $quiet): void { - self::$quiet = $quiet; - } - - /** - * @param Debug::LOG_* $level - */ - public static function set_loglevel(int $level): void { - self::$loglevel = $level; - } - - /** - * @return int Debug::LOG_* - */ - public static function get_loglevel(): int { - return self::$loglevel; - } - - /** - * @param int $level integer loglevel value - * @return Debug::LOG_* if valid, warn and return LOG_DISABLED otherwise - */ - public static function map_loglevel(int $level) : int { - if (in_array($level, self::ALL_LOG_LEVELS)) { - /** @phpstan-ignore-next-line */ - return $level; - } else { - user_error("Passed invalid debug log level: $level", E_USER_WARNING); - return self::LOG_DISABLED; - } - } - - public static function enable_html(bool $enable) : void { - self::$enable_html = $enable; - } - - /** - * @param Debug::LOG_* $level log level - */ - public static function log(string $message, int $level = Debug::LOG_NORMAL): bool { - - if (!self::$enabled || self::$loglevel < $level) return false; - - $ts = date("H:i:s", time()); - if (function_exists('posix_getpid')) { - $ts = "$ts/" . posix_getpid(); - } - - $orig_message = $message; - - if ($message === self::SEPARATOR) { - $message = self::$enable_html ? "
" : - "================================================================================================================================="; - } - - if (self::$logfile) { - $fp = fopen(self::$logfile, 'a+'); - - if ($fp) { - $locked = false; - - if (function_exists("flock")) { - $tries = 0; - - // try to lock logfile for writing - while ($tries < 5 && !$locked = flock($fp, LOCK_EX | LOCK_NB)) { - sleep(1); - ++$tries; - } - - if (!$locked) { - fclose($fp); - user_error("Unable to lock debugging log file: " . self::$logfile, E_USER_WARNING); - return false; - } - } - - fputs($fp, "[$ts] $message\n"); - - if (function_exists("flock")) { - flock($fp, LOCK_UN); - } - - fclose($fp); - - if (self::$quiet) - return false; - - } else { - user_error("Unable to open debugging log file: " . self::$logfile, E_USER_WARNING); - } - } - - if (self::$enable_html) { - if ($orig_message === self::SEPARATOR) { - print "$message\n"; - } else { - print "$ts $message\n"; - } - } else { - print "[$ts] $message\n"; - } - - return true; - } -} diff --git a/classes/digest.php b/classes/digest.php deleted file mode 100644 index 27009530f..000000000 --- a/classes/digest.php +++ /dev/null @@ -1,209 +0,0 @@ -query("SELECT id,email FROM ttrss_users - WHERE email != '' AND (last_digest_sent IS NULL OR $interval_qpart)"); - - while ($line = $res->fetch()) { - - if (get_pref(Prefs::DIGEST_ENABLE, $line['id'])) { - $preferred_ts = strtotime(get_pref(Prefs::DIGEST_PREFERRED_TIME, $line['id']) ?? ''); - - // try to send digests within 2 hours of preferred time - if ($preferred_ts && time() >= $preferred_ts && - time() - $preferred_ts <= 7200 - ) { - - Debug::log("Sending digest for UID:" . $line['id'] . " - " . $line["email"]); - - $do_catchup = get_pref(Prefs::DIGEST_CATCHUP, $line['id']); - - global $tz_offset; - - // reset tz_offset global to prevent tz cache clash between users - $tz_offset = -1; - - $tuple = Digest::prepare_headlines_digest($line["id"], 1, $limit); - $digest = $tuple[0]; - $headlines_count = $tuple[1]; - $affected_ids = $tuple[2]; - $digest_text = $tuple[3]; - - if ($headlines_count > 0) { - - $mailer = new Mailer(); - - //$rc = $mail->quickMail($line["email"], $line["login"], Config::get(Config::DIGEST_SUBJECT), $digest, $digest_text); - - $rc = $mailer->mail(["to_name" => $line["login"], - "to_address" => $line["email"], - "subject" => Config::get(Config::DIGEST_SUBJECT), - "message" => $digest_text, - "message_html" => $digest]); - - //if (!$rc && $debug) Debug::log("ERROR: " . $mailer->lastError()); - - Debug::log("RC=$rc"); - - if ($rc && $do_catchup) { - Debug::log("Marking affected articles as read..."); - Article::_catchup_by_id($affected_ids, Article::CATCHUP_MODE_MARK_AS_READ, $line["id"]); - } - } else { - Debug::log("No headlines"); - } - - $sth = $pdo->prepare("UPDATE ttrss_users SET last_digest_sent = NOW() - WHERE id = ?"); - $sth->execute([$line["id"]]); - - } - } - } - - $span->end(); - Debug::log("All done."); - } - - /** - * @return array{0: string, 1: int, 2: array, 3: string} - */ - static function prepare_headlines_digest(int $user_id, int $days = 1, int $limit = 1000) { - - $tpl = new Templator(); - $tpl_t = new Templator(); - - $tpl->readTemplateFromFile("digest_template_html.txt"); - $tpl_t->readTemplateFromFile("digest_template.txt"); - - $user_tz_string = get_pref(Prefs::USER_TIMEZONE, $user_id); - - if ($user_tz_string == 'Automatic') - $user_tz_string = 'GMT'; - - $local_ts = TimeHelper::convert_timestamp(time(), 'UTC', $user_tz_string); - - $tpl->setVariable('CUR_DATE', date('Y/m/d', $local_ts)); - $tpl->setVariable('CUR_TIME', date('G:i', $local_ts)); - $tpl->setVariable('TTRSS_HOST', Config::get_self_url()); - - $tpl_t->setVariable('CUR_DATE', date('Y/m/d', $local_ts)); - $tpl_t->setVariable('CUR_TIME', date('G:i', $local_ts)); - $tpl_t->setVariable('TTRSS_HOST', Config::get_self_url()); - - $affected_ids = array(); - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $interval_qpart = "ttrss_entries.date_updated > NOW() - INTERVAL '$days days'"; - } else /* if (Config::get(Config::DB_TYPE) == "mysql") */ { - $interval_qpart = "ttrss_entries.date_updated > DATE_SUB(NOW(), INTERVAL $days DAY)"; - } - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT ttrss_entries.title, - ttrss_feeds.title AS feed_title, - COALESCE(ttrss_feed_categories.title, '" . __('Uncategorized') . "') AS cat_title, - date_updated, - ttrss_user_entries.ref_id, - link, - score, - content, - ".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated - FROM - ttrss_user_entries,ttrss_entries,ttrss_feeds - LEFT JOIN - ttrss_feed_categories ON (cat_id = ttrss_feed_categories.id) - WHERE - ref_id = ttrss_entries.id AND feed_id = ttrss_feeds.id - AND include_in_digest = true - AND $interval_qpart - AND ttrss_user_entries.owner_uid = :user_id - AND unread = true - AND score >= 0 - ORDER BY ttrss_feed_categories.title, ttrss_feeds.title, score DESC, date_updated DESC - LIMIT " . (int)$limit); - $sth->execute([':user_id' => $user_id]); - - $headlines_count = 0; - $headlines = array(); - - while ($line = $sth->fetch()) { - array_push($headlines, $line); - $headlines_count++; - } - - for ($i = 0; $i < sizeof($headlines); $i++) { - - $line = $headlines[$i]; - - array_push($affected_ids, $line["ref_id"]); - - $updated = TimeHelper::make_local_datetime($line['last_updated'], false, - $user_id); - - if (get_pref(Prefs::ENABLE_FEED_CATS, $user_id)) { - $line['feed_title'] = $line['cat_title'] . " / " . $line['feed_title']; - } - - $article_labels = Article::_get_labels($line["ref_id"], $user_id); - $article_labels_formatted = ""; - - if (is_array($article_labels) && count($article_labels) > 0) { - $article_labels_formatted = implode(", ", array_map(fn($a) => $a[1], $article_labels)); - } - - $tpl->setVariable('FEED_TITLE', $line["feed_title"]); - $tpl->setVariable('ARTICLE_TITLE', $line["title"]); - $tpl->setVariable('ARTICLE_LINK', $line["link"]); - $tpl->setVariable('ARTICLE_UPDATED', $updated); - $tpl->setVariable('ARTICLE_EXCERPT', - truncate_string(strip_tags($line["content"]), 300)); -// $tpl->setVariable('ARTICLE_CONTENT', -// strip_tags($article_content)); - $tpl->setVariable('ARTICLE_LABELS', $article_labels_formatted, true); - - $tpl->addBlock('article'); - - $tpl_t->setVariable('FEED_TITLE', $line["feed_title"]); - $tpl_t->setVariable('ARTICLE_TITLE', $line["title"]); - $tpl_t->setVariable('ARTICLE_LINK', $line["link"]); - $tpl_t->setVariable('ARTICLE_UPDATED', $updated); - $tpl_t->setVariable('ARTICLE_LABELS', $article_labels_formatted, true); - $tpl_t->setVariable('ARTICLE_EXCERPT', - truncate_string(strip_tags($line["content"]), 300, "..."), true); - - $tpl_t->addBlock('article'); - - if ($headlines[$i]['feed_title'] != $headlines[$i + 1]['feed_title']) { - $tpl->addBlock('feed'); - $tpl_t->addBlock('feed'); - } - - } - - $tpl->addBlock('digest'); - $tpl->generateOutputToString($tmp); - - $tpl_t->addBlock('digest'); - $tpl_t->generateOutputToString($tmp_t); - - return array($tmp, $headlines_count, $affected_ids, $tmp_t); - } -} diff --git a/classes/diskcache.php b/classes/diskcache.php deleted file mode 100644 index 290fbd9c3..000000000 --- a/classes/diskcache.php +++ /dev/null @@ -1,492 +0,0 @@ - $instances */ - private static $instances = []; - - /** - * https://stackoverflow.com/a/53662733 - * - * @var array - */ - private array $mimeMap = [ - 'video/3gpp2' => '3g2', - 'video/3gp' => '3gp', - 'video/3gpp' => '3gp', - 'application/x-compressed' => '7zip', - 'audio/x-acc' => 'aac', - 'audio/ac3' => 'ac3', - 'application/postscript' => 'ai', - 'audio/x-aiff' => 'aif', - 'audio/aiff' => 'aif', - 'audio/x-au' => 'au', - 'video/x-msvideo' => 'avi', - 'video/msvideo' => 'avi', - 'video/avi' => 'avi', - 'application/x-troff-msvideo' => 'avi', - 'application/macbinary' => 'bin', - 'application/mac-binary' => 'bin', - 'application/x-binary' => 'bin', - 'application/x-macbinary' => 'bin', - 'image/bmp' => 'bmp', - 'image/x-bmp' => 'bmp', - 'image/x-bitmap' => 'bmp', - 'image/x-xbitmap' => 'bmp', - 'image/x-win-bitmap' => 'bmp', - 'image/x-windows-bmp' => 'bmp', - 'image/ms-bmp' => 'bmp', - 'image/x-ms-bmp' => 'bmp', - 'application/bmp' => 'bmp', - 'application/x-bmp' => 'bmp', - 'application/x-win-bitmap' => 'bmp', - 'application/cdr' => 'cdr', - 'application/coreldraw' => 'cdr', - 'application/x-cdr' => 'cdr', - 'application/x-coreldraw' => 'cdr', - 'image/cdr' => 'cdr', - 'image/x-cdr' => 'cdr', - 'zz-application/zz-winassoc-cdr' => 'cdr', - 'application/mac-compactpro' => 'cpt', - 'application/pkix-crl' => 'crl', - 'application/pkcs-crl' => 'crl', - 'application/x-x509-ca-cert' => 'crt', - 'application/pkix-cert' => 'crt', - 'text/css' => 'css', - 'text/x-comma-separated-values' => 'csv', - 'text/comma-separated-values' => 'csv', - 'application/vnd.msexcel' => 'csv', - 'application/x-director' => 'dcr', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', - 'application/x-dvi' => 'dvi', - 'message/rfc822' => 'eml', - 'application/x-msdownload' => 'exe', - 'video/x-f4v' => 'f4v', - 'audio/x-flac' => 'flac', - 'video/x-flv' => 'flv', - 'image/gif' => 'gif', - 'application/gpg-keys' => 'gpg', - 'application/x-gtar' => 'gtar', - 'application/x-gzip' => 'gzip', - 'application/mac-binhex40' => 'hqx', - 'application/mac-binhex' => 'hqx', - 'application/x-binhex40' => 'hqx', - 'application/x-mac-binhex40' => 'hqx', - 'text/html' => 'html', - 'image/x-icon' => 'ico', - 'image/x-ico' => 'ico', - 'image/vnd.microsoft.icon' => 'ico', - 'text/calendar' => 'ics', - 'application/java-archive' => 'jar', - 'application/x-java-application' => 'jar', - 'application/x-jar' => 'jar', - 'image/jp2' => 'jp2', - 'video/mj2' => 'jp2', - 'image/jpx' => 'jp2', - 'image/jpm' => 'jp2', - 'image/jpeg' => 'jpg', - 'image/pjpeg' => 'jpg', - 'application/x-javascript' => 'js', - 'application/json' => 'json', - 'text/json' => 'json', - 'application/vnd.google-earth.kml+xml' => 'kml', - 'application/vnd.google-earth.kmz' => 'kmz', - 'text/x-log' => 'log', - 'audio/x-m4a' => 'm4a', - 'audio/mp4' => 'm4a', - 'application/vnd.mpegurl' => 'm4u', - 'audio/midi' => 'mid', - 'application/vnd.mif' => 'mif', - 'video/quicktime' => 'mov', - 'video/x-sgi-movie' => 'movie', - 'audio/mpeg' => 'mp3', - 'audio/mpg' => 'mp3', - 'audio/mpeg3' => 'mp3', - 'audio/mp3' => 'mp3', - 'video/mp4' => 'mp4', - 'video/mpeg' => 'mpeg', - 'application/oda' => 'oda', - 'audio/ogg' => 'ogg', - 'video/ogg' => 'ogg', - 'application/ogg' => 'ogg', - 'font/otf' => 'otf', - 'application/x-pkcs10' => 'p10', - 'application/pkcs10' => 'p10', - 'application/x-pkcs12' => 'p12', - 'application/x-pkcs7-signature' => 'p7a', - 'application/pkcs7-mime' => 'p7c', - 'application/x-pkcs7-mime' => 'p7c', - 'application/x-pkcs7-certreqresp' => 'p7r', - 'application/pkcs7-signature' => 'p7s', - 'application/pdf' => 'pdf', - 'application/octet-stream' => 'pdf', - 'application/x-x509-user-cert' => 'pem', - 'application/x-pem-file' => 'pem', - 'application/pgp' => 'pgp', - 'application/x-httpd-php' => 'php', - 'application/php' => 'php', - 'application/x-php' => 'php', - 'text/php' => 'php', - 'text/x-php' => 'php', - 'application/x-httpd-php-source' => 'php', - 'image/png' => 'png', - 'image/x-png' => 'png', - 'application/powerpoint' => 'ppt', - 'application/vnd.ms-powerpoint' => 'ppt', - 'application/vnd.ms-office' => 'ppt', - 'application/msword' => 'ppt', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', - 'application/x-photoshop' => 'psd', - 'image/vnd.adobe.photoshop' => 'psd', - 'audio/x-realaudio' => 'ra', - 'audio/x-pn-realaudio' => 'ram', - 'application/x-rar' => 'rar', - 'application/rar' => 'rar', - 'application/x-rar-compressed' => 'rar', - 'audio/x-pn-realaudio-plugin' => 'rpm', - 'application/x-pkcs7' => 'rsa', - 'text/rtf' => 'rtf', - 'text/richtext' => 'rtx', - 'video/vnd.rn-realvideo' => 'rv', - 'application/x-stuffit' => 'sit', - 'application/smil' => 'smil', - 'text/srt' => 'srt', - 'image/svg+xml' => 'svg', - 'application/x-shockwave-flash' => 'swf', - 'application/x-tar' => 'tar', - 'application/x-gzip-compressed' => 'tgz', - 'image/tiff' => 'tiff', - 'font/ttf' => 'ttf', - 'text/plain' => 'txt', - 'text/x-vcard' => 'vcf', - 'application/videolan' => 'vlc', - 'text/vtt' => 'vtt', - 'audio/x-wav' => 'wav', - 'audio/wave' => 'wav', - 'audio/wav' => 'wav', - 'application/wbxml' => 'wbxml', - 'video/webm' => 'webm', - 'image/webp' => 'webp', - 'audio/x-ms-wma' => 'wma', - 'application/wmlc' => 'wmlc', - 'video/x-ms-wmv' => 'wmv', - 'video/x-ms-asf' => 'wmv', - 'font/woff' => 'woff', - 'font/woff2' => 'woff2', - 'application/xhtml+xml' => 'xhtml', - 'application/excel' => 'xl', - 'application/msexcel' => 'xls', - 'application/x-msexcel' => 'xls', - 'application/x-ms-excel' => 'xls', - 'application/x-excel' => 'xls', - 'application/x-dos_ms_excel' => 'xls', - 'application/xls' => 'xls', - 'application/x-xls' => 'xls', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', - 'application/vnd.ms-excel' => 'xlsx', - 'application/xml' => 'xml', - 'text/xml' => 'xml', - 'text/xsl' => 'xsl', - 'application/xspf+xml' => 'xspf', - 'application/x-compress' => 'z', - 'application/x-zip' => 'zip', - 'application/zip' => 'zip', - 'application/x-zip-compressed' => 'zip', - 'application/s-compressed' => 'zip', - 'multipart/x-zip' => 'zip', - 'text/x-scriptzsh' => 'zsh' - ]; - - public static function instance(string $dir) : DiskCache { - if ((self::$instances[$dir] ?? null) == null) - self::$instances[$dir] = new self($dir); - - return self::$instances[$dir]; - } - - public function __construct(string $dir) { - foreach (PluginHost::getInstance()->get_plugins() as $n => $p) { - if (implements_interface($p, "Cache_Adapter")) { - - /** @var Cache_Adapter $p */ - $this->adapter = clone $p; // we need separate object instances for separate directories - $this->adapter->set_dir($dir); - return; - } - } - - $this->adapter = new Cache_Local(); - $this->adapter->set_dir($dir); - } - - public function remove(string $filename): bool { - $span = Tracer::start(__METHOD__); - $span->setAttribute('file.name', $filename); - - $rc = $this->adapter->remove($filename); - $span->end(); - - return $rc; - } - - public function set_dir(string $dir) : void { - $this->adapter->set_dir($dir); - } - - /** - * @return int|false -1 if the file doesn't exist, false if an error occurred, timestamp otherwise - */ - public function get_mtime(string $filename) { - return $this->adapter->get_mtime(basename($filename)); - } - - public function make_dir(): bool { - return $this->adapter->make_dir(); - } - - /** @param string|null $filename null means check that cache directory itself is writable */ - public function is_writable(?string $filename = null): bool { - return $this->adapter->is_writable($filename ? basename($filename) : null); - } - - public function exists(string $filename): bool { - $span = OpenTelemetry\API\Trace\Span::getCurrent(); - $span->addEvent("DiskCache::exists: $filename"); - - $rc = $this->adapter->exists(basename($filename)); - - return $rc; - } - - /** - * @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) { - $span = Tracer::start(__METHOD__); - $span->setAttribute('file.name', $filename); - - $rc = $this->adapter->get_size(basename($filename)); - $span->end(); - - return $rc; - } - - /** - * @param mixed $data - * - * @return int|false Bytes written or false if an error occurred. - */ - public function put(string $filename, $data) { - $span = Tracer::start(__METHOD__); - $rc = $this->adapter->put(basename($filename), $data); - $span->end(); - - return $rc; - } - - /** @deprecated we can't assume cached files are local, and other storages - * might not support this operation (object metadata may be immutable) */ - public function touch(string $filename): bool { - user_error("DiskCache: called unsupported method touch() for $filename", E_USER_DEPRECATED); - - return false; - } - - public function get(string $filename): ?string { - return $this->adapter->get(basename($filename)); - } - - public function expire_all(): void { - $this->adapter->expire_all(); - } - - public function get_dir(): string { - return $this->adapter->get_dir(); - } - - /** Downloads $url to cache as $local_filename if its missing (unless $force-ed) - * @param string $url - * @param string $local_filename - * @param array $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 send(string $filename) { - $span = Tracer::start(__METHOD__); - $span->setAttribute('file.name', $filename); - - $filename = basename($filename); - - if (!$this->exists($filename)) { - header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); - echo "File not found."; - - $span->setAttribute('error', '404 not found'); - $span->end(); - return false; - } - - $file_mtime = $this->get_mtime($filename); - $gmt_modified = gmdate("D, d M Y H:i:s", (int)$file_mtime) . " GMT"; - - if (($_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '') == $gmt_modified || ($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') == $file_mtime) { - header('HTTP/1.1 304 Not Modified'); - - $span->setAttribute('error', '304 not modified'); - $span->end(); - return false; - } - - $mimetype = $this->get_mime_type($filename); - - if ($mimetype == "application/octet-stream") - $mimetype = "video/mp4"; - - # block SVG because of possible embedded javascript (.....) - $mimetype_blacklist = [ "image/svg+xml" ]; - - /* only serve video and images */ - if (!preg_match("/(image|audio|video)\//", (string)$mimetype) || in_array($mimetype, $mimetype_blacklist)) { - http_response_code(400); - header("Content-type: text/plain"); - - print "Stored file has disallowed content type ($mimetype)"; - - $span->setAttribute('error', '400 disallowed content type'); - $span->end(); - return false; - } - - $fake_extension = $this->get_fake_extension($filename); - - if ($fake_extension) - $fake_extension = ".$fake_extension"; - - header("Content-Disposition: inline; filename=\"{$filename}{$fake_extension}\""); - header("Content-type: $mimetype"); - - $stamp_expires = gmdate("D, d M Y H:i:s", - (int)$this->get_mtime($filename) + 86400 * Config::get(Config::CACHE_MAX_DAYS)) . " GMT"; - - header("Expires: $stamp_expires", true); - header("Last-Modified: $gmt_modified", true); - header("Cache-Control: no-cache"); - header("ETag: $file_mtime"); - - header_remove("Pragma"); - - $span->setAttribute('mimetype', $mimetype); - - $rc = $this->adapter->send($filename); - - $span->end(); - - return $rc; - } - - public function get_full_path(string $filename): string { - return $this->adapter->get_full_path(basename($filename)); - } - - public function get_mime_type(string $filename) { - return $this->adapter->get_mime_type(basename($filename)); - } - - public function get_fake_extension(string $filename): string { - $mimetype = $this->adapter->get_mime_type(basename($filename)); - - if ($mimetype) - return isset($this->mimeMap[$mimetype]) ? $this->mimeMap[$mimetype] : ""; - else - return ""; - } - - public function get_url(string $filename): string { - return Config::get_self_url() . "/public.php?op=cached&file=" . basename($this->adapter->get_dir()) . "/" . basename($filename); - } - - // check for locally cached (media) URLs and rewrite to local versions - // 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(string $str): string { - $span = OpenTelemetry\API\Trace\Span::getCurrent(); - $span->addEvent("DiskCache::rewrite_urls"); - - $res = trim($str); - - if (!$res) { - $span->end(); - return ''; - } - - $doc = new DOMDocument(); - if (@$doc->loadHTML('' . $res)) { - $xpath = new DOMXPath($doc); - $cache = DiskCache::instance("images"); - - $entries = $xpath->query('(//img[@src]|//source[@src|@srcset]|//video[@poster|@src])'); - - $need_saving = false; - - foreach ($entries as $entry) { - $span->addEvent("entry: " . $entry->tagName); - - foreach (array('src', 'poster') as $attr) { - if ($entry->hasAttribute($attr)) { - $url = $entry->getAttribute($attr); - $cached_filename = sha1($url); - - if ($cache->exists($cached_filename)) { - $url = $cache->get_url($cached_filename); - - $entry->setAttribute($attr, $url); - $entry->removeAttribute("srcset"); - - $need_saving = true; - } - } - } - - if ($entry->hasAttribute("srcset")) { - $matches = RSSUtils::decode_srcset($entry->getAttribute('srcset')); - - for ($i = 0; $i < count($matches); $i++) { - $cached_filename = sha1($matches[$i]["url"]); - - if ($cache->exists($cached_filename)) { - $matches[$i]["url"] = $cache->get_url($cached_filename); - - $need_saving = true; - } - } - - $entry->setAttribute("srcset", RSSUtils::encode_srcset($matches)); - } - } - - if ($need_saving) { - if (isset($doc->firstChild)) - $doc->removeChild($doc->firstChild); //remove doctype - - $res = $doc->saveHTML(); - } - } - - return $res; - } -} diff --git a/classes/errors.php b/classes/errors.php deleted file mode 100644 index aa626d017..000000000 --- a/classes/errors.php +++ /dev/null @@ -1,40 +0,0 @@ - $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 deleted file mode 100644 index b5f5cc411..000000000 --- a/classes/feedenclosure.php +++ /dev/null @@ -1,21 +0,0 @@ - */ - abstract function get_categories(): array; - - /** @return array */ - 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 deleted file mode 100755 index f6c96f959..000000000 --- a/classes/feeditem/atom.php +++ /dev/null @@ -1,224 +0,0 @@ -elem->getElementsByTagName("id")->item(0); - - if ($id) { - return $id->nodeValue; - } else { - return clean($this->get_link()); - } - } - - /** - * @return int|false a timestamp on success, false otherwise - */ - function get_date() { - $updated = $this->elem->getElementsByTagName("updated")->item(0); - - if ($updated) { - return strtotime($updated->nodeValue ?? ''); - } - - $published = $this->elem->getElementsByTagName("published")->item(0); - - if ($published) { - return strtotime($published->nodeValue ?? ''); - } - - $date = $this->xpath->query("dc:date", $this->elem)->item(0); - - if ($date) { - return strtotime($date->nodeValue ?? ''); - } - - // consistent with strtotime failing to parse - return false; - } - - - function get_link(): string { - $links = $this->elem->getElementsByTagName("link"); - - foreach ($links as $link) { - /** @phpstan-ignore-next-line */ - if ($link->hasAttribute("href") && - (!$link->hasAttribute("rel") - || $link->getAttribute("rel") == "alternate" - || $link->getAttribute("rel") == "standout")) { - $base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $link); - - if ($base) - return UrlHelper::rewrite_relative($base, clean(trim($link->getAttribute("href")))); - else - return clean(trim($link->getAttribute("href"))); - } - } - - return ''; - } - - function get_title(): string { - $title = $this->elem->getElementsByTagName("title")->item(0); - return $title ? clean(trim($title->nodeValue)) : ''; - } - - /** - * @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)) { - - $tmpdoc = new DOMDocument(); - if (@$tmpdoc->loadHTML('' . $content)) { - $tmpxpath = new DOMXPath($tmpdoc); - - $elems = $tmpxpath->query("(//*[@href]|//*[@src])"); - - foreach ($elems as $elem) { - if ($elem->hasAttribute("href")) { - $elem->setAttribute("href", - UrlHelper::rewrite_relative($base, $elem->getAttribute("href"))); - } else if ($elem->hasAttribute("src")) { - $elem->setAttribute("src", - UrlHelper::rewrite_relative($base, $elem->getAttribute("src"))); - } - } - - // 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(): string { - /** @var DOMElement|null */ - $content = $this->elem->getElementsByTagName("content")->item(0); - - if ($content) { - $base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $content); - - if ($content->hasAttribute('type')) { - if ($content->getAttribute('type') == 'xhtml') { - for ($i = 0; $i < $content->childNodes->length; $i++) { - $child = $content->childNodes->item($i); - - if ($child->hasChildNodes()) { - return $this->rewrite_content_to_base($base, $this->doc->saveHTML($child)); - } - } - } - } - - 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(): string { - /** @var DOMElement|null */ - $content = $this->elem->getElementsByTagName("summary")->item(0); - - if ($content) { - $base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $content); - - if ($content->hasAttribute('type')) { - if ($content->getAttribute('type') == 'xhtml') { - for ($i = 0; $i < $content->childNodes->length; $i++) { - $child = $content->childNodes->item($i); - - if ($child->hasChildNodes()) { - return $this->rewrite_content_to_base($base, $this->doc->saveHTML($child)); - } - } - } - } - - return $this->rewrite_content_to_base($base, $this->subtree_or_text($content)); - } - - return ''; - } - - /** - * @return array - */ - function get_categories(): array { - $categories = $this->elem->getElementsByTagName("category"); - $cats = []; - - foreach ($categories as $cat) { - if ($cat->hasAttribute("term")) - array_push($cats, $cat->getAttribute("term")); - } - - $categories = $this->xpath->query("dc:subject", $this->elem); - - foreach ($categories as $cat) { - array_push($cats, $cat->nodeValue); - } - - return $this->normalize_categories($cats); - } - - /** - * @return array - */ - function get_enclosures(): array { - $links = $this->elem->getElementsByTagName("link"); - - $encs = []; - - foreach ($links as $link) { - /** @phpstan-ignore-next-line */ - if ($link->hasAttribute("href") && $link->hasAttribute("rel")) { - $base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $link); - - if ($link->getAttribute("rel") == "enclosure") { - $enc = new FeedEnclosure(); - - $enc->type = clean($link->getAttribute("type")); - $enc->length = clean($link->getAttribute("length")); - $enc->link = clean($link->getAttribute("href")); - - if (!empty($base)) { - $enc->link = UrlHelper::rewrite_relative($base, $enc->link); - } - - array_push($encs, $enc); - } - } - } - - array_push($encs, ...parent::get_enclosures()); - - return $encs; - } - - function get_language(): string { - $lang = $this->elem->getAttributeNS(self::NS_XML, "lang"); - - if (!empty($lang)) { - return clean($lang); - } else { - // Fall back to the language declared on the feed, if any. - foreach ($this->doc->childNodes as $child) { - if (method_exists($child, "getAttributeNS")) { - return clean($child->getAttributeNS(self::NS_XML, "lang")); - } - } - } - return ''; - } -} diff --git a/classes/feeditem/common.php b/classes/feeditem/common.php deleted file mode 100755 index fde481179..000000000 --- a/classes/feeditem/common.php +++ /dev/null @@ -1,221 +0,0 @@ -elem = $elem; - $this->xpath = $xpath; - $this->doc = $doc; - - try { - $source = $elem->getElementsByTagName("source")->item(0); - - // we don't need element - if ($source) - $elem->removeChild($source); - } catch (DOMException $e) { - // - } - } - - function get_element(): DOMElement { - return $this->elem; - } - - function get_author(): string { - /** @var DOMElement|null */ - $author = $this->elem->getElementsByTagName("author")->item(0); - - if ($author) { - $name = $author->getElementsByTagName("name")->item(0); - - if ($name) return clean($name->nodeValue); - - $email = $author->getElementsByTagName("email")->item(0); - - if ($email) return clean($email->nodeValue); - - if ($author->nodeValue) - return clean($author->nodeValue); - } - - $author_elems = $this->xpath->query("dc:creator", $this->elem); - $authors = []; - - foreach ($author_elems as $author) { - array_push($authors, clean($author->nodeValue)); - } - - return implode(", ", $authors); - } - - 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); - - if ($com_url) - return clean($com_url->nodeValue); - - //Atom Threading Extension (RFC 4685) stuff. Could be used in RSS feeds, so it's in common. - //'text/html' for type is too restrictive? - $com_url = $this->xpath->query("atom:link[@rel='replies' and contains(@type,'text/html')]/@href", $this->elem)->item(0); - - if ($com_url) - return clean($com_url->nodeValue); - - return ''; - } - - 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 && 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 - * - * @return array - */ - function get_enclosures(): array { - $encs = []; - - $enclosures = $this->xpath->query("media:content", $this->elem); - - foreach ($enclosures as $enclosure) { - $enc = new FeedEnclosure(); - - $enc->type = clean($enclosure->getAttribute("type")); - $enc->link = clean($enclosure->getAttribute("url")); - $enc->length = clean($enclosure->getAttribute("length")); - $enc->height = clean($enclosure->getAttribute("height")); - $enc->width = clean($enclosure->getAttribute("width")); - - $medium = clean($enclosure->getAttribute("medium")); - if (!$enc->type && $medium) { - $enc->type = strtolower("$medium/generic"); - } - - $desc = $this->xpath->query("media:description", $enclosure)->item(0); - if ($desc) $enc->title = clean($desc->nodeValue); - - array_push($encs, $enc); - } - - $enclosures = $this->xpath->query("media:group", $this->elem); - - foreach ($enclosures as $enclosure) { - $enc = new FeedEnclosure(); - - /** @var DOMElement|null */ - $content = $this->xpath->query("media:content", $enclosure)->item(0); - - if ($content) { - $enc->type = clean($content->getAttribute("type")); - $enc->link = clean($content->getAttribute("url")); - $enc->length = clean($content->getAttribute("length")); - $enc->height = clean($content->getAttribute("height")); - $enc->width = clean($content->getAttribute("width")); - - $medium = clean($content->getAttribute("medium")); - if (!$enc->type && $medium) { - $enc->type = strtolower("$medium/generic"); - } - - $desc = $this->xpath->query("media:description", $content)->item(0); - if ($desc) { - $enc->title = clean($desc->nodeValue); - } else { - $desc = $this->xpath->query("media:description", $enclosure)->item(0); - if ($desc) $enc->title = clean($desc->nodeValue); - } - - array_push($encs, $enc); - } - } - - $enclosures = $this->xpath->query("media:thumbnail", $this->elem); - - foreach ($enclosures as $enclosure) { - $enc = new FeedEnclosure(); - - $enc->type = "image/generic"; - $enc->link = clean($enclosure->getAttribute("url")); - $enc->height = clean($enclosure->getAttribute("height")); - $enc->width = clean($enclosure->getAttribute("width")); - - array_push($encs, $enc); - } - - return $encs; - } - - function count_children(DOMElement $node): int { - return $node->getElementsByTagName("*")->length; - } - - /** - * @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 { - return $node->c14n(); - } - } - - /** - * @param array $cats - * - * @return array - */ - static function normalize_categories(array $cats): array { - - $tmp = []; - - foreach ($cats as $rawcat) { - array_push($tmp, ...explode(",", $rawcat)); - } - - $tmp = array_map(function($srccat) { - $cat = clean(trim(mb_strtolower($srccat))); - - // we don't support numeric tags - if (is_numeric($cat)) - $cat = 't:' . $cat; - - $cat = preg_replace('/[,\'\"]/', "", $cat); - - if (Config::get(Config::DB_TYPE) == "mysql") { - $cat = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $cat); - } - - if (mb_strlen($cat) > 250) - $cat = mb_substr($cat, 0, 250); - - return $cat; - }, $tmp); - - // remove empty values - $tmp = array_filter($tmp, 'strlen'); - - asort($tmp); - - return array_unique($tmp); - } -} diff --git a/classes/feeditem/rss.php b/classes/feeditem/rss.php deleted file mode 100755 index b5710ef4f..000000000 --- a/classes/feeditem/rss.php +++ /dev/null @@ -1,169 +0,0 @@ -elem->getElementsByTagName("guid")->item(0); - - if ($id) { - return clean($id->nodeValue); - } else { - return clean($this->get_link()); - } - } - - /** - * @return int|false a timestamp on success, false otherwise - */ - function get_date() { - $pubDate = $this->elem->getElementsByTagName("pubDate")->item(0); - - if ($pubDate) { - return strtotime($pubDate->nodeValue ?? ''); - } - - $date = $this->xpath->query("dc:date", $this->elem)->item(0); - - if ($date) { - return strtotime($date->nodeValue ?? ''); - } - - // consistent with strtotime failing to parse - return false; - } - - function get_link(): string { - $links = $this->xpath->query("atom:link", $this->elem); - - foreach ($links as $link) { - if ($link && $link->hasAttribute("href") && - (!$link->hasAttribute("rel") - || $link->getAttribute("rel") == "alternate" - || $link->getAttribute("rel") == "standout")) { - - return clean(trim($link->getAttribute("href"))); - } - } - - /** @var DOMElement|null */ - $link = $this->elem->getElementsByTagName("guid")->item(0); - - if ($link && $link->hasAttributes() && $link->getAttribute("isPermaLink") == "true") { - return clean(trim($link->nodeValue)); - } - - $link = $this->elem->getElementsByTagName("link")->item(0); - - if ($link) { - return clean(trim($link->nodeValue)); - } - - return ''; - } - - function get_title(): string { - $title = $this->xpath->query("title", $this->elem)->item(0); - - if ($title) { - return clean(trim($title->nodeValue)); - } - - // if the document has a default namespace then querying for - // title would fail because of reasons so let's try the old way - $title = $this->elem->getElementsByTagName("title")->item(0); - - if ($title) { - return clean(trim($title->nodeValue)); - } - - return ''; - } - - 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) { - $resultA = $this->subtree_or_text($contentA); - $resultB = $this->subtree_or_text($contentB); - - return mb_strlen($resultA) > mb_strlen($resultB) ? $resultA : $resultB; - } - - if ($contentA) { - return $this->subtree_or_text($contentA); - } - - if ($contentB) { - return $this->subtree_or_text($contentB); - } - - return ''; - } - - function get_description(): string { - $summary = $this->elem->getElementsByTagName("description")->item(0); - - if ($summary) { - return $summary->nodeValue; - } - - return ''; - } - - /** - * @return array - */ - function get_categories(): array { - $categories = $this->elem->getElementsByTagName("category"); - $cats = []; - - foreach ($categories as $cat) { - array_push($cats, $cat->nodeValue); - } - - $categories = $this->xpath->query("dc:subject", $this->elem); - - foreach ($categories as $cat) { - array_push($cats, $cat->nodeValue); - } - - return $this->normalize_categories($cats); - } - - /** - * @return array - */ - function get_enclosures(): array { - $enclosures = $this->elem->getElementsByTagName("enclosure"); - - $encs = array(); - - foreach ($enclosures as $enclosure) { - $enc = new FeedEnclosure(); - - $enc->type = clean($enclosure->getAttribute("type")); - $enc->link = clean($enclosure->getAttribute("url")); - $enc->length = clean($enclosure->getAttribute("length")); - $enc->height = clean($enclosure->getAttribute("height")); - $enc->width = clean($enclosure->getAttribute("width")); - - array_push($encs, $enc); - } - - array_push($encs, ...parent::get_enclosures()); - - return $encs; - } - - function get_language(): string { - $languages = $this->doc->getElementsByTagName('language'); - - if (count($languages) == 0) { - return ""; - } - - return clean($languages[0]->textContent); - } -} diff --git a/classes/feedparser.php b/classes/feedparser.php deleted file mode 100644 index 4b9c63f56..000000000 --- a/classes/feedparser.php +++ /dev/null @@ -1,245 +0,0 @@ - */ - private $libxml_errors = []; - - /** @var array */ - 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(string $data) { - libxml_use_internal_errors(true); - libxml_clear_errors(); - $this->doc = new DOMDocument(); - $this->doc->loadXML($data); - - mb_substitute_character("none"); - - $error = libxml_get_last_error(); - - if ($error) { - foreach (libxml_get_errors() as $error) { - if ($error->level == LIBXML_ERR_FATAL) { - // currently only the first error is reported - $this->error ??= Errors::format_libxml_error($error); - $this->libxml_errors[] = Errors::format_libxml_error($error); - } - } - } - libxml_clear_errors(); - } - - function init() : void { - $root = $this->doc->firstChild; - $xpath = new DOMXPath($this->doc); - $xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom'); - $xpath->registerNamespace('atom03', 'http://purl.org/atom/ns#'); - $xpath->registerNamespace('media', 'http://search.yahoo.com/mrss/'); - $xpath->registerNamespace('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'); - $xpath->registerNamespace('slash', 'http://purl.org/rss/1.0/modules/slash/'); - $xpath->registerNamespace('dc', 'http://purl.org/dc/elements/1.1/'); - $xpath->registerNamespace('content', 'http://purl.org/rss/1.0/modules/content/'); - $xpath->registerNamespace('thread', 'http://purl.org/syndication/thread/1.0'); - - $this->xpath = $xpath; - - $root_list = $xpath->query("(//atom03:feed|//atom:feed|//channel|//rdf:rdf|//rdf:RDF)"); - - if (!empty($root_list) && $root_list->length > 0) { - - /** @var DOMElement|null $root */ - $root = $root_list->item(0); - - if ($root) { - switch (mb_strtolower($root->tagName)) { - case "rdf:rdf": - $this->type = $this::FEED_RDF; - break; - case "channel": - $this->type = $this::FEED_RSS; - break; - case "feed": - case "atom:feed": - $this->type = $this::FEED_ATOM; - break; - default: - $this->error ??= "Unknown/unsupported feed type"; - return; - } - } - - switch ($this->type) { - case $this::FEED_ATOM: - - $title = $xpath->query("//atom:feed/atom:title")->item(0); - - if (!$title) - $title = $xpath->query("//atom03:feed/atom03:title")->item(0); - - - if ($title) { - $this->title = $title->nodeValue; - } - - $link = $xpath->query("//atom:feed/atom:link[not(@rel)]")->item(0); - - if (!$link) - $link = $xpath->query("//atom:feed/atom:link[@rel='alternate']")->item(0); - - if (!$link) - $link = $xpath->query("//atom03:feed/atom03:link[not(@rel)]")->item(0); - - 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"); - } - - $articles = $xpath->query("//atom:entry"); - - if (empty($articles) || $articles->length == 0) - $articles = $xpath->query("//atom03:entry"); - - foreach ($articles as $article) { - array_push($this->items, new FeedItem_Atom($article, $this->doc, $this->xpath)); - } - - break; - case $this::FEED_RSS: - $title = $xpath->query("//channel/title")->item(0); - - if ($title) { - $this->title = $title->nodeValue; - } - - /** @var DOMElement|null $link */ - $link = $xpath->query("//channel/link")->item(0); - - if ($link) { - if ($link->getAttribute("href")) - $this->link = $link->getAttribute("href"); - else if ($link->nodeValue) - $this->link = $link->nodeValue; - } - - $articles = $xpath->query("//channel/item"); - - foreach ($articles as $article) { - array_push($this->items, new FeedItem_RSS($article, $this->doc, $this->xpath)); - } - - break; - case $this::FEED_RDF: - $xpath->registerNamespace('rssfake', 'http://purl.org/rss/1.0/'); - - $title = $xpath->query("//rssfake:channel/rssfake:title")->item(0); - - if ($title) { - $this->title = $title->nodeValue; - } - - $link = $xpath->query("//rssfake:channel/rssfake:link")->item(0); - - if ($link) { - $this->link = $link->nodeValue; - } - - $articles = $xpath->query("//rssfake:item"); - - foreach ($articles as $article) { - array_push($this->items, new FeedItem_RSS($article, $this->doc, $this->xpath)); - } - - break; - - } - - if ($this->title) $this->title = trim($this->title); - if ($this->link) $this->link = trim($this->link); - - } else { - $this->error ??= "Unknown/unsupported feed type"; - 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() : string { - return UConverter::transcode($this->error ?? '', 'UTF-8', 'UTF-8'); - } - - /** @return array - WARNING: may return invalid unicode data */ - function errors() : array { - return $this->libxml_errors; - } - - function get_link() : string { - return clean($this->link ?? ''); - } - - function get_title() : string { - return clean($this->title ?? ''); - } - - /** @return array */ - function get_items() : array { - return $this->items; - } - - /** @return array */ - function get_links(string $rel) : array { - $rv = array(); - - switch ($this->type) { - case $this::FEED_ATOM: - $links = $this->xpath->query("//atom:feed/atom:link"); - - foreach ($links as $link) { - if (!$rel || $link->hasAttribute('rel') && $link->getAttribute('rel') == $rel) { - array_push($rv, clean(trim($link->getAttribute('href')))); - } - } - break; - case $this::FEED_RSS: - $links = $this->xpath->query("//atom:link"); - - foreach ($links as $link) { - if (!$rel || $link->hasAttribute('rel') && $link->getAttribute('rel') == $rel) { - array_push($rv, clean(trim($link->getAttribute('href')))); - } - } - break; - } - - return $rv; - } -} diff --git a/classes/feeds.php b/classes/feeds.php deleted file mode 100755 index a97ac221f..000000000 --- a/classes/feeds.php +++ /dev/null @@ -1,2507 +0,0 @@ -, 1: int, 2: int, 3: bool, 4: array} $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 = false, ? string $order_by = ''): array { - - $disable_cache = false; - - $span = Tracer::start(__METHOD__); - $span->setAttribute('func.args', json_encode(func_get_args())); - - $reply = []; - $rgba_cache = []; - $topmost_article_ids = []; - - if (!$offset) $offset = 0; - if ($method == "undefined") $method = ""; - - $method_split = explode(":", $method); - - if ($method == "ForceUpdate" && $feed > 0 && is_numeric($feed)) { - $sth = $this->pdo->prepare("UPDATE ttrss_feeds - SET last_updated = '1970-01-01', last_update_started = '1970-01-01' - WHERE id = ?"); - $sth->execute([$feed]); - } - - if ($method_split[0] == "MarkAllReadGR") { - $this->_catchup($method_split[1], false); - } - - // FIXME: might break tag display? - - if (is_numeric($feed) && $feed > 0 && !$cat_view) { - $sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE id = ? LIMIT 1"); - $sth->execute([$feed]); - - if (!$sth->fetch()) { - $reply['content'] = "
".__('Feed not found.')."
"; - } - } - - $search = $_REQUEST["query"] ?? ""; - $search_language = $_REQUEST["search_language"] ?? ""; // PGSQL only - - if ($search) { - $disable_cache = true; - } - - $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)); - - if ($handler) { - $options = array( - "limit" => $limit, - "view_mode" => $view_mode, - "cat_view" => $cat_view, - "search" => $search, - "override_order" => $override_order, - "offset" => $offset, - "owner_uid" => $_SESSION["uid"], - "filter" => false, - "since_id" => 0, - "include_children" => $include_children, - "order_by" => $order_by); - - $qfh_ret = $handler->get_headlines(PluginHost::feed_to_pfeed_id($feed), - $options); - } - - } else { - - $params = array( - "feed" => $feed, - "limit" => $limit, - "view_mode" => $view_mode, - "cat_view" => $cat_view, - "search" => $search, - "search_language" => $search_language, - "override_order" => $override_order, - "offset" => $offset, - "include_children" => $include_children, - "check_first_id" => $check_first_id, - "skip_first_id_check" => $skip_first_id_check, - "order_by" => $order_by - ); - - $qfh_ret = $this->_get_headlines($params); - } - - $vfeed_group_enabled = get_pref(Prefs::VFEED_GROUP_BY_FEED) && - !(in_array($feed, self::NEVER_GROUP_FEEDS) && !$cat_view); - - $result = $qfh_ret[0]; // this could be either a PDO query result or a -1 if first id changed - $feed_title = $qfh_ret[1]; - $feed_site_url = $qfh_ret[2]; - $last_error = $qfh_ret[3]; - $last_updated = strpos($qfh_ret[4] ?? "", '1970-') === false ? - TimeHelper::make_local_datetime($qfh_ret[4], false) : __("Never"); - $highlight_words = $qfh_ret[5]; - $reply['first_id'] = $qfh_ret[6]; - $reply['is_vfeed'] = $qfh_ret[7]; - $query_error_override = $qfh_ret[8]; - - $reply['search_query'] = [$search, $search_language]; - $reply['vfeed_group_enabled'] = $vfeed_group_enabled; - - $span->addEvent('plugin_menu_items'); - - $plugin_menu_items = ""; - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2, - function ($result) use (&$plugin_menu_items) { - $plugin_menu_items .= $result; - }, - $feed, $cat_view); - - $plugin_buttons = ""; - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HEADLINE_TOOLBAR_BUTTON, - function ($result) use (&$plugin_buttons) { - $plugin_buttons .= $result; - }, - $feed, $cat_view); - - $reply['toolbar'] = [ - 'site_url' => $feed_site_url, - 'title' => strip_tags($feed_title), - 'error' => $last_error, - 'last_updated' => $last_updated, - 'plugin_menu_items' => $plugin_menu_items, - 'plugin_buttons' => $plugin_buttons, - ]; - - $reply['content'] = []; - - if ($offset == 0) - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HEADLINES_BEFORE, - function ($result) use (&$reply) { - $reply['content'] .= $result; - }, - $feed, $cat_view, $qfh_ret); - - $span->addEvent('articles'); - - $headlines_count = 0; - - if ($result instanceof PDOStatement) { - while ($line = $result->fetch(PDO::FETCH_ASSOC)) { - $span->addEvent('article: ' . $line['id']); - - ++$headlines_count; - - if (!get_pref(Prefs::SHOW_CONTENT_PREVIEW)) { - $line["content_preview"] = ""; - } else { - $line["content_preview"] = "— " . truncate_string(strip_tags($line["content"]), 250); - - $max_excerpt_length = 250; - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES, - function ($result) use (&$line) { - $line = $result; - }, - $line, $max_excerpt_length); - } - - $id = $line["id"]; - - // frontend doesn't expect pdo returning booleans as strings on mysql - if (Config::get(Config::DB_TYPE) == "mysql") { - foreach (["unread", "marked", "published"] as $k) { - if (is_integer($line[$k])) { - $line[$k] = $line[$k] === 1; - } else { - $line[$k] = $line[$k] === "1"; - } - } - } - - // normalize archived feed - if ($line['feed_id'] === null) { - $line['feed_id'] = Feeds::FEED_ARCHIVED; - $line["feed_title"] = __("Archived articles"); - } - - $feed_id = $line["feed_id"]; - - if ($line["num_labels"] > 0) { - $label_cache = $line["label_cache"]; - $labels = false; - - if ($label_cache) { - $label_cache = json_decode($label_cache, true); - - if ($label_cache) { - if ($label_cache["no-labels"] ?? 0 == 1) - $labels = []; - else - $labels = $label_cache; - } - } else { - $labels = Article::_get_labels($id); - } - - $line["labels"] = $labels; - } else { - $line["labels"] = []; - } - - if (count($topmost_article_ids) < 3) { - array_push($topmost_article_ids, $id); - } - - $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, $plugin) use (&$line, &$button_doc) { - if ($result && $button_doc->loadXML($result)) { - - /** @var DOMElement|null $child */ - $child = $button_doc->firstChild; - - if ($child) { - do { - /** @var DOMElement|null $child */ - $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, $plugin) use (&$line, &$button_doc) { - if ($result && $button_doc->loadXML($result)) { - - /** @var DOMElement|null $child */ - $child = $button_doc->firstChild; - - if ($child) { - do { - /** @var DOMElement|null $child */ - $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); - - $line["content"] = Sanitizer::sanitize($line["content"], - $line['hide_images'], null, $line["site_url"], $highlight_words, $line["id"]); - - if (!get_pref(Prefs::CDM_EXPANDED)) { - $line["cdm_excerpt"] = " - remove_circle"; - - if (get_pref(Prefs::SHOW_CONTENT_PREVIEW)) { - $line["cdm_excerpt"] .= "" . $line["content_preview"] . ""; - } - } - - if ($line["num_enclosures"] > 0) { - $line["enclosures"] = Article::_format_enclosures($id, - sql_bool_to_bool($line["always_display_enclosures"]), - $line["content"], - sql_bool_to_bool($line["hide_images"])); - } else { - $line["enclosures"] = [ 'formatted' => '', 'entries' => [] ]; - } - - $line["updated_long"] = TimeHelper::make_local_datetime($line["updated"],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)); - - if ($line["tag_cache"]) - $tags = explode(",", $line["tag_cache"]); - else - $tags = []; - - $line["tags"] = $tags; - - //$line["tags"] = Article::_get_tags($line["id"], false, $line["tag_cache"]); - - $line['has_icon'] = self::_has_icon($feed_id); - - //setting feed headline background color, needs to change text color based on dark/light - $fav_color = $line['favicon_avg_color'] ?? false; - - $span->addEvent("colors"); - - require_once "colors.php"; - - if (!isset($rgba_cache[$feed_id])) { - if ($fav_color && $fav_color != 'fail') { - $rgba_cache[$feed_id] = \Colors\_color_unpack($fav_color); - } else { - $rgba_cache[$feed_id] = \Colors\_color_unpack($this->_color_of($line['feed_title'])); - } - } - - if (isset($rgba_cache[$feed_id])) { - $line['feed_bg_color'] = 'rgba(' . implode(",", $rgba_cache[$feed_id]) . ',0.3)'; - } - - $span->addEvent("HOOK_RENDER_ARTICLE_CDM"); - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_CDM, - function ($result, $plugin) use (&$line) { - $line = $result; - }, - $line); - - $line['content'] = DiskCache::rewrite_urls($line['content']); - - /* we don't need those */ - - foreach (["date_entered", "guid", "last_published", "last_marked", "tag_cache", "favicon_avg_color", - "uuid", "label_cache", "yyiw", "num_enclosures"] as $k) - unset($line[$k]); - - array_push($reply['content'], $line); - } - } - - if (!$headlines_count) { - - if ($result instanceof PDOStatement) { - - if ($query_error_override) { - $message = $query_error_override; - } else { - switch ($view_mode) { - case "unread": - $message = __("No unread articles found to display."); - break; - case "updated": - $message = __("No updated articles found to display."); - break; - case "marked": - $message = __("No starred articles found to display."); - break; - default: - if ($feed < LABEL_BASE_INDEX) { - $message = __("No articles found to display. You can assign articles to labels manually from article header context menu (applies to all selected articles) or use a filter."); - } else { - $message = __("No articles found to display."); - } - } - } - - if (!$offset && $message) { - $reply['content'] = "
$message"; - - $reply['content'] .= "

"; - - $sth = $this->pdo->prepare("SELECT " . SUBSTRING_FOR_DATE . "(MAX(last_updated), 1, 19) AS last_updated FROM ttrss_feeds - WHERE owner_uid = ?"); - $sth->execute([$_SESSION['uid']]); - $row = $sth->fetch(); - - $last_updated = TimeHelper::make_local_datetime($row["last_updated"], false); - - $reply['content'] .= sprintf(__("Feeds last updated at %s"), $last_updated); - - $num_errors = ORM::for_table('ttrss_feeds') - ->where_not_equal('last_error', '') - ->where('owner_uid', $_SESSION['uid']) - ->where_gte('update_interval', 0) - ->count('id'); - - if ($num_errors > 0) { - $reply['content'] .= "
"; - $reply['content'] .= "" . - __('Some feeds have update errors (click for details)') . ""; - } - $reply['content'] .= "

"; - - } - } else if (is_numeric($result) && $result == -1) { - $reply['first_id_changed'] = true; - } - } - - $span->end(); - - return array($topmost_article_ids, $headlines_count, $feed, $disable_cache, $reply); - } - - 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']]); - - print json_encode(array("message" => "UPDATE_COUNTERS")); - } - - function view(): void { - $reply = array(); - - $feed = $_REQUEST["feed"]; - $method = $_REQUEST["m"] ?? ""; - $view_mode = $_REQUEST["view_mode"] ?? ""; - $limit = 30; - $cat_view = self::_param_to_bool($_REQUEST["cat"] ?? false); - $next_unread_feed = $_REQUEST["nuf"] ?? 0; - $offset = (int) ($_REQUEST["skip"] ?? 0); - $order_by = $_REQUEST["order_by"] ?? ""; - $check_first_id = $_REQUEST["fid"] ?? 0; - - if (is_numeric($feed)) $feed = (int) $feed; - - if ($feed == Feeds::FEED_DASHBOARD) { - print json_encode($this->_generate_dashboard_feed()); - return; - } - - $sth = false; - if ($feed < LABEL_BASE_INDEX) { - - $label_feed = Labels::feed_to_label_id($feed); - - $sth = $this->pdo->prepare("SELECT id FROM ttrss_labels2 WHERE - id = ? AND owner_uid = ?"); - $sth->execute([$label_feed, $_SESSION['uid']]); - - } else if (!$cat_view && is_numeric($feed) && $feed > 0) { - - $sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE - id = ? AND owner_uid = ?"); - $sth->execute([$feed, $_SESSION['uid']]); - - } else if ($cat_view && is_numeric($feed) && $feed > 0) { - - $sth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories WHERE - id = ? AND owner_uid = ?"); - - $sth->execute([$feed, $_SESSION['uid']]); - } - - if ($sth && !$sth->fetch()) { - print json_encode($this->_generate_error_feed(__("Feed not found."))); - return; - } - - set_pref(Prefs::_DEFAULT_VIEW_MODE, $view_mode); - set_pref(Prefs::_DEFAULT_VIEW_ORDER_BY, $order_by); - - /* bump login timestamp if needed */ - if (time() - $_SESSION["last_login_update"] > 3600) { - $user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]); - $user->last_login = Db::NOW(); - $user->save(); - - $_SESSION["last_login_update"] = time(); - } - - if (!$cat_view && is_numeric($feed) && $feed > 0) { - $sth = $this->pdo->prepare("UPDATE ttrss_feeds SET last_viewed = NOW() - WHERE id = ? AND owner_uid = ?"); - $sth->execute([$feed, $_SESSION['uid']]); - } - - $reply['headlines'] = []; - - list($override_order, $skip_first_id_check) = self::_order_to_override_query($order_by); - - $ret = $this->_format_headlines_list($feed, $method, - $view_mode, $limit, $cat_view, $offset, - $override_order, true, $check_first_id, $skip_first_id_check, $order_by); - - $headlines_count = $ret[1]; - $disable_cache = $ret[3]; - $reply['headlines'] = $ret[4]; - - if (!$next_unread_feed) - $reply['headlines']['id'] = $feed; - else - $reply['headlines']['id'] = $next_unread_feed; - - $reply['headlines']['is_cat'] = $cat_view; - - $reply['headlines-info'] = ["count" => (int) $headlines_count, - "disable_cache" => (bool) $disable_cache]; - - // this is parsed by handleRpcJson() on first viewfeed() to set cdm expanded, etc - $reply['runtime-info'] = RPC::_make_runtime_info(); - - print json_encode($reply); - } - - /** - * @return array> - */ - private function _generate_dashboard_feed(): array { - $reply = array(); - - $reply['headlines']['id'] = Feeds::FEED_DASHBOARD; - $reply['headlines']['is_cat'] = false; - - $reply['headlines']['toolbar'] = ''; - - $reply['headlines']['content'] = "
".__('No feed selected.'); - - $reply['headlines']['content'] .= "

"; - - $sth = $this->pdo->prepare("SELECT ".SUBSTRING_FOR_DATE."(MAX(last_updated), 1, 19) AS last_updated FROM ttrss_feeds - WHERE owner_uid = ?"); - $sth->execute([$_SESSION['uid']]); - $row = $sth->fetch(); - - $last_updated = TimeHelper::make_local_datetime($row["last_updated"], false); - - $reply['headlines']['content'] .= sprintf(__("Feeds last updated at %s"), $last_updated); - - $num_errors = ORM::for_table('ttrss_feeds') - ->where_not_equal('last_error', '') - ->where('owner_uid', $_SESSION['uid']) - ->where_gte('update_interval', 0) - ->count('id'); - - if ($num_errors > 0) { - $reply['headlines']['content'] .= "
"; - $reply['headlines']['content'] .= "". - __('Some feeds have update errors (click for details)').""; - } - $reply['headlines']['content'] .= "

"; - - $reply['headlines-info'] = array("count" => 0, - "unread" => 0, - "disable_cache" => true); - - return $reply; - } - - /** - * @return array - */ - private function _generate_error_feed(string $error): array { - $reply = array(); - - $reply['headlines']['id'] = Feeds::FEED_ERROR; - $reply['headlines']['is_cat'] = false; - - $reply['headlines']['toolbar'] = ''; - $reply['headlines']['content'] = "
". $error . "
"; - - $reply['headlines-info'] = array("count" => 0, - "unread" => 0, - "disable_cache" => true); - - return $reply; - } - - function subscribeToFeed(): void { - print json_encode([ - "cat_select" => \Controls\select_feeds_cats("cat") - ]); - } - - 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, - "all_languages" => Pref_Feeds::get_ts_languages(), - "default_language" => get_pref(Prefs::DEFAULT_SEARCH_LANGUAGE) - ]); - } - - 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"] : Debug::LOG_VERBOSE; - - if (!in_array($xdebug, Debug::ALL_LOG_LEVELS)) { - $xdebug = Debug::LOG_VERBOSE; - } - - Debug::set_enabled(true); - Debug::set_loglevel((int)Debug::map_loglevel($xdebug)); - - $feed_id = (int)$_REQUEST["feed_id"]; - $do_update = ($_REQUEST["action"] ?? "") == "do_update"; - $csrf_token = $_POST["csrf_token"]; - - $sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE id = ? AND owner_uid = ?"); - $sth->execute([$feed_id, $_SESSION['uid']]); - - if (!$sth->fetch()) { - print "Access denied."; - return; - } - ?> - - - - Feed Debugger - - - - - - - - - - - -
-

Feed Debugger: _get_title($feed_id) ?>

-
-
- - - - - - -
- -
- -
- -
- -
- -
- -
- -
- - -
- -
- -
-
-
- - - $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']; - - $pdo = Db::pdo(); - - if (is_array($search) && $search[0]) { - $search_qpart = ""; - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_SEARCH, - function ($result) use (&$search_qpart, &$search_words) { - if (!empty($result)) { - list($search_qpart, $search_words) = $result; - return true; - } - }, - $search[0]); - - // fall back in case of no plugins - if (empty($search_qpart)) { - list($search_qpart, $search_words) = self::_search_to_sql($search[0], $search[1], $owner_uid); - } - } else { - $search_qpart = "true"; - } - - // TODO: all this interval stuff needs some generic generator function - - switch ($mode) { - case "1day": - if (Config::get(Config::DB_TYPE) == "pgsql") { - $date_qpart = "date_entered < NOW() - INTERVAL '1 day' "; - } else { - $date_qpart = "date_entered < DATE_SUB(NOW(), INTERVAL 1 DAY) "; - } - break; - case "1week": - if (Config::get(Config::DB_TYPE) == "pgsql") { - $date_qpart = "date_entered < NOW() - INTERVAL '1 week' "; - } else { - $date_qpart = "date_entered < DATE_SUB(NOW(), INTERVAL 1 WEEK) "; - } - break; - case "2week": - if (Config::get(Config::DB_TYPE) == "pgsql") { - $date_qpart = "date_entered < NOW() - INTERVAL '2 week' "; - } else { - $date_qpart = "date_entered < DATE_SUB(NOW(), INTERVAL 2 WEEK) "; - } - break; - default: - $date_qpart = "true"; - } - - if (is_numeric($feed_id_or_tag_name)) { - $feed_id = (int) $feed_id_or_tag_name; - - if ($cat_view) { - - if ($feed_id >= 0) { - - if ($feed_id == Feeds::CATEGORY_UNCATEGORIZED) { - $cat_qpart = "cat_id IS NULL"; - } else { - $children = self::_get_child_cats($feed_id, $owner_uid); - array_push($children, $feed_id); - $children = array_map("intval", $children); - - $children = join(",", $children); - - $cat_qpart = "cat_id IN ($children)"; - } - - $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 IN - (SELECT id FROM ttrss_feeds WHERE $cat_qpart) AND $date_qpart AND $search_qpart) as tmp)"); - $sth->execute([$owner_uid]); - - } else if ($feed_id == Feeds::CATEGORY_LABELS) { - - $sth = $pdo->prepare("UPDATE ttrss_user_entries - SET unread = false,last_read = NOW() WHERE (SELECT COUNT(*) - FROM ttrss_user_labels2, ttrss_entries WHERE article_id = ref_id AND id = ref_id AND $date_qpart AND $search_qpart) > 0 - AND unread = true AND owner_uid = ?"); - $sth->execute([$owner_uid]); - } - - } 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_id]); - - } else if ($feed_id < 0 && $feed_id > LABEL_BASE_INDEX) { // special, like starred - - if ($feed_id == Feeds::FEED_STARRED) { - $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 marked = true AND $date_qpart AND $search_qpart) as tmp)"); - $sth->execute([$owner_uid]); - } - - if ($feed_id == Feeds::FEED_PUBLISHED) { - $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 published = true AND $date_qpart AND $search_qpart) as tmp)"); - $sth->execute([$owner_uid]); - } - - if ($feed_id == Feeds::FEED_FRESH) { - - $intl = (int) get_pref(Prefs::FRESH_ARTICLE_MAX_AGE); - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $match_part = "date_entered > NOW() - INTERVAL '$intl hour' "; - } else { - $match_part = "date_entered > DATE_SUB(NOW(), - INTERVAL $intl HOUR) "; - } - - $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 score >= 0 AND unread = true AND $date_qpart AND $match_part AND $search_qpart) as tmp)"); - $sth->execute([$owner_uid]); - } - - if ($feed_id == Feeds::FEED_ALL) { - $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 $date_qpart AND $search_qpart) as tmp)"); - $sth->execute([$owner_uid]); - } - } else if ($feed_id < LABEL_BASE_INDEX) { // label - - $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 - (SELECT id FROM - (SELECT DISTINCT ttrss_entries.id FROM ttrss_entries, ttrss_user_entries, ttrss_user_labels2 WHERE ref_id = id - 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([$tag_name, $owner_uid]); - } - } - - /** - * @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 { - $span = OpenTelemetry\API\Trace\Span::getCurrent(); - - $span->addEvent(__METHOD__ . ": $feed ($is_cat)"); - - $n_feed = (int) $feed; - $need_entries = false; - - $pdo = Db::pdo(); - - if (!$owner_uid) $owner_uid = $_SESSION["uid"]; - - if ($unread_only) { - $unread_qpart = "unread = true"; - } else { - $unread_qpart = "true"; - } - - $match_part = ""; - - if ($is_cat) { - return self::_get_cat_unread($n_feed, $owner_uid); - } else if(is_numeric($feed) && $feed < PLUGIN_FEED_BASE_INDEX && $feed > LABEL_BASE_INDEX) { // virtual Feed - $feed_id = PluginHost::feed_to_pfeed_id($feed); - $handler = PluginHost::getInstance()->get_feed_handler($feed_id); - if (implements_interface($handler, 'IVirtualFeed')) { - /** @var IVirtualFeed $handler */ - //$span->end(); - return $handler->get_unread($feed_id); - } else { - //$span->end(); - return 0; - } - } else if ($n_feed == Feeds::FEED_RECENTLY_READ) { - //$span->end(); - return 0; - // tags - } else if ($feed != "0" && $n_feed == 0) { - - $sth = $pdo->prepare("SELECT SUM((SELECT COUNT(int_id) - FROM ttrss_user_entries,ttrss_entries WHERE int_id = post_int_id - AND ref_id = id AND $unread_qpart)) AS count FROM ttrss_tags - WHERE owner_uid = ? AND tag_name = ?"); - - $sth->execute([$owner_uid, $feed]); - $row = $sth->fetch(); - - // Handle 'SUM()' returning null if there are no results - //$span->end(); - return $row["count"] ?? 0; - - } else if ($n_feed == Feeds::FEED_STARRED) { - $match_part = "marked = true"; - } else if ($n_feed == Feeds::FEED_PUBLISHED) { - $match_part = "published = true"; - } else if ($n_feed == Feeds::FEED_FRESH) { - $match_part = "unread = true AND score >= 0"; - - $intl = (int) get_pref(Prefs::FRESH_ARTICLE_MAX_AGE, $owner_uid); - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $match_part .= " AND date_entered > NOW() - INTERVAL '$intl hour' "; - } else { - $match_part .= " AND date_entered > DATE_SUB(NOW(), INTERVAL $intl HOUR) "; - } - - $need_entries = true; - - } else if ($n_feed == Feeds::FEED_ALL) { - $match_part = "true"; - } else if ($n_feed >= 0) { - - if ($n_feed != Feeds::FEED_ARCHIVED) { - $match_part = sprintf("feed_id = %d", $n_feed); - } else { - $match_part = "feed_id IS NULL"; - } - - } else if ($feed < LABEL_BASE_INDEX) { - - $label_id = Labels::feed_to_label_id($feed); - - //$span->end(); - return self::_get_label_unread($label_id, $owner_uid); - } - - if ($match_part) { - - if ($need_entries) { - $from_qpart = "ttrss_user_entries,ttrss_entries"; - $from_where = "ttrss_entries.id = ttrss_user_entries.ref_id AND"; - } else { - $from_qpart = "ttrss_user_entries"; - $from_where = ""; - } - - $sth = $pdo->prepare("SELECT count(int_id) AS unread - FROM $from_qpart WHERE - $unread_qpart AND $from_where ($match_part) AND ttrss_user_entries.owner_uid = ?"); - $sth->execute([$owner_uid]); - $row = $sth->fetch(); - - //$span->end(); - return $row["unread"]; - - } else { - - $sth = $pdo->prepare("SELECT COUNT(post_int_id) AS unread - FROM ttrss_tags,ttrss_user_entries,ttrss_entries - WHERE tag_name = ? AND post_int_id = int_id AND ref_id = ttrss_entries.id - AND $unread_qpart AND ttrss_tags.owner_uid = ,"); - - $sth->execute([$feed, $owner_uid]); - $row = $sth->fetch(); - - //$span->end(); - return $row["unread"]; - } - } - - function add(): void { - $feed = clean($_REQUEST['feed']); - $cat = (int) clean($_REQUEST['cat'] ?? ''); - $need_auth = isset($_REQUEST['need_auth']); - $login = $need_auth ? clean($_REQUEST['login']) : ''; - $pass = $need_auth ? clean($_REQUEST['pass']) : ''; - - $rc = Feeds::_subscribe($feed, $cat, $login, $pass); - - print json_encode(array("result" => $rc)); - } - - /** - * @return array (code => Status code, message => error message if available) - * - * 0 - OK, Feed already exists - * 1 - OK, Feed added - * 2 - Invalid URL - * 3 - URL content is HTML, no feeds available - * 4 - URL content is HTML which contains multiple feeds. - * Here you should call extractfeedurls in rpc-backend - * to get all possible feeds. - * 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(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(); - - $url = UrlHelper::validate($url); - - 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, - function ($result) use (&$contents) { - $contents = $result; - }, - $contents, $url, $auth_login, $auth_pass); - - if (empty($contents)) { - if (preg_match("/cloudflare\.com/", UrlHelper::$fetch_last_error_content)) { - UrlHelper::$fetch_last_error .= " (feed behind Cloudflare)"; - } - - return array("code" => 5, "message" => UrlHelper::$fetch_last_error); - } - - if (mb_strpos(UrlHelper::$fetch_last_content_type, "html") !== false && self::_is_html($contents)) { - $feedUrls = self::_get_feeds_from_html($url, $contents); - - if (count($feedUrls) == 0) { - return array("code" => 3); - } else if (count($feedUrls) > 1) { - return array("code" => 4, "feeds" => $feedUrls); - } - //use feed url as new URL - $url = key($feedUrls); - } - - $feed = ORM::for_table('ttrss_feeds') - ->where('feed_url', $url) - ->where('owner_uid', $_SESSION['uid']) - ->find_one(); - - if ($feed) { - return ["code" => 0, "feed_id" => $feed->id]; - } else { - $feed = ORM::for_table('ttrss_feeds')->create(); - - $feed->set([ - 'owner_uid' => $_SESSION['uid'], - 'feed_url' => $url, - 'title' => "[Unknown]", - 'cat_id' => $cat_id ? $cat_id : null, - 'auth_login' => (string)$auth_login, - 'auth_pass' => (string)$auth_pass, - 'update_method' => 0, - 'auth_pass_encrypted' => false, - ]); - - if ($feed->save()) { - RSSUtils::update_basic_info($feed->id); - return ["code" => 1, "feed_id" => (int) $feed->id]; - } - - return ["code" => 7]; - } - } - - static function _get_icon_file(int $feed_id): string { - $favicon_cache = DiskCache::instance('feed-icons'); - - return $favicon_cache->get_full_path((string)$feed_id); - } - - static function _get_icon_url(int $feed_id, string $fallback_url = "") : string { - if (self::_has_icon($feed_id)) { - $icon_url = Config::get_self_url() . "/public.php?" . http_build_query([ - 'op' => 'feed_icon', - 'id' => $feed_id, - ]); - - return $icon_url; - } - - return $fallback_url; - } - - static function _has_icon(int $feed_id): bool { - $favicon_cache = DiskCache::instance('feed-icons'); - - return $favicon_cache->exists((string)$feed_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 Feeds::FEED_ARCHIVED: - return "archive"; - case Feeds::FEED_STARRED: - return "star"; - case Feeds::FEED_PUBLISHED: - return "rss_feed"; - case Feeds::FEED_FRESH: - return "whatshot"; - case Feeds::FEED_ALL: - return "inbox"; - case Feeds::FEED_RECENTLY_READ: - return "restore"; - default: - if ($id < LABEL_BASE_INDEX) { - return "label"; - } else { - return self::_get_icon_url($id); - } - } - } - - /** - * @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) - ->where('feed_url', $feed_url) - ->find_one(); - - if ($feed) { - return $feed->id; - } else { - return false; - } - } - - /** - * $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); - } else if ($id == Feeds::FEED_STARRED) { - return __("Starred articles"); - } else if ($id == Feeds::FEED_PUBLISHED) { - return __("Published articles"); - } else if ($id == Feeds::FEED_FRESH) { - return __("Fresh articles"); - } else if ($id == Feeds::FEED_ALL) { - return __("All articles"); - } else if ($id === Feeds::FEED_ARCHIVED) { - return __("Archived articles"); - } else if ($id == Feeds::FEED_RECENTLY_READ) { - return __("Recently read"); - } else if ($id < LABEL_BASE_INDEX) { - - $label_id = Labels::feed_to_label_id($id); - - $sth = $pdo->prepare("SELECT caption FROM ttrss_labels2 WHERE id = ?"); - $sth->execute([$label_id]); - - if ($row = $sth->fetch()) { - return $row["caption"]; - } else { - return "Unknown label ($label_id)"; - } - - } else if (is_numeric($id) && $id > 0) { - - $sth = $pdo->prepare("SELECT title FROM ttrss_feeds WHERE id = ?"); - $sth->execute([$id]); - - if ($row = $sth->fetch()) { - return $row["title"]; - } else { - return "Unknown feed ($id)"; - } - - } else { - return "$id"; - } - } - - // only real cats - static function _get_cat_marked(int $cat, int $owner_uid = 0): int { - - if (!$owner_uid) $owner_uid = $_SESSION["uid"]; - - $pdo = Db::pdo(); - - if ($cat >= 0) { - - $sth = $pdo->prepare("SELECT SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS marked - 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)) - AND owner_uid = :uid) - AND owner_uid = :uid"); - - $sth->execute(["cat" => $cat ? $cat : null, "uid" => $owner_uid]); - - if ($row = $sth->fetch()) { - return (int) $row["marked"]; - } - } - return 0; - } - - static function _get_cat_unread(int $cat, int $owner_uid = 0): int { - - if (!$owner_uid) $owner_uid = $_SESSION["uid"]; - - $pdo = Db::pdo(); - - if ($cat >= 0) { - - $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)) - AND owner_uid = :uid) - AND owner_uid = :uid"); - - $sth->execute(["cat" => $cat ? $cat : null, "uid" => $owner_uid]); - - if ($row = $sth->fetch()) { - return (int) $row["unread"]; - } - } else if ($cat == Feeds::CATEGORY_SPECIAL) { - return 0; - } else if ($cat == Feeds::CATEGORY_LABELS) { - - $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]); - - 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): int { - if (!$owner_uid) $owner_uid = $_SESSION["uid"]; - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT id FROM ttrss_feed_categories WHERE parent_cat = ? - AND owner_uid = ?"); - $sth->execute([$cat, $owner_uid]); - - $unread = 0; - - while ($line = $sth->fetch()) { - $unread += self::_get_cat_unread($line["id"], $owner_uid); - $unread += self::_get_cat_children_unread($line["id"], $owner_uid); - } - - return $unread; - } - - static function _get_global_unread(int $user_id = 0): int { - - if (!$user_id) $user_id = $_SESSION["uid"]; - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count - FROM ttrss_user_entries ue - WHERE ue.owner_uid = ?"); - - $sth->execute([$user_id]); - $row = $sth->fetch(); - - // 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): string { - switch ($cat_id) { - case Feeds::CATEGORY_UNCATEGORIZED: - return __("Uncategorized"); - case Feeds::CATEGORY_SPECIAL: - return __("Special"); - case Feeds::CATEGORY_LABELS: - return __("Labels"); - default: - $cat = ORM::for_table('ttrss_feed_categories') - ->find_one($cat_id); - - if ($cat) { - return $cat->title; - } else { - return "UNKNOWN"; - } - } - } - - private static function _get_label_unread(int $label_id, ?int $owner_uid = null): int { - if (!$owner_uid) $owner_uid = $_SESSION["uid"]; - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT COUNT(ref_id) AS unread FROM ttrss_user_entries, ttrss_user_labels2 - WHERE owner_uid = ? AND unread = true AND label_id = ? AND article_id = ref_id"); - - $sth->execute([$owner_uid, $label_id]); - - if ($row = $sth->fetch()) { - return $row["unread"]; - } else { - return 0; - } - } - - /** - * @param array $params - * @return array $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 { - - $span = Tracer::start(__METHOD__); - $span->setAttribute('func.args', json_encode(func_get_args())); - - $pdo = Db::pdo(); - - // WARNING: due to highly dynamic nature of this query its going to quote parameters - // right before adding them to SQL part - - $feed = $params["feed"]; - $limit = isset($params["limit"]) ? $params["limit"] : 30; - $view_mode = $params["view_mode"]; - $cat_view = isset($params["cat_view"]) ? $params["cat_view"] : false; - $search = isset($params["search"]) ? $params["search"] : false; - $search_language = isset($params["search_language"]) ? $params["search_language"] : ""; - $override_order = isset($params["override_order"]) ? $params["override_order"] : false; - $offset = isset($params["offset"]) ? $params["offset"] : 0; - $owner_uid = isset($params["owner_uid"]) ? $params["owner_uid"] : $_SESSION["uid"]; - $since_id = isset($params["since_id"]) ? $params["since_id"] : 0; - $include_children = isset($params["include_children"]) ? $params["include_children"] : false; - $ignore_vfeed_group = isset($params["ignore_vfeed_group"]) ? $params["ignore_vfeed_group"] : false; - $override_strategy = isset($params["override_strategy"]) ? $params["override_strategy"] : false; - $override_vfeed = isset($params["override_vfeed"]) ? $params["override_vfeed"] : false; - $start_ts = isset($params["start_ts"]) ? $params["start_ts"] : false; - $check_first_id = isset($params["check_first_id"]) ? $params["check_first_id"] : false; - $skip_first_id_check = isset($params["skip_first_id_check"]) ? $params["skip_first_id_check"] : false; - //$order_by = isset($params["order_by"]) ? $params["order_by"] : false; - - $ext_tables_part = ""; - $limit_query_part = ""; - $query_error_override = ""; - - $search_words = []; - - if ($search) { - $search_query_part = ""; - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_SEARCH, - function ($result) use (&$search_query_part, &$search_words) { - if (!empty($result)) { - list($search_query_part, $search_words) = $result; - return true; - } - }, - $search); - - // fall back in case of no plugins - if (!$search_query_part) { - list($search_query_part, $search_words) = self::_search_to_sql($search, $search_language, $owner_uid); - } - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $test_sth = $pdo->prepare("select $search_query_part - FROM ttrss_entries, ttrss_user_entries WHERE id = ref_id limit 1"); - - try { - $test_sth->execute(); - } catch (PDOException $e) { - // looks like tsquery syntax is invalid - $search_query_part = "false"; - - $query_error_override = T_sprintf("Incorrect search syntax: %s.", implode(" ", $search_words)); - } - } - - $search_query_part .= " AND "; - } else { - $search_query_part = ""; - } - - if ($since_id) { - $since_id_part = "ttrss_entries.id > ".$pdo->quote($since_id)." AND "; - } else { - $since_id_part = ""; - } - - $view_query_part = ""; - - if ($view_mode == "adaptive") { - if ($search) { - $view_query_part = " "; - } else if ($feed != -1) { - // not Feeds::FEED_STARRED or Feeds::CATEGORY_SPECIAL - - $unread = Feeds::_get_counters($feed, $cat_view, true); - - if ($cat_view && $feed > 0 && $include_children) - $unread += self::_get_cat_children_unread($feed); - - if ($unread > 0) { - $view_query_part = " unread = true AND "; - } - } - } - - if ($view_mode == "marked") { - $view_query_part = " marked = true AND "; - } - - if ($view_mode == "has_note") { - $view_query_part = " (note IS NOT NULL AND note != '') AND "; - } - - if ($view_mode == "published") { - $view_query_part = " published = true AND "; - } - - if ($view_mode == "unread" && $feed != Feeds::FEED_RECENTLY_READ) { - $view_query_part = " unread = true AND "; - } - - if ($limit > 0) { - $limit_query_part = "LIMIT " . (int)$limit; - } - - $allow_archived = false; - - $vfeed_query_part = ""; - - /* tags */ - if (!is_numeric($feed)) { - $query_strategy_part = "true"; - $vfeed_query_part = "(SELECT title FROM ttrss_feeds WHERE - id = feed_id) as feed_title,"; - } else if ($feed > 0) { - - if ($cat_view) { - - if ($feed > 0) { - if ($include_children) { - # sub-cats - $subcats = self::_get_child_cats($feed, $owner_uid); - array_push($subcats, $feed); - $subcats = array_map("intval", $subcats); - - $query_strategy_part = "cat_id IN (". - implode(",", $subcats).")"; - - } else { - $query_strategy_part = "cat_id = " . $pdo->quote((string)$feed); - } - - } else { - $query_strategy_part = "cat_id IS NULL"; - } - - $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; - - } else { - $query_strategy_part = "feed_id = " . $pdo->quote((string)$feed); - } - } else if ($feed == Feeds::FEED_ARCHIVED && !$cat_view) { // archive virtual feed - $query_strategy_part = "feed_id IS NULL"; - $allow_archived = true; - } else if ($feed == Feeds::CATEGORY_UNCATEGORIZED && $cat_view) { // uncategorized - $query_strategy_part = "cat_id IS NULL AND feed_id IS NOT NULL"; - $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; - } else if ($feed == -1) { // starred virtual feed, Feeds::FEED_STARRED or Feeds::CATEGORY_SPECIAL - $query_strategy_part = "marked = true"; - $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; - $allow_archived = true; - - if (!$override_order) { - $override_order = "last_marked DESC, date_entered DESC, updated DESC"; - } - - } else if ($feed == -2) { // published virtual feed (Feeds::FEED_PUBLISHED) OR labels category (Feeds::CATEGORY_LABELS) - - if (!$cat_view) { - $query_strategy_part = "published = true"; - $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; - $allow_archived = true; - - if (!$override_order) { - $override_order = "last_published DESC, date_entered DESC, updated DESC"; - } - - } else { - $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; - - $ext_tables_part = "ttrss_labels2,ttrss_user_labels2,"; - - $query_strategy_part = "ttrss_labels2.id = ttrss_user_labels2.label_id AND - ttrss_user_labels2.article_id = ref_id"; - - } - } else if ($feed == Feeds::FEED_RECENTLY_READ) { // recently read - $query_strategy_part = "unread = false AND last_read IS NOT NULL"; - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $query_strategy_part .= " AND last_read > NOW() - INTERVAL '1 DAY' "; - } else { - $query_strategy_part .= " AND last_read > DATE_SUB(NOW(), INTERVAL 1 DAY) "; - } - - $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; - $allow_archived = true; - $ignore_vfeed_group = true; - - if (!$override_order) $override_order = "last_read DESC"; - - } else if ($feed == Feeds::FEED_FRESH) { // fresh virtual feed - $query_strategy_part = "unread = true AND score >= 0"; - - $intl = (int) get_pref(Prefs::FRESH_ARTICLE_MAX_AGE, $owner_uid); - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $query_strategy_part .= " AND date_entered > NOW() - INTERVAL '$intl hour' "; - } else { - $query_strategy_part .= " AND date_entered > DATE_SUB(NOW(), INTERVAL $intl HOUR) "; - } - - $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; - } else if ($feed == Feeds::FEED_ALL) { // all articles virtual feed - $allow_archived = true; - $query_strategy_part = "true"; - $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; - } else if ($feed <= LABEL_BASE_INDEX) { // labels - $label_id = Labels::feed_to_label_id($feed); - - $query_strategy_part = "label_id = $label_id AND - ttrss_labels2.id = ttrss_user_labels2.label_id AND - ttrss_user_labels2.article_id = ref_id"; - - $vfeed_query_part = "ttrss_feeds.title AS feed_title,"; - $ext_tables_part = "ttrss_labels2,ttrss_user_labels2,"; - $allow_archived = true; - - } else { - $query_strategy_part = "true"; - } - - $order_by = "score DESC, date_entered DESC, updated DESC"; - - if ($override_order) { - $order_by = $override_order; - } - - if ($override_strategy) { - $query_strategy_part = $override_strategy; - } - - if ($override_vfeed) { - $vfeed_query_part = $override_vfeed; - } - - $feed_title = ""; - $feed_site_url = ""; - $last_error = ""; - $last_updated = ""; - - if ($search) { - $feed_title = T_sprintf("Search results: %s", $search); - } else { - if ($cat_view) { - $feed_title = self::_get_cat_title($feed); - } else { - if (is_numeric($feed) && $feed > 0) { - $ssth = $pdo->prepare("SELECT title,site_url,last_error,last_updated - FROM ttrss_feeds WHERE id = ? AND owner_uid = ?"); - $ssth->execute([$feed, $owner_uid]); - $row = $ssth->fetch(); - - $feed_title = $row["title"]; - $feed_site_url = $row["site_url"]; - $last_error = $row["last_error"]; - $last_updated = $row["last_updated"]; - } else { - $feed_title = self::_get_title($feed); - } - } - } - - $content_query_part = "content, "; - - if ($limit_query_part) { - $offset_query_part = "OFFSET " . (int)$offset; - } else { - $offset_query_part = ""; - } - - if ($start_ts) { - $start_ts_formatted = date("Y/m/d H:i:s", strtotime($start_ts)); - $start_ts_query_part = "date_entered >= '$start_ts_formatted' AND"; - } else { - $start_ts_query_part = ""; - } - - $first_id = 0; - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $yyiw_qpart = "to_char(date_entered, 'IYYY-IW') AS yyiw"; - } else { - $yyiw_qpart = "date_format(date_entered, '%Y-%u') AS yyiw"; - } - - if (is_numeric($feed)) { - // proper override_order applied above - if ($vfeed_query_part && !$ignore_vfeed_group && get_pref(Prefs::VFEED_GROUP_BY_FEED, $owner_uid)) { - - if (!(in_array($feed, self::NEVER_GROUP_BY_DATE) && !$cat_view)) { - $yyiw_desc = $order_by == "date_reverse" ? "" : "desc"; - $yyiw_order_qpart = "yyiw $yyiw_desc, "; - } else { - $yyiw_order_qpart = ""; - } - - if (!$override_order) { - $order_by = "$yyiw_order_qpart ttrss_feeds.title, $order_by"; - } else { - $order_by = "$yyiw_order_qpart ttrss_feeds.title, $override_order"; - } - } - - if (!$allow_archived) { - $from_qpart = "{$ext_tables_part}ttrss_entries LEFT JOIN ttrss_user_entries ON (ref_id = ttrss_entries.id), ttrss_feeds"; - $feed_check_qpart = "ttrss_user_entries.feed_id = ttrss_feeds.id AND"; - - } else { - $from_qpart = "{$ext_tables_part}ttrss_entries LEFT JOIN ttrss_user_entries ON (ref_id = ttrss_entries.id) - LEFT JOIN ttrss_feeds ON (feed_id = ttrss_feeds.id)"; - $feed_check_qpart = ""; - } - - if ($vfeed_query_part) $vfeed_query_part .= "favicon_avg_color,"; - - $first_id_query_strategy_part = $query_strategy_part; - - if ($feed == Feeds::FEED_FRESH) - $first_id_query_strategy_part = "true"; - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $sanity_interval_qpart = "date_entered >= NOW() - INTERVAL '1 hour' AND"; - - $distinct_columns = str_replace("desc", "", strtolower($order_by)); - $distinct_qpart = "DISTINCT ON (id, $distinct_columns)"; - } else { - $sanity_interval_qpart = "date_entered >= DATE_SUB(NOW(), INTERVAL 1 hour) AND"; - $distinct_qpart = "DISTINCT"; //fallback - } - - // except for Labels category - if (get_pref(Prefs::HEADLINES_NO_DISTINCT, $owner_uid) && !($feed == Feeds::CATEGORY_LABELS && $cat_view)) { - $distinct_qpart = ""; - } - - if (!$search && !$skip_first_id_check) { - // if previous topmost article id changed that means our current pagination is no longer valid - $query = "SELECT - ttrss_entries.id, - date_entered, - $yyiw_qpart, - guid, - ttrss_entries.title, - ttrss_feeds.title, - updated, - score, - marked, - published, - last_marked, - last_published, - last_read - FROM - $from_qpart - WHERE - $feed_check_qpart - ttrss_user_entries.owner_uid = ".$pdo->quote($owner_uid)." AND - $search_query_part - $start_ts_query_part - $since_id_part - $sanity_interval_qpart - $first_id_query_strategy_part ORDER BY $order_by LIMIT 1"; - - if (!empty($_REQUEST["debug"])) { - print "\n*** FIRST ID QUERY ***\n$query\n"; - } - - $res = $pdo->query($query); - - if (!empty($res) && $row = $res->fetch()) { - $first_id = (int)$row["id"]; - - if ($offset > 0 && $first_id && $check_first_id && $first_id != $check_first_id) { - return array(-1, $feed_title, $feed_site_url, $last_error, $last_updated, $search_words, $first_id, $vfeed_query_part != "", $query_error_override); - } - } - } - - $query = "SELECT $distinct_qpart - ttrss_entries.id AS id, - date_entered, - $yyiw_qpart, - guid, - ttrss_entries.title, - updated, - label_cache, - tag_cache, - always_display_enclosures, - site_url, - note, - num_comments, - comments, - int_id, - uuid, - lang, - hide_images, - unread,feed_id,marked,published,link,last_read, - last_marked, last_published, - $vfeed_query_part - $content_query_part - author,score, - (SELECT count(label_id) FROM ttrss_user_labels2 WHERE article_id = ttrss_entries.id) AS num_labels, - (SELECT count(id) FROM ttrss_enclosures WHERE post_id = ttrss_entries.id) AS num_enclosures - FROM - $from_qpart - WHERE - $feed_check_qpart - ttrss_user_entries.owner_uid = ".$pdo->quote($owner_uid)." AND - $search_query_part - $start_ts_query_part - $view_query_part - $since_id_part - $query_strategy_part ORDER BY $order_by - $limit_query_part $offset_query_part"; - - //if ($_REQUEST["debug"]) print $query; - - if (!empty($_REQUEST["debug"])) { - print "\n*** HEADLINES QUERY ***\n$query\n"; - } - - $res = $pdo->query($query); - - } else { - // browsing by tag - - if (get_pref(Prefs::HEADLINES_NO_DISTINCT, $owner_uid)) { - $distinct_qpart = ""; - } else { - if (Config::get(Config::DB_TYPE) == "pgsql") { - $distinct_columns = str_replace("desc", "", strtolower($order_by)); - $distinct_qpart = "DISTINCT ON (id, $distinct_columns)"; - } else { - $distinct_qpart = "DISTINCT"; //fallback - } - } - - $query = "SELECT $distinct_qpart - ttrss_entries.id AS id, - date_entered, - $yyiw_qpart, - guid, - ttrss_entries.title, - updated, - label_cache, - tag_cache, - always_display_enclosures, - site_url, - note, - num_comments, - comments, - int_id, - uuid, - lang, - hide_images, - unread,feed_id,marked,published,link,last_read, - last_marked, last_published, - $since_id_part - $vfeed_query_part - $content_query_part - author, score, - (SELECT count(label_id) FROM ttrss_user_labels2 WHERE article_id = ttrss_entries.id) AS num_labels, - (SELECT count(id) FROM ttrss_enclosures WHERE post_id = ttrss_entries.id) AS num_enclosures - FROM ttrss_entries, - ttrss_user_entries LEFT JOIN ttrss_feeds ON (ttrss_feeds.id = ttrss_user_entries.feed_id), - ttrss_tags - WHERE - ref_id = ttrss_entries.id AND - ttrss_user_entries.owner_uid = ".$pdo->quote($owner_uid)." AND - post_int_id = int_id AND - tag_name = ".$pdo->quote($feed)." AND - $view_query_part - $search_query_part - $start_ts_query_part - $query_strategy_part ORDER BY $order_by - $limit_query_part $offset_query_part"; - - //if ($_REQUEST["debug"]) print $query; - - if (!empty($_REQUEST["debug"])) { - print "\n*** TAGS QUERY ***\n$query\n"; - } - - $res = $pdo->query($query); - } - - $span->end(); - - return array($res, $feed_title, $feed_site_url, $last_error, $last_updated, $search_words, $first_id, $vfeed_query_part != "", $query_error_override); - } - - /** - * @return array - */ - static function _get_parent_cats(int $cat, int $owner_uid): array { - $rv = array(); - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT parent_cat FROM ttrss_feed_categories - WHERE id = ? AND parent_cat IS NOT NULL AND owner_uid = ?"); - $sth->execute([$cat, $owner_uid]); - - while ($line = $sth->fetch()) { - $cat = (int) $line["parent_cat"]; - array_push($rv, $cat, ...self::_get_parent_cats($cat, $owner_uid)); - } - - return $rv; - } - - /** - * @return array - */ - static function _get_child_cats(int $cat, int $owner_uid): array { - $rv = array(); - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT id FROM ttrss_feed_categories - WHERE parent_cat = ? AND owner_uid = ?"); - $sth->execute([$cat, $owner_uid]); - - while ($line = $sth->fetch()) { - array_push($rv, $line["id"], ...self::_get_child_cats($line["id"], $owner_uid)); - } - - return $rv; - } - - /** - * @param array $feeds - * @return array - */ - static function _cats_of(array $feeds, int $owner_uid, bool $with_parents = false): array { - if (count($feeds) == 0) - return []; - - $pdo = Db::pdo(); - - $feeds_qmarks = arr_qmarks($feeds); - - $sth = $pdo->prepare("SELECT DISTINCT cat_id, fc.parent_cat FROM ttrss_feeds f LEFT JOIN ttrss_feed_categories fc - ON (fc.id = f.cat_id) - WHERE f.owner_uid = ? AND f.id IN ($feeds_qmarks)"); - $sth->execute([$owner_uid, ...$feeds]); - - $rv = []; - - if ($row = $sth->fetch()) { - $cat_id = (int) $row["cat_id"]; - $rv[] = $cat_id; - array_push($rv, (int)$row["cat_id"]); - - if ($with_parents && $row["parent_cat"]) { - array_push($rv, ...self::_get_parent_cats($cat_id, $owner_uid)); - } - } - - $rv = array_unique($rv); - - return $rv; - } - - // returns Uncategorized as 0 - static function _cat_of(int $feed) : int { - $feed = ORM::for_table('ttrss_feeds')->find_one($feed); - - if ($feed) { - return (int)$feed->cat_id; - } else { - return -1; - } - } - - 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; - - for ($i = 0; $i < strlen($name); $i++) { - $sum += ord($name[$i]); - } - - $sum %= count($colormap); - - return $colormap[$sum]; - } - - /** - * @return array 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 = []; - - $doc = new DOMDocument(); - if (@$doc->loadHTML($content)) { - $xpath = new DOMXPath($doc); - $entries = $xpath->query('/html/head/link[@rel="alternate" and '. - '(contains(@type,"rss") or contains(@type,"atom"))]|/html/head/link[@rel="feed"]'); - - foreach ($entries as $entry) { - if ($entry->hasAttribute('href')) { - $title = $entry->getAttribute('title'); - if ($title == '') { - $title = $entry->getAttribute('type'); - } - $feedUrl = UrlHelper::rewrite_relative($baseUrl, $entry->getAttribute('href')); - $feedUrls[$feedUrl] = $title; - } - } - } - return $feedUrls; - } - - static function _is_html(string $content): bool { - return preg_match("/where('owner_uid', $owner_uid) - ->find_one($id); - - if ($cat) - $cat->delete(); - } - - 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) - ->where('parent_cat', $parent_cat) - ->where('title', $title) - ->find_one(); - - if (!$cat) { - $cat = ORM::for_table('ttrss_feed_categories')->create(); - - $cat->set([ - 'owner_uid' => $owner_uid, - 'parent_cat' => $parent_cat, - 'order_id' => $order_id, - 'title' => $title, - ]); - - return $cat->save(); - } - - return false; - } - - static function _clear_access_keys(int $owner_uid): void { - $key = ORM::for_table('ttrss_access_keys') - ->where('owner_uid', $owner_uid) - ->delete_many(); - } - - /** - * @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) - ->where('is_cat', $is_cat) - ->delete_many(); - - return self::_get_access_key($feed_id, $is_cat, $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) - ->where('is_cat', $is_cat) - ->find_one(); - - if ($key) { - return $key->access_key; - } - - $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(); - - if ($key->save()) { - return $key->access_key; - } - - return null; - } - - static function _purge(int $feed_id, int $purge_interval): ?int { - - if (!$purge_interval) $purge_interval = self::_get_purge_interval($feed_id); - - $pdo = Db::pdo(); - - $owner_uid = false; - $rows_deleted = 0; - - $sth = $pdo->prepare("SELECT owner_uid FROM ttrss_feeds WHERE id = ?"); - $sth->execute([$feed_id]); - - if ($row = $sth->fetch()) { - $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); - $purge_unread = true; - $purge_interval = Config::get(Config::FORCE_ARTICLE_PURGE); - } else { - $purge_unread = get_pref(Prefs::PURGE_UNREAD_ARTICLES, $owner_uid); - } - - $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); - - if ($purge_interval <= 0) { - Debug::log("purge_feed: purging disabled for this feed, nothing to do.", Debug::LOG_VERBOSE); - return null; - } - - if (!$purge_unread) - $query_limit = " unread = false AND "; - else - $query_limit = ""; - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $sth = $pdo->prepare("DELETE FROM ttrss_user_entries - USING ttrss_entries - WHERE ttrss_entries.id = ref_id AND - marked = false AND - feed_id = ? AND - $query_limit - ttrss_entries.date_updated < NOW() - INTERVAL '$purge_interval days'"); - $sth->execute([$feed_id]); - - } else { - $sth = $pdo->prepare("DELETE FROM ttrss_user_entries - USING ttrss_user_entries, ttrss_entries - WHERE ttrss_entries.id = ref_id AND - marked = false AND - feed_id = ? AND - $query_limit - ttrss_entries.date_updated < DATE_SUB(NOW(), INTERVAL $purge_interval DAY)"); - $sth->execute([$feed_id]); - - } - - $rows_deleted = $sth->rowCount(); - - 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); - } - - return $rows_deleted; - } - - private static function _get_purge_interval(int $feed_id): int { - $feed = ORM::for_table('ttrss_feeds')->find_one($feed_id); - - if ($feed) { - if ($feed->purge_interval != 0) - return $feed->purge_interval; - else - return get_pref(Prefs::PURGE_OLD_DAYS, $feed->owner_uid); - } else { - return -1; - } - } - - /** - * @return array{0: string, 1: array} [$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(); - $search_query_leftover = array(); - - $pdo = Db::pdo(); - - if ($search_language) - $search_language = $pdo->quote(mb_strtolower($search_language)); - else - $search_language = $pdo->quote(mb_strtolower(get_pref(Prefs::DEFAULT_SEARCH_LANGUAGE, $owner_uid))); - - foreach ($keywords as $k) { - if (strpos($k, "-") === 0) { - $k = substr($k, 1); - $not = "NOT"; - } else { - $not = ""; - } - - $commandpair = explode(":", mb_strtolower($k), 2); - - switch ($commandpair[0]) { - case "title": - if ($commandpair[1]) { - array_push($query_keywords, "($not (LOWER(ttrss_entries.title) LIKE ". - $pdo->quote('%' . mb_strtolower($commandpair[1]) . '%') ."))"); - } else { - array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER('%$k%') - OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); - array_push($search_words, $k); - } - break; - case "author": - if ($commandpair[1]) { - array_push($query_keywords, "($not (LOWER(author) LIKE ". - $pdo->quote('%' . mb_strtolower($commandpair[1]) . '%')."))"); - } else { - array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER('%$k%') - OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); - array_push($search_words, $k); - } - break; - case "note": - if ($commandpair[1]) { - if ($commandpair[1] == "true") - array_push($query_keywords, "($not (note IS NOT NULL AND note != ''))"); - else if ($commandpair[1] == "false") - array_push($query_keywords, "($not (note IS NULL OR note = ''))"); - else - array_push($query_keywords, "($not (LOWER(note) LIKE ". - $pdo->quote('%' . mb_strtolower($commandpair[1]) . '%')."))"); - } else { - array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER(".$pdo->quote("%$k%").") - OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); - if (!$not) array_push($search_words, $k); - } - break; - case "star": - - if ($commandpair[1]) { - if ($commandpair[1] == "true") - array_push($query_keywords, "($not (marked = true))"); - else - array_push($query_keywords, "($not (marked = false))"); - } else { - array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER(".$pdo->quote("%$k%").") - OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); - if (!$not) array_push($search_words, $k); - } - break; - case "pub": - if ($commandpair[1]) { - if ($commandpair[1] == "true") - array_push($query_keywords, "($not (published = true))"); - else - array_push($query_keywords, "($not (published = false))"); - - } else { - array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER('%$k%') - OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); - if (!$not) array_push($search_words, $k); - } - break; - case "label": - if ($commandpair[1]) { - $label_id = Labels::find_id($commandpair[1], $_SESSION["uid"]); - - if ($label_id) { - array_push($query_keywords, "($not - (ttrss_entries.id IN ( - SELECT article_id FROM ttrss_user_labels2 WHERE - label_id = $label_id)))"); - } else { - array_push($query_keywords, "(false)"); - } - } else { - array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER(".$pdo->quote("%$k%").") - OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); - if (!$not) array_push($search_words, $k); - } - break; - case "unread": - if ($commandpair[1]) { - if ($commandpair[1] == "true") - array_push($query_keywords, "($not (unread = true))"); - else - array_push($query_keywords, "($not (unread = false))"); - - } else { - array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER(".$pdo->quote("%$k%").") - OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); - if (!$not) array_push($search_words, $k); - } - break; - default: - if (strpos($k, "@") === 0) { - - $user_tz_string = get_pref(Prefs::USER_TIMEZONE, $_SESSION['uid']); - $orig_ts = strtotime(substr($k, 1)); - $k = date("Y-m-d", TimeHelper::convert_timestamp($orig_ts, $user_tz_string, 'UTC')); - - //$k = date("Y-m-d", strtotime(substr($k, 1))); - - array_push($query_keywords, "(".SUBSTRING_FOR_DATE."(updated,1,LENGTH('$k')) $not = '$k')"); - } else { - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $k = mb_strtolower($k); - array_push($search_query_leftover, $not ? "!$k" : $k); - } else { - $k = mb_strtolower($k); - array_push($search_query_leftover, $not ? "-$k" : $k); - - //array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER(".$pdo->quote("%$k%").") - // OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); - } - - if (!$not) array_push($search_words, $k); - } - } - } - - if (count($search_query_leftover) > 0) { - - if (Config::get(Config::DB_TYPE) == "pgsql") { - - // if there's no joiners consider this a "simple" search and - // concatenate everything with &, otherwise don't try to mess with tsquery syntax - if (preg_match("/[&|]/", implode(" " , $search_query_leftover))) { - $tsquery = $pdo->quote(implode(" ", $search_query_leftover)); - } else { - $tsquery = $pdo->quote(implode(" & ", $search_query_leftover)); - } - - array_push($query_keywords, - "(tsvector_combined @@ to_tsquery($search_language, $tsquery))"); - } else { - $ft_query = $pdo->quote(implode(" ", $search_query_leftover)); - - array_push($query_keywords, - "MATCH (ttrss_entries.title, ttrss_entries.content) AGAINST ($ft_query IN BOOLEAN MODE)"); - } - } - - if (count($query_keywords) > 0) - $search_query_part = implode("AND ", $query_keywords); - else - $search_query_part = "false"; - - return array($search_query_part, $search_words); - } - - /** - * @return array{0: string, 1: bool} - */ - static function _order_to_override_query(string $order): array { - $query = ""; - $skip_first_id = false; - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE, - function ($result) use (&$query, &$skip_first_id) { - list ($query, $skip_first_id) = $result; - - // run until first hard match - return !empty($query); - }, - $order); - - if (is_string($query) && $query !== "") { - return [$query, $skip_first_id]; - } - - switch ($order) { - case "title": - $query = "ttrss_entries.title, date_entered, updated"; - break; - case "date_reverse": - $query = "updated"; - $skip_first_id = true; - break; - case "feed_dates": - $query = "updated DESC"; - break; - } - - return [$query, $skip_first_id]; - } - -} - diff --git a/classes/handler.php b/classes/handler.php deleted file mode 100644 index 5b54570d8..000000000 --- a/classes/handler.php +++ /dev/null @@ -1,35 +0,0 @@ - */ - protected array $args; - - /** - * @param array $args - */ - function __construct(array $args) { - $this->pdo = Db::pdo(); - $this->args = $args; - } - - function csrf_ignore(string $method): bool { - return false; - } - - function before(string $method): bool { - return true; - } - - 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 deleted file mode 100644 index 533cb3630..000000000 --- a/classes/handler/administrative.php +++ /dev/null @@ -1,11 +0,0 @@ -= UserHelper::ACCESS_LEVEL_ADMIN) { - return true; - } - } - return false; - } -} diff --git a/classes/handler/protected.php b/classes/handler/protected.php deleted file mode 100644 index a15fc0956..000000000 --- a/classes/handler/protected.php +++ /dev/null @@ -1,7 +0,0 @@ - $owner_uid, - "feed" => $feed, - "limit" => $limit, - "view_mode" => $view_mode, - "cat_view" => $is_cat, - "search" => $search, - "override_order" => $override_order, - "include_children" => true, - "ignore_vfeed_group" => true, - "offset" => $offset, - "start_ts" => $start_ts - ); - - if (!$is_cat && is_numeric($feed) && $feed < PLUGIN_FEED_BASE_INDEX && $feed > LABEL_BASE_INDEX) { - - $user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $owner_uid); - - $tmppluginhost = new PluginHost(); - $tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL); - $tmppluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $owner_uid); - //$tmppluginhost->load_data(); - - $handler = $tmppluginhost->get_feed_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 { - $qfh_ret = Feeds::_get_headlines($params); - } - - $result = $qfh_ret[0]; - $feed_title = htmlspecialchars($qfh_ret[1]); - $feed_site_url = $qfh_ret[2]; - /* $last_error = $qfh_ret[3]; */ - - $feed_self_url = Config::get_self_url() . - "/public.php?op=rss&id=$feed&key=" . - Feeds::_get_access_key($feed, false, $owner_uid); - - if (!$feed_site_url) $feed_site_url = Config::get_self_url(); - - if ($format == 'atom') { - $tpl = new Templator(); - - $tpl->readTemplateFromFile("generated_feed.txt"); - - $tpl->setVariable('FEED_TITLE', $feed_title, true); - $tpl->setVariable('VERSION', Config::get_version(), true); - $tpl->setVariable('FEED_URL', htmlspecialchars($feed_self_url), true); - - $tpl->setVariable('SELF_URL', htmlspecialchars(Config::get_self_url()), true); - while ($line = $result->fetch()) { - - $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, $max_excerpt_length); - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_EXPORT_FEED, - function ($result) use (&$line) { - $line = $result; - }, - $line, $feed, $is_cat, $owner_uid); - - $tpl->setVariable('ARTICLE_ID', - htmlspecialchars($orig_guid ? $line['link'] : - $this->_make_article_tag_uri($line['id'], $line['date_entered'])), true); - $tpl->setVariable('ARTICLE_LINK', htmlspecialchars($line['link']), true); - $tpl->setVariable('ARTICLE_TITLE', htmlspecialchars($line['title']), true); - $tpl->setVariable('ARTICLE_EXCERPT', $line["content_preview"], true); - - $content = Sanitizer::sanitize($line["content"], false, $owner_uid, - $feed_site_url, null, $line["id"]); - - $content = DiskCache::rewrite_urls($content); - - if ($line['note']) { - $content = "
Article note: " . $line['note'] . "
" . - $content; - $tpl->setVariable('ARTICLE_NOTE', htmlspecialchars($line['note']), true); - } - - $tpl->setVariable('ARTICLE_CONTENT', $content, true); - - $tpl->setVariable('ARTICLE_UPDATED_ATOM', - date('c', strtotime($line["updated"] ?? '')), true); - $tpl->setVariable('ARTICLE_UPDATED_RFC822', - date(DATE_RFC822, strtotime($line["updated"] ?? '')), true); - - $tpl->setVariable('ARTICLE_AUTHOR', htmlspecialchars($line['author']), true); - - $tpl->setVariable('ARTICLE_SOURCE_LINK', htmlspecialchars($line['site_url'] ? $line["site_url"] : Config::get_self_url()), true); - $tpl->setVariable('ARTICLE_SOURCE_TITLE', htmlspecialchars($line['feed_title'] ?? $feed_title), true); - - foreach ($line["tags"] as $tag) { - $tpl->setVariable('ARTICLE_CATEGORY', htmlspecialchars($tag), true); - $tpl->addBlock('category'); - } - - $enclosures = Article::_get_enclosures($line["id"]); - - if (count($enclosures) > 0) { - foreach ($enclosures as $e) { - $type = htmlspecialchars($e['content_type']); - $url = htmlspecialchars($e['content_url']); - $length = $e['duration'] ? $e['duration'] : 1; - - $tpl->setVariable('ARTICLE_ENCLOSURE_URL', $url, true); - $tpl->setVariable('ARTICLE_ENCLOSURE_TYPE', $type, true); - $tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', $length, true); - - $tpl->addBlock('enclosure'); - } - } else { - $tpl->setVariable('ARTICLE_ENCLOSURE_URL', "", true); - $tpl->setVariable('ARTICLE_ENCLOSURE_TYPE', "", true); - $tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', "", true); - } - - list ($og_image, $og_stream) = Article::_get_image($enclosures, $line['content'], $feed_site_url, $line); - - $tpl->setVariable('ARTICLE_OG_IMAGE', $og_image, true); - - $tpl->addBlock('entry'); - } - - $tmp = ""; - - $tpl->addBlock('feed'); - $tpl->generateOutputToString($tmp); - - if (empty($_REQUEST["noxml"])) { - header("Content-Type: text/xml; charset=utf-8"); - } else { - header("Content-Type: text/plain; charset=utf-8"); - } - - print $tmp; - } else if ($format == 'json') { - - $feed = array(); - - $feed['title'] = $feed_title; - $feed['feed_url'] = $feed_self_url; - $feed['self_url'] = Config::get_self_url(); - $feed['articles'] = []; - - while ($line = $result->fetch()) { - - $line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...')); - $line["tags"] = Article::_get_tags($line["id"], $owner_uid); - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES, - function ($result) use (&$line) { - $line = $result; - }, - $line); - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_EXPORT_FEED, - function ($result) use (&$line) { - $line = $result; - }, - $line, $feed, $is_cat, $owner_uid); - - $article = array(); - - $article['id'] = $line['link']; - $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, null, $line["id"]); - $article['updated'] = date('c', strtotime($line["updated"] ?? '')); - - if (!empty($line['note'])) $article['note'] = $line['note']; - if (!empty($line['author'])) $article['author'] = $line['author']; - - $article['source'] = [ - 'link' => $line['site_url'] ? $line["site_url"] : Config::get_self_url(), - 'title' => $line['feed_title'] ?? $feed_title - ]; - - if (count($line["tags"]) > 0) { - $article['tags'] = array(); - - foreach ($line["tags"] as $tag) { - array_push($article['tags'], $tag); - } - } - - $enclosures = Article::_get_enclosures($line["id"]); - - if (count($enclosures) > 0) { - $article['enclosures'] = array(); - - foreach ($enclosures as $e) { - $type = $e['content_type']; - $url = $e['content_url']; - $length = $e['duration']; - - array_push($article['enclosures'], array("url" => $url, "type" => $type, "length" => $length)); - } - } - - array_push($feed['articles'], $article); - } - - header("Content-Type: text/json; charset=utf-8"); - print json_encode($feed); - - } else { - header("Content-Type: text/plain; charset=utf-8"); - print "Unknown format: $format."; - } - } - - function getUnread(): void { - $login = clean($_REQUEST["login"]); - $fresh = clean($_REQUEST["fresh"] ?? "0") == "1"; - - $uid = UserHelper::find_user_by_login($login); - - if ($uid) { - print Feeds::_get_global_unread($uid); - - if ($fresh) { - print ";"; - print Feeds::_get_counters(Feeds::FEED_FRESH, false, true, $uid); - } - } else { - print "-1;User not found"; - } - } - - function getProfiles(): void { - $login = clean($_REQUEST["login"]); - $rv = []; - - if ($login) { - $profiles = ORM::for_table('ttrss_settings_profiles') - ->table_alias('p') - ->select_many('title' , 'p.id') - ->join('ttrss_users', ['owner_uid', '=', 'u.id'], 'u') - ->where_raw('LOWER(login) = LOWER(?)', [$login]) - ->order_by_asc('title') - ->find_many(); - - $rv = [ [ "value" => 0, "label" => __("Default profile") ] ]; - - foreach ($profiles as $profile) { - array_push($rv, [ "label" => $profile->title, "value" => $profile->id ]); - } - } - - print json_encode($rv); - } - - function logout(): void { - if (validate_csrf($_POST["csrf_token"])) { - - $login = $_SESSION["name"]; - $user_id = $_SESSION["uid"]; - - UserHelper::logout(); - - $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 = Config::get_self_url() . "/index.php"; - - header("Location: " . $redirect_url); - } else { - header("Content-Type: text/json"); - print Errors::to_json(Errors::E_UNAUTHORIZED); - } - } - - function rss(): void { - $feed = clean($_REQUEST["id"]); - $key = clean($_REQUEST["key"]); - $is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false); - $limit = (int)clean($_REQUEST["limit"] ?? 0); - $offset = (int)clean($_REQUEST["offset"] ?? 0); - - $search = clean($_REQUEST["q"] ?? ""); - $view_mode = clean($_REQUEST["view-mode"] ?? ""); - $order = clean($_REQUEST["order"] ?? ""); - $start_ts = clean($_REQUEST["ts"] ?? ""); - - $format = clean($_REQUEST['format'] ?? "atom"); - $orig_guid = clean($_REQUEST["orig_guid"] ?? ""); - - if (Config::get(Config::SINGLE_USER_MODE)) { - UserHelper::authenticate("admin", null); - } - - if ($key) { - $access_key = ORM::for_table('ttrss_access_keys') - ->select('owner_uid') - ->where(['access_key' => $key, 'feed_id' => $feed]) - ->find_one(); - - if ($access_key) { - $this->generate_syndicated_feed($access_key->owner_uid, $feed, $is_cat, $limit, - $offset, $search, $view_mode, $format, $order, $orig_guid, $start_ts); - return; - } - } - - header('HTTP/1.1 403 Forbidden'); - } - - function updateTask(): void { - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK); - } - - function housekeepingTask(): void { - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); - } - - function globalUpdateFeeds(): void { - RPC::updaterandomfeed_real(); - - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK); - } - - function login(): void { - if (!Config::get(Config::SINGLE_USER_MODE)) { - - $login = clean($_POST["login"]); - $password = clean($_POST["password"]); - $remember_me = clean($_POST["remember_me"] ?? false); - $safe_mode = checkbox_to_sql_bool($_POST["safe_mode"] ?? false); - - if (session_status() != PHP_SESSION_ACTIVE) { - if ($remember_me) { - session_set_cookie_params(Config::get(Config::SESSION_COOKIE_LIFETIME)); - } else { - session_set_cookie_params(0); - } - } - - if (UserHelper::authenticate($login, $password)) { - $_POST["password"] = ""; - - $_SESSION["ref_schema_version"] = Config::get_schema_version(); - $_SESSION["bw_limit"] = !!clean($_POST["bw_limit"] ?? false); - $_SESSION["safe_mode"] = $safe_mode; - - if (!empty($_POST["profile"])) { - $profile = (int) clean($_POST["profile"]); - - $profile_obj = ORM::for_table('ttrss_settings_profiles') - ->where(['id' => $profile, 'owner_uid' => $_SESSION['uid']]) - ->find_one(); - - $_SESSION["profile"] = $profile_obj ? $profile : null; - } - } else { - - // start an empty session to deliver login error message - if (session_status() != PHP_SESSION_ACTIVE) - session_start(); - - $_SESSION["login_error_msg"] ??= __("Incorrect username or password"); - } - - $return = clean($_REQUEST['return'] ?? ''); - - if ($return && mb_strpos($return, Config::get_self_url()) === 0) { - header("Location: $return"); - } else { - header("Location: " . Config::get_self_url()); - } - } - } - - function index(): void { - header("Content-Type: text/plain"); - print Errors::to_json(Errors::E_UNKNOWN_METHOD); - } - - function forgotpass(): void { - startup_gettext(); - session_start(); - - $hash = clean($_REQUEST["hash"] ?? ''); - - header('Content-Type: text/html; charset=utf-8'); - ?> - - - - Tiny Tiny RSS - - - - - - - -
- - - ".__("Password recovery").""; - print "
"; - - $method = clean($_POST['method'] ?? ''); - - if ($hash) { - $login = clean($_REQUEST["login"]); - - if ($login) { - $user = ORM::for_table('ttrss_users') - ->select_many('id', 'resetpass_token') - ->where_raw('LOWER(login) = LOWER(?)', [$login]) - ->find_one(); - - if ($user) { - list($timestamp, $resetpass_token) = explode(":", $user->resetpass_token); - - if ($timestamp && $resetpass_token && - $timestamp >= time() - 15*60*60 && - $resetpass_token === $hash) { - $user->resetpass_token = null; - $user->save(); - - UserHelper::reset_password($user->id, true); - - print "

"."Completed."."

"; - - } else { - print_error("Some of the information provided is missing or incorrect."); - } - } else { - print_error("Some of the information provided is missing or incorrect."); - } - } else { - print_error("Some of the information provided is missing or incorrect."); - } - - print "".__("Return to Tiny Tiny RSS").""; - - } else if (!$method) { - print_notice(__("You will need to provide valid account name and email. Password reset link will be sent to your email address.")); - - print "
- - - -
- - -
- -
- - -
"; - - $_SESSION["pwdreset:testvalue1"] = rand(1,10); - $_SESSION["pwdreset:testvalue2"] = rand(1,10); - - print "
- - -
- -
-
- - ".__("Return to Tiny Tiny RSS")." -
- -
"; - } else if ($method == 'do') { - $login = clean($_POST["login"]); - $email = clean($_POST["email"]); - $test = clean($_POST["test"]); - - if ($test != ($_SESSION["pwdreset:testvalue1"] + $_SESSION["pwdreset:testvalue2"]) || !$email || !$login) { - print_error(__('Some of the required form parameters are missing or incorrect.')); - - print "
- - -
"; - } else { - // prevent submitting this form multiple times - $_SESSION["pwdreset:testvalue1"] = rand(1, 1000); - $_SESSION["pwdreset:testvalue2"] = rand(1, 1000); - - $user = ORM::for_table('ttrss_users') - ->select('id') - ->where_raw('LOWER(login) = LOWER(?)', [$login]) - ->where('email', $email) - ->find_one(); - - if ($user) { - print_notice("Password reset instructions are being sent to your email address."); - - $resetpass_token = sha1(get_random_bytes(128)); - $resetpass_link = Config::get_self_url() . "/public.php?op=forgotpass&hash=" . $resetpass_token . - "&login=" . urlencode($login); - - $tpl = new Templator(); - - $tpl->readTemplateFromFile("resetpass_link_template.txt"); - - $tpl->setVariable('LOGIN', $login); - $tpl->setVariable('RESETPASS_LINK', $resetpass_link); - $tpl->setVariable('TTRSS_HOST', Config::get_self_url()); - - $tpl->addBlock('message'); - - $message = ""; - - $tpl->generateOutputToString($message); - - $mailer = new Mailer(); - - $rc = $mailer->mail(["to_name" => $login, - "to_address" => $email, - "subject" => __("[tt-rss] Password reset request"), - "message" => $message]); - - if (!$rc) print_error($mailer->error()); - - $user->resetpass_token = time() . ":" . $resetpass_token; - $user->save(); - - print "".__("Return to Tiny Tiny RSS").""; - } else { - print_error(__("Sorry, login and email combination not found.")); - - print "
- - -
"; - } - } - } - - print "
"; - print "
"; - print ""; - print ""; - } - - function dbupdate(): void { - startup_gettext(); - - if (!Config::get(Config::SINGLE_USER_MODE) && ($_SESSION["access_level"] ?? 0) < 10) { - $_SESSION["login_error_msg"] = __("Your access level is insufficient to run this script."); - $this->_render_login_form(); - exit; - } - - ?> - - - - Tiny Tiny RSS: Database Updater - - - - - - - - - - - - - - - - -
-

- -
- - is_migration_needed()) { - ?> - -

- -
migrate();
-							Debug::set_loglevel(Debug::LOG_NORMAL);
-							Debug::set_enabled(false);
-						?>
- - - - -
- - "return confirmDbUpdate()"]) ?> -
- - - - - - - - - - - is_migration_needed()) { - - ?> -

- - - -
- - "return confirmDbUpdate()"]) ?> -
- - - - - - - - - -
-
- - - exists($filename)) { - $cache->send($filename); - } else { - header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); - echo "File not found."; - } - } - - function feed_icon() : void { - $id = (int)$_REQUEST['id']; - $cache = DiskCache::instance('feed-icons'); - - if ($cache->exists((string)$id)) { - $cache->send((string)$id); - } else { - header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); - echo "File not found."; - } - } - - private function _make_article_tag_uri(int $id, string $timestamp): string { - - $timestamp = date("Y-m-d", strtotime($timestamp)); - - return "tag:" . parse_url(Config::get_self_url(), PHP_URL_HOST) . ",$timestamp:/$id"; - } - - // 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(): void { - $host = new PluginHost(); - - $plugin_name = basename(clean($_REQUEST["plugin"])); - $method = clean($_REQUEST["pmethod"]); - - $host->load($plugin_name, PluginHost::KIND_ALL, 0); - //$host->load_data(); - - $plugin = $host->get_plugin($plugin_name); - - if ($plugin) { - if (method_exists($plugin, $method)) { - if ($plugin->is_public_method($method)) { - $plugin->$method(); - } else { - user_error("PluginHandler[PUBLIC]: Requested private method '$method' of plugin '$plugin_name'.", E_USER_WARNING); - header("Content-Type: text/json"); - print Errors::to_json(Errors::E_UNAUTHORIZED); - } - } else { - user_error("PluginHandler[PUBLIC]: Requested unknown method '$method' of plugin '$plugin_name'.", E_USER_WARNING); - header("Content-Type: text/json"); - print Errors::to_json(Errors::E_UNKNOWN_METHOD); - } - } else { - user_error("PluginHandler[PUBLIC]: Requested method '$method' of unknown plugin '$plugin_name'.", E_USER_WARNING); - header("Content-Type: text/json"); - print Errors::to_json(Errors::E_UNKNOWN_PLUGIN, ['plugin' => $plugin_name]); - } - } - - static function _render_login_form(string $return_to = ""): void { - header('Cache-Control: public'); - - if ($return_to) - $_REQUEST['return'] = $return_to; - - require_once "login_form.php"; - exit; - } - -} -?> diff --git a/classes/iauthmodule.php b/classes/iauthmodule.php deleted file mode 100644 index dbf8c5587..000000000 --- a/classes/iauthmodule.php +++ /dev/null @@ -1,18 +0,0 @@ -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/icatchall.php b/classes/icatchall.php deleted file mode 100644 index 29954d35a..000000000 --- a/classes/icatchall.php +++ /dev/null @@ -1,4 +0,0 @@ - $options - * @return array - */ - function get_headlines(int $feed_id, array $options) : array; -} diff --git a/classes/labels.php b/classes/labels.php deleted file mode 100644 index 026e6621f..000000000 --- a/classes/labels.php +++ /dev/null @@ -1,233 +0,0 @@ -prepare("SELECT id FROM ttrss_labels2 WHERE LOWER(caption) = LOWER(?) - AND owner_uid = ? LIMIT 1"); - $sth->execute([$label, $owner_uid]); - - if ($row = $sth->fetch()) { - return $row['id']; - } else { - return 0; - } - } - - static function find_caption(int $label, int $owner_uid): string { - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT caption FROM ttrss_labels2 WHERE id = ? - AND owner_uid = ? LIMIT 1"); - $sth->execute([$label, $owner_uid]); - - if ($row = $sth->fetch()) { - return $row['caption']; - } else { - return ""; - } - } - - /** - * @return array> - */ - static function get_as_hash(int $owner_uid): array { - $rv = []; - $labels = Labels::get_all($owner_uid); - - foreach ($labels as $i => $label) { - $rv[(int)$label["id"]] = $labels[$i]; - } - - return $rv; - } - - /** - * @return array> An array of label detail arrays - */ - static function get_all(int $owner_uid) { - $rv = array(); - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT id, fg_color, bg_color, caption FROM ttrss_labels2 - WHERE owner_uid = ? ORDER BY caption"); - $sth->execute([$owner_uid]); - - while ($line = $sth->fetch(PDO::FETCH_ASSOC)) { - array_push($rv, $line); - } - - return $rv; - } - - /** - * @param array{'no-labels': 1}|array> $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) - self::clear_cache($id); - - if (!$labels) - $labels = Article::_get_labels($id); - - $labels = json_encode($labels); - - $sth = $pdo->prepare("UPDATE ttrss_user_entries SET - label_cache = ? WHERE ref_id = ? AND owner_uid = ?"); - $sth->execute([$labels, $id, $owner_uid]); - - } - - static function clear_cache(int $id): void { - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("UPDATE ttrss_user_entries SET - label_cache = '' WHERE ref_id = ?"); - $sth->execute([$id]); - - } - - static function remove_article(int $id, string $label, int $owner_uid): void { - - $label_id = self::find_id($label, $owner_uid); - - if (!$label_id) return; - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("DELETE FROM ttrss_user_labels2 - WHERE - label_id = ? AND - article_id = ?"); - - $sth->execute([$label_id, $id]); - - self::clear_cache($id); - } - - static function add_article(int $id, string $label, int $owner_uid): void { - - $label_id = self::find_id($label, $owner_uid); - - if (!$label_id) return; - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT - article_id FROM ttrss_labels2, ttrss_user_labels2 - WHERE - label_id = id AND - label_id = ? AND - article_id = ? AND owner_uid = ? - LIMIT 1"); - - $sth->execute([$label_id, $id, $owner_uid]); - - if (!$sth->fetch()) { - $sth = $pdo->prepare("INSERT INTO ttrss_user_labels2 - (label_id, article_id) VALUES (?, ?)"); - - $sth->execute([$label_id, $id]); - } - - self::clear_cache($id); - - } - - static function remove(int $id, int $owner_uid): void { - if (!$owner_uid) $owner_uid = $_SESSION["uid"]; - - $pdo = Db::pdo(); - $tr_in_progress = false; - - try { - $pdo->beginTransaction(); - } catch (Exception $e) { - $tr_in_progress = true; - } - - $sth = $pdo->prepare("SELECT caption FROM ttrss_labels2 - WHERE id = ?"); - $sth->execute([$id]); - - $row = $sth->fetch(); - $caption = $row['caption']; - - $sth = $pdo->prepare("DELETE FROM ttrss_labels2 WHERE id = ? - AND owner_uid = ?"); - $sth->execute([$id, $owner_uid]); - - if ($sth->rowCount() != 0 && $caption) { - - /* Remove access key for the label */ - - $ext_id = LABEL_BASE_INDEX - 1 - $id; - - $sth = $pdo->prepare("DELETE FROM ttrss_access_keys WHERE - feed_id = ? AND owner_uid = ?"); - $sth->execute([$ext_id, $owner_uid]); - - /* Remove cached data */ - - $sth = $pdo->prepare("UPDATE ttrss_user_entries SET label_cache = '' - WHERE owner_uid = ?"); - $sth->execute([$owner_uid]); - - } - - if (!$tr_in_progress) $pdo->commit(); - } - - /** - * @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']; - - $pdo = Db::pdo(); - - $tr_in_progress = false; - - try { - $pdo->beginTransaction(); - } catch (Exception $e) { - $tr_in_progress = true; - } - - $sth = $pdo->prepare("SELECT id FROM ttrss_labels2 - WHERE LOWER(caption) = LOWER(?) AND owner_uid = ?"); - $sth->execute([$caption, $owner_uid]); - - if (!$sth->fetch()) { - $sth = $pdo->prepare("INSERT INTO ttrss_labels2 - (caption,owner_uid,fg_color,bg_color) VALUES (?, ?, ?, ?)"); - - $sth->execute([$caption, $owner_uid, $fg_color, $bg_color]); - - $result = $sth->rowCount(); - } else { - $result = false; - } - - if (!$tr_in_progress) $pdo->commit(); - - return $result; - } -} diff --git a/classes/logger.php b/classes/logger.php deleted file mode 100755 index ef6173a42..000000000 --- a/classes/logger.php +++ /dev/null @@ -1,89 +0,0 @@ - 'E_ERROR', - 2 => 'E_WARNING', - 4 => 'E_PARSE', - 8 => 'E_NOTICE', - 16 => 'E_CORE_ERROR', - 32 => 'E_CORE_WARNING', - 64 => 'E_COMPILE_ERROR', - 128 => 'E_COMPILE_WARNING', - 256 => 'E_USER_ERROR', - 512 => 'E_USER_WARNING', - 1024 => 'E_USER_NOTICE', - 2048 => 'E_STRICT', - 4096 => 'E_RECOVERABLE_ERROR', - 8192 => 'E_DEPRECATED', - 16384 => 'E_USER_DEPRECATED', - 32767 => 'E_ALL']; - - 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(int $errno, string $errstr, string $file, int $line, string $context): bool { - //if ($errno == E_NOTICE) return false; - - if ($this->adapter) - return $this->adapter->log_error($errno, $errstr, $file, $line, $context); - else - return false; - } - - 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, string $context = ""): bool { - if ($this->adapter) - return $this->adapter->log_error($errno, $errstr, '', 0, $context); - else - return user_error($errstr, $errno); - } - - private function __clone() { - // - } - - function __construct() { - switch (Config::get(Config::LOG_DESTINATION)) { - case self::LOG_DEST_SQL: - $this->adapter = new Logger_SQL(); - break; - case self::LOG_DEST_SYSLOG: - $this->adapter = new Logger_Syslog(); - break; - case self::LOG_DEST_STDOUT: - $this->adapter = new Logger_Stdout(); - break; - default: - $this->adapter = null; - } - - if ($this->adapter && !implements_interface($this->adapter, "Logger_Adapter")) - user_error("Adapter for LOG_DESTINATION: " . Config::LOG_DESTINATION . " does not implement required interface.", E_USER_ERROR); - } - - private static function get_instance() : Logger { - if (self::$instance == null) - self::$instance = new self(); - - return self::$instance; - } - - static function get() : Logger { - user_error("Please don't use Logger::get(), call Logger::log(...) instead.", E_USER_DEPRECATED); - return self::get_instance(); - } -} diff --git a/classes/logger/adapter.php b/classes/logger/adapter.php deleted file mode 100644 index b0287b5fa..000000000 --- a/classes/logger/adapter.php +++ /dev/null @@ -1,4 +0,0 @@ - 117) { - - // limit context length, DOMDocument dumps entire XML in here sometimes, which may be huge - $context = mb_substr($context, 0, 8192); - - $server_params = [ - "Real IP" => "HTTP_X_REAL_IP", - "Forwarded For" => "HTTP_X_FORWARDED_FOR", - "Forwarded Protocol" => "HTTP_X_FORWARDED_PROTO", - "Remote IP" => "REMOTE_ADDR", - "Request URI" => "REQUEST_URI", - "User agent" => "HTTP_USER_AGENT", - ]; - - foreach ($server_params as $n => $p) { - if (isset($_SERVER[$p])) - $context .= "\n$n: " . $_SERVER[$p]; - } - - // passed error message may contain invalid unicode characters, failing to insert an error here - // would break the execution entirely by generating an actual fatal error instead of a E_WARNING etc - $errstr = UConverter::transcode($errstr, 'UTF-8', 'UTF-8'); - $context = UConverter::transcode($context, 'UTF-8', 'UTF-8'); - - // can't use $_SESSION["uid"] ?? null because what if its, for example, false? or zero? - // this would cause a PDOException on insert below - $owner_uid = !empty($_SESSION["uid"]) ? $_SESSION["uid"] : null; - - $entry = ORM::for_table('ttrss_error_log', get_class($this))->create(); - - $entry->set([ - 'errno' => $errno, - 'errstr' => $errstr, - 'filename' => $file, - 'lineno' => (int)$line, - 'context' => $context, - 'owner_uid' => $owner_uid, - 'created_at' => Db::NOW(), - ]); - - return $entry->save(); - } - - return false; - } - -} diff --git a/classes/logger/stdout.php b/classes/logger/stdout.php deleted file mode 100644 index b15649028..000000000 --- a/classes/logger/stdout.php +++ /dev/null @@ -1,31 +0,0 @@ - $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"]; - $subject = $params["subject"]; - $message = $params["message"]; - $message_html = $params["message_html"] ?? ""; - $from_name = $params["from_name"] ?? Config::get(Config::SMTP_FROM_NAME); - $from_address = $params["from_address"] ?? Config::get(Config::SMTP_FROM_ADDRESS); - $additional_headers = $params["headers"] ?? []; - - $from_combined = $from_name ? "$from_name <$from_address>" : $from_address; - $to_combined = $to_name ? "$to_name <$to_address>" : $to_address; - - if (Config::get(Config::LOG_SENT_MAIL)) - Logger::log(E_USER_NOTICE, "Sending mail from $from_combined to $to_combined [$subject]: $message"); - - // HOOK_SEND_MAIL plugin instructions: - // 1. return 1 or true if mail is handled - // 2. return -1 if there's been a fatal error and no further action is allowed - // 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); - - if ($rc == 1) - return $rc; - - if ($rc == -1) - return 0; - - ++$hooks_tried; - } - - $headers = [ "From: $from_combined", "Content-Type: text/plain; charset=UTF-8" ]; - - $rc = mail($to_combined, $subject, $message, implode("\r\n", [...$headers, ...$additional_headers])); - - if (!$rc) { - $this->set_error(error_get_last()['message'] ?? T_sprintf("Unknown error while sending mail. Hooks tried: %d.", $hooks_tried)); - } - - return $rc; - } - - function set_error(string $message): void { - $this->last_error = $message; - user_error("Error sending mail: $message", E_USER_WARNING); - } - - function error(): string { - return $this->last_error; - } -} diff --git a/classes/opml.php b/classes/opml.php deleted file mode 100644 index b9f5f2eab..000000000 --- a/classes/opml.php +++ /dev/null @@ -1,703 +0,0 @@ -opml_export($output_name, $owner_uid, false, $include_settings); - - return $rc; - } - - function import(): void { - $owner_uid = $_SESSION["uid"]; - - header('Content-Type: text/html; charset=utf-8'); - - print " - - ".stylesheet_tag("themes/light.css")." - ".__("OPML Utility")." - - - -

".__('OPML Utility')."

"; - - Feeds::_add_cat("Imported feeds", $owner_uid); - - $this->opml_notice(__("Importing OPML...")); - - $this->opml_import($owner_uid); - - print "
- -
"; - - print "
"; - } - - // Export - - 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 = '')"; - else - $hide_qpart = "true"; - - $out = ""; - - $ttrss_specific_qpart = ""; - - if ($cat_id) { - $sth = $this->pdo->prepare("SELECT title,order_id - FROM ttrss_feed_categories WHERE id = ? - AND owner_uid = ?"); - $sth->execute([$cat_id, $owner_uid]); - $row = $sth->fetch(); - $cat_title = htmlspecialchars($row['title']); - - if ($include_settings) { - $order_id = (int)$row["order_id"]; - $ttrss_specific_qpart = "ttrssSortOrder=\"$order_id\""; - } - } else { - $cat_title = ""; - } - - if ($cat_title) $out .= "\n"; - - $sth = $this->pdo->prepare("SELECT id,title - FROM ttrss_feed_categories WHERE - (parent_cat = :cat OR (:cat = 0 AND parent_cat IS NULL)) AND - owner_uid = :uid ORDER BY order_id, title"); - - $sth->execute([':cat' => $cat_id, ':uid' => $owner_uid]); - - while ($line = $sth->fetch()) { - $out .= $this->opml_export_category($owner_uid, $line["id"], $hide_private_feeds, $include_settings); - } - - $fsth = $this->pdo->prepare("select title, feed_url, site_url, update_interval, order_id, purge_interval - FROM ttrss_feeds WHERE - (cat_id = :cat OR (:cat = 0 AND cat_id IS NULL)) AND owner_uid = :uid AND $hide_qpart - ORDER BY order_id, title"); - - $fsth->execute([':cat' => $cat_id, ':uid' => $owner_uid]); - - while ($fline = $fsth->fetch()) { - $title = htmlspecialchars($fline["title"]); - $url = htmlspecialchars($fline["feed_url"]); - $site_url = htmlspecialchars($fline["site_url"]); - - if ($include_settings) { - $update_interval = (int)$fline["update_interval"]; - $order_id = (int)$fline["order_id"]; - $purge_interval = (int)$fline["purge_interval"]; - - $ttrss_specific_qpart = "ttrssSortOrder=\"$order_id\" ttrssPurgeInterval=\"$purge_interval\" ttrssUpdateInterval=\"$update_interval\""; - } else { - $ttrss_specific_qpart = ""; - } - - if ($site_url) { - $html_url_qpart = "htmlUrl=\"$site_url\""; - } else { - $html_url_qpart = ""; - } - - $out .= "\n"; - } - - if ($cat_title) $out .= "\n"; - - return $out; - } - - /** - * @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) - if (!isset($_REQUEST["debug"])) { - header("Content-type: application/xml+opml"); - header("Content-Disposition: attachment; filename=$filename"); - } else { - header("Content-type: text/xml"); - } - - $out = ""; - - $out .= ""; - $out .= " - " . date("r", time()) . " - Tiny Tiny RSS Feed Export - "; - $out .= ""; - - $out .= $this->opml_export_category($owner_uid, 0, $hide_private_feeds, $include_settings); - - # export tt-rss settings - - if ($include_settings) { - $out .= ""; - - $sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2 WHERE - profile IS NULL AND owner_uid = ? ORDER BY pref_name"); - $sth->execute([$owner_uid]); - - while ($line = $sth->fetch()) { - $name = $line["pref_name"]; - $value = htmlspecialchars($line["value"]); - - $out .= ""; - } - - $out .= ""; - - $out .= ""; - - $sth = $this->pdo->prepare("SELECT * FROM ttrss_labels2 WHERE - owner_uid = ?"); - $sth->execute([$owner_uid]); - - while ($line = $sth->fetch()) { - $name = htmlspecialchars($line['caption']); - $fg_color = htmlspecialchars($line['fg_color']); - $bg_color = htmlspecialchars($line['bg_color']); - - $out .= ""; - - } - - $out .= ""; - - $out .= ""; - - $sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2 - WHERE owner_uid = ? ORDER BY id"); - $sth->execute([$owner_uid]); - - while ($line = $sth->fetch(PDO::FETCH_ASSOC)) { - $line["rules"] = array(); - $line["actions"] = array(); - - $tmph = $this->pdo->prepare("SELECT * FROM ttrss_filters2_rules - WHERE filter_id = ?"); - $tmph->execute([$line['id']]); - - while ($tmp_line = $tmph->fetch(PDO::FETCH_ASSOC)) { - unset($tmp_line["id"]); - unset($tmp_line["filter_id"]); - - $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"]); - } - - unset($tmp_line["feed_id"]); - unset($tmp_line["cat_id"]); - - array_push($line["rules"], $tmp_line); - } - - $tmph = $this->pdo->prepare("SELECT * FROM ttrss_filters2_actions - WHERE filter_id = ?"); - $tmph->execute([$line['id']]); - - while ($tmp_line = $tmph->fetch(PDO::FETCH_ASSOC)) { - unset($tmp_line["id"]); - unset($tmp_line["filter_id"]); - - array_push($line["actions"], $tmp_line); - } - - unset($line["id"]); - unset($line["owner_uid"]); - $filter = json_encode($line); - - $out .= ""; - - } - - - $out .= ""; - } - - $out .= ""; - - // Format output. - $doc = new DOMDocument(); - $doc->formatOutput = true; - $doc->preserveWhiteSpace = false; - $doc->loadXML($out); - - $xpath = new DOMXPath($doc); - $outlines = $xpath->query("//outline[@title]"); - - // cleanup empty categories - foreach ($outlines as $node) { - if ($node->getElementsByTagName('outline')->length == 0) - $node->parentNode->removeChild($node); - } - - $res = $doc->saveXML(); - -/* // saveXML uses a two-space indent. Change to tabs. - $res = preg_replace_callback('/^(?: )+/mu', - create_function( - '$matches', - 'return str_repeat("\t", intval(strlen($matches[0])/2));'), - $res); */ - - if ($file_output) - return file_put_contents($filename, $res) > 0; - - print $res; - return true; - } - - // Import - - 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); - if (!$feed_title) $feed_title = mb_substr($attrs->getNamedItem('title')->nodeValue, 0, 250); - - $feed_url = $attrs->getNamedItem('xmlUrl')->nodeValue; - if (!$feed_url) $feed_url = $attrs->getNamedItem('xmlURL')->nodeValue; - - $site_url = mb_substr($attrs->getNamedItem('htmlUrl')->nodeValue, 0, 250); - - if ($feed_url) { - $sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE - feed_url = ? AND owner_uid = ?"); - $sth->execute([$feed_url, $owner_uid]); - - if (!$feed_title) $feed_title = '[Unknown]'; - - 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), $nest); - - if (!$cat_id) $cat_id = null; - - $update_interval = (int) $attrs->getNamedItem('ttrssUpdateInterval')->nodeValue; - if (!$update_interval) $update_interval = 0; - - $order_id = (int) $attrs->getNamedItem('ttrssSortOrder')->nodeValue; - if (!$order_id) $order_id = 0; - - $purge_interval = (int) $attrs->getNamedItem('ttrssPurgeInterval')->nodeValue; - if (!$purge_interval) $purge_interval = 0; - - $sth = $this->pdo->prepare("INSERT INTO ttrss_feeds - (title, feed_url, owner_uid, cat_id, site_url, order_id, update_interval, purge_interval) VALUES - (?, ?, ?, ?, ?, ?, ?, ?)"); - - $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), $nest); - } - } - } - - private function opml_import_label(DOMNode $node, int $owner_uid, int $nest): void { - $attrs = $node->attributes; - $label_name = $attrs->getNamedItem('label-name')->nodeValue; - - if ($label_name) { - $fg_color = $attrs->getNamedItem('label-fg-color')->nodeValue; - $bg_color = $attrs->getNamedItem('label-bg-color')->nodeValue; - - 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)), $nest); - } - } - } - - private function opml_import_preference(DOMNode $node, int $owner_uid, int $nest): void { - $attrs = $node->attributes; - $pref_name = $attrs->getNamedItem('pref-name')->nodeValue; - - if ($pref_name) { - $pref_value = $attrs->getNamedItem('value')->nodeValue; - - $this->opml_notice(T_sprintf("Setting preference key %s to %s", - $pref_name, $pref_value), $nest); - - set_pref($pref_name, $pref_value, $owner_uid); - } - } - - private function opml_import_filter(DOMNode $node, int $owner_uid, int $nest): void { - $attrs = $node->attributes; - - $filter_type = $attrs->getNamedItem('filter-type')->nodeValue; - - if ($filter_type == '2') { - $filter = json_decode($node->nodeValue, true); - - if ($filter) { - $match_any_rule = bool_to_sql_bool($filter["match_any_rule"]); - $enabled = bool_to_sql_bool($filter["enabled"]); - $inverse = bool_to_sql_bool($filter["inverse"]); - $title = $filter["title"]; - - //print "F: $title, $inverse, $enabled, $match_any_rule"; - - $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, $owner_uid]); - - $sth = $this->pdo->prepare("SELECT MAX(id) AS id FROM ttrss_filters2 WHERE - owner_uid = ?"); - $sth->execute([$owner_uid]); - - $row = $sth->fetch(); - $filter_id = $row['id']; - - if ($filter_id) { - $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"] ?? false) { - - $match_on = []; - - foreach ($rule["match"] as $match) { - list ($name, $is_cat, $is_id) = $match; - - if ($is_id) { - array_push($match_on, ($is_cat ? "CAT:" : "") . $name); - } else { - - $match_id = Feeds::_find_by_title($name, $is_cat, $owner_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']; - - array_push($match_on, $match_id); - } - } 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()) { - $match_id = $row['id']; - - 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); - - $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]); - - } else { - - $match_id = Feeds::_find_by_title($rule['feed'] ?? "", $rule['cat_filter'], $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']]); - - if ($row = $tsth->fetch()) { - $feed_id = $row['id']; - } - } else { - $tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories - 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"]); - - $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]); - } - } - - foreach ($filter["actions"] as $action) { - - $action_id = (int)$action["action_id"]; - $action_param = $action["action_param"]; - - $usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_actions - (filter_id,action_id,action_param) - VALUES - (?, ?, ?)"); - $usth->execute([$filter_id, $action_id, $action_param]); - } - } - } - } - } - - 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); - - if (!$cat_title) - $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, $owner_uid, $parent_id); - - if ($cat_id === 0) { - $order_id = (int) $root_node->attributes->getNamedItem('ttrssSortOrder')->nodeValue; - - 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 { - $cat_id = 0; - } - - $outlines = $root_node->childNodes; - - } else { - $xpath = new DOMXPath($doc); - $outlines = $xpath->query("//opml/body/outline"); - - $cat_id = 0; - $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")), $nest); - - foreach ($outlines as $node) { - if ($node->hasAttributes() && strtolower($node->tagName) == "outline") { - $attrs = $node->attributes; - $node_cat_title = $attrs->getNamedItem('text') ? $attrs->getNamedItem('text')->nodeValue : false; - - if (!$node_cat_title) - $node_cat_title = $attrs->getNamedItem('title') ? $attrs->getNamedItem('title')->nodeValue : false; - - $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, $nest+1); - } else { - - if (!$cat_id) { - $dst_cat_id = $default_cat_id; - } else { - $dst_cat_id = $cat_id; - } - - switch ($cat_title) { - case "tt-rss-prefs": - $this->opml_import_preference($node, $owner_uid, $nest+1); - break; - case "tt-rss-labels": - $this->opml_import_label($node, $owner_uid, $nest+1); - break; - case "tt-rss-filters": - $this->opml_import_filter($node, $owner_uid, $nest+1); - break; - default: - $this->opml_import_feed($node, $dst_cat_id, $owner_uid, $nest+1); - } - } - } - } - } - - /** $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 (!$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'); - - $result = move_uploaded_file($_FILES['opml_file']['tmp_name'], - $tmp_file); - - if (!$result) { - print_error(__("Unable to move uploaded file.")); - return false; - } - } else { - print_error(__('Error: please upload OPML file.')); - return false; - } - } else { - $tmp_file = $filename; - } - - if (!is_readable($tmp_file)) { - $this->opml_notice(T_sprintf("Error: file is not readable: %s", $filename)); - return false; - } - - $loaded = false; - - $doc = new DOMDocument(); - - if (version_compare(PHP_VERSION, '8.0.0', '<')) { - libxml_disable_entity_loader(false); - } - - $loaded = $doc->load($tmp_file); - - if (version_compare(PHP_VERSION, '8.0.0', '<')) { - libxml_disable_entity_loader(true); - } - - // only remove temporary i.e. HTTP uploaded files - if (!$filename) - unlink($tmp_file); - - if ($loaded) { - // 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 { - $this->opml_notice(__('Error while parsing document.')); - return false; - } - - return true; - } - - 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 . "
"; - } - } - - 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' => $owner_uid]); - - if ($row = $sth->fetch()) { - return $row['id']; - } else { - return 0; - } - } - -} diff --git a/classes/plugin.php b/classes/plugin.php deleted file mode 100644 index d941a1616..000000000 --- a/classes/plugin.php +++ /dev/null @@ -1,709 +0,0 @@ - */ - abstract function about(); - // return array(1.0, "plugin", "No description", "No author", false); - - function __construct() { - $this->pdo = Db::pdo(); - } - - /** @return array */ - function flags() { - /* associative array, possible keys: - needs_curl = boolean - */ - 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_login_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); - - 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 $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 $article - * @return array - * @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 $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 $hotkeys - * @return array - * @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 $article - * @return array - * @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 $article - * @return array - * @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 $allowed_elements - * @param array $disallowed_attributes - * @param int $article_id - * @return DOMDocument|array> - * @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|null, 'headline': array|null} $params - * @return array - * @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> $hotkeys - * @return array> - * @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 $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 $row - * @param int $excerpt_length - * @return array - * @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> - 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> $enclosures - * @param int $article_id - * @param bool $always_display_enclosures - * @param string $article_content - * @param bool $hide_images - * @return string|array>> ($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 $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 $entry - * @param int $article_id - * @param array $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 $article - * @param string $action - * @return array ($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 $line - * @param int $feed - * @param bool $is_cat - * @param int $owner_uid - * @return array ($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 $entry - * @param int $id article id - * @param array{'formatted': string, 'entries': array>} $rv - * @return array ($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 $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 $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 $article - * @param array $matched_filters - * @param array $matched_rules - * @param array $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 $enclosures - * @param string $content - * @param string $site_url - * @param array $article - * @return string|array - * @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 - * @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 -- (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 (
) does not provide an ability to set passwords.", - $_SESSION["auth_module"])); - } - } - - private function index_auth_app_passwords(): void { - print_notice("Separate passwords used for API clients. Required if you enable OTP."); - ?> - -
- appPasswordList() ?> -
- -
- - - - - - - -
- - - - - - -
- - -
- -
- - - -
- - _get_otp_qrcode_img()).">"; - - print_notice("You will need to generate app passwords for API clients if you enable OTP."); - - $otp_secret = UserHelper::get_otp_secret($_SESSION["uid"]); - ?> - -
- - - - -
- - format_otp_secret($otp_secret) ?> -
- - - - -
- - -
- -
- - -
- -
- - - -
- auth_internal authentication module."); - } - } - - function index_auth(): void { - ?> -
-
- index_auth_personal() ?> -
-
- index_auth_password() ?> -
-
- get_plugin('auth_internal')) { ?> - index_auth_app_passwords() ?> - - auth_internal plugin is enabled."); ?> - -
-
- index_auth_2fa() ?> -
-
- */ - $prefs_available = []; - - /** @var array */ - $listed_boolean_prefs = []; - - foreach (Prefs::get_all($_SESSION["uid"], $profile) as $pref) { - - if (in_array($pref["pref_name"], $this->pref_blacklist)) { - continue; - } - - if ($profile && in_array($pref["pref_name"], Prefs::_PROFILE_BLACKLIST)) { - continue; - } - - $pref_name = $pref["pref_name"]; - $short_desc = $this->_get_short_desc($pref_name); - - if (!$short_desc) - continue; - - $prefs_available[$pref_name] = [ - 'type_hint' => $pref['type_hint'], - 'value' => $pref['value'], - 'help_text' => $this->_get_help_text($pref_name), - 'short_desc' => $short_desc - ]; - } - - foreach (array_keys($this->pref_item_map) as $section) { - - print "

$section

"; - - foreach ($this->pref_item_map[$section] as $pref_name) { - - if ($pref_name == self::BLOCK_SEPARATOR && !$profile) { - print "
"; - continue; - } - - if ($pref_name == Prefs::DEFAULT_SEARCH_LANGUAGE && Config::get(Config::DB_TYPE) != "pgsql") { - continue; - } - - if (isset($prefs_available[$pref_name])) { - - $item = $prefs_available[$pref_name]; - - print "
"; - - print ""; - - $value = $item['value']; - $type_hint = $item['type_hint']; - - if ($pref_name == Prefs::USER_LANGUAGE) { - print \Controls\select_hash($pref_name, $value, get_translations(), - ["style" => 'width : 220px; margin : 0px']); - - } 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 == Prefs::BLACKLISTED_TAGS) { # TODO: other possible
"; - - print "
" . $this->pref_help_bottom[$pref_name] . "
"; - - print "
"; - - } else if ($pref_name == Prefs::USER_CSS_THEME) { - - $theme_files = array_map("basename", [ - ...glob("themes/*.php") ?: [], - ...glob("themes/*.css") ?: [], - ...glob("themes.local/*.css") ?: [], - ]); - - asort($theme_files); - - $themes = [ "" => __("default") ]; - - foreach ($theme_files as $file) { - $themes[$file] = basename($file, ".css"); - } - ?> - - - "Helpers.Prefs.customizeCSS()"]) ?> - "alt-info", "onclick" => "window.open(\"https://tt-rss.org/wiki/Themes\")"]) ?> - - $is_disabled], "CB_$pref_name"); - - if ($pref_name == Prefs::DIGEST_ENABLE) { - print \Controls\button_tag(\Controls\icon("info") . " " . __('Preview'), '', - ['onclick' => 'Helpers.Digest.preview()', 'style' => 'margin-left : 10px']); - } - - } 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 == Prefs::PURGE_OLD_DAYS && Config::get(Config::FORCE_ARTICLE_PURGE) != 0) { - $attributes = ["disabled" => true, "required" => true]; - $value = Config::get(Config::FORCE_ARTICLE_PURGE); - } else { - $attributes = ["required" => true]; - } - - if ($type_hint == Config::T_INT) - print \Controls\number_spinner_tag($pref_name, $value, $attributes); - else - print \Controls\input_tag($pref_name, $value, "text", $attributes); - - } else if ($pref_name == Prefs::SSL_CERT_SERIAL) { - - print \Controls\input_tag($pref_name, $value, "text", ["readonly" => true], "SSL_CERT_SERIAL"); - - $cert_serial = htmlspecialchars(self::_get_ssl_certificate_id()); - $has_serial = ($cert_serial) ? true : false; - - print \Controls\button_tag(\Controls\icon("security") . " " . __('Register'), "", [ - "disabled" => !$has_serial, - "onclick" => "dijit.byId('SSL_CERT_SERIAL').attr('value', '$cert_serial')"]); - - print \Controls\button_tag(\Controls\icon("clear") . " " . __('Clear'), "", [ - "class" => "alt-danger", - "onclick" => "dijit.byId('SSL_CERT_SERIAL').attr('value', '')"]); - - print \Controls\button_tag(\Controls\icon("help") . " " . __("More info..."), "", [ - "class" => "alt-info", - "onclick" => "window.open('https://tt-rss.org/wiki/SSL%20Certificate%20Authentication')"]); - - } else if ($pref_name == Prefs::DIGEST_PREFERRED_TIME) { - print ""; - $item['help_text'] .= ". " . T_sprintf("Current server time: %s", date("H:i")); - } else { - $regexp = ($type_hint == Config::T_INT) ? 'regexp="^\d*$"' : ''; - - print ""; - } - - if ($item['help_text']) - print "
"; - - print ""; - } - } - } - print \Controls\hidden_tag("boolean_prefs", htmlspecialchars(join(",", $listed_boolean_prefs))); - } - - private function index_prefs(): void { - ?> -
- - - - - -
-
- index_prefs_list() ?> - run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefPrefsPrefsInside") ?> -
-
- -
- -
-
- -
-
-
- - - - - - run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefPrefsPrefsOutside") ?> -
-
-
- load_all($tmppluginhost::KIND_ALL, $_SESSION["uid"], true); - - $rv = []; - - foreach ($tmppluginhost->get_plugins() as $name => $plugin) { - $about = $plugin->about(); - $is_local = $tmppluginhost->is_local($plugin); - $version = htmlspecialchars($this->_get_plugin_version($plugin)); - - array_push($rv, [ - "name" => $name, - "is_local" => $is_local, - "system_enabled" => in_array($name, $system_enabled), - "user_enabled" => in_array($name, $user_enabled), - "has_data" => count($tmppluginhost->get_all($plugin)) > 0, - "is_system" => (bool)($about[3] ?? false), - "version" => $version, - "author" => $about[2] ?? "", - "description" => $about[1] ?? "", - "more_info" => $about[4] ?? "", - ]); - } - - usort($rv, function($a, $b) { return strcmp($a["name"], $b["name"]); }); - - print json_encode(['plugins' => $rv, 'is_admin' => $_SESSION['access_level'] >= UserHelper::ACCESS_LEVEL_ADMIN]); - } - - function index_plugins(): void { - ?> -
- - - - -
-
-
- - -
- -
- -
-
-
-
-
-
- -
- - - - - -
    -
  • -
- -
-
- - - - "alt-primary", - "onclick" => "Helpers.Plugins.enableSelected()"]) ?> - - __("Reload"), "onclick" => "Helpers.Plugins.reload()"]) ?> - - = UserHelper::ACCESS_LEVEL_ADMIN) { ?> - - - - - - - - - -
-
-
- -
-
- - -
-
- index_prefs() ?> -
-
- index_plugins() ?> -
- run_hooks(PluginHost::HOOK_PREFS_TAB, "prefPrefs") ?> -
- render($otpurl); - } - - return null; - } - - 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"; - } else { - print "ERROR:".__("Incorrect one time password"); - } - } else { - print "ERROR:".__("Incorrect password"); - } - } - - 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)) { - - $sth = $this->pdo->prepare("SELECT email, login FROM ttrss_users WHERE id = ?"); - $sth->execute([$_SESSION['uid']]); - - if ($row = $sth->fetch()) { - $mailer = new Mailer(); - - $tpl = new Templator(); - - $tpl->readTemplateFromFile("otp_disabled_template.txt"); - - $tpl->setVariable('LOGIN', $row["login"]); - $tpl->setVariable('TTRSS_HOST', Config::get_self_url()); - - $tpl->addBlock('message'); - - $tpl->generateOutputToString($message); - - $mailer->mail(["to_name" => $row["login"], - "to_address" => $row["email"], - "subject" => "[tt-rss] OTP change notification", - "message" => $message]); - } - - UserHelper::disable_otp($_SESSION["uid"]); - - print "OK"; - } else { - print "ERROR: ".__("Incorrect password"); - } - - } - - function setplugins(): void { - $plugins = array_filter($_REQUEST["plugins"] ?? [], 'clean'); - - set_pref(Prefs::_ENABLED_PLUGINS, implode(",", $plugins)); - } - - 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]); - } - - $ref = new ReflectionClass(get_class($plugin)); - - $plugin_dir = dirname($ref->getFileName()); - - if (basename($plugin_dir) == "plugins") { - return ""; - } - - 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 ""; - } - - /** - * @return array - */ - 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) { - if (is_dir("$dir/.git")) { - $plugin_name = basename($dir); - - array_push($rv, ["plugin" => $plugin_name, "rv" => self::_plugin_needs_update($root_dir, $plugin_name)]); - } - } - - $rv = array_values(array_filter($rv, fn($item) => $item["rv"]["need_update"])); - - return $rv; - } - - /** - * @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; - - if (is_dir($plugin_dir) && is_dir("$plugin_dir/.git")) { - $pipes = []; - - $descriptorspec = [ - //0 => ["pipe", "r"], // STDIN - 1 => ["pipe", "w"], // STDOUT - 2 => ["pipe", "w"], // STDERR - ]; - - $proc = proc_open("git fetch -q origin -a && git log HEAD..origin/master --oneline", $descriptorspec, $pipes, $plugin_dir); - - if (is_resource($proc)) { - $rv = [ - "stdout" => stream_get_contents($pipes[1]), - "stderr" => stream_get_contents($pipes[2]), - "git_status" => proc_close($proc), - ]; - $rv["need_update"] = !empty($rv["stdout"]); - } - } - - return $rv; - } - - - /** - * @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 = []; - - if (is_dir($plugin_dir) && is_dir("$plugin_dir/.git")) { - $pipes = []; - - $descriptorspec = [ - //0 => ["pipe", "r"], // STDIN - 1 => ["pipe", "w"], // STDOUT - 2 => ["pipe", "w"], // STDERR - ]; - - $proc = proc_open("git fetch origin -a && git log HEAD..origin/master --oneline && git pull --ff-only origin master", $descriptorspec, $pipes, $plugin_dir); - - if (is_resource($proc)) { - $rv["stdout"] = stream_get_contents($pipes[1]); - $rv["stderr"] = stream_get_contents($pipes[2]); - $rv["git_status"] = proc_close($proc); - } - } - - return $rv; - } - - // https://gist.github.com/mindplay-dk/a4aad91f5a4f1283a5e2#gistcomment-2036828 - 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. - } elseif (is_file($dir) || is_link($dir)) { - return unlink($dir); // Delete file/link. - } - - // Delete all children. - $files = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST - ); - - foreach ($files as $fileinfo) { - $action = $fileinfo->isDir() ? 'rmdir' : 'unlink'; - if (!$action($fileinfo->getRealPath())) { - return false; // Abort due to the failure. - } - } - - return $keep_root ? true : rmdir($dir); - } - - // https://stackoverflow.com/questions/7153000/get-class-name-from-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++) { - if (isset($tokens[$i][0]) && $tokens[$i][0] == T_CLASS) { - for ($j = $i+1; $j < count($tokens); $j++) { - if (isset($tokens[$j][1]) && $tokens[$j][1] != " ") { - return $tokens[$j][1]; - } - } - } - } - - return ""; - } - - function uninstallPlugin(): void { - if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN) { - $plugin_name = basename(clean($_REQUEST['plugin'])); - $status = 0; - - $plugin_dir = dirname(dirname(__DIR__)) . "/plugins.local/$plugin_name"; - - if (is_dir($plugin_dir)) { - $status = $this->_recursive_rmdir($plugin_dir); - } - - print json_encode(['status' => $status]); - } - } - - 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"; - - $work_dir = "$plugin_dir/plugin-installer"; - - $rv = [ ]; - - if (is_dir($work_dir) || mkdir($work_dir)) { - foreach ($all_plugins as $plugin) { - if ($plugin['name'] == $plugin_name) { - - $tmp_dir = tempnam($work_dir, $plugin_name); - - if (file_exists($tmp_dir)) { - unlink($tmp_dir); - - $pipes = []; - - $descriptorspec = [ - 1 => ["pipe", "w"], // STDOUT - 2 => ["pipe", "w"], // STDERR - ]; - - $proc = proc_open("git clone " . escapeshellarg($plugin['clone_url']) . " " . $tmp_dir, - $descriptorspec, $pipes, sys_get_temp_dir()); - - $status = 0; - - if (is_resource($proc)) { - $rv["stdout"] = stream_get_contents($pipes[1]); - $rv["stderr"] = stream_get_contents($pipes[2]); - $status = proc_close($proc); - $rv["git_status"] = $status; - - // yeah I know about mysterious RC = -1 - if (file_exists("$tmp_dir/init.php")) { - $class_name = strtolower(basename($this->_get_class_name_from_file("$tmp_dir/init.php"))); - - if ($class_name) { - $dst_dir = "$plugin_dir/$class_name"; - - if (is_dir($dst_dir)) { - $rv['result'] = self::PI_RES_ALREADY_INSTALLED; - } else { - if (rename($tmp_dir, "$plugin_dir/$class_name")) { - $rv['result'] = self::PI_RES_SUCCESS; - } - } - } else { - $rv['result'] = self::PI_ERR_NO_CLASS; - } - } else { - $rv['result'] = self::PI_ERR_NO_INIT_PHP; - } - - } else { - $rv['result'] = self::PI_ERR_EXEC_FAILED; - } - } else { - $rv['result'] = self::PI_ERR_NO_TEMPDIR; - } - - // cleanup after failure - if ($tmp_dir && is_dir($tmp_dir)) { - $this->_recursive_rmdir($tmp_dir); - } - - break; - } - } - - if (empty($rv['result'])) - $rv['result'] = self::PI_ERR_PLUGIN_NOT_FOUND; - - } else { - $rv["result"] = self::PI_ERR_NO_WORKDIR; - } - - print json_encode($rv); - } - } - - /** - * @return array, '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(): void { - if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN) { - print json_encode($this->_get_available_plugins()); - } else { - print "[]"; - } - } - - 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/ - - $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(): 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 ($plugins) { - foreach ($plugins as $plugin_name) { - array_push($rv, ["plugin" => $plugin_name, "rv" => $this->_update_plugin($root_dir, $plugin_name)]); - } - } else { - $plugin_dirs = array_filter(glob("$root_dir/plugins.local/*"), "is_dir"); - - foreach ($plugin_dirs as $dir) { - if (is_dir("$dir/.git")) { - $plugin_name = basename($dir); - - $test = self::_plugin_needs_update($root_dir, $plugin_name); - - if (!empty($test["stdout"])) - array_push($rv, ["plugin" => $plugin_name, "rv" => $this->_update_plugin($root_dir, $plugin_name)]); - } - } - } - - print json_encode($rv); - } - } - - function clearplugindata(): void { - $name = clean($_REQUEST["name"]); - - PluginHost::getInstance()->clear_data(PluginHost::getInstance()->get_plugin($name)); - } - - function customizeCSS(): void { - $value = get_pref(Prefs::USER_STYLESHEET); - $value = str_replace("
", "\n", $value); - - print json_encode(["value" => $value]); - } - - function activateprofile(): void { - $id = (int) ($_REQUEST['id'] ?? 0); - - $profile = ORM::for_table('ttrss_settings_profiles') - ->where('owner_uid', $_SESSION['uid']) - ->find_one($id); - - if ($profile) { - $_SESSION["profile"] = $id; - } else { - $_SESSION["profile"] = null; - } - } - - function cloneprofile(): void { - $old_profile = $_REQUEST["old_profile"] ?? 0; - $new_title = clean($_REQUEST["new_title"]); - - if ($old_profile && $new_title) { - $new_profile = ORM::for_table('ttrss_settings_profiles')->create(); - $new_profile->title = $new_title; - $new_profile->owner_uid = $_SESSION['uid']; - - if ($new_profile->save()) { - $sth = $this->pdo->prepare("INSERT INTO ttrss_user_prefs - (owner_uid, pref_name, profile, value) - SELECT - :uid, - pref_name, - :new_profile, - value - FROM ttrss_user_prefs - WHERE owner_uid = :uid AND profile = :old_profile"); - - $sth->execute([ - "uid" => $_SESSION["uid"], - "new_profile" => $new_profile->id, - "old_profile" => $old_profile, - ]); - } - } - } - - function remprofiles(): void { - $ids = $_REQUEST["ids"] ?? []; - - ORM::for_table('ttrss_settings_profiles') - ->where('owner_uid', $_SESSION['uid']) - ->where_in('id', $ids) - ->where_not_equal('id', $_SESSION['profile'] ?? 0) - ->delete_many(); - } - - function addprofile(): void { - $title = clean($_REQUEST["title"]); - - if ($title) { - $profile = ORM::for_table('ttrss_settings_profiles') - ->where('owner_uid', $_SESSION['uid']) - ->where('title', $title) - ->find_one(); - - if (!$profile) { - $profile = ORM::for_table('ttrss_settings_profiles')->create(); - - $profile->title = $title; - $profile->owner_uid = $_SESSION['uid']; - $profile->save(); - } - } - } - - function saveprofile(): void { - $id = (int)$_REQUEST["id"]; - $title = clean($_REQUEST["value"]); - - if ($title && $id) { - $profile = ORM::for_table('ttrss_settings_profiles') - ->where('owner_uid', $_SESSION['uid']) - ->find_one($id); - - if ($profile) { - $profile->title = $title; - $profile->save(); - } - } - } - - // TODO: this maybe needs to be unified with Public::getProfiles() - function getProfiles(): void { - $rv = []; - - $profiles = ORM::for_table('ttrss_settings_profiles') - ->where('owner_uid', $_SESSION['uid']) - ->order_by_expr('title') - ->find_many(); - - array_push($rv, ["title" => __("Default profile"), - "id" => 0, - "initialized" => true, - "active" => empty($_SESSION["profile"]) - ]); - - foreach ($profiles as $profile) { - $profile['active'] = ($_SESSION["profile"] ?? 0) == $profile->id; - - $num_settings = ORM::for_table('ttrss_user_prefs') - ->where('profile', $profile->id) - ->count(); - - $profile['initialized'] = $num_settings > 0; - - array_push($rv, $profile->as_array()); - }; - - print json_encode($rv); - } - - 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(string $pref_name): string { - if (isset($this->pref_help[$pref_name][1])) { - return $this->pref_help[$pref_name][1]; - } - return ""; - } - - private function appPasswordList(): void { - ?> -
-
- -
-
-
-
-
-
- -
- - - - - - - - where('owner_uid', $_SESSION['uid']) - ->order_by_asc('title') - ->find_many(); - - foreach ($passwords as $pass) { ?> - '> - - - - - - -
- - - - - - - -
-
- where('owner_uid', $_SESSION['uid']) - ->where_in('id', $_REQUEST['ids'] ?? []) - ->delete_many(); - - $this->appPasswordList(); - } - - function generateAppPassword(): void { - $title = clean($_REQUEST['title']); - $new_password = make_password(16); - $new_salt = UserHelper::get_salt(); - $new_password_hash = UserHelper::hash_password($new_password, $new_salt, UserHelper::HASH_ALGOS[0]); - - print_warning(T_sprintf("Generated password %s for %s. Please remember it for future reference.", $new_password, $title)); - - $password = ORM::for_table('ttrss_app_passwords')->create(); - - $password->title = $title; - $password->owner_uid = $_SESSION['uid']; - $password->pwd_hash = "$new_password_hash:$new_salt"; - $password->service = Auth_Base::AUTH_SERVICE_API; - $password->created = Db::NOW(); - - $password->save(); - - $this->appPasswordList(); - } - - function previewDigest(): void { - print json_encode(Digest::prepare_headlines_digest($_SESSION["uid"], 1, 16)); - } - - 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"] . - $_SERVER["REDIRECT_SSL_CLIENT_V_END"] . - $_SERVER["REDIRECT_SSL_CLIENT_S_DN"]); - } - if ($_SERVER["SSL_CLIENT_M_SERIAL"] ?? false) { - return sha1($_SERVER["SSL_CLIENT_M_SERIAL"] . - $_SERVER["SSL_CLIENT_V_START"] . - $_SERVER["SSL_CLIENT_V_END"] . - $_SERVER["SSL_CLIENT_S_DN"]); - } - return ""; - } - - 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 deleted file mode 100644 index 806291c72..000000000 --- a/classes/pref/system.php +++ /dev/null @@ -1,225 +0,0 @@ -pdo->query("DELETE FROM ttrss_error_log"); - } - - function sendTestEmail(): void { - $mail_address = clean($_REQUEST["mail_address"]); - - $mailer = new Mailer(); - - $rc = $mailer->mail(["to_name" => "", - "to_address" => $mail_address, - "subject" => __("Test message from tt-rss"), - "message" => ("This message confirms that tt-rss can send outgoing mail.") - ]); - - print json_encode(['rc' => $rc, 'error' => $mailer->error()]); - } - - function getphpinfo(): void { - ob_start(); - phpinfo(); - $info = ob_get_contents(); - ob_end_clean(); - - print preg_replace( '%^.*(.*).*$%ms','$1', (string)$info); - } - - private function _log_viewer(int $page, int $severity): void { - $errno_values = []; - - switch ($severity) { - case E_USER_ERROR: - $errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR ]; - break; - case E_USER_WARNING: - $errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR, E_WARNING, E_USER_WARNING, E_DEPRECATED, E_USER_DEPRECATED ]; - break; - } - - if (count($errno_values) > 0) { - $errno_qmarks = arr_qmarks($errno_values); - $errno_filter_qpart = "errno IN ($errno_qmarks)"; - } else { - $errno_filter_qpart = "true"; - } - - $offset = self::LOG_PAGE_LIMIT * $page; - - $sth = $this->pdo->prepare("SELECT - COUNT(id) AS total_pages - FROM - ttrss_error_log - WHERE - $errno_filter_qpart"); - - $sth->execute($errno_values); - - if ($res = $sth->fetch()) { - $total_pages = (int)($res["total_pages"] / self::LOG_PAGE_LIMIT); - } else { - $total_pages = 0; - } - - ?> -
-
- - - - - - - - - - - -
- - - __("Errors"), - E_USER_WARNING => __("Warnings"), - E_USER_NOTICE => __("Everything") - ], ["onchange"=> "Helpers.EventLog.refresh()"], "severity") ?> -
-
- -
- - - - - - - - - - - - pdo->prepare("SELECT - errno, errstr, filename, lineno, created_at, login, context - FROM - ttrss_error_log LEFT JOIN ttrss_users ON (owner_uid = ttrss_users.id) - WHERE - $errno_filter_qpart - ORDER BY - ttrss_error_log.id DESC - LIMIT ". self::LOG_PAGE_LIMIT ." OFFSET $offset"); - - $sth->execute($errno_values); - - while ($line = $sth->fetch()) { - foreach ($line as $k => $v) { $line[$k] = htmlspecialchars($v ?? ''); } - ?> - - - - - - - - -
- - - -
-
-
- -
- -
'> - _log_viewer($page, $severity); - ?> -
- -
'> -
- -
- - - - - -
- - 1]) ?> - - -
-
-
-
-
'> - - -
- - run_hooks(PluginHost::HOOK_PREFS_TAB, "prefSystem") ?> -
- select_expr("id,login,access_level,email,full_name,otp_enabled") - ->find_one((int)$_REQUEST["id"]) - ->as_array(); - - global $access_level_names; - - if ($user) { - print json_encode([ - "user" => $user, - "access_level_names" => $access_level_names - ]); - } - } - - function userdetails(): void { - $id = (int) clean($_REQUEST["id"]); - - $sth = $this->pdo->prepare("SELECT login, - ".SUBSTRING_FOR_DATE."(last_login,1,16) AS last_login, - access_level, - (SELECT COUNT(int_id) FROM ttrss_user_entries - WHERE owner_uid = id) AS stored_articles, - ".SUBSTRING_FOR_DATE."(created,1,16) AS created - FROM ttrss_users - WHERE id = ?"); - $sth->execute([$id]); - - if ($row = $sth->fetch()) { - - $last_login = TimeHelper::make_local_datetime( - $row["last_login"], true); - - $created = TimeHelper::make_local_datetime( - $row["created"], true); - - $stored_articles = $row["stored_articles"]; - - $sth = $this->pdo->prepare("SELECT COUNT(id) as num_feeds FROM ttrss_feeds - WHERE owner_uid = ?"); - $sth->execute([$id]); - $row = $sth->fetch(); - - $num_feeds = $row["num_feeds"]; - - ?> - -
- - -
- -
- - -
- -
- - -
- -
- - -
- - pdo->prepare("SELECT id,title,site_url FROM ttrss_feeds - WHERE owner_uid = ? ORDER BY title"); - $sth->execute([$id]); - ?> - -
    - fetch()) { ?> -
  • - - - - - "> - - -
  • - -
- - find_one($id); - - if ($user) { - $login = clean($_REQUEST["login"]); - - if ($id == 1) $login = "admin"; - if (!$login) return; - - $user->login = mb_strtolower($login); - $user->access_level = (int) clean($_REQUEST["access_level"]); - $user->email = clean($_REQUEST["email"]); - $user->otp_enabled = checkbox_to_sql_bool($_REQUEST["otp_enabled"] ?? ""); - - // force new OTP secret when next enabled - if (Config::get_schema_version() >= 143 && !$user->otp_enabled) { - $user->otp_secret = null; - } - - $user->save(); - } - - if ($password) { - UserHelper::reset_password($id, false, $password); - } - } - - function remove(): void { - $ids = explode(",", clean($_REQUEST["ids"])); - - foreach ($ids as $id) { - if ($id != $_SESSION["uid"] && $id != 1) { - $sth = $this->pdo->prepare("DELETE FROM ttrss_tags WHERE owner_uid = ?"); - $sth->execute([$id]); - - $sth = $this->pdo->prepare("DELETE FROM ttrss_feeds WHERE owner_uid = ?"); - $sth->execute([$id]); - - $sth = $this->pdo->prepare("DELETE FROM ttrss_users WHERE id = ?"); - $sth->execute([$id]); - } - } - } - - function add(): void { - $login = clean($_REQUEST["login"]); - - if (!$login) return; // no blank usernames - - if (!UserHelper::find_user_by_login($login)) { - - $new_password = make_password(); - - $user = ORM::for_table('ttrss_users')->create(); - - $user->salt = UserHelper::get_salt(); - $user->login = mb_strtolower($login); - $user->pwd_hash = UserHelper::hash_password($new_password, $user->salt); - $user->access_level = 0; - $user->created = Db::NOW(); - $user->save(); - - if (!is_null(UserHelper::find_user_by_login($login))) { - print T_sprintf("Added user %s with password %s", - $login, $new_password); - } else { - print T_sprintf("Could not create user %s", $login); - } - } else { - print T_sprintf("User %s already exists.", $login); - } - } - - function resetPass(): void { - UserHelper::reset_password(clean($_REQUEST["id"])); - } - - function index(): void { - - global $access_level_names; - - $user_search = clean($_REQUEST["search"] ?? ""); - - if (array_key_exists("search", $_REQUEST)) { - $_SESSION["prefs_user_search"] = $user_search; - } else { - $user_search = ($_SESSION["prefs_user_search"] ?? ""); - } - - $sort = clean($_REQUEST["sort"] ?? ""); - - if (!$sort || $sort == "undefined") { - $sort = "login"; - } - - if (!in_array($sort, ["login", "access_level", "created", "num_feeds", "created", "last_login"])) - $sort = "login"; - - if ($sort != "login") $sort = "$sort DESC"; - ?> - -
-
-
- -
- - -
- -
- -
-
-
-
-
- - - - - - - - run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefUsersToolbar") ?> - -
-
-
- - - - - - - - - - - - - table_alias('u') - ->left_outer_join("ttrss_feeds", ["owner_uid", "=", "u.id"], 'f') - ->select_expr('u.*,COUNT(f.id) AS num_feeds') - ->where_like("login", $user_search ? "%$user_search%" : "%") - ->order_by_expr($sort) - ->group_by_expr('u.id') - ->find_many(); - - foreach ($users as $user) { ?> - - - - - - - - - - - -
- - - person - -
-
- run_hooks(PluginHost::HOOK_PREFS_TAB, "prefUsers") ?> -
- [ 60, Config::T_INT ], - Prefs::DEFAULT_UPDATE_INTERVAL => [ 30, Config::T_INT ], - //Prefs::DEFAULT_ARTICLE_LIMIT => [ 30, Config::T_INT ], - //Prefs::ALLOW_DUPLICATE_POSTS => [ false, Config::T_BOOL ], - Prefs::ENABLE_FEED_CATS => [ true, Config::T_BOOL ], - Prefs::SHOW_CONTENT_PREVIEW => [ true, Config::T_BOOL ], - Prefs::SHORT_DATE_FORMAT => [ "M d, G:i", Config::T_STRING ], - Prefs::LONG_DATE_FORMAT => [ "D, M d Y - G:i", Config::T_STRING ], - Prefs::COMBINED_DISPLAY_MODE => [ true, Config::T_BOOL ], - Prefs::HIDE_READ_FEEDS => [ false, Config::T_BOOL ], - Prefs::ON_CATCHUP_SHOW_NEXT_FEED => [ false, Config::T_BOOL ], - Prefs::FEEDS_SORT_BY_UNREAD => [ false, Config::T_BOOL ], - Prefs::REVERSE_HEADLINES => [ false, Config::T_BOOL ], - Prefs::DIGEST_ENABLE => [ false, Config::T_BOOL ], - Prefs::CONFIRM_FEED_CATCHUP => [ true, Config::T_BOOL ], - Prefs::CDM_AUTO_CATCHUP => [ false, Config::T_BOOL ], - Prefs::_DEFAULT_VIEW_MODE => [ "adaptive", Config::T_STRING ], - Prefs::_DEFAULT_VIEW_LIMIT => [ 30, Config::T_INT ], - //Prefs::_PREFS_ACTIVE_TAB => [ "", Config::T_STRING ], - //Prefs::STRIP_UNSAFE_TAGS => [ true, Config::T_BOOL ], - Prefs::BLACKLISTED_TAGS => [ 'main, generic, misc, uncategorized, blog, blogroll, general, news', Config::T_STRING ], - Prefs::FRESH_ARTICLE_MAX_AGE => [ 24, Config::T_INT ], - Prefs::DIGEST_CATCHUP => [ false, Config::T_BOOL ], - Prefs::CDM_EXPANDED => [ true, Config::T_BOOL ], - Prefs::PURGE_UNREAD_ARTICLES => [ true, Config::T_BOOL ], - Prefs::HIDE_READ_SHOWS_SPECIAL => [ true, Config::T_BOOL ], - Prefs::VFEED_GROUP_BY_FEED => [ false, Config::T_BOOL ], - Prefs::STRIP_IMAGES => [ false, Config::T_BOOL ], - Prefs::_DEFAULT_VIEW_ORDER_BY => [ "default", Config::T_STRING ], - Prefs::ENABLE_API_ACCESS => [ false, Config::T_BOOL ], - //Prefs::_COLLAPSED_SPECIAL => [ false, Config::T_BOOL ], - //Prefs::_COLLAPSED_LABELS => [ false, Config::T_BOOL ], - //Prefs::_COLLAPSED_UNCAT => [ false, Config::T_BOOL ], - //Prefs::_COLLAPSED_FEEDLIST => [ false, Config::T_BOOL ], - //Prefs::_MOBILE_ENABLE_CATS => [ false, Config::T_BOOL ], - //Prefs::_MOBILE_SHOW_IMAGES => [ false, Config::T_BOOL ], - //Prefs::_MOBILE_HIDE_READ => [ false, Config::T_BOOL ], - //Prefs::_MOBILE_SORT_FEEDS_UNREAD => [ false, Config::T_BOOL ], - //Prefs::_MOBILE_BROWSE_CATS => [ true, Config::T_BOOL ], - //Prefs::_THEME_ID => [ 0, Config::T_BOOL ], - Prefs::USER_TIMEZONE => [ "Automatic", Config::T_STRING ], - Prefs::USER_STYLESHEET => [ "", Config::T_STRING ], - //Prefs::SORT_HEADLINES_BY_FEED_DATE => [ false, Config::T_BOOL ], - Prefs::SSL_CERT_SERIAL => [ "", Config::T_STRING ], - Prefs::DIGEST_PREFERRED_TIME => [ "00:00", Config::T_STRING ], - //Prefs::_PREFS_SHOW_EMPTY_CATS => [ false, Config::T_BOOL ], - Prefs::_DEFAULT_INCLUDE_CHILDREN => [ false, Config::T_BOOL ], - //Prefs::AUTO_ASSIGN_LABELS => [ false, Config::T_BOOL ], - Prefs::_ENABLED_PLUGINS => [ "", Config::T_STRING ], - //Prefs::_MOBILE_REVERSE_HEADLINES => [ false, Config::T_BOOL ], - Prefs::USER_CSS_THEME => [ "" , Config::T_STRING ], - Prefs::USER_LANGUAGE => [ "" , Config::T_STRING ], - Prefs::DEFAULT_SEARCH_LANGUAGE => [ "" , Config::T_STRING ], - Prefs::_PREFS_MIGRATED => [ false, Config::T_BOOL ], - Prefs::HEADLINES_NO_DISTINCT => [ false, Config::T_BOOL ], - Prefs::DEBUG_HEADLINE_IDS => [ false, Config::T_BOOL ], - Prefs::DISABLE_CONDITIONAL_COUNTERS => [ false, Config::T_BOOL ], - Prefs::WIDESCREEN_MODE => [ false, Config::T_BOOL ], - Prefs::CDM_ENABLE_GRID => [ false, Config::T_BOOL ], - ]; - - const _PROFILE_BLACKLIST = [ - //Prefs::ALLOW_DUPLICATE_POSTS, - Prefs::PURGE_OLD_DAYS, - Prefs::PURGE_UNREAD_ARTICLES, - Prefs::DIGEST_ENABLE, - Prefs::DIGEST_CATCHUP, - Prefs::BLACKLISTED_TAGS, - Prefs::ENABLE_API_ACCESS, - //Prefs::UPDATE_POST_ON_CHECKSUM_CHANGE, - Prefs::DEFAULT_UPDATE_INTERVAL, - Prefs::USER_TIMEZONE, - //Prefs::SORT_HEADLINES_BY_FEED_DATE, - Prefs::SSL_CERT_SERIAL, - Prefs::DIGEST_PREFERRED_TIME, - Prefs::_PREFS_MIGRATED - ]; - - /** @var Prefs|null */ - private static $instance; - - /** @var array */ - private $cache = []; - - /** @var PDO */ - private $pdo; - - public static function get_instance() : Prefs { - if (self::$instance == null) - self::$instance = new self(); - - return self::$instance; - } - - 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]; - else - return null; - } - - function __construct() { - $this->pdo = Db::pdo(); - - if (!empty($_SESSION["uid"])) { - $owner_uid = (int) $_SESSION["uid"]; - $profile_id = $_SESSION["profile"] ?? null; - - $this->cache_all($owner_uid, $profile_id); - $this->migrate($owner_uid, $profile_id); - }; - } - - private function __clone() { - // - } - - /** - * @return array> - */ - static function get_all(int $owner_uid, int $profile_id = null) { - return self::get_instance()->_get_all($owner_uid, $profile_id); - } - - /** - * @return array> - */ - private function _get_all(int $owner_uid, int $profile_id = null) { - $rv = []; - - $ref = new ReflectionClass(get_class($this)); - - foreach ($ref->getConstants() as $const => $cvalue) { - if (isset($this::_DEFAULTS[$const])) { - list ($def_val, $type_hint) = $this::_DEFAULTS[$const]; - - array_push($rv, [ - "pref_name" => $const, - "value" => $this->_get($const, $owner_uid, $profile_id), - "type_hint" => $type_hint, - ]); - } - } - - return $rv; - } - - private function cache_all(int $owner_uid, ?int $profile_id): void { - if (!$profile_id) $profile_id = null; - - // fill cache with defaults - $ref = new ReflectionClass(get_class($this)); - foreach ($ref->getConstants() as $const => $cvalue) { - if (isset($this::_DEFAULTS[$const])) { - list ($def_val, $type_hint) = $this::_DEFAULTS[$const]; - - $this->_set_cache($const, $def_val, $owner_uid, $profile_id); - } - } - - if (Config::get_schema_version() >= 141) { - // fill in any overrides from the database - $sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2 - WHERE owner_uid = :uid AND - (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); - - $sth->execute(["uid" => $owner_uid, "profile" => $profile_id]); - - while ($row = $sth->fetch()) { - $this->_set_cache($row["pref_name"], $row["value"], $owner_uid, $profile_id); - } - } - } - - /** - * @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); - } - - /** - * @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; - - list ($def_val, $type_hint) = self::_DEFAULTS[$pref_name]; - - $cached_value = $this->_get_cache($pref_name, $owner_uid, $profile_id); - - if ($this->_is_cached($pref_name, $owner_uid, $profile_id)) { - $cached_value = $this->_get_cache($pref_name, $owner_uid, $profile_id); - return Config::cast_to($cached_value, $type_hint); - } else if (Config::get_schema_version() >= 141) { - $sth = $this->pdo->prepare("SELECT value FROM ttrss_user_prefs2 - WHERE pref_name = :name AND owner_uid = :uid AND - (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); - - $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name ]); - - if ($row = $sth->fetch(PDO::FETCH_ASSOC)) { - $this->_set_cache($pref_name, $row["value"], $owner_uid, $profile_id); - - return Config::cast_to($row["value"], $type_hint); - } else { - $this->_set_cache($pref_name, $def_val, $owner_uid, $profile_id); - - return $def_val; - } - } else { - return Config::cast_to($def_val, $type_hint); - - } - } else { - user_error("Attempt to get invalid preference key: $pref_name (UID: $owner_uid, profile: $profile_id)", E_USER_WARNING); - } - - return 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]); - } - - /** - * @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])) - return $this->cache[$cache_key]; - - return 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; - } - - /** - * @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); - } - - /** - * @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)) - return false; - - if (isset(self::_DEFAULTS[$pref_name])) { - list ($def_val, $type_hint) = self::_DEFAULTS[$pref_name]; - - if ($strip_tags) - $value = trim(strip_tags($value)); - - $value = Config::cast_to($value, $type_hint); - - if ($value == $this->_get($pref_name, $owner_uid, $profile_id)) - return false; - - $this->_set_cache($pref_name, $value, $owner_uid, $profile_id); - - $sth = $this->pdo->prepare("SELECT COUNT(pref_name) AS count FROM ttrss_user_prefs2 - WHERE pref_name = :name AND owner_uid = :uid AND - (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); - $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name ]); - - if ($row = $sth->fetch()) { - if ($row["count"] == 0) { - $sth = $this->pdo->prepare("INSERT INTO ttrss_user_prefs2 - (pref_name, value, owner_uid, profile) - VALUES - (:name, :value, :uid, :profile)"); - - return $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name, "value" => $value ]); - - } else { - $sth = $this->pdo->prepare("UPDATE ttrss_user_prefs2 - SET value = :value - WHERE pref_name = :name AND owner_uid = :uid AND - (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); - - return $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name, "value" => $value ]); - } - } - } else { - user_error("Attempt to set invalid preference key: $pref_name (UID: $owner_uid, profile: $profile_id)", E_USER_WARNING); - } - - return false; - } - - function migrate(int $owner_uid, ?int $profile_id): void { - if (Config::get_schema_version() < 141) - return; - - if (!$profile_id) $profile_id = null; - - if (!$this->_get(Prefs::_PREFS_MIGRATED, $owner_uid, $profile_id)) { - - $in_nested_tr = false; - - try { - $this->pdo->beginTransaction(); - } catch (PDOException $e) { - $in_nested_tr = true; - } - - $sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs - WHERE owner_uid = :uid AND - (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); - $sth->execute(["uid" => $owner_uid, "profile" => $profile_id]); - - while ($row = $sth->fetch(PDO::FETCH_ASSOC)) { - if (isset(self::_DEFAULTS[$row["pref_name"]])) { - list ($def_val, $type_hint) = self::_DEFAULTS[$row["pref_name"]]; - - $user_val = Config::cast_to($row["value"], $type_hint); - - if ($user_val != $def_val) { - $this->_set($row["pref_name"], $user_val, $owner_uid, $profile_id); - } - } - } - - $this->_set(Prefs::_PREFS_MIGRATED, "1", $owner_uid, $profile_id); - - if (!$in_nested_tr) - $this->pdo->commit(); - - Logger::log(E_USER_NOTICE, sprintf("Migrated preferences of user %d (profile %d)", $owner_uid, $profile_id)); - } - } - - 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 - WHERE owner_uid = :uid AND pref_name != :mig_key AND - (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); - - $sth->execute(["uid" => $owner_uid, "mig_key" => self::_PREFS_MIGRATED, "profile" => $profile_id]); - } -} diff --git a/classes/rpc.php b/classes/rpc.php deleted file mode 100755 index e21671d78..000000000 --- a/classes/rpc.php +++ /dev/null @@ -1,846 +0,0 @@ - - */ - private function _translations_as_array(): array { - - global $text_domains; - - $rv = []; - - foreach (array_keys($text_domains) as $domain) { - - /** @var gettext_reader $l10n */ - $l10n = _get_reader($domain); - - for ($i = 0; $i < $l10n->total; $i++) { - if (isset($l10n->table_originals[$i * 2 + 2]) && $orig = $l10n->get_original_string($i)) { - if(strpos($orig, "\000") !== false) { // Plural forms - $key = explode(chr(0), $orig); - - $rv[$key[0]] = _ngettext($key[0], $key[1], 1); // Singular - $rv[$key[1]] = _ngettext($key[0], $key[1], 2); // Plural - } else { - $translation = _dgettext($domain,$orig); - $rv[$orig] = $translation; - } - } - } - } - - return $rv; - } - - - function togglepref(): void { - $key = clean($_REQUEST["key"]); - set_pref($key, !get_pref($key)); - $value = get_pref($key); - - print json_encode(array("param" =>$key, "value" => $value)); - } - - function setpref(): void { - // set_pref escapes input, so no need to double escape it here - $key = clean($_REQUEST['key']); - $value = $_REQUEST['value']; - - set_pref($key, $value, $_SESSION["uid"], $key != 'USER_STYLESHEET'); - - print json_encode(array("param" =>$key, "value" => $value)); - } - - function mark(): void { - $mark = clean($_REQUEST["mark"]); - $id = clean($_REQUEST["id"]); - - $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET marked = ?, - last_marked = NOW() - WHERE ref_id = ? AND owner_uid = ?"); - - $sth->execute([$mark, $id, $_SESSION['uid']]); - - print json_encode(array("message" => "UPDATE_COUNTERS")); - } - - function delete(): void { - $ids = explode(",", clean($_REQUEST["ids"])); - $ids_qmarks = arr_qmarks($ids); - - $sth = $this->pdo->prepare("DELETE FROM ttrss_user_entries - WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); - $sth->execute([...$ids, $_SESSION['uid']]); - - Article::_purge_orphans(); - - print json_encode(array("message" => "UPDATE_COUNTERS")); - } - - function publ(): void { - $pub = clean($_REQUEST["pub"]); - $id = clean($_REQUEST["id"]); - - $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET - published = ?, last_published = NOW() - WHERE ref_id = ? AND owner_uid = ?"); - - $sth->execute([$pub, $id, $_SESSION['uid']]); - - print json_encode(array("message" => "UPDATE_COUNTERS")); - } - - function getRuntimeInfo(): void { - $reply = [ - 'runtime-info' => $this->_make_runtime_info() - ]; - - print json_encode($reply); - } - - function getAllCounters(): void { - $span = Tracer::start(__METHOD__); - - @$seq = (int) $_REQUEST['seq']; - - $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 - // we need null because it means "return all counters"; [] would return nothing - if ($feed_id_count == -1) - $feed_ids = null; - else - $feed_ids = array_map("intval", clean($_REQUEST["feed_ids"] ?? [])); - - if ($label_id_count == -1) - $label_ids = null; - else - $label_ids = array_map("intval", clean($_REQUEST["label_ids"] ?? [])); - - $counters = is_array($feed_ids) && !get_pref(Prefs::DISABLE_CONDITIONAL_COUNTERS) ? - Counters::get_conditional($feed_ids, $label_ids) : Counters::get_all(); - - $reply = [ - 'counters' => $counters, - 'seq' => $seq - ]; - - $span->end(); - print json_encode($reply); - } - - /* GET["cmode"] = 0 - mark as read, 1 - as unread, 2 - toggle */ - function catchupSelected(): void { - $ids = array_map("intval", clean($_REQUEST["ids"] ?? [])); - $cmode = (int)clean($_REQUEST["cmode"]); - - if (count($ids) > 0) - Article::_catchup_by_id($ids, $cmode); - - print json_encode(["message" => "UPDATE_COUNTERS", - "labels" => Article::_labels_of($ids), - "feeds" => Article::_feeds_of($ids)]); - } - - function markSelected(): void { - $ids = array_map("intval", clean($_REQUEST["ids"] ?? [])); - $cmode = (int)clean($_REQUEST["cmode"]); - - if (count($ids) > 0) - $this->markArticlesById($ids, $cmode); - - print json_encode(["message" => "UPDATE_COUNTERS", - "labels" => Article::_labels_of($ids), - "feeds" => Article::_feeds_of($ids)]); - } - - function publishSelected(): void { - $ids = array_map("intval", clean($_REQUEST["ids"] ?? [])); - $cmode = (int)clean($_REQUEST["cmode"]); - - if (count($ids) > 0) - $this->publishArticlesById($ids, $cmode); - - print json_encode(["message" => "UPDATE_COUNTERS", - "labels" => Article::_labels_of($ids), - "feeds" => Article::_feeds_of($ids)]); - } - - function sanityCheck(): void { - $span = Tracer::start(__METHOD__); - - $_SESSION["hasSandbox"] = self::_param_to_bool($_REQUEST["hasSandbox"] ?? false); - $_SESSION["clientTzOffset"] = clean($_REQUEST["clientTzOffset"]); - - $client_location = $_REQUEST["clientLocation"]; - - $error = Errors::E_SUCCESS; - $error_params = []; - - $client_scheme = parse_url($client_location, PHP_URL_SCHEME); - $server_scheme = parse_url(Config::get_self_url(), PHP_URL_SCHEME); - - if (Config::is_migration_needed()) { - $error = Errors::E_SCHEMA_MISMATCH; - } else if ($client_scheme != $server_scheme) { - $error = Errors::E_URL_SCHEME_MISMATCH; - $error_params["client_scheme"] = $client_scheme; - $error_params["server_scheme"] = $server_scheme; - $error_params["self_url_path"] = Config::get_self_url(); - } - - if ($error == Errors::E_SUCCESS) { - $reply = []; - - $reply['init-params'] = $this->_make_init_params(); - $reply['runtime-info'] = $this->_make_runtime_info(); - $reply['translations'] = $this->_translations_as_array(); - - print json_encode($reply); - } else { - print Errors::to_json($error, $error_params); - } - - $span->end(); - } - - /*function completeLabels() { - $search = clean($_REQUEST["search"]); - - $sth = $this->pdo->prepare("SELECT DISTINCT caption FROM - ttrss_labels2 - WHERE owner_uid = ? AND - LOWER(caption) LIKE LOWER(?) ORDER BY caption - LIMIT 5"); - $sth->execute([$_SESSION['uid'], "%$search%"]); - - print "
    "; - while ($line = $sth->fetch()) { - print "
  • " . $line["caption"] . "
  • "; - } - print "
"; - }*/ - - function catchupFeed(): void { - $feed_id = clean($_REQUEST['feed_id']); - $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, 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()]); - - //print json_encode(array("message" => "UPDATE_COUNTERS")); - } - - function setWidescreen(): void { - $wide = (int) clean($_REQUEST["wide"]); - - set_pref(Prefs::WIDESCREEN_MODE, $wide); - - print json_encode(["wide" => $wide]); - } - - static function updaterandomfeed_real(): void { - $span = Tracer::start(__METHOD__); - - $default_interval = (int) Prefs::get_default(Prefs::DEFAULT_UPDATE_INTERVAL); - - // Test if the feed need a update (update interval exceded). - if (Config::get(Config::DB_TYPE) == "pgsql") { - $update_limit_qpart = "AND (( - update_interval = 0 - AND (p.value IS NULL OR p.value != '-1') - AND last_updated < NOW() - CAST((COALESCE(p.value, '$default_interval') || ' minutes') AS INTERVAL) - ) OR ( - update_interval > 0 - AND last_updated < NOW() - CAST((update_interval || ' minutes') AS INTERVAL) - ) OR ( - update_interval >= 0 - AND (p.value IS NULL OR p.value != '-1') - AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) - ))"; - } else { - $update_limit_qpart = "AND (( - update_interval = 0 - AND (p.value IS NULL OR p.value != '-1') - AND last_updated < DATE_SUB(NOW(), INTERVAL CONVERT(COALESCE(p.value, '$default_interval'), SIGNED INTEGER) MINUTE) - ) OR ( - update_interval > 0 - AND last_updated < DATE_SUB(NOW(), INTERVAL update_interval MINUTE) - ) OR ( - update_interval >= 0 - AND (p.value IS NULL OR p.value != '-1') - AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) - ))"; - } - - // Test if feed is currently being updated by another process. - if (Config::get(Config::DB_TYPE) == "pgsql") { - $updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < NOW() - INTERVAL '5 minutes')"; - } else { - $updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < DATE_SUB(NOW(), INTERVAL 5 MINUTE))"; - } - - $random_qpart = Db::sql_random_function(); - - $pdo = Db::pdo(); - - // we could be invoked from public.php with no active session - if (!empty($_SESSION["uid"])) { - $owner_check_qpart = "AND f.owner_uid = ".$pdo->quote($_SESSION["uid"]); - } else { - $owner_check_qpart = ""; - } - - $query = "SELECT f.feed_url,f.id - 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 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 - ORDER BY $random_qpart LIMIT 30"; - - $res = $pdo->query($query); - - $num_updated = 0; - - $tstart = time(); - - while ($line = $res->fetch()) { - $feed_id = $line["id"]; - - if (time() - $tstart < ini_get("max_execution_time") * 0.7) { - RSSUtils::update_rss_feed($feed_id, true); - ++$num_updated; - } else { - break; - } - } - - // Purge orphans and cleanup tags - Article::_purge_orphans(); - //cleanup_tags(14, 50000); - - if ($num_updated > 0) { - print json_encode(array("message" => "UPDATE_COUNTERS", - "num_updated" => $num_updated)); - } else { - print json_encode(array("message" => "NOTHING_TO_UPDATE")); - } - - $span->end(); - } - - function updaterandomfeed(): void { - self::updaterandomfeed_real(); - } - - /** - * @param array $ids - */ - private function markArticlesById(array $ids, int $cmode): void { - - $ids_qmarks = arr_qmarks($ids); - - 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 == 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 = ?"); - } else { - $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET - marked = NOT marked,last_marked = NOW() - WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); - } - - $sth->execute([...$ids, $_SESSION['uid']]); - } - - /** - * @param array $ids - */ - private function publishArticlesById(array $ids, int $cmode): void { - - $ids_qmarks = arr_qmarks($ids); - - 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 == 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 = ?"); - } else { - $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET - published = NOT published,last_published = NOW() - WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); - } - - $sth->execute([...$ids, $_SESSION['uid']]); - } - - function log(): void { - $span = Tracer::start(__METHOD__); - - $msg = clean($_REQUEST['msg'] ?? ""); - $file = basename(clean($_REQUEST['file'] ?? "")); - $line = (int) clean($_REQUEST['line'] ?? 0); - $context = clean($_REQUEST['context'] ?? ""); - - if ($msg) { - Logger::log_error(E_USER_WARNING, - $msg, 'client-js:' . $file, $line, $context); - - echo json_encode(array("message" => "HOST_ERROR_LOGGED")); - } - - $span->end(); - } - - function checkforupdates(): void { - $span = Tracer::start(__METHOD__); - - $rv = ["changeset" => [], "plugins" => []]; - - $version = Config::get_version(false); - - $git_timestamp = $version["timestamp"] ?? false; - $git_commit = $version["commit"] ?? false; - - 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) { - $content = json_decode($content, true); - - if ($content && isset($content["changeset"])) { - if ($git_timestamp < (int)$content["changeset"]["timestamp"] && - $git_commit != $content["changeset"]["id"]) { - - $rv["changeset"] = $content["changeset"]; - } - } - } - - $rv["plugins"] = Pref_Prefs::_get_updated_plugins(); - } - - $span->end(); - - print json_encode($rv); - } - - /** - * @return array - */ - private function _make_init_params(): array { - $span = Tracer::start(__METHOD__); - - $params = array(); - - foreach ([Prefs::ON_CATCHUP_SHOW_NEXT_FEED, Prefs::HIDE_READ_FEEDS, - Prefs::ENABLE_FEED_CATS, Prefs::FEEDS_SORT_BY_UNREAD, - Prefs::CONFIRM_FEED_CATCHUP, Prefs::CDM_AUTO_CATCHUP, - Prefs::FRESH_ARTICLE_MAX_AGE, Prefs::HIDE_READ_SHOWS_SPECIAL, - Prefs::COMBINED_DISPLAY_MODE, Prefs::DEBUG_HEADLINE_IDS, Prefs::CDM_ENABLE_GRID] as $param) { - - $params[strtolower($param)] = (int) get_pref($param); - } - - $params["safe_mode"] = !empty($_SESSION["safe_mode"]); - $params["check_for_updates"] = Config::get(Config::CHECK_FOR_UPDATES); - $params["icons_url"] = Config::get_self_url() . '/public.php'; - $params["cookie_lifetime"] = Config::get(Config::SESSION_COOKIE_LIFETIME); - $params["default_view_mode"] = get_pref(Prefs::_DEFAULT_VIEW_MODE); - $params["default_view_limit"] = (int) get_pref(Prefs::_DEFAULT_VIEW_LIMIT); - $params["default_view_order_by"] = get_pref(Prefs::_DEFAULT_VIEW_ORDER_BY); - $params["bw_limit"] = (int) ($_SESSION["bw_limit"] ?? false); - $params["is_default_pw"] = UserHelper::is_default_password(); - $params["label_base_index"] = LABEL_BASE_INDEX; - - $theme = get_pref(Prefs::USER_CSS_THEME); - $params["theme"] = theme_exists($theme) ? $theme : ""; - - $params["plugins"] = implode(", ", PluginHost::getInstance()->get_plugin_names()); - - $params["php_platform"] = PHP_OS; - $params["php_version"] = PHP_VERSION; - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM - ttrss_feeds WHERE owner_uid = ?"); - $sth->execute([$_SESSION['uid']]); - $row = $sth->fetch(); - - $max_feed_id = $row["mid"]; - $num_feeds = $row["nf"]; - - $params["self_url_prefix"] = Config::get_self_url(); - $params["max_feed_id"] = (int) $max_feed_id; - $params["num_feeds"] = (int) $num_feeds; - $params["hotkeys"] = $this->get_hotkeys_map(); - $params["widescreen"] = (int) get_pref(Prefs::WIDESCREEN_MODE); - $params['simple_update'] = Config::get(Config::SIMPLE_UPDATE_MODE); - $params["icon_indicator_white"] = $this->image_to_base64("images/indicator_white.gif"); - $params["icon_oval"] = $this->image_to_base64("images/oval.svg"); - $params["icon_three_dots"] = $this->image_to_base64("images/three-dots.svg"); - $params["icon_blank"] = $this->image_to_base64("images/blank_icon.gif"); - $params["labels"] = Labels::get_all($_SESSION["uid"]); - - $span->end(); - - return $params; - } - - private function image_to_base64(string $filename): string { - if (file_exists($filename)) { - $ext = pathinfo($filename, PATHINFO_EXTENSION); - - if ($ext == "svg") $ext = "svg+xml"; - - return "data:image/$ext;base64," . base64_encode((string)file_get_contents($filename)); - } else { - return ""; - } - } - - /** - * @return array - */ - static function _make_runtime_info(): array { - $span = Tracer::start(__METHOD__); - - $data = array(); - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM - ttrss_feeds WHERE owner_uid = ?"); - $sth->execute([$_SESSION['uid']]); - $row = $sth->fetch(); - - $max_feed_id = $row['mid']; - $num_feeds = $row['nf']; - - $data["max_feed_id"] = (int) $max_feed_id; - $data["num_feeds"] = (int) $num_feeds; - $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'] >= UserHelper::ACCESS_LEVEL_ADMIN) { - if (Config::get(Config::DB_TYPE) == 'pgsql') { - $log_interval = "created_at > NOW() - interval '1 hour'"; - } else { - $log_interval = "created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)"; - } - - $sth = $pdo->prepare("SELECT COUNT(id) AS cid - FROM ttrss_error_log - 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(); - - if ($row = $sth->fetch()) { - $data['recent_log_events'] = $row['cid']; - } - } - - if (file_exists(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.lock")) { - - $data['daemon_is_running'] = (int) file_is_locked("update_daemon.lock"); - - if (time() - ($_SESSION["daemon_stamp_check"] ?? 0) > 30) { - - $stamp = (int) @file_get_contents(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.stamp"); - - if ($stamp) { - $stamp_delta = time() - $stamp; - - if ($stamp_delta > 1800) { - $stamp_check = 0; - } else { - $stamp_check = 1; - $_SESSION["daemon_stamp_check"] = time(); - } - - $data['daemon_stamp_ok'] = $stamp_check; - - $stamp_fmt = date("Y.m.d, G:i", $stamp); - - $data['daemon_stamp'] = $stamp_fmt; - } - } - } - - $span->end(); - - return $data; - } - - /** - * @return array> - */ - static function get_hotkeys_info(): array { - $hotkeys = array( - __("Navigation") => array( - "next_feed" => __("Open next feed"), - "next_unread_feed" => __("Open next unread feed"), - "prev_feed" => __("Open previous feed"), - "prev_unread_feed" => __("Open previous unread feed"), - "next_article_or_scroll" => __("Open next article (in combined mode, scroll down)"), - "prev_article_or_scroll" => __("Open previous article (in combined mode, scroll up)"), - "next_headlines_page" => __("Scroll headlines by one page down"), - "prev_headlines_page" => __("Scroll headlines by one page up"), - "next_article_noscroll" => __("Open next article"), - "prev_article_noscroll" => __("Open previous article"), - "next_article_noexpand" => __("Move to next article (don't expand)"), - "prev_article_noexpand" => __("Move to previous article (don't expand)"), - "search_dialog" => __("Show search dialog"), - "cancel_search" => __("Cancel active search")), - __("Article") => array( - "toggle_mark" => __("Toggle starred"), - "toggle_publ" => __("Toggle published"), - "toggle_unread" => __("Toggle unread"), - "edit_tags" => __("Edit tags"), - "open_in_new_window" => __("Open in new window"), - "catchup_below" => __("Mark below as read"), - "catchup_above" => __("Mark above as read"), - "article_scroll_down" => __("Scroll down"), - "article_scroll_up" => __("Scroll up"), - "article_page_down" => __("Scroll down page"), - "article_page_up" => __("Scroll up page"), - "select_article_cursor" => __("Select article under cursor"), - "email_article" => __("Email article"), - "close_article" => __("Close/collapse article"), - "toggle_expand" => __("Toggle article expansion (combined mode)"), - "toggle_widescreen" => __("Toggle widescreen mode"), - "toggle_full_text" => __("Toggle full article text via Readability")), - __("Article selection") => array( - "select_all" => __("Select all articles"), - "select_unread" => __("Select unread"), - "select_marked" => __("Select starred"), - "select_published" => __("Select published"), - "select_invert" => __("Invert selection"), - "select_none" => __("Deselect everything")), - __("Feed") => array( - "feed_refresh" => __("Refresh current feed"), - "feed_unhide_read" => __("Un/hide read feeds"), - "feed_subscribe" => __("Subscribe to feed"), - "feed_edit" => __("Edit feed"), - "feed_catchup" => __("Mark as read"), - "feed_reverse" => __("Reverse headlines"), - "feed_toggle_vgroup" => __("Toggle headline grouping"), - "feed_toggle_grid" => __("Toggle grid view"), - "feed_debug_update" => __("Debug feed update"), - "feed_debug_viewfeed" => __("Debug viewfeed()"), - "catchup_all" => __("Mark all feeds as read"), - "cat_toggle_collapse" => __("Un/collapse current category"), - "toggle_cdm_expanded" => __("Toggle auto expand in combined mode"), - "toggle_combined_mode" => __("Toggle combined mode")), - __("Go to") => array( - "goto_all" => __("All articles"), - "goto_fresh" => __("Fresh"), - "goto_marked" => __("Starred"), - "goto_published" => __("Published"), - "goto_read" => __("Recently read"), - "goto_prefs" => __("Preferences")), - __("Other") => array( - "create_label" => __("Create label"), - "create_filter" => __("Create filter"), - "collapse_sidebar" => __("Un/collapse sidebar"), - "help_dialog" => __("Show help dialog")) - ); - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HOTKEY_INFO, - function ($result) use (&$hotkeys) { - $hotkeys = $result; - }, - $hotkeys); - - return $hotkeys; - } - - /** - * {3} - 3 panel mode only - * {C} - combined mode only - * - * @return array{0: array, 1: array} $prefixes, $hotkeys - */ - static function get_hotkeys_map() { - $hotkeys = array( - "k" => "next_feed", - "K" => "next_unread_feed", - "j" => "prev_feed", - "J" => "prev_unread_feed", - "n" => "next_article_noscroll", - "p" => "prev_article_noscroll", - "N" => "article_page_down", - "P" => "article_page_up", - "*(33)|Shift+PgUp" => "article_page_up", - "*(34)|Shift+PgDn" => "article_page_down", - "{3}(38)|Up" => "prev_article_or_scroll", - "{3}(40)|Down" => "next_article_or_scroll", - "*(38)|Shift+Up" => "article_scroll_up", - "*(40)|Shift+Down" => "article_scroll_down", - "^(38)|Ctrl+Up" => "prev_article_noscroll", - "^(40)|Ctrl+Down" => "next_article_noscroll", - "/" => "search_dialog", - "\\" => "cancel_search", - "s" => "toggle_mark", - "S" => "toggle_publ", - "u" => "toggle_unread", - "T" => "edit_tags", - "o" => "open_in_new_window", - "c p" => "catchup_below", - "c n" => "catchup_above", - "a W" => "toggle_widescreen", - "a e" => "toggle_full_text", - "e" => "email_article", - "a q" => "close_article", - "a s" => "article_span_grid", - "a a" => "select_all", - "a u" => "select_unread", - "a U" => "select_marked", - "a p" => "select_published", - "a i" => "select_invert", - "a n" => "select_none", - "f r" => "feed_refresh", - "f a" => "feed_unhide_read", - "f s" => "feed_subscribe", - "f e" => "feed_edit", - "f q" => "feed_catchup", - "f x" => "feed_reverse", - "f g" => "feed_toggle_vgroup", - "f G" => "feed_toggle_grid", - "f D" => "feed_debug_update", - "f %" => "feed_debug_viewfeed", - "f C" => "toggle_combined_mode", - "f c" => "toggle_cdm_expanded", - "Q" => "catchup_all", - "x" => "cat_toggle_collapse", - "g a" => "goto_all", - "g f" => "goto_fresh", - "g s" => "goto_marked", - "g p" => "goto_published", - "g r" => "goto_read", - "g P" => "goto_prefs", - "r" => "select_article_cursor", - "c l" => "create_label", - "c f" => "create_filter", - "c s" => "collapse_sidebar", - "?" => "help_dialog", - ); - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HOTKEY_MAP, - function ($result) use (&$hotkeys) { - $hotkeys = $result; - }, - $hotkeys); - - $prefixes = array(); - - foreach (array_keys($hotkeys) as $hotkey) { - $pair = explode(" ", (string)$hotkey, 2); - - if (count($pair) > 1 && !in_array($pair[0], $prefixes)) { - array_push($prefixes, $pair[0]); - } - } - - return array($prefixes, $hotkeys); - } - - function hotkeyHelp(): void { - $info = self::get_hotkeys_info(); - $imap = self::get_hotkeys_map(); - $omap = []; - - foreach ($imap[1] as $sequence => $action) { - $omap[$action] ??= []; - $omap[$action][] = $sequence; - } - - ?> -
    - $hotkeys) { - ?> -
  • - $description) { - - if (!empty($omap[$action])) { - foreach ($omap[$action] as $sequence) { - if (strpos($sequence, "|") !== false) { - $sequence = substr($sequence, - strpos($sequence, "|")+1, - strlen($sequence)); - } else { - $keys = explode(" ", $sequence); - - for ($i = 0; $i < count($keys); $i++) { - if (strlen($keys[$i]) > 1) { - $tmp = ''; - foreach (str_split($keys[$i]) as $c) { - switch ($c) { - case '*': - $tmp .= __('Shift') . '+'; - break; - case '^': - $tmp .= __('Ctrl') . '+'; - break; - default: - $tmp .= $c; - } - } - $keys[$i] = $tmp; - } - } - $sequence = join(" ", $keys); - } - - ?> -
  • -
    -
    -
  • - -
-
- -
- $article - */ - static function calculate_article_hash(array $article, PluginHost $pluginhost): string { - $tmp = ""; - - $ignored_fields = [ "feed", "guid", "guid_hashed", "owner_uid", "force_catchup" ]; - - foreach ($article as $k => $v) { - if (in_array($k, $ignored_fields)) - continue; - - if ($k != "feed" && isset($v)) { - $x = strip_tags( - is_array($v) ? implode(",", array_keys($v)) : $v); - - $tmp .= sha1("$k:" . sha1($x)); - } - } - - return sha1(implode(",", $pluginhost->get_plugin_names()) . $tmp); - } - - // Strips utf8mb4 characters (i.e. emoji) for mysql - static function strip_utf8mb4(string $str): string { - return preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $str); - } - - static function cleanup_feed_browser(): void { - $pdo = Db::pdo(); - $pdo->query("DELETE FROM ttrss_feedbrowser_cache"); - } - - static function cleanup_feed_icons(): void { - $pdo = Db::pdo(); - $sth = $pdo->prepare("SELECT id FROM ttrss_feeds WHERE id = ?"); - - $cache = DiskCache::instance('feed-icons'); - - if ($cache->is_writable()) { - $dh = opendir($cache->get_full_path("")); - - if ($dh) { - while (($icon = readdir($dh)) !== false) { - if (preg_match('/^[0-9]{1,}$/', $icon) && $cache->get_mtime($icon) < time() - 86400 * Config::get(Config::CACHE_MAX_DAYS)) { - - $sth->execute([(int)$icon]); - - if ($sth->fetch()) { - $cache->put($icon, $cache->get($icon)); - } else { - $icon_path = $cache->get_full_path($icon); - - Debug::log("Removing orphaned feed icon: $icon_path"); - unlink($icon_path); - } - } - } - - closedir($dh); - } - } - } - - /** - * @param array $options - */ - static function update_daemon_common(int $limit = 0, array $options = []): int { - $span = Tracer::start(__METHOD__); - - if (!$limit) $limit = Config::get(Config::DAEMON_FEED_LIMIT); - - if (Config::get_schema_version() != Config::SCHEMA_VERSION) { - die("Schema version is wrong, please upgrade the database.\n"); - } - - $pdo = Db::pdo(); - - if (!Config::get(Config::SINGLE_USER_MODE) && Config::get(Config::DAEMON_UPDATE_LOGIN_LIMIT) > 0) { - $login_limit = (int) Config::get(Config::DAEMON_UPDATE_LOGIN_LIMIT); - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $login_thresh_qpart = "AND last_login >= NOW() - INTERVAL '$login_limit days'"; - } else { - $login_thresh_qpart = "AND last_login >= DATE_SUB(NOW(), INTERVAL $login_limit DAY)"; - } - } else { - $login_thresh_qpart = ""; - } - - $default_interval = (int) Prefs::get_default(Prefs::DEFAULT_UPDATE_INTERVAL); - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $update_limit_qpart = "AND (( - update_interval = 0 - AND (p.value IS NULL OR p.value != '-1') - AND last_updated < NOW() - CAST((COALESCE(p.value, '$default_interval') || ' minutes') AS INTERVAL) - ) OR ( - update_interval > 0 - AND last_updated < NOW() - CAST((update_interval || ' minutes') AS INTERVAL) - ) OR ( - update_interval >= 0 - AND (p.value IS NULL OR p.value != '-1') - AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) - ))"; - } else { - $update_limit_qpart = "AND (( - update_interval = 0 - AND (p.value IS NULL OR p.value != '-1') - AND last_updated < DATE_SUB(NOW(), INTERVAL CONVERT(COALESCE(p.value, '$default_interval'), SIGNED INTEGER) MINUTE) - ) OR ( - update_interval > 0 - AND last_updated < DATE_SUB(NOW(), INTERVAL update_interval MINUTE) - ) OR ( - update_interval >= 0 - AND (p.value IS NULL OR p.value != '-1') - AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) - ))"; - } - - // Test if feed is currently being updated by another process. - if (Config::get(Config::DB_TYPE) == "pgsql") { - $updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < NOW() - INTERVAL '10 minutes')"; - } else { - $updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < DATE_SUB(NOW(), INTERVAL 10 MINUTE))"; - } - - $query_limit = $limit ? sprintf("LIMIT %d", $limit) : ""; - - // Update the least recently updated feeds first - $query_order = "ORDER BY last_updated"; - - if (Config::get(Config::DB_TYPE) == "pgsql") - $query_order .= " NULLS FIRST"; - - $query = "SELECT f.feed_url, f.last_updated - 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 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 - $query_order $query_limit"; - - //print "$query\n"; - - $res = $pdo->query($query); - - $feeds_to_update = array(); - while ($line = $res->fetch()) { - array_push($feeds_to_update, $line['feed_url']); - } - - Debug::log(sprintf("Scheduled %d feeds to update...", count($feeds_to_update))); - - // Update last_update_started before actually starting the batch - // in order to minimize collision risk for parallel daemon tasks - if (count($feeds_to_update) > 0) { - $feeds_qmarks = arr_qmarks($feeds_to_update); - - $tmph = $pdo->prepare("UPDATE ttrss_feeds SET last_update_started = NOW() - WHERE feed_url IN ($feeds_qmarks)"); - $tmph->execute($feeds_to_update); - } - - $nf = 0; - $bstarted = microtime(true); - - $batch_owners = []; - - $user_query = "SELECT f.id, - last_updated, - f.owner_uid, - u.login AS owner, - f.title - 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 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 - ORDER BY f.id $query_limit"; - - //print "$user_query\n"; - - // since we have feed xml cached, we can deal with other feeds with the same url - $usth = $pdo->prepare($user_query); - - foreach ($feeds_to_update as $feed) { - Debug::log("Base feed: $feed"); - - $usth->execute(["feed" => $feed]); - - if ($tline = $usth->fetch()) { - Debug::log(sprintf("=> %s (ID: %d, U: %s [%d]), last updated: %s", $tline["title"], $tline["id"], - $tline["owner"], $tline["owner_uid"], - $tline["last_updated"] ? $tline["last_updated"] : "never")); - - if (!in_array($tline["owner_uid"], $batch_owners)) - array_push($batch_owners, $tline["owner_uid"]); - - $fstarted = microtime(true); - - $quiet = (isset($options["quiet"])) ? "--quiet" : ""; - $log = function_exists("flock") && isset($options['log']) ? '--log '.$options['log'] : ''; - $log_level = isset($options['log-level']) ? '--log-level '.$options['log-level'] : ''; - - /* shared hosting may have this disabled and it's not strictly required */ - if (self::function_enabled('passthru')) { - $exit_code = 0; - - passthru(Config::get(Config::PHP_EXECUTABLE) . " update.php --update-feed " . $tline["id"] . " --pidlock feed-" . $tline["id"] . " $quiet $log $log_level", $exit_code); - - Debug::log(sprintf("<= %.4f (sec) exit code: %d", microtime(true) - $fstarted, $exit_code)); - - // -1 can be caused by a SIGCHLD handler which daemon master process installs (not every setup, apparently) - if ($exit_code != 0 && $exit_code != -1) { - $festh = $pdo->prepare("SELECT last_error FROM ttrss_feeds WHERE id = ?"); - $festh->execute([$tline["id"]]); - - if ($ferow = $festh->fetch()) { - $error_message = $ferow["last_error"]; - } else { - $error_message = "N/A"; - } - - Debug::log("!! Last error: $error_message"); - - Logger::log(E_USER_NOTICE, - sprintf("Update process for feed %d (%s, owner UID: %d) failed with exit code: %d (%s).", - $tline["id"], clean($tline["title"]), $tline["owner_uid"], $exit_code, clean($error_message))); - - $combined_error_message = sprintf("Update process failed with exit code: %d (%s)", - $exit_code, clean($error_message)); - - # mark failed feed as having an update error (unless it is already marked) - $fusth = $pdo->prepare("UPDATE ttrss_feeds SET last_error = ? WHERE id = ? AND last_error = ''"); - $fusth->execute([$combined_error_message, $tline["id"]]); - } - - } else { - try { - if (!self::update_rss_feed($tline["id"], true)) { - Logger::log(E_USER_NOTICE, - sprintf("Update request for feed %d (%s, owner UID: %d) failed: %s.", - $tline["id"], clean($tline["title"]), $tline["owner_uid"], clean(UrlHelper::$fetch_last_error))); - } - - Debug::log(sprintf("<= %.4f (sec) (not using a separate process)", microtime(true) - $fstarted)); - - } catch (PDOException $e) { - Logger::log_error(E_USER_WARNING, $e->getMessage(), $e->getFile(), $e->getLine(), $e->getTraceAsString()); - - try { - $pdo->rollback(); - } catch (PDOException $e) { - // it doesn't matter if there wasn't actually anything to rollback, PDO Exception can be - // thrown outside of an active transaction during feed update - } - } - } - - ++$nf; - } - } - - if ($nf > 0) { - Debug::log(sprintf("Processed %d feeds in %.4f (sec), %.4f (sec/feed avg)", $nf, - microtime(true) - $bstarted, (microtime(true) - $bstarted) / $nf)); - } - - foreach ($batch_owners as $owner_uid) { - Debug::log("Running housekeeping tasks for user $owner_uid..."); - - self::housekeeping_user($owner_uid); - } - - // Send feed digests by email if needed. - Digest::send_headlines_digests(); - - $span->end(); - - return $nf; - } - - /** this is used when subscribing */ - 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); - - if ($feed) { - $pluginhost = new PluginHost(); - $user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $feed->owner_uid); - - $pluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL); - $pluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $feed->owner_uid); - //$pluginhost->load_data(); - - $basic_info = []; - - $pluginhost->run_hooks_callback(PluginHost::HOOK_FEED_BASIC_INFO, function ($result) use (&$basic_info) { - $basic_info = $result; - }, $basic_info, $feed->feed_url, $feed->owner_uid, $feed_id, $feed->auth_login, $feed->auth_pass); - - if (!$basic_info) { - $feed_data = UrlHelper::fetch([ - 'url' => $feed->feed_url, - 'login' => $feed->auth_login, - 'pass' => $feed->auth_pass, - 'timeout' => Config::get(Config::FEED_FETCH_TIMEOUT), - ]); - - $feed_data = trim($feed_data); - - if ($feed_data) { - $rss = new FeedParser($feed_data); - $rss->init(); - - if (!$rss->error()) { - $basic_info = [ - 'title' => mb_substr(clean($rss->get_title()), 0, 199), - 'site_url' => mb_substr(UrlHelper::rewrite_relative($feed->feed_url, clean($rss->get_link())), 0, 245), - ]; - } else { - Debug::log(sprintf("unable to parse feed for basic info: %s", $rss->error()), Debug::LOG_VERBOSE); - } - } else { - Debug::log(sprintf("unable to fetch feed for basic info: %s [%s]", UrlHelper::$fetch_last_error, UrlHelper::$fetch_last_error_code), Debug::LOG_VERBOSE); - } - } - - if ($basic_info && is_array($basic_info)) { - if (!empty($basic_info['title']) && (!$feed->title || $feed->title == '[Unknown]')) { - $feed->title = $basic_info['title']; - } - - if (!empty($basic_info['site_url']) && $feed->site_url != $basic_info['site_url']) { - $feed->site_url = $basic_info['site_url']; - } - - $feed->save(); - } - } - } - - static function update_rss_feed(int $feed, bool $no_cache = false, bool $html_output = false) : bool { - - $span = Tracer::start(__METHOD__); - $span->setAttribute('func.args', json_encode(func_get_args())); - - Debug::enable_html($html_output); - Debug::log("start", Debug::LOG_VERBOSE); - - $pdo = Db::pdo(); - - /** @var DiskCache $cache */ - $cache = DiskCache::instance('feeds'); - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $favicon_interval_qpart = "favicon_last_checked < NOW() - INTERVAL '12 hour'"; - } else { - $favicon_interval_qpart = "favicon_last_checked < DATE_SUB(NOW(), INTERVAL 12 HOUR)"; - } - - $feed_obj = ORM::for_table('ttrss_feeds') - ->select_expr("ttrss_feeds.*, - ".SUBSTRING_FOR_DATE."(last_unconditional, 1, 19) AS last_unconditional, - (favicon_is_custom IS NOT TRUE AND - (favicon_last_checked IS NULL OR $favicon_interval_qpart)) AS favicon_needs_check") - ->find_one($feed); - - if ($feed_obj) { - $feed_obj->last_update_started = Db::NOW(); - $feed_obj->save(); - - $feed_language = mb_strtolower($feed_obj->feed_language); - - 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"); - $span->end(); - return false; - } - } else { - // this would indicate database corruption of some kind - Debug::log("error: owner not found for feed: $feed"); - $span->end(); - return false; - } - - } else { - Debug::log("error: feeds table record not found for feed: $feed"); - $span->end(); - return false; - } - - // feed was batch-subscribed or something, we need to get basic info - // this is not optimal currently as it fetches stuff separately TODO: optimize - if ($feed_obj->title == "[Unknown]" || empty($feed_obj->title) || empty($feed_obj->site_url)) { - Debug::log("setting basic feed info for $feed..."); - self::update_basic_info($feed); - } - - $date_feed_processed = date('Y-m-d H:i'); - - $cache_filename = sha1($feed_obj->feed_url) . ".xml"; - - $pluginhost = new PluginHost(); - $user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $feed_obj->owner_uid); - - $pluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL); - $pluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $feed_obj->owner_uid); - - $rss_hash = false; - - $force_refetch = isset($_REQUEST["force_refetch"]); - $dump_feed_xml = isset($_REQUEST["dump_feed_xml"]); - $feed_data = ""; - - Debug::log("running HOOK_FETCH_FEED handlers...", Debug::LOG_VERBOSE); - - $start_ts = microtime(true); - $last_article_timestamp = 0; - - $hff_owner_uid = $feed_obj->owner_uid; - $hff_feed_url = $feed_obj->feed_url; - - $pluginhost->chain_hooks_callback(PluginHost::HOOK_FETCH_FEED, - function ($result, $plugin) use (&$feed_data, $start_ts) { - $feed_data = $result; - Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), Debug::LOG_VERBOSE); - }, - $feed_data, $hff_feed_url, $hff_owner_uid, $feed, $last_article_timestamp, $auth_login, $auth_pass); - - if ($feed_data) { - Debug::log("feed data has been modified by a plugin.", Debug::LOG_VERBOSE); - } else { - Debug::log("feed data has not been modified by a plugin.", Debug::LOG_VERBOSE); - } - - // try cache - if (!$feed_data && - $cache->exists($cache_filename) && - !$feed_obj->auth_login && !$feed_obj->auth_pass && - $cache->get_mtime($cache_filename) > time() - 30) { - - Debug::log("using local cache: {$cache_filename}.", Debug::LOG_VERBOSE); - - $feed_data = $cache->get($cache_filename); - - if ($feed_data) { - $rss_hash = sha1($feed_data); - } - - } else { - Debug::log("local cache will not be used for this feed", Debug::LOG_VERBOSE); - } - - // fetch feed from source - if (!$feed_data) { - Debug::log("last unconditional update request: {$feed_obj->last_unconditional}", Debug::LOG_VERBOSE); - - if (ini_get("open_basedir") && function_exists("curl_init")) { - Debug::log("not using CURL due to open_basedir restrictions", Debug::LOG_VERBOSE); - } - - if (time() - strtotime($feed_obj->last_unconditional ?? "") > Config::get(Config::MAX_CONDITIONAL_INTERVAL)) { - Debug::log("maximum allowed interval for conditional requests exceeded, forcing refetch", Debug::LOG_VERBOSE); - - $force_refetch = true; - } else { - Debug::log("stored last modified for conditional request: {$feed_obj->last_modified}", Debug::LOG_VERBOSE); - } - - Debug::log("fetching {$feed_obj->feed_url} (force_refetch: $force_refetch)...", Debug::LOG_VERBOSE); - - $feed_data = UrlHelper::fetch([ - "url" => $feed_obj->feed_url, - "login" => $feed_obj->auth_login, - "pass" => $feed_obj->auth_pass, - "timeout" => $no_cache ? Config::get(Config::FEED_FETCH_NO_CACHE_TIMEOUT) : Config::get(Config::FEED_FETCH_TIMEOUT), - "last_modified" => $force_refetch ? "" : $feed_obj->last_modified - ]); - - $feed_data = trim($feed_data); - - Debug::log("fetch done.", Debug::LOG_VERBOSE); - Debug::log(sprintf("effective URL (after redirects): %s (IP: %s) ", UrlHelper::$fetch_effective_url, UrlHelper::$fetch_effective_ip_addr), Debug::LOG_VERBOSE); - Debug::log("server last modified: " . UrlHelper::$fetch_last_modified, Debug::LOG_VERBOSE); - - if ($feed_data && UrlHelper::$fetch_last_modified != $feed_obj->last_modified) { - $feed_obj->last_modified = substr(UrlHelper::$fetch_last_modified, 0, 245); - $feed_obj->save(); - } - - // cache vanilla feed data for re-use - if ($feed_data && !$feed_obj->auth_pass && !$feed_obj->auth_login && $cache->is_writable()) { - $new_rss_hash = sha1($feed_data); - - if ($new_rss_hash != $rss_hash) { - Debug::log("saving to local cache: $cache_filename", Debug::LOG_VERBOSE); - $cache->put($cache_filename, $feed_data); - } - } - } - - if (!$feed_data) { - Debug::log(sprintf("unable to fetch: %s [%s]", UrlHelper::$fetch_last_error, UrlHelper::$fetch_last_error_code), Debug::LOG_VERBOSE); - - // If-Modified-Since - if (UrlHelper::$fetch_last_error_code == 304) { - Debug::log("source claims data not modified (304), nothing to do.", Debug::LOG_VERBOSE); - $error_message = ""; - - $feed_obj->set([ - 'last_error' => '', - 'last_successful_update' => Db::NOW(), - 'last_updated' => Db::NOW(), - ]); - - $feed_obj->save(); - - } else if (UrlHelper::$fetch_last_error_code == 429) { - - // randomize interval using Config::HTTP_429_THROTTLE_INTERVAL as a base value (1-2x) - $http_429_throttle_interval = rand(Config::get(Config::HTTP_429_THROTTLE_INTERVAL), - Config::get(Config::HTTP_429_THROTTLE_INTERVAL)*2); - - $error_message = UrlHelper::$fetch_last_error; - - Debug::log("source claims we're requesting too often (429), throttling updates for $http_429_throttle_interval seconds.", - Debug::LOG_VERBOSE); - - $feed_obj->set([ - 'last_error' => $error_message . " (updates throttled for $http_429_throttle_interval seconds.)", - 'last_successful_update' => Db::NOW($http_429_throttle_interval), - 'last_updated' => Db::NOW($http_429_throttle_interval), - ]); - - $feed_obj->save(); - } else { - $error_message = UrlHelper::$fetch_last_error; - - $feed_obj->set([ - 'last_error' => $error_message, - 'last_updated' => Db::NOW(), - ]); - - $feed_obj->save(); - } - - $span->end(); - return $error_message == ""; - } - - Debug::log("running HOOK_FEED_FETCHED handlers...", Debug::LOG_VERBOSE); - $feed_data_checksum = md5($feed_data); - - // because chain_hooks_callback() accepts variables by value - $pff_owner_uid = $feed_obj->owner_uid; - $pff_feed_url = $feed_obj->feed_url; - - if ($dump_feed_xml) { - Debug::log("feed data before hooks:", Debug::LOG_VERBOSE); - - Debug::log(Debug::SEPARATOR, Debug::LOG_VERBOSE); - print("" . htmlspecialchars($feed_data). "\n"); - Debug::log(Debug::SEPARATOR, Debug::LOG_VERBOSE); - } - - $start_ts = microtime(true); - $pluginhost->chain_hooks_callback(PluginHost::HOOK_FEED_FETCHED, - function ($result, $plugin) use (&$feed_data, $start_ts) { - $feed_data = $result; - Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), Debug::LOG_VERBOSE); - }, - $feed_data, $pff_feed_url, $pff_owner_uid, $feed); - - if (md5($feed_data) != $feed_data_checksum) { - Debug::log("feed data has been modified by a plugin.", Debug::LOG_VERBOSE); - } else { - Debug::log("feed data has not been modified by a plugin.", Debug::LOG_VERBOSE); - } - - if ($dump_feed_xml) { - Debug::log("feed data after hooks:", Debug::LOG_VERBOSE); - - Debug::log(Debug::SEPARATOR, Debug::LOG_VERBOSE); - print("" . htmlspecialchars($feed_data). "\n"); - Debug::log(Debug::SEPARATOR, Debug::LOG_VERBOSE); - } - - $rss = new FeedParser($feed_data); - $rss->init(); - - if (!$rss->error()) { - - Debug::log("running HOOK_FEED_PARSED handlers...", Debug::LOG_VERBOSE); - - // We use local pluginhost here because we need to load different per-user feed plugins - - $start_ts = microtime(true); - $pluginhost->chain_hooks_callback(PluginHost::HOOK_FEED_PARSED, - function($result, $plugin) use ($start_ts) { - Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), Debug::LOG_VERBOSE); - }, - $rss, $feed); - - Debug::log("language: $feed_language", Debug::LOG_VERBOSE); - Debug::log("processing feed data...", Debug::LOG_VERBOSE); - - // this is a fallback, in case RSSUtils::update_basic_info() fails. - // TODO: is this necessary? remove unless it is. - if (empty($feed_obj->site_url)) { - $feed_obj->site_url = mb_substr(UrlHelper::rewrite_relative($feed_obj->feed_url, clean($rss->get_link())), 0, 245); - $feed_obj->save(); - } - - Debug::log("site_url: {$feed_obj->site_url}", Debug::LOG_VERBOSE); - Debug::log("feed_title: {$rss->get_title()}", Debug::LOG_VERBOSE); - - Debug::log('favicon: needs check: ' . ($feed_obj->favicon_needs_check ? 'true' : 'false') - . ', is custom: ' . ($feed_obj->favicon_is_custom ? 'true' : 'false') - . ", avg color: {$feed_obj->favicon_avg_color}", - Debug::LOG_VERBOSE); - - if ($feed_obj->favicon_needs_check || $force_refetch - || ($feed_obj->favicon_is_custom && !$feed_obj->favicon_avg_color)) { - - // restrict update attempts to once per 12h - $feed_obj->favicon_last_checked = Db::NOW(); - $feed_obj->save(); - - $favicon_cache = DiskCache::instance('feed-icons'); - - $favicon_modified = $favicon_cache->exists($feed) ? $favicon_cache->get_mtime($feed) : -1; - - // don't try to redownload custom favicons - if (!$feed_obj->favicon_is_custom) { - Debug::log("favicon: trying to update favicon...", Debug::LOG_VERBOSE); - self::update_favicon($feed_obj->site_url, $feed); - - if (!$favicon_cache->exists($feed) || $favicon_cache->get_mtime($feed) > $favicon_modified) { - $feed_obj->favicon_avg_color = null; - $feed_obj->save(); - } - } - - /* terrible hack: if we crash on floicon shit here, we won't check - * the icon avgcolor again (unless icon got updated) */ - if (file_exists($favicon_cache->get_full_path($feed)) && function_exists("imagecreatefromstring") && empty($feed_obj->favicon_avg_color)) { - require_once "colors.php"; - - Debug::log("favicon: trying to calculate average color...", Debug::LOG_VERBOSE); - - $feed_obj->favicon_avg_color = 'fail'; - $feed_obj->save(); - - $calculated_avg_color = \Colors\calculate_avg_color($favicon_cache->get_full_path($feed)); - if ($calculated_avg_color) { - $feed_obj->favicon_avg_color = $calculated_avg_color; - $feed_obj->save(); - } - - Debug::log("favicon: calculated avg color: {$calculated_avg_color}, setting avg color: {$feed_obj->favicon_avg_color}", Debug::LOG_VERBOSE); - - } else if ($feed_obj->favicon_avg_color == 'fail') { - Debug::log("floicon failed on $feed or a suitable avg color couldn't be determined, not trying to recalculate avg color", Debug::LOG_VERBOSE); - } - } - - Debug::log("loading filters & labels...", Debug::LOG_VERBOSE); - - $filters = self::load_filters($feed, $feed_obj->owner_uid); - - if (Debug::get_loglevel() >= Debug::LOG_EXTENDED) { - print_r($filters); - } - - Debug::log("" . count($filters) . " filters loaded.", Debug::LOG_VERBOSE); - - $items = $rss->get_items(); - - if (!is_array($items)) { - Debug::log("no articles found.", Debug::LOG_VERBOSE); - - $feed_obj->set([ - 'last_updated' => Db::NOW(), - 'last_unconditional' => Db::NOW(), - 'last_error' => '', - ]); - - $feed_obj->save(); - $span->end(); - return true; // no articles - } - - Debug::log("processing articles...", Debug::LOG_VERBOSE); - - $tstart = time(); - - foreach ($items as $item) { - $a_span = Tracer::start('article'); - - $pdo->beginTransaction(); - - Debug::log(Debug::SEPARATOR, Debug::LOG_VERBOSE); - - if (Debug::get_loglevel() >= 3) { - print_r($item); - } - - if (ini_get("max_execution_time") > 0 && time() - $tstart >= ((float)ini_get("max_execution_time") * 0.7)) { - Debug::log("looks like there's too many articles to process at once, breaking out.", Debug::LOG_VERBOSE); - $pdo->commit(); - break; - } - - $entry_guid = strip_tags($item->get_id()); - if (!$entry_guid) $entry_guid = strip_tags($item->get_link()); - if (!$entry_guid) $entry_guid = self::make_guid_from_title($item->get_title()); - - if (!$entry_guid) { - $pdo->commit(); - continue; - } - - $entry_guid_hashed_compat = 'SHA1:' . sha1("{$feed_obj->owner_uid},$entry_guid"); - $entry_guid_hashed = json_encode(["ver" => 2, "uid" => $feed_obj->owner_uid, "hash" => 'SHA1:' . sha1($entry_guid)]); - $entry_guid = "$feed_obj->owner_uid,$entry_guid"; - - Debug::log("guid $entry_guid (hash: $entry_guid_hashed compat: $entry_guid_hashed_compat)", Debug::LOG_VERBOSE); - - $entry_timestamp = (int)$item->get_date(); - - Debug::log(sprintf("orig date: %s (%s)", $item->get_date(), date("Y-m-d H:i:s", $item->get_date())), - Debug::LOG_VERBOSE); - - $entry_title = strip_tags($item->get_title()); - - $entry_link = UrlHelper::rewrite_relative($feed_obj->site_url, clean($item->get_link()), "a", "href"); - - $entry_language = mb_substr(trim($item->get_language()), 0, 2); - - Debug::log("title $entry_title", Debug::LOG_VERBOSE); - Debug::log("link $entry_link", Debug::LOG_VERBOSE); - Debug::log("language $entry_language", Debug::LOG_VERBOSE); - - if (!$entry_title) $entry_title = date("Y-m-d H:i:s", $entry_timestamp);; - - $entry_content = $item->get_content(); - if (!$entry_content) $entry_content = $item->get_description(); - - if (Debug::get_loglevel() >= 3) { - print "content: "; - print htmlspecialchars($entry_content); - print "\n"; - } - - $entry_comments = mb_substr(strip_tags($item->get_comments_url()), 0, 245); - $num_comments = $item->get_comments_count(); - - $entry_author = strip_tags($item->get_author()); - $entry_guid = mb_substr($entry_guid, 0, 245); - - Debug::log("author $entry_author", Debug::LOG_VERBOSE); - Debug::log("looking for tags...", Debug::LOG_VERBOSE); - - $entry_tags = $item->get_categories(); - Debug::log("tags found: " . join(", ", $entry_tags), Debug::LOG_VERBOSE); - - Debug::log("done collecting data.", Debug::LOG_VERBOSE); - - $sth = $pdo->prepare("SELECT id, content_hash, lang FROM ttrss_entries - WHERE guid IN (?, ?, ?)"); - $sth->execute([$entry_guid, $entry_guid_hashed, $entry_guid_hashed_compat]); - - if ($row = $sth->fetch()) { - $base_entry_id = $row["id"]; - $entry_stored_hash = $row["content_hash"]; - $article_labels = Article::_get_labels($base_entry_id, $feed_obj->owner_uid); - - $existing_tags = Article::_get_tags($base_entry_id, $feed_obj->owner_uid); - $entry_tags = array_unique([...$entry_tags, ...$existing_tags]); - } else { - $base_entry_id = false; - $entry_stored_hash = ""; - $article_labels = array(); - } - - Debug::log("looking for enclosures...", Debug::LOG_VERBOSE); - - // enclosures - - $enclosures = array(); - - $encs = $item->get_enclosures(); - - if (is_array($encs)) { - foreach ($encs as $e) { - - $pluginhost->chain_hooks_callback(PluginHost::HOOK_ENCLOSURE_IMPORTED, - function ($result) use (&$e) { - $e = $result; - }, - $e, $feed); - - // TODO: Just use FeedEnclosure (and modify it to cover whatever justified this)? - $e_item = array( - UrlHelper::rewrite_relative($feed_obj->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" - if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET) != "UTF8MB4") { - for ($i = 0; $i < count($e_item); $i++) { - if (is_string($e_item[$i])) { - $e_item[$i] = self::strip_utf8mb4($e_item[$i]); - } - } - } - - array_push($enclosures, $e_item); - } - } - - $article = array("owner_uid" => $feed_obj->owner_uid, // read only - "guid" => $entry_guid, // read only - "guid_hashed" => $entry_guid_hashed, // read only - "title" => $entry_title, - "content" => $entry_content, - "link" => $entry_link, - "labels" => $article_labels, // current limitation: can add labels to article, can't remove them - "tags" => $entry_tags, - "author" => $entry_author, - "force_catchup" => false, // ugly hack for the time being - "score_modifier" => 0, // no previous value, plugin should recalculate score modifier based on content if needed - "language" => $entry_language, - "timestamp" => $entry_timestamp, - "num_comments" => $num_comments, - "enclosures" => $enclosures, - "feed" => array("id" => $feed, - "fetch_url" => $feed_obj->feed_url, - "site_url" => $feed_obj->site_url, - "cache_images" => $feed_obj->cache_images) - ); - - $entry_plugin_data = ""; - $entry_current_hash = self::calculate_article_hash($article, $pluginhost); - - Debug::log("article hash: $entry_current_hash [stored=$entry_stored_hash]", Debug::LOG_VERBOSE); - - if ($entry_current_hash == $entry_stored_hash && !isset($_REQUEST["force_rehash"])) { - Debug::log("stored article seems up to date [IID: $base_entry_id], updating timestamp only.", Debug::LOG_VERBOSE); - - // we keep encountering the entry in feeds, so we need to - // update date_updated column so that we don't get horrible - // 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(); - - continue; - } - - Debug::log("hash differs, running HOOK_ARTICLE_FILTER handlers...", Debug::LOG_VERBOSE); - - $start_ts = microtime(true); - - $pluginhost->chain_hooks_callback(PluginHost::HOOK_ARTICLE_FILTER, - function ($result, $plugin) use (&$article, &$entry_plugin_data, $start_ts) { - $article = $result; - - $entry_plugin_data .= mb_strtolower(get_class($plugin)) . ","; - - Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), - Debug::LOG_VERBOSE); - }, - $article); - - if (Debug::get_loglevel() >= 3) { - print "processed content: "; - print htmlspecialchars($article["content"]); - print "\n"; - } - - Debug::log("plugin data: {$entry_plugin_data}", Debug::LOG_VERBOSE); - - // Workaround: 4-byte unicode requires utf8mb4 in MySQL. See https://tt-rss.org/forum/viewtopic.php?f=1&t=3377&p=20077#p20077 - if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET) != "UTF8MB4") { - foreach ($article as $k => $v) { - // i guess we'll have to take the risk of 4byte unicode labels & tags here - if (is_string($article[$k])) { - $article[$k] = self::strip_utf8mb4($v); - } - } - } - - /* Collect article tags here so we could filter by them: */ - - $matched_rules = []; - $matched_filters = []; - - $article_filters = self::get_article_filters($filters, $article["title"], - $article["content"], $article["link"], $article["author"], - $article["tags"], $matched_rules, $matched_filters); - - // $article_filters should be renamed to something like $filter_actions; actual filter objects are in $matched_filters - $pluginhost->run_hooks(PluginHost::HOOK_FILTER_TRIGGERED, - $feed, $feed_obj->owner_uid, $article, $matched_filters, $matched_rules, $article_filters); - - $matched_filter_ids = array_map(fn(array $f) => $f['id'], $matched_filters); - - 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) - ->find_many(); - - foreach ($filter_objs as $filter_obj) { - $filter_obj->set('last_triggered', Db::NOW()); - $filter_obj->save(); - } - } - - if (Debug::get_loglevel() >= Debug::LOG_EXTENDED) { - Debug::log("matched filters: ", Debug::LOG_VERBOSE); - - if (count($matched_filters) != 0) { - print_r($matched_filters); - } - - Debug::log("matched filter rules: ", Debug::LOG_VERBOSE); - - if (count($matched_rules) != 0) { - print_r($matched_rules); - } - - Debug::log("filter actions: ", Debug::LOG_VERBOSE); - - if (count($article_filters) != 0) { - print_r($article_filters); - } - } - - $plugin_filter_names = self::find_article_filters($article_filters, "plugin"); - $plugin_filter_actions = $pluginhost->get_filter_actions(); - - if (count($plugin_filter_names) > 0) { - Debug::log("applying plugin filter actions...", Debug::LOG_VERBOSE); - - foreach ($plugin_filter_names as $pfn) { - list($pfclass,$pfaction) = explode(":", $pfn["param"]); - - if (isset($plugin_filter_actions[$pfclass])) { - $plugin = $pluginhost->get_plugin($pfclass); - - Debug::log("... $pfclass: $pfaction", Debug::LOG_VERBOSE); - - if ($plugin) { - $start = microtime(true); - $article = $plugin->hook_article_filter_action($article, $pfaction); - - Debug::log(sprintf("=== %.4f (sec)", microtime(true) - $start), Debug::LOG_VERBOSE); - } else { - Debug::log("??? $pfclass: plugin object not found.", Debug::LOG_VERBOSE); - } - } else { - Debug::log("??? $pfclass: filter plugin not registered.", Debug::LOG_VERBOSE); - } - } - } - - $entry_tags = $article["tags"]; - $entry_title = strip_tags($article["title"]); - $entry_author = mb_substr(strip_tags($article["author"]), 0, 245); - $entry_link = strip_tags($article["link"]); - $entry_content = $article["content"]; // escaped below - $entry_force_catchup = $article["force_catchup"]; - $article_labels = $article["labels"]; - $entry_score_modifier = (int) $article["score_modifier"]; - $entry_language = $article["language"]; - $entry_timestamp = $article["timestamp"]; - $num_comments = $article["num_comments"]; - $enclosures = $article["enclosures"]; - - if ($entry_timestamp == -1 || !$entry_timestamp || $entry_timestamp > time()) { - $entry_timestamp = time(); - } - - $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); - - if (Debug::get_loglevel() >= Debug::LOG_EXTENDED) { - Debug::log("article labels:", Debug::LOG_VERBOSE); - - if (count($article_labels) != 0) { - print_r($article_labels); - } - } - - Debug::log("force catchup: $entry_force_catchup", Debug::LOG_VERBOSE); - - if ($feed_obj->cache_images) - self::cache_media($entry_content, $feed_obj->site_url); - - $csth = $pdo->prepare("SELECT id FROM ttrss_entries - WHERE guid IN (?, ?, ?)"); - $csth->execute([$entry_guid, $entry_guid_hashed, $entry_guid_hashed_compat]); - - if (!$row = $csth->fetch()) { - - Debug::log("base guid [$entry_guid or $entry_guid_hashed] not found, creating...", Debug::LOG_VERBOSE); - - // base post entry does not exist, create it - - $usth = $pdo->prepare( - "INSERT INTO ttrss_entries - (title, - guid, - link, - updated, - content, - content_hash, - no_orig_date, - date_updated, - date_entered, - comments, - num_comments, - plugin_data, - lang, - author) - VALUES - (?, ?, ?, ?, ?, ?, - false, - NOW(), - ?, ?, ?, ?, ?, ?)"); - - $usth->execute([$entry_title, - $entry_guid_hashed, - $entry_link, - $entry_timestamp_fmt, - "$entry_content", - $entry_current_hash, - $date_feed_processed, - $entry_comments, - (int)$num_comments, - $entry_plugin_data, - "$entry_language", - "$entry_author"]); - - } - - $csth->execute([$entry_guid, $entry_guid_hashed, $entry_guid_hashed_compat]); - - $entry_ref_id = 0; - $entry_int_id = 0; - - if ($row = $csth->fetch()) { - - Debug::log("base guid found, checking for user record", Debug::LOG_VERBOSE); - - $ref_id = $row['id']; - $entry_ref_id = $ref_id; - - if (self::find_article_filter($article_filters, "filter")) { - Debug::log("article is filtered out, nothing to do.", Debug::LOG_VERBOSE); - $pdo->commit(); - continue; - } - - $score = self::calculate_article_score($article_filters) + $entry_score_modifier; - - Debug::log("initial score: $score [including plugin modifier: $entry_score_modifier]", Debug::LOG_VERBOSE); - - // check for user post link to main table - - $sth = $pdo->prepare("SELECT ref_id, int_id FROM ttrss_user_entries WHERE - ref_id = ? AND owner_uid = ?"); - $sth->execute([$ref_id, $feed_obj->owner_uid]); - - // okay it doesn't exist - create user entry - if ($row = $sth->fetch()) { - $entry_ref_id = $row["ref_id"]; - $entry_int_id = $row["int_id"]; - - Debug::log("user record FOUND: RID: $entry_ref_id, IID: $entry_int_id", Debug::LOG_VERBOSE); - } else { - - Debug::log("user record not found, creating...", Debug::LOG_VERBOSE); - - if ($score >= -500 && !self::find_article_filter($article_filters, 'catchup') && !$entry_force_catchup) { - $unread = 1; - $last_read_qpart = null; - } else { - $unread = 0; - $last_read_qpart = date("Y-m-d H:i"); // we can't use NOW() here because it gets quoted - } - - if (self::find_article_filter($article_filters, 'mark') || $score > 1000) { - $marked = 1; - } else { - $marked = 0; - } - - if (self::find_article_filter($article_filters, 'publish')) { - $published = 1; - } else { - $published = 0; - } - - $last_marked = ($marked == 1) ? 'NOW()' : 'NULL'; - $last_published = ($published == 1) ? 'NOW()' : 'NULL'; - - $sth = $pdo->prepare( - "INSERT INTO ttrss_user_entries - (ref_id, owner_uid, feed_id, unread, last_read, marked, - published, score, tag_cache, label_cache, uuid, - last_marked, last_published) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', '', '', ".$last_marked.", ".$last_published.")"); - - $sth->execute([$ref_id, $feed_obj->owner_uid, $feed, $unread, $last_read_qpart, $marked, - $published, $score]); - - $sth = $pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE - ref_id = ? AND owner_uid = ? AND - feed_id = ? LIMIT 1"); - - $sth->execute([$ref_id, $feed_obj->owner_uid, $feed]); - - if ($row = $sth->fetch()) - $entry_int_id = $row['int_id']; - } - - Debug::log("resulting RID: $entry_ref_id, IID: $entry_int_id", Debug::LOG_VERBOSE); - - if (Config::get(Config::DB_TYPE) == "pgsql") - $tsvector_qpart = "tsvector_combined = to_tsvector(:ts_lang, :ts_content),"; - else - $tsvector_qpart = ""; - - $sth = $pdo->prepare("UPDATE ttrss_entries - SET title = :title, - $tsvector_qpart - content = :content, - content_hash = :content_hash, - updated = :updated, - date_updated = NOW(), - num_comments = :num_comments, - plugin_data = :plugin_data, - author = :author, - lang = :lang - WHERE id = :id"); - - $params = [":title" => $entry_title, - ":content" => "$entry_content", - ":content_hash" => $entry_current_hash, - ":updated" => $entry_timestamp_fmt, - ":num_comments" => (int)$num_comments, - ":plugin_data" => $entry_plugin_data, - ":author" => "$entry_author", - ":lang" => $entry_language, - ":id" => $ref_id]; - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $params[":ts_lang"] = $feed_language; - $params[":ts_content"] = mb_substr(strip_tags($entry_title) . " " . \Soundasleep\Html2Text::convert($entry_content), 0, 900000); - } - - $sth->execute($params); - - // update aux data - $sth = $pdo->prepare("UPDATE ttrss_user_entries - SET score = ? WHERE ref_id = ?"); - $sth->execute([$score, $ref_id]); - - if ($feed_obj->mark_unread_on_update && - !$entry_force_catchup && - !self::find_article_filter($article_filters, 'catchup')) { - - Debug::log("article updated, marking unread as requested.", Debug::LOG_VERBOSE); - - $sth = $pdo->prepare("UPDATE ttrss_user_entries - SET last_read = null, unread = true WHERE ref_id = ?"); - $sth->execute([$ref_id]); - } else { - Debug::log("article updated, but we're forbidden to mark it unread.", Debug::LOG_VERBOSE); - } - } - - Debug::log("assigning labels [other]...", Debug::LOG_VERBOSE); - - foreach ($article_labels as $label) { - Labels::add_article($entry_ref_id, $label[1], $feed_obj->owner_uid); - } - - Debug::log("assigning labels [filters]...", Debug::LOG_VERBOSE); - - self::assign_article_to_label_filters($entry_ref_id, $article_filters, - $feed_obj->owner_uid, $article_labels); - - if ($feed_obj->cache_images) - self::cache_enclosures($enclosures, $feed_obj->site_url); - - if (Debug::get_loglevel() >= Debug::LOG_EXTENDED) { - Debug::log("article enclosures:", Debug::LOG_VERBOSE); - print_r($enclosures); - } - - $esth = $pdo->prepare("SELECT id FROM ttrss_enclosures - WHERE content_url = ? AND content_type = ? AND post_id = ?"); - - $usth = $pdo->prepare("INSERT INTO ttrss_enclosures - (content_url, content_type, title, duration, post_id, width, height) VALUES - (?, ?, ?, ?, ?, ?, ?)"); - - foreach ($enclosures as $enc) { - $enc_url = $enc[0]; - $enc_type = $enc[1]; - $enc_dur = (int)$enc[2]; - $enc_title = $enc[3]; - $enc_width = intval($enc[4]); - $enc_height = intval($enc[5]); - - $esth->execute([$enc_url, $enc_type, $entry_ref_id]); - - if (!$esth->fetch()) { - $usth->execute([$enc_url, $enc_type, (string)$enc_title, $enc_dur, $entry_ref_id, $enc_width, $enc_height]); - } - } - - // 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 = [...$entry_tags, ...FeedItem_Common::normalize_categories(explode(",", $f["param"]))]; - } - } - - // 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 - $entry_tags = FeedItem_Common::normalize_categories( - array_diff($entry_tags, - FeedItem_Common::normalize_categories(explode(",", - get_pref(Prefs::BLACKLISTED_TAGS, $feed_obj->owner_uid))))); - - 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 - WHERE tag_name = ? AND post_int_id = ? AND - owner_uid = ? LIMIT 1"); - - $usth = $pdo->prepare("INSERT INTO ttrss_tags - (owner_uid,tag_name,post_int_id) - VALUES (?, ?, ?)"); - - foreach ($entry_tags as $tag) { - $tsth->execute([$tag, $entry_int_id, $feed_obj->owner_uid]); - - if (!$tsth->fetch()) { - $usth->execute([$feed_obj->owner_uid, $tag, $entry_int_id]); - } - } - - /* update the cache */ - - $tsth = $pdo->prepare("UPDATE ttrss_user_entries - SET tag_cache = ? WHERE ref_id = ? - AND owner_uid = ?"); - - $tsth->execute([ - join(",", $entry_tags), - $entry_ref_id, - $feed_obj->owner_uid - ]); - } - - Debug::log("article processed.", Debug::LOG_VERBOSE); - - $pdo->commit(); - $a_span->end(); - } - - Debug::log(Debug::SEPARATOR, Debug::LOG_VERBOSE); - - Debug::log("purging feed...", Debug::LOG_VERBOSE); - - Feeds::_purge($feed, 0); - - $feed_obj->set([ - 'last_updated' => Db::NOW(), - 'last_unconditional' => Db::NOW(), - 'last_successful_update' => Db::NOW(), - 'last_error' => '', - ]); - - $feed_obj->save(); - - } else { - - $error_msg = mb_substr($rss->error(), 0, 245); - - Debug::log("fetch error: $error_msg", Debug::LOG_VERBOSE); - - if (count($rss->errors()) > 1) { - foreach ($rss->errors() as $error) { - Debug::log("+ $error", Debug::LOG_VERBOSE); - } - } - - $feed_obj->set([ - 'last_updated' => Db::NOW(), - 'last_unconditional' => Db::NOW(), - 'last_error' => $error_msg, - ]); - - $feed_obj->save(); - - unset($rss); - - Debug::log("update failed.", Debug::LOG_VERBOSE); - $span->end(); - return false; - } - - Debug::log("update done.", Debug::LOG_VERBOSE); - $span->end(); - return true; - } - - /** - * TODO: move to DiskCache? - * - * @param array> $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 = DiskCache::instance("images"); - - if ($cache->is_writable()) { - foreach ($enclosures as $enc) { - - if (preg_match("/(image|audio|video)/", $enc[1])) { - $src = UrlHelper::rewrite_relative($site_url, $enc[0]); - - $local_filename = sha1($src); - - Debug::log("cache_enclosures: downloading: $src to $local_filename", Debug::LOG_VERBOSE); - - if (!$cache->exists($local_filename)) { - $file_content = UrlHelper::fetch(array("url" => $src, - "http_referrer" => $src, - "max_size" => Config::get(Config::MAX_CACHE_FILE_SIZE))); - - if ($file_content) { - $cache->put($local_filename, $file_content); - } else { - Debug::log("cache_enclosures: failed with ".UrlHelper::$fetch_last_error_code.": ".UrlHelper::$fetch_last_error); - } - } - } - } - } - } - - /* TODO: move to DiskCache? */ - 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); - - if (!$cache->exists($local_filename)) { - Debug::log("cache_media: downloading: $url to $local_filename", Debug::LOG_VERBOSE); - - $file_content = UrlHelper::fetch(array("url" => $url, - "http_referrer" => $url, - "max_size" => Config::get(Config::MAX_CACHE_FILE_SIZE))); - - if ($file_content) { - $cache->put($local_filename, $file_content); - } else { - Debug::log("cache_media: failed with ".UrlHelper::$fetch_last_error_code.": ".UrlHelper::$fetch_last_error); - } - } - } - - /* TODO: move to DiskCache? */ - static function cache_media(string $html, string $site_url): void { - $cache = DiskCache::instance("images"); - - if ($html && $cache->is_writable()) { - $doc = new DOMDocument(); - if (@$doc->loadHTML($html)) { - $xpath = new DOMXPath($doc); - - $entries = $xpath->query('(//img[@src]|//source[@src|@srcset]|//video[@poster|@src])'); - - foreach ($entries as $entry) { - foreach (array('src', 'poster') as $attr) { - if ($entry->hasAttribute($attr) && strpos($entry->getAttribute($attr), "data:") !== 0) { - self::cache_media_url($cache, $entry->getAttribute($attr), $site_url); - } - } - - if ($entry->hasAttribute("srcset")) { - $matches = self::decode_srcset($entry->getAttribute('srcset')); - - for ($i = 0; $i < count($matches); $i++) { - self::cache_media_url($cache, $matches[$i]["url"], $site_url); - } - } - } - } - } - } - - static function expire_error_log(): void { - Debug::log("Removing old error log entries..."); - - $pdo = Db::pdo(); - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $pdo->query("DELETE FROM ttrss_error_log - WHERE created_at < NOW() - INTERVAL '7 days'"); - } else { - $pdo->query("DELETE FROM ttrss_error_log - WHERE created_at < DATE_SUB(NOW(), INTERVAL 7 DAY)"); - } - } - - /** - * @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(): void { - Debug::log("Removing old lock files...", Debug::LOG_VERBOSE); - - $num_deleted = 0; - - if (is_writable(Config::get(Config::LOCK_DIRECTORY))) { - $files = glob(Config::get(Config::LOCK_DIRECTORY) . "/*.lock"); - - if ($files) { - foreach ($files as $file) { - if (!file_is_locked(basename($file)) && time() - filemtime($file) > 86400*2) { - unlink($file); - ++$num_deleted; - } - } - } - } - - Debug::log("Removed $num_deleted old lock files."); - } - - /** - * Source: http://www.php.net/manual/en/function.parse-url.php#104527 - * Returns the url query as associative array - * - * @param string query - * @return array params - */ - /* static function convertUrlQuery($query) { - $queryParts = explode('&', $query); - - $params = array(); - - foreach ($queryParts as $param) { - $item = explode('=', $param); - $params[$item[0]] = $item[1]; - } - - return $params; - } */ - - /** - * @param array> $filters - * @param array $tags - * @param array>|null $matched_rules - * @param array>|null $matched_filters - * - * @return array> 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 { - $span = Tracer::start(__METHOD__); - - $matches = array(); - - foreach ($filters as $filter) { - $match_any_rule = $filter["match_any_rule"] ?? false; - $inverse = $filter["inverse"] ?? false; - $filter_match = false; - $last_processed_rule = false; - $regexp_matches = []; - - foreach ($filter["rules"] as $rule) { - $match = false; - $reg_exp = str_replace('/', '\/', (string)$rule["reg_exp"]); - $reg_exp = str_replace("\n", "", $reg_exp); // reg_exp may be formatted with CRs now because of textarea, we need to strip those - $rule_inverse = $rule["inverse"] ?? false; - $last_processed_rule = $rule; - - if (empty($reg_exp)) - continue; - - switch ($rule["type"]) { - case "title": - $match = @preg_match("/$reg_exp/iu", $title, $regexp_matches); - break; - case "content": - // we don't need to deal with multiline regexps - $content = (string)preg_replace("/[\r\n\t]/", "", $content); - - $match = @preg_match("/$reg_exp/iu", $content, $regexp_matches); - break; - case "both": - // we don't need to deal with multiline regexps - $content = (string)preg_replace("/[\r\n\t]/", "", $content); - - $match = (@preg_match("/$reg_exp/iu", $title, $regexp_matches) || @preg_match("/$reg_exp/iu", $content, $regexp_matches)); - break; - case "link": - $match = @preg_match("/$reg_exp/iu", $link, $regexp_matches); - break; - case "author": - $match = @preg_match("/$reg_exp/iu", $author, $regexp_matches); - break; - case "tag": - if (count($tags) == 0) - array_push($tags, ''); // allow matching if there are no tags - - foreach ($tags as $tag) { - if (@preg_match("/$reg_exp/iu", $tag, $regexp_matches)) { - $match = true; - break; - } - } - break; - } - - if ($rule_inverse) $match = !$match; - - if ($match_any_rule) { - if ($match) { - $filter_match = true; - break; - } - } else { - $filter_match = $match; - if (!$match) { - break; - } - } - } - - if ($inverse) $filter_match = !$filter_match; - - if ($filter_match) { - $last_processed_rule["regexp_matches"] = $regexp_matches; - - if (is_array($matched_rules)) array_push($matched_rules, $last_processed_rule); - if (is_array($matched_filters)) array_push($matched_filters, $filter); - - foreach ($filter["actions"] AS $action) { - array_push($matches, $action); - - // if Stop action encountered, perform no further processing - if (isset($action["type"]) && $action["type"] == "stop") return $matches; - } - } - } - - $span->end(); - - return $matches; - } - - /** - * @param array> $filters An array of filter action arrays with keys "type" and "param" - * - * @return array|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 null; - } - - /** - * @param array> $filters An array of filter action arrays with keys "type" and "param" - * - * @return array> 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) { - if ($f["type"] == $filter_name) { - array_push($results, $f); - }; - } - return $results; - } - - /** - * @param array> $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) { - if ($f["type"] == "score") { - $score += $f["param"]; - }; - } - return $score; - } - - /** - * @param array> $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; - } - } - - return false; - } - - /** - * @param array> $filters An array of filter action arrays with keys "type" and "param" - * @param array> $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"])) { - Labels::add_article($id, $f["param"], $owner_uid); - } - } - } - } - - 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(): void { - $pdo = Db::pdo(); - - $pdo->query("DELETE FROM ttrss_counters_cache"); - $pdo->query("DELETE FROM ttrss_cat_counters_cache"); - } - - static function disable_failed_feeds(): void { - if (Config::get(Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT) > 0) { - - $pdo = Db::pdo(); - - $pdo->beginTransaction(); - - $days = Config::get(Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT); - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $interval_query = "last_successful_update < NOW() - INTERVAL '$days days' AND last_updated > NOW() - INTERVAL '1 days'"; - } else /* if (Config::get(Config::DB_TYPE) == "mysql") */ { - $interval_query = "last_successful_update < DATE_SUB(NOW(), INTERVAL $days DAY) AND last_updated > DATE_SUB(NOW(), INTERVAL 1 DAY)"; - } - - $sth = $pdo->prepare("SELECT id, title, owner_uid - FROM ttrss_feeds - WHERE update_interval != -1 AND last_successful_update IS NOT NULL AND $interval_query"); - - $sth->execute(); - - while ($row = $sth->fetch()) { - Logger::log(E_USER_NOTICE, - sprintf("Auto disabling feed %d (%s, UID: %d) because it failed to update for %d days.", - $row["id"], clean($row["title"]), $row["owner_uid"], Config::get(Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT))); - - Debug::log(sprintf("Auto-disabling feed %d (%s) (failed to update for %d days).", $row["id"], - clean($row["title"]), Config::get(Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT))); - } - - $sth = $pdo->prepare("UPDATE ttrss_feeds SET update_interval = -1 WHERE - update_interval != -1 AND last_successful_update IS NOT NULL AND $interval_query"); - $sth->execute(); - - $pdo->commit(); - } - } - - static function housekeeping_user(int $owner_uid): void { - $tmph = new PluginHost(); - - UserHelper::load_user_plugins($owner_uid, $tmph); - - $tmph->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); - } - - /** migrates favicons from legacy storage in feed-icons/ to cache/feed-icons/using new naming (sans .ico suffix) */ - static function migrate_feed_icons() : void { - $old_dir = Config::get(Config::ICONS_DIR); - $new_dir = Config::get(Config::CACHE_DIR) . '/feed-icons'; - - $dh = opendir($old_dir); - - $cache = DiskCache::instance('feed-icons'); - - if ($dh) { - while (($old_filename = readdir($dh)) !== false) { - if (strpos($old_filename, ".ico") !== false) { - $new_filename = str_replace(".ico", "", $old_filename); - $old_full_path = "$old_dir/$old_filename"; - - if (is_file($old_full_path) && $cache->put($new_filename, file_get_contents($old_full_path))) { - unlink($old_full_path); - } - } - } - - closedir($dh); - } - } - - static function housekeeping_common(): void { - $cache = DiskCache::instance(""); - $cache->expire_all(); - - self::migrate_feed_icons(); - self::expire_lock_files(); - self::expire_error_log(); - self::expire_feed_archive(); - self::cleanup_feed_browser(); - self::cleanup_feed_icons(); - self::disable_failed_feeds(); - - Article::_purge_orphans(); - self::cleanup_counters_cache(); - - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); - } - - /** - * @return false|string - */ - static function update_favicon(string $site_url, int $feed) { - $favicon_urls = self::get_favicon_urls($site_url); - - if (count($favicon_urls) == 0) { - Debug::log("favicon: couldn't find any favicon URLs for $site_url", Debug::LOG_VERBOSE); - return false; - } - - // i guess we'll have to go through all of them until something looks valid... - foreach ($favicon_urls as $favicon_url) { - - // Limiting to "image" type misses those served with text/plain - $contents = UrlHelper::fetch([ - 'url' => $favicon_url, - 'max_size' => Config::get(Config::MAX_FAVICON_FILE_SIZE), - //'type' => 'image', - ]); - - if (!$contents) { - Debug::log("favicon: fetching $favicon_url failed, skipping...", Debug::LOG_VERBOSE); - break; - } - - // TODO: we could use mime_conent_type() here instead of below hacks but we'll need to - // save every favicon to disk and go from there. - // also, if SVG is allowed in the future, we'll need to specifically forbid 'image/svg+xml'. - - // Crude image type matching. - // Patterns gleaned from the file(1) source code. - if (preg_match('/^\x00\x00\x01\x00/', $contents)) { - // 0 string \000\000\001\000 MS Windows icon resource - //error_log("update_favicon: favicon_url=$favicon_url isa MS Windows icon resource"); - } - elseif (preg_match('/^GIF8/', $contents)) { - // 0 string GIF8 GIF image data - //error_log("update_favicon: favicon_url=$favicon_url isa GIF image"); - } - elseif (preg_match('/^\x89PNG\x0d\x0a\x1a\x0a/', $contents)) { - // 0 string \x89PNG\x0d\x0a\x1a\x0a PNG image data - //error_log("update_favicon: favicon_url=$favicon_url isa PNG image"); - } - elseif (preg_match('/^\xff\xd8/', $contents)) { - // 0 beshort 0xffd8 JPEG image data - //error_log("update_favicon: favicon_url=$favicon_url isa JPG image"); - } - elseif (preg_match('/^BM/', $contents)) { - // 0 string BM PC bitmap (OS2, Windows BMP files) - //error_log("update_favicon, favicon_url=$favicon_url isa BMP image"); - } - else { - //error_log("update_favicon: favicon_url=$favicon_url isa UNKNOWN type"); - Debug::log("favicon $favicon_url type is unknown, skipping...", Debug::LOG_VERBOSE); - break; - } - - $favicon_cache = DiskCache::instance('feed-icons'); - - if ($favicon_cache->is_writable()) { - Debug::log("favicon: $favicon_url looks valid, saving to cache", Debug::LOG_VERBOSE); - - // we deal with this manually - if (!$favicon_cache->exists(".no-auto-expiry")) - $favicon_cache->put(".no-auto-expiry", ""); - - return $favicon_cache->put((string)$feed, $contents); - } else { - Debug::log("favicon: $favicon_url skipping, local cache is not writable", Debug::LOG_VERBOSE); - } - } - - return false; - } - - static function is_gzipped(string $feed_data): bool { - return strpos(substr($feed_data, 0, 3), - "\x1f" . "\x8b" . "\x08", 0) === 0; - } - - /** - * @return array> 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(); - - $feed_id = (int) $feed_id; - $cat_id = Feeds::_cat_of($feed_id); - - if (!$cat_id) - $null_cat_qpart = "cat_id IS NULL OR"; - else - $null_cat_qpart = ""; - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT * FROM ttrss_filters2 WHERE - owner_uid = ? AND enabled = true ORDER BY order_id, title"); - $sth->execute([$owner_uid]); - - $check_cats = [...Feeds::_get_parent_cats($cat_id, $owner_uid), $cat_id]; - - $check_cats_str = join(",", $check_cats); - $check_cats_fullids = array_map(fn(int $a) => "CAT:$a", $check_cats); - - while ($line = $sth->fetch()) { - $filter_id = $line["id"]; - - $match_any_rule = sql_bool_to_bool($line["match_any_rule"]); - - $sth2 = $pdo->prepare("SELECT - r.reg_exp, r.inverse, r.feed_id, r.cat_id, r.cat_filter, r.match_on, t.name AS type_name - FROM ttrss_filters2_rules AS r, - ttrss_filter_types AS t - WHERE - (match_on IS NOT NULL OR - (($null_cat_qpart (cat_id IS NULL AND cat_filter = false) OR cat_id IN ($check_cats_str)) AND - (feed_id IS NULL OR feed_id = ?))) AND - filter_type = t.id AND filter_id = ?"); - $sth2->execute([$feed_id, $filter_id]); - - $rules = array(); - $actions = array(); - - while ($rule_line = $sth2->fetch()) { - # print_r($rule_line); - - if ($rule_line["match_on"]) { - $match_on = json_decode($rule_line["match_on"], true); - - if (in_array("0", $match_on) || in_array($feed_id, $match_on) || count(array_intersect($check_cats_fullids, $match_on)) > 0) { - - $rule = array(); - $rule["reg_exp"] = $rule_line["reg_exp"]; - $rule["type"] = $rule_line["type_name"]; - $rule["inverse"] = sql_bool_to_bool($rule_line["inverse"]); - - array_push($rules, $rule); - } else if (!$match_any_rule) { - // this filter contains a rule that doesn't match to this feed/category combination - // thus filter has to be rejected - - $rules = []; - break; - } - - } else { - - $rule = array(); - $rule["reg_exp"] = $rule_line["reg_exp"]; - $rule["type"] = $rule_line["type_name"]; - $rule["inverse"] = sql_bool_to_bool($rule_line["inverse"]); - - array_push($rules, $rule); - } - } - - if (count($rules) > 0) { - $sth2 = $pdo->prepare("SELECT a.action_param,t.name AS type_name - FROM ttrss_filters2_actions AS a, - ttrss_filter_actions AS t - WHERE - action_id = t.id AND filter_id = ?"); - $sth2->execute([$filter_id]); - - while ($action_line = $sth2->fetch()) { - # print_r($action_line); - - $action = array(); - $action["type"] = $action_line["type_name"]; - $action["param"] = $action_line["action_param"]; - - array_push($actions, $action); - } - } - - $filter = []; - $filter["id"] = $filter_id; - $filter["match_any_rule"] = sql_bool_to_bool($line["match_any_rule"]); - $filter["inverse"] = sql_bool_to_bool($line["inverse"]); - $filter["rules"] = $rules; - $filter["actions"] = $actions; - - if (count($rules) > 0 && count($actions) > 0) { - array_push($filters, $filter); - } - } - - return $filters; - } - - /** - * Returns first determined favicon URL for a feed. - * @param string $url A feed or page URL - * @access public - * @return false|string The favicon URL string, or false if none was found. - */ - static function get_favicon_url(string $url) { - - $favicon_urls = self::get_favicon_urls($url); - - if (count($favicon_urls) > 0) - return $favicon_urls[0]; - else - return false; - } - - /** - * Try to determine all favicon URLs for a feed. - * adapted from wordpress favicon plugin by Jeff Minard (http://thecodepro.com/) - * http://dev.wp-plugins.org/file/favatars/trunk/favatars.php - * - * @param string $url A feed or page URL - * @access public - * @return array List of all determined favicon URLs or an empty array - */ - static function get_favicon_urls(string $url) : array { - - $favicon_urls = []; - - if ($html = @UrlHelper::fetch($url)) { - - $doc = new DOMDocument(); - if (@$doc->loadHTML($html)) { - $xpath = new DOMXPath($doc); - - $base = $xpath->query('/html/head/base[@href]'); - foreach ($base as $b) { - $url = UrlHelper::rewrite_relative($url, $b->getAttribute("href")); - break; - } - - $entries = $xpath->query('/html/head/link[@rel="shortcut icon" or @rel="icon" or @rel="alternate icon"]'); - if (count($entries) > 0) { - foreach ($entries as $entry) { - $favicon_url = UrlHelper::rewrite_relative($url, $entry->getAttribute("href")); - - if ($favicon_url) - array_push($favicon_urls, $favicon_url); - - } - } - } - } - - if (count($favicon_urls) == 0) { - $favicon_url = UrlHelper::rewrite_relative($url, "/favicon.ico"); - - if ($favicon_url) - array_push($favicon_urls, $favicon_url); - } - - return $favicon_urls; - } - - /** - * @see https://community.tt-rss.org/t/problem-with-img-srcset/3519 - * - * @return array> An array of srcset subitem arrays with keys "url" and "size" - */ - static function decode_srcset(string $srcset): array { - $matches = []; - - preg_match_all( - '/(?:\A|,)\s*(?P(?!,)\S+(?\s\d+w|\s\d+(?:\.\d+)?(?:[eE][+-]?\d+)?x|)\s*(?=,|\Z)/', - $srcset, $matches, PREG_SET_ORDER - ); - - foreach ($matches as $m) { - array_push($matches, [ - "url" => trim($m["url"]), - "size" => trim($m["size"]) - ]); - } - - return $matches; - } - - /** - * @param array> $matches An array of srcset subitem arrays with keys "url" and "size" - */ - static function encode_srcset(array $matches): string { - $tokens = []; - - foreach ($matches as $m) { - array_push($tokens, trim($m["url"]) . " " . trim($m["size"])); - } - - return implode(",", $tokens); - } - - 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 deleted file mode 100644 index a7bea9e5f..000000000 --- a/classes/sanitizer.php +++ /dev/null @@ -1,238 +0,0 @@ - $allowed_elements - * @param array $disallowed_attributes - */ - private static function strip_harmful_tags(DOMDocument $doc, array $allowed_elements, $disallowed_attributes): DOMDocument { - $xpath = new DOMXPath($doc); - $entries = $xpath->query('//*'); - - foreach ($entries as $entry) { - if (!in_array($entry->nodeName, $allowed_elements)) { - $entry->parentNode->removeChild($entry); - } - - if ($entry->hasAttributes()) { - $attrs_to_remove = array(); - - foreach ($entry->attributes as $attr) { - - if (strpos($attr->nodeName, 'on') === 0) { - array_push($attrs_to_remove, $attr); - } - - if (strpos($attr->nodeName, "data-") === 0) { - array_push($attrs_to_remove, $attr); - } - - if ($attr->nodeName == 'href' && stripos($attr->value, 'javascript:') === 0) { - array_push($attrs_to_remove, $attr); - } - - if (in_array($attr->nodeName, $disallowed_attributes)) { - array_push($attrs_to_remove, $attr); - } - } - - foreach ($attrs_to_remove as $attr) { - $entry->removeAttributeNode($attr); - } - } - } - - return $doc; - } - - public static function iframe_whitelisted(DOMElement $entry): bool { - $src = parse_url($entry->getAttribute("src"), PHP_URL_HOST); - - if (!empty($src)) - return PluginHost::getInstance()->run_hooks_until(PluginHost::HOOK_IFRAME_WHITELISTED, true, $src); - - return false; - } - - private static function is_prefix_https(): bool { - return parse_url(Config::get_self_url(), PHP_URL_SCHEME) == 'https'; - } - - /** - * @param array|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) { - $span = OpenTelemetry\API\Trace\Span::getCurrent(); - $span->addEvent("Sanitizer::sanitize"); - - if (!$owner && isset($_SESSION["uid"])) - $owner = $_SESSION["uid"]; - - $res = trim($str); if (!$res) return ''; - - $doc = new DOMDocument(); - $doc->loadHTML('' . $res); - $xpath = new DOMXPath($doc); - - // is it a good idea to possibly rewrite urls to our own prefix? - // $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]|//video[@poster])'); - - foreach ($entries as $entry) { - - if ($entry->hasAttribute('href')) { - $entry->setAttribute('href', - UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('href'), $entry->tagName, "href")); - - $entry->setAttribute('rel', 'noopener noreferrer'); - $entry->setAttribute("target", "_blank"); - } - - if ($entry->hasAttribute('src')) { - $entry->setAttribute('src', - UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('src'), $entry->tagName, "src")); - } - - if ($entry->nodeName == 'img') { - $entry->setAttribute('referrerpolicy', 'no-referrer'); - $entry->setAttribute('loading', 'lazy'); - } - - if ($entry->hasAttribute('srcset')) { - $matches = RSSUtils::decode_srcset($entry->getAttribute('srcset')); - - for ($i = 0; $i < count($matches); $i++) { - $matches[$i]["url"] = UrlHelper::rewrite_relative($rewrite_base_url, $matches[$i]["url"]); - } - - $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)) { - - $p = $doc->createElement('p'); - - $a = $doc->createElement('a'); - $a->setAttribute('href', $entry->getAttribute('src')); - - $a->appendChild(new DOMText($entry->getAttribute('src'))); - $a->setAttribute('target', '_blank'); - $a->setAttribute('rel', 'noopener noreferrer'); - - $p->appendChild($a); - - if ($entry->nodeName == 'source') { - - if ($entry->parentNode && $entry->parentNode->parentNode) - $entry->parentNode->parentNode->replaceChild($p, $entry->parentNode); - - } else if ($entry->nodeName == 'img') { - if ($entry->parentNode) - $entry->parentNode->replaceChild($p, $entry); - } - } - } - - $entries = $xpath->query('//iframe'); - foreach ($entries as $entry) { - if (!self::iframe_whitelisted($entry)) { - $entry->setAttribute('sandbox', 'allow-scripts'); - } else { - if (self::is_prefix_https()) { - $entry->setAttribute("src", - str_replace("http://", "https://", - $entry->getAttribute("src"))); - } - } - } - - $allowed_elements = array('a', 'abbr', 'address', 'acronym', 'audio', 'article', 'aside', - 'b', 'bdi', 'bdo', 'big', 'blockquote', 'body', 'br', - 'caption', 'cite', 'center', 'code', 'col', 'colgroup', - 'data', 'dd', 'del', 'details', 'description', 'dfn', 'div', 'dl', 'font', - 'dt', 'em', 'footer', 'figure', 'figcaption', - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'html', 'i', - 'img', 'ins', 'kbd', 'li', 'main', 'mark', 'nav', 'noscript', - 'ol', 'p', 'picture', 'pre', 'q', 'ruby', 'rp', 'rt', 's', 'samp', 'section', - 'small', 'source', 'span', 'strike', 'strong', 'sub', 'summary', - 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', - 'tr', 'track', 'tt', 'u', 'ul', 'var', 'wbr', 'video', 'xml:namespace' ); - - if ($_SESSION['hasSandbox'] ?? false) $allowed_elements[] = 'iframe'; - - $disallowed_attributes = array('id', 'style', 'class', 'width', 'height', 'allow'); - - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_SANITIZE, - function ($result) use (&$doc, &$allowed_elements, &$disallowed_attributes) { - if (is_array($result)) { - $doc = $result[0]; - $allowed_elements = $result[1]; - $disallowed_attributes = $result[2]; - } else { - $doc = $result; - } - }, - $doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id); - - $doc->removeChild($doc->firstChild); //remove doctype - $doc = self::strip_harmful_tags($doc, $allowed_elements, $disallowed_attributes); - - $entries = $xpath->query('//iframe'); - foreach ($entries as $entry) { - $div = $doc->createElement('div'); - $div->setAttribute('class', 'embed-responsive'); - $entry->parentNode->replaceChild($div, $entry); - $div->appendChild($entry); - } - - if (is_array($highlight_words)) { - foreach ($highlight_words as $word) { - - // http://stackoverflow.com/questions/4081372/highlight-keywords-in-a-paragraph - - $elements = $xpath->query("//*/text()"); - - foreach ($elements as $child) { - - $fragment = $doc->createDocumentFragment(); - $text = $child->textContent; - - while (($pos = mb_stripos($text, $word)) !== false) { - $fragment->appendChild(new DOMText(mb_substr($text, 0, (int)$pos))); - $word = mb_substr($text, (int)$pos, mb_strlen($word)); - $highlight = $doc->createElement('span'); - $highlight->appendChild(new DOMText($word)); - $highlight->setAttribute('class', 'highlight'); - $fragment->appendChild($highlight); - $text = mb_substr($text, $pos + mb_strlen($word)); - } - - if (!empty($text)) $fragment->appendChild(new DOMText($text)); - - $child->parentNode->replaceChild($fragment, $child); - } - } - } - - $res = $doc->saveHTML(); - - /* strip everything outside of ... */ - - $res_frag = array(); - if (preg_match('/(.*)<\/body>/is', $res, $res_frag)) { - return $res_frag[1]; - } else { - return $res; - } - } - -} diff --git a/classes/templator.php b/classes/templator.php deleted file mode 100644 index b682f8b82..000000000 --- a/classes/templator.php +++ /dev/null @@ -1,21 +0,0 @@ -getOffset($dt); - } else { - $tz_offset = (int) -($_SESSION["clientTzOffset"] ?? 0); - } - - $user_timestamp = $dt->format('U') + $tz_offset; - - if (!$no_smart_dt) { - return self::smart_date_time($user_timestamp, - $tz_offset, $owner_uid, $eta_min); - } else { - if ($long) - $format = get_pref(Prefs::LONG_DATE_FORMAT, $owner_uid); - else - $format = get_pref(Prefs::SHORT_DATE_FORMAT, $owner_uid); - - return date($format, $user_timestamp); - } - } - - static function convert_timestamp(int $timestamp, string $source_tz, string $dest_tz): int { - - try { - $source_tz = new DateTimeZone($source_tz); - } catch (Exception $e) { - $source_tz = new DateTimeZone('UTC'); - } - - try { - $dest_tz = new DateTimeZone($dest_tz); - } catch (Exception $e) { - $dest_tz = new DateTimeZone('UTC'); - } - - $dt = new DateTime(date('Y-m-d H:i:s', $timestamp), $source_tz); - - return (int)$dt->format('U') + $dest_tz->getOffset($dt); - } - -} diff --git a/classes/tracer.php b/classes/tracer.php deleted file mode 100644 index 7163adb90..000000000 --- a/classes/tracer.php +++ /dev/null @@ -1,216 +0,0 @@ -create($OPENTELEMETRY_ENDPOINT, 'application/x-protobuf'); - $exporter = new SpanExporter($transport); - - $resource = ResourceInfoFactory::emptyResource()->merge( - ResourceInfo::create(Attributes::create( - [ResourceAttributes::SERVICE_NAME => Config::get(Config::OPENTELEMETRY_SERVICE)] - ), ResourceAttributes::SCHEMA_URL), - ); - - $this->tracerProvider = TracerProvider::builder() - ->addSpanProcessor(new SimpleSpanProcessor($exporter)) - ->setResource($resource) - ->setSampler(new ParentBased(new AlwaysOnSampler())) - ->build(); - - $this->tracer = $this->tracerProvider->getTracer('io.opentelemetry.contrib.php'); - - $context = TraceContextPropagator::getInstance()->extract(getallheaders()); - - $span = $this->tracer->spanBuilder($_SESSION['name'] ?? 'not logged in') - ->setParent($context) - ->setSpanKind(SpanKind::KIND_SERVER) - ->setAttribute('php.request', json_encode($_REQUEST)) - ->setAttribute('php.server', json_encode($_SERVER)) - ->setAttribute('php.session', json_encode($_SESSION ?? [])) - ->startSpan(); - - $scope = $span->activate(); - - register_shutdown_function(function() use ($span, $scope) { - $span->end(); - $scope->detach(); - $this->tracerProvider->shutdown(); - }); - } - } - - /** - * @param string $name - * @return OpenTelemetry\API\Trace\SpanInterface - */ - private function _start(string $name) { - if ($this->tracer != null) { - $span = $this->tracer - ->spanBuilder($name) - ->setSpanKind(SpanKind::KIND_SERVER) - ->startSpan(); - - $span->activate(); - } else { - $span = new DummySpanInterface(); - } - - return $span; - } - - /** - * @param string $name - * @return OpenTelemetry\API\Trace\SpanInterface - */ - public static function start(string $name) { - return self::get_instance()->_start($name); - } - - public static function get_instance() : Tracer { - if (self::$instance == null) - self::$instance = new self(); - - return self::$instance; - } -} diff --git a/classes/urlhelper.php b/classes/urlhelper.php deleted file mode 100644 index dbbde55e6..000000000 --- a/classes/urlhelper.php +++ /dev/null @@ -1,656 +0,0 @@ - [ "magnet" ], - ]; - - static string $fetch_last_error; - static int $fetch_last_error_code; - static string $fetch_last_error_content; - static string $fetch_last_content_type; - static string $fetch_last_modified; - static string $fetch_effective_url; - static string $fetch_effective_ip_addr; - static bool $fetch_curl_used; - - /** - * @param array $parts - */ - static function build_url(array $parts): string { - $tmp = $parts['scheme'] . "://" . $parts['host']; - - if (isset($parts['path'])) $tmp .= $parts['path']; - if (isset($parts['query'])) $tmp .= '?' . $parts['query']; - if (isset($parts['fragment'])) $tmp .= '#' . $parts['fragment']; - - return $tmp; - } - - /** - * Converts a (possibly) relative URL to a absolute one, using provided base URL. - * Provides some exceptions for additional schemes like data: if called with owning element/attribute. - * - * @param string $base_url Base URL (i.e. from where the document is) - * @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 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 = "", - string $content_type = "") { - - $rel_parts = parse_url($rel_url); - - if (!$rel_url) return $base_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); - - // protocol-relative URL (rare but they exist) - } else if (strpos($rel_url, "//") === 0) { - return self::validate("https:" . $rel_url); - // allow some extra schemes for A href - } else if (in_array($rel_parts["scheme"] ?? "", self::EXTRA_HREF_SCHEMES, true) && - $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" && - preg_match('%^image/(webp|gif|jpg|png|svg);base64,%', $rel_parts["path"]) && - $owner_element == "img" && - $owner_attribute == "src") { - return $rel_url; - } else { - $base_parts = parse_url($base_url); - - $rel_parts['host'] = $base_parts['host'] ?? ""; - $rel_parts['scheme'] = $base_parts['scheme'] ?? ""; - - if ($rel_parts['path'] ?? "") { - - // we append dirname() of base path to relative URL path as per RFC 3986 section 5.2.2 - $base_path = with_trailing_slash(dirname($base_parts['path'] ?? "")); - - // 1. absolute relative path (/test.html) = no-op, proceed as is - - // 2. dotslash relative URI (./test.html) - strip "./", append base path - if (strpos($rel_parts['path'], './') === 0) { - $rel_parts['path'] = $base_path . substr($rel_parts['path'], 2); - // 3. anything else relative (test.html) - append dirname() of base path - } else if (strpos($rel_parts['path'], '/') !== 0) { - $rel_parts['path'] = $base_path . $rel_parts['path']; - } - - //$rel_parts['path'] = str_replace("/./", "/", $rel_parts['path']); - //$rel_parts['path'] = str_replace("//", "/", $rel_parts['path']); - } - - return self::validate(self::build_url($rel_parts)); - } - } - - /** 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); - - # fix protocol-relative URLs - if (strpos($url, "//") === 0) - $url = "https:" . $url; - - $tokens = parse_url($url); - - // this isn't really necessary because filter_var(... FILTER_VALIDATE_URL) requires host and scheme - // as per https://php.watch/versions/7.3/filter-var-flag-deprecation but it might save time - if (empty($tokens['host'])) - return false; - - if (!in_array(strtolower($tokens['scheme']), ['http', 'https'])) - return false; - - //convert IDNA hostname to punycode if possible - if (function_exists("idn_to_ascii")) { - if (mb_detect_encoding($tokens['host']) != 'ASCII') { - if (defined('IDNA_NONTRANSITIONAL_TO_ASCII') && defined('INTL_IDNA_VARIANT_UTS46')) { - $tokens['host'] = idn_to_ascii($tokens['host'], IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46); - } else { - $tokens['host'] = idn_to_ascii($tokens['host']); - } - - // if `idn_to_ascii` failed - if ($tokens['host'] === false) { - return false; - } - } - } - - // separate set of tokens with urlencoded 'path' because filter_var() rightfully fails on non-latin characters - // (used for validation only, we actually request the original URL, in case of urlencode breaking it) - $tokens_filter_var = $tokens; - - if ($tokens['path'] ?? false) { - $tokens_filter_var['path'] = implode("/", - array_map("rawurlencode", - array_map("rawurldecode", - explode("/", $tokens['path'])))); - } - - $url = self::build_url($tokens); - $url_filter_var = self::build_url($tokens_filter_var); - - if (filter_var($url_filter_var, FILTER_VALIDATE_URL) === false) - return false; - - if ($extended_filtering) { - if (!in_array($tokens['port'] ?? '', [80, 443, ''])) - return false; - - if (strtolower($tokens['host']) == 'localhost' || $tokens['host'] == '::1' || strpos($tokens['host'], '127.') === 0) - return false; - } - - return $url; - } - - /** - * @return false|string - */ - static function resolve_redirects(string $url, int $timeout, int $nest = 0) { - $span = Tracer::start(__METHOD__); - $span->setAttribute('func.args', json_encode(func_get_args())); - - // too many redirects - if ($nest > 10) { - $span->setAttribute('error', 'too many redirects'); - $span->end(); - return false; - } - - $context_options = array( - 'http' => array( - 'header' => array( - 'Connection: close' - ), - 'method' => 'HEAD', - 'timeout' => $timeout, - 'protocol_version'=> 1.1) - ); - - if (Config::get(Config::HTTP_PROXY)) { - $context_options['http']['request_fulluri'] = true; - $context_options['http']['proxy'] = Config::get(Config::HTTP_PROXY); - } - - $context = stream_context_create($context_options); - - // PHP 8 changed the second param from int to bool, but we still support PHP >= 7.4.0 - // @phpstan-ignore-next-line - $headers = get_headers($url, 0, $context); - - if (is_array($headers)) { - $headers = array_reverse($headers); // last one is the correct one - - foreach($headers as $header) { - if (stripos($header, 'Location:') === 0) { - $url = self::rewrite_relative($url, trim(substr($header, strlen('Location:')))); - - return self::resolve_redirects($url, $timeout, $nest + 1); - } - } - - $span->end(); - return $url; - } - - $span->setAttribute('error', 'request failed'); - $span->end(); - // request failed? - return false; - } - - /** - * @param array|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 = ""; - self::$fetch_last_error_code = -1; - self::$fetch_last_error_content = ""; - self::$fetch_last_content_type = ""; - self::$fetch_curl_used = false; - self::$fetch_last_modified = ""; - self::$fetch_effective_url = ""; - self::$fetch_effective_ip_addr = ""; - - if (!is_array($options)) { - - // falling back on compatibility shim - $option_names = [ "url", "type", "login", "pass", "post_query", "timeout", "last_modified", "useragent" ]; - $tmp = []; - - for ($i = 0; $i < func_num_args(); $i++) { - $tmp[$option_names[$i]] = func_get_arg($i); - } - - $options = $tmp; - - /*$options = array( - "url" => func_get_arg(0), - "type" => @func_get_arg(1), - "login" => @func_get_arg(2), - "pass" => @func_get_arg(3), - "post_query" => @func_get_arg(4), - "timeout" => @func_get_arg(5), - "timestamp" => @func_get_arg(6), - "useragent" => @func_get_arg(7) - ); */ - } - $url = $options["url"]; - - $span = Tracer::start(__METHOD__); - $span->setAttribute('func.args', json_encode(func_get_args())); - - $type = isset($options["type"]) ? $options["type"] : false; - $login = isset($options["login"]) ? $options["login"] : false; - $pass = isset($options["pass"]) ? $options["pass"] : false; - $post_query = isset($options["post_query"]) ? $options["post_query"] : false; - $timeout = isset($options["timeout"]) ? $options["timeout"] : false; - $last_modified = isset($options["last_modified"]) ? $options["last_modified"] : ""; - $useragent = isset($options["useragent"]) ? $options["useragent"] : false; - $followlocation = isset($options["followlocation"]) ? $options["followlocation"] : true; - $max_size = isset($options["max_size"]) ? $options["max_size"] : Config::get(Config::MAX_DOWNLOAD_FILE_SIZE); // in bytes - $http_accept = isset($options["http_accept"]) ? $options["http_accept"] : false; - $http_referrer = isset($options["http_referrer"]) ? $options["http_referrer"] : false; - - $url = ltrim($url, ' '); - $url = str_replace(' ', '%20', $url); - - Debug::log("[UrlHelper] fetching: $url", Debug::LOG_EXTENDED); - - $url = self::validate($url, true); - - if (!$url) { - self::$fetch_last_error = "Requested URL failed extended validation."; - - $span->setAttribute('error', self::$fetch_last_error); - $span->end(); - return false; - } - - $url_host = parse_url($url, PHP_URL_HOST); - $ip_addr = gethostbyname($url_host); - - if (!$ip_addr || strpos($ip_addr, "127.") === 0) { - self::$fetch_last_error = "URL hostname failed to resolve or resolved to a loopback address ($ip_addr)"; - - $span->setAttribute('error', self::$fetch_last_error); - $span->end(); - return false; - } - - if (function_exists('curl_init') && !ini_get("open_basedir")) { - - self::$fetch_curl_used = true; - - $ch = curl_init($url); - - if (!$ch) { - self::$fetch_last_error = "curl_init() failed"; - $span->setAttribute('error', self::$fetch_last_error); - $span->end(); - return false; - } - - $curl_http_headers = []; - - if ($last_modified && !$post_query) - array_push($curl_http_headers, "If-Modified-Since: $last_modified"); - - if ($http_accept) - array_push($curl_http_headers, "Accept: " . $http_accept); - - if (count($curl_http_headers) > 0) - curl_setopt($ch, CURLOPT_HTTPHEADER, $curl_http_headers); - - 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, $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_BASIC); - 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); - - if ($max_size) { - curl_setopt($ch, CURLOPT_NOPROGRESS, false); - curl_setopt($ch, CURLOPT_BUFFERSIZE, 16384); // needed to get 5 arguments in progress function? - - // holy shit closures in php - // download & upload are *expected* sizes respectively, could be zero - curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function($curl_handle, $download_size, $downloaded, $upload_size, $uploaded) use(&$max_size, $url) { - //Debug::log("[curl progressfunction] $downloaded $max_size", Debug::$LOG_EXTENDED); - - if ($downloaded > $max_size) { - Debug::log("[UrlHelper] fetch error: curl reached max size of $max_size bytes downloading $url, aborting.", Debug::LOG_VERBOSE); - return 1; - } - - return 0; - }); - - } - - if (Config::get(Config::HTTP_PROXY)) { - curl_setopt($ch, CURLOPT_PROXY, Config::get(Config::HTTP_PROXY)); - } - - if ($post_query) { - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, $post_query); - } - - if ($login && $pass) - curl_setopt($ch, CURLOPT_USERPWD, "$login:$pass"); - - $ret = @curl_exec($ch); - $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - // CURLAUTH_BASIC didn't work, let's retry with CURLAUTH_ANY in case it's actually something - // unusual like NTLM... - if ($http_code == 403 && $login && $pass) { - curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_ANY); - - $ret = @curl_exec($ch); - } - - if (curl_errno($ch) === 23 || curl_errno($ch) === 61) { - curl_setopt($ch, CURLOPT_ENCODING, 'none'); - $ret = @curl_exec($ch); - } - - $headers_length = curl_getinfo($ch, CURLINFO_HEADER_SIZE); - $headers = explode("\r\n", substr($ret, 0, $headers_length)); - $contents = substr($ret, $headers_length); - - foreach ($headers as $header) { - if (strstr($header, ": ") !== false) { - list ($key, $value) = explode(": ", $header); - - if (strtolower($key) == "last-modified") { - self::$fetch_last_modified = $value; - } - } - - if (substr(strtolower($header), 0, 7) == 'http/1.') { - self::$fetch_last_error_code = (int) substr($header, 9, 3); - self::$fetch_last_error = $header; - } - } - - $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - self::$fetch_last_content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); - - self::$fetch_effective_url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); - - if (!self::validate(self::$fetch_effective_url, true)) { - self::$fetch_last_error = "URL received after redirection failed extended validation."; - - $span->setAttribute('error', self::$fetch_last_error); - $span->end(); - return false; - } - - self::$fetch_effective_ip_addr = gethostbyname(parse_url(self::$fetch_effective_url, PHP_URL_HOST)); - - if (!self::$fetch_effective_ip_addr || strpos(self::$fetch_effective_ip_addr, "127.") === 0) { - self::$fetch_last_error = "URL hostname received after redirection failed to resolve or resolved to a loopback address (".self::$fetch_effective_ip_addr.")"; - - $span->setAttribute('error', self::$fetch_last_error); - $span->end(); - return false; - } - - self::$fetch_last_error_code = $http_code; - - if ($http_code != 200 || $type && strpos(self::$fetch_last_content_type, "$type") === false) { - - 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; - curl_close($ch); - - $span->setAttribute('error', self::$fetch_last_error); - $span->end(); - return false; - } - - if (!$contents) { - if (curl_errno($ch) === 0) { - self::$fetch_last_error = 'Successful response, but no content was received.'; - } else { - self::$fetch_last_error = curl_errno($ch) . " " . curl_error($ch); - } - curl_close($ch); - - $span->setAttribute('error', self::$fetch_last_error); - $span->end(); - return false; - } - - curl_close($ch); - - $is_gzipped = RSSUtils::is_gzipped($contents); - - if ($is_gzipped && is_string($contents)) { - $tmp = @gzdecode($contents); - - if ($tmp) $contents = $tmp; - } - - $span->end(); - - return $contents; - } else { - - self::$fetch_curl_used = false; - - if ($login && $pass){ - $url_parts = array(); - - preg_match("/(^[^:]*):\/\/(.*)/", $url, $url_parts); - - $pass = urlencode($pass); - - if ($url_parts[1] && $url_parts[2]) { - $url = $url_parts[1] . "://$login:$pass@" . $url_parts[2]; - } - } - - // TODO: should this support POST requests or not? idk - - $context_options = array( - 'http' => array( - 'header' => array( - 'Connection: close' - ), - 'method' => 'GET', - 'ignore_errors' => true, - 'timeout' => $timeout ? $timeout : Config::get(Config::FILE_FETCH_TIMEOUT), - 'protocol_version'=> 1.1) - ); - - if (!$post_query && $last_modified) - array_push($context_options['http']['header'], "If-Modified-Since: $last_modified"); - - if ($http_accept) - array_push($context_options['http']['header'], "Accept: $http_accept"); - - if ($http_referrer) - array_push($context_options['http']['header'], "Referer: $http_referrer"); - - if (Config::get(Config::HTTP_PROXY)) { - $context_options['http']['request_fulluri'] = true; - $context_options['http']['proxy'] = Config::get(Config::HTTP_PROXY); - } - - $context = stream_context_create($context_options); - - $old_error = error_get_last(); - - self::$fetch_effective_url = self::resolve_redirects($url, $timeout ? $timeout : Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT)); - - if (!self::validate(self::$fetch_effective_url, true)) { - self::$fetch_last_error = "URL received after redirection failed extended validation."; - - $span->setAttribute('error', self::$fetch_last_error); - $span->end(); - return false; - } - - self::$fetch_effective_ip_addr = gethostbyname(parse_url(self::$fetch_effective_url, PHP_URL_HOST)); - - if (!self::$fetch_effective_ip_addr || strpos(self::$fetch_effective_ip_addr, "127.") === 0) { - self::$fetch_last_error = "URL hostname received after redirection failed to resolve or resolved to a loopback address (".self::$fetch_effective_ip_addr.")"; - - $span->setAttribute('error', self::$fetch_last_error); - $span->end(); - return false; - } - - $data = @file_get_contents($url, false, $context); - - if ($data === false) { - self::$fetch_last_error = "'file_get_contents' failed."; - - $span->setAttribute('error', self::$fetch_last_error); - $span->end(); - return false; - } - - foreach ($http_response_header as $header) { - if (strstr($header, ": ") !== false) { - list ($key, $value) = explode(": ", $header); - - $key = strtolower($key); - - if ($key == 'content-type') { - self::$fetch_last_content_type = $value; - // don't abort here b/c there might be more than one - // e.g. if we were being redirected -- last one is the right one - } else if ($key == 'last-modified') { - self::$fetch_last_modified = $value; - } else if ($key == 'location') { - self::$fetch_effective_url = $value; - } - } - - if (substr(strtolower($header), 0, 7) == 'http/1.') { - self::$fetch_last_error_code = (int) substr($header, 9, 3); - self::$fetch_last_error = $header; - } - } - - if (self::$fetch_last_error_code != 200) { - $error = error_get_last(); - - if (($error['message'] ?? '') != ($old_error['message'] ?? '')) { - self::$fetch_last_error .= "; " . $error["message"]; - } - - self::$fetch_last_error_content = $data; - - $span->setAttribute('error', self::$fetch_last_error); - $span->end(); - return false; - } - - if ($data) { - $is_gzipped = RSSUtils::is_gzipped($data); - - if ($is_gzipped) { - $tmp = @gzdecode($data); - - if ($tmp) $data = $tmp; - } - - $span->end(); - return $data; - } else { - self::$fetch_last_error = 'Successful response, but no content was received.'; - - $span->setAttribute('error', self::$fetch_last_error); - $span->end(); - return false; - } - } - } - - /** - * @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 = [ - "/\/\/www\.youtube-nocookie\.com\/v\/([\w-]+)/", - "/\/\/www\.youtube-nocookie\.com\/embed\/([\w-]+)/", - "/\/\/www\.youtube-nocookie\.com\/watch?v=([\w-]+)/", - "/\/\/youtu.be\/([\w-]+)/", - ]; - - foreach ($regexps as $re) { - $matches = []; - - if (preg_match($re, $url, $matches)) { - return $matches[1]; - } - } - - return false; - } - - -} diff --git a/classes/userhelper.php b/classes/userhelper.php deleted file mode 100644 index 4d9f30548..000000000 --- a/classes/userhelper.php +++ /dev/null @@ -1,520 +0,0 @@ -chain_hooks_callback(PluginHost::HOOK_AUTH_USER, - function ($result, $plugin) use (&$user_id, &$auth_module) { - if ($result) { - $user_id = (int)$result; - $auth_module = strtolower(get_class($plugin)); - return true; - } - }, - $login, $password, $service); - - if ($user_id && !$check_only) { - - if (session_status() != PHP_SESSION_ACTIVE) - session_start(); - - session_regenerate_id(true); - - $user = ORM::for_table('ttrss_users')->find_one($user_id); - - if ($user && $user->access_level != self::ACCESS_LEVEL_DISABLED) { - self::set_session_for_user($user_id); - $_SESSION["auth_module"] = $auth_module; - $_SESSION["name"] = $user->login; - $_SESSION["access_level"] = $user->access_level; - $_SESSION["pwd_hash"] = $user->pwd_hash; - - $user->last_login = Db::NOW(); - $user->save(); - - return true; - } - - return false; - } - - if ($login && $password && !$user_id && !$check_only) - Logger::log(E_USER_WARNING, "Failed login attempt for $login (service: $service) from " . UserHelper::get_user_ip()); - - return false; - - } else { - self::set_session_for_user(1); - $_SESSION["name"] = "admin"; - $_SESSION["access_level"] = self::ACCESS_LEVEL_ADMIN; - - $_SESSION["hide_hello"] = true; - $_SESSION["hide_logout"] = true; - - $_SESSION["auth_module"] = false; - - return true; - } - } - - static function set_session_for_user(int $owner_uid): void { - $_SESSION["uid"] = $owner_uid; - $_SESSION["last_login_update"] = time(); - $_SESSION["ip_address"] = UserHelper::get_user_ip(); - - if (empty($_SESSION["csrf_token"])) - $_SESSION["csrf_token"] = bin2hex(get_random_bytes(16)); - - if (Config::get_schema_version() >= 120) { - $_SESSION["language"] = get_pref(Prefs::USER_LANGUAGE, $owner_uid); - } - } - - static function load_user_plugins(int $owner_uid, PluginHost $pluginhost = null): void { - - if (!$pluginhost) $pluginhost = PluginHost::getInstance(); - - if ($owner_uid && Config::get_schema_version() >= 100 && empty($_SESSION["safe_mode"])) { - $plugins = get_pref(Prefs::_ENABLED_PLUGINS, $owner_uid); - - $pluginhost->load((string)$plugins, PluginHost::KIND_USER, $owner_uid); - - /*if (get_schema_version() > 100) { - $pluginhost->load_data(); - }*/ - } - } - - static function login_sequence(): void { - $pdo = Db::pdo(); - - if (Config::get(Config::SINGLE_USER_MODE)) { - if (session_status() != PHP_SESSION_ACTIVE) - session_start(); - - self::authenticate("admin", null); - startup_gettext(); - self::load_user_plugins($_SESSION["uid"]); - } else { - if (!\Sessions\validate_session()) - $_SESSION["uid"] = null; - - if (empty($_SESSION["uid"])) { - - if (Config::get(Config::AUTH_AUTO_LOGIN) && self::authenticate(null, null)) { - $_SESSION["ref_schema_version"] = Config::get_schema_version(); - } else { - self::authenticate(null, null, true); - } - - if (empty($_SESSION["uid"])) { - UserHelper::logout(); - - Handler_Public::_render_login_form(); - exit; - } - - } else { - /* bump login timestamp */ - $user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]); - $user->last_login = Db::NOW(); - $user->save(); - - $_SESSION["last_login_update"] = time(); - } - - if ($_SESSION["uid"]) { - startup_gettext(); - self::load_user_plugins($_SESSION["uid"]); - } - } - } - - static function print_user_stylesheet(): void { - $value = get_pref(Prefs::USER_STYLESHEET); - - if ($value) { - print ""; - } - - } - - static function get_user_ip(): ?string { - foreach (["HTTP_X_REAL_IP", "REMOTE_ADDR"] as $hdr) { - if (isset($_SERVER[$hdr])) - return $_SERVER[$hdr]; - } - - return null; - } - - static function get_login_by_id(int $id): ?string { - $user = ORM::for_table('ttrss_users') - ->find_one($id); - - if ($user) - return $user->login; - else - return null; - } - - static function find_user_by_login(string $login): ?int { - $user = ORM::for_table('ttrss_users') - ->where('login', $login) - ->find_one(); - - if ($user) - return $user->id; - else - return null; - } - - static function logout(): void { - if (session_status() === PHP_SESSION_ACTIVE) - session_destroy(); - - if (isset($_COOKIE[session_name()])) { - setcookie(session_name(), '', time()-42000, '/'); - - } - session_commit(); - } - - static function get_salt(): string { - return substr(bin2hex(get_random_bytes(125)), 0, 250); - } - - /** TODO: this should invoke UserHelper::user_modify() */ - static function reset_password(int $uid, bool $format_output = false, string $new_password = ""): void { - - $user = ORM::for_table('ttrss_users')->find_one($uid); - $message = ""; - - if ($user) { - - $login = $user->login; - - $new_salt = self::get_salt(); - $tmp_user_pwd = $new_password ? $new_password : make_password(); - - $pwd_hash = self::hash_password($tmp_user_pwd, $new_salt, self::HASH_ALGOS[0]); - - $user->pwd_hash = $pwd_hash; - $user->salt = $new_salt; - $user->save(); - - $message = T_sprintf("Changed password of user %s to %s", "$login", "$tmp_user_pwd"); - } else { - $message = __("User not found"); - } - - if ($format_output) - print_notice($message); - else - print $message; - } - - static function check_otp(int $owner_uid, int $otp_check) : bool { - $otp = TOTP::create(self::get_otp_secret($owner_uid, true)); - - return $otp->now() == $otp_check; - } - - static function disable_otp(int $owner_uid) : bool { - $user = ORM::for_table('ttrss_users')->find_one($owner_uid); - - if ($user) { - $user->otp_enabled = false; - - // force new OTP secret when next enabled - if (Config::get_schema_version() >= 143) { - $user->otp_secret = null; - } - - $user->save(); - - return true; - } else { - return false; - } - } - - static function enable_otp(int $owner_uid, int $otp_check) : bool { - $secret = self::get_otp_secret($owner_uid); - - if ($secret) { - $otp = TOTP::create($secret); - $user = ORM::for_table('ttrss_users')->find_one($owner_uid); - - if ($otp->now() == $otp_check && $user) { - - $user->otp_enabled = true; - $user->save(); - - return true; - } - } - return false; - } - - - static function is_otp_enabled(int $owner_uid) : bool { - $user = ORM::for_table('ttrss_users')->find_one($owner_uid); - - if ($user) { - return $user->otp_enabled; - } else { - return 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) { - - $salt_based_secret = mb_substr(sha1($user->salt), 0, 12); - - if (Config::get_schema_version() >= 143) { - $secret = $user->otp_secret; - - if (empty($secret)) { - - /* migrate secret if OTP is already enabled, otherwise make a new one */ - if ($user->otp_enabled) { - $user->otp_secret = $salt_based_secret; - } else { - $user->otp_secret = bin2hex(get_random_bytes(10)); - } - - $user->save(); - - $secret = $user->otp_secret; - } - } else { - $secret = $salt_based_secret; - } - - if (!$user->otp_enabled || $show_if_enabled) { - return \ParagonIE\ConstantTime\Base32::encodeUpperUnpadded($secret); - } - } - - return null; - } - - /** - * @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only - * @return bool - * @throws PDOException - * @throws Exception - */ - static function is_default_password(?int $owner_uid = null): bool { - return self::user_has_password($owner_uid, 'password'); - } - - /** - * @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) { - case self::HASH_ALGO_SHA1: - $pass_hash = sha1($pass); - break; - case self::HASH_ALGO_SHA1X: - $pass_hash = sha1("$salt:$pass"); - break; - case self::HASH_ALGO_MODE2: - case self::HASH_ALGO_SSHA256: - $pass_hash = hash('sha256', $salt . $pass); - break; - case self::HASH_ALGO_SSHA512: - $pass_hash = hash('sha512', $salt . $pass); - break; - default: - user_error("hash_password: unknown hash algo: $algo", E_USER_ERROR); - } - - if ($pass_hash) - return "$algo:$pass_hash"; - else - return false; - } - - /** - * @param string $login Login for new user (case-insensitive) - * @param string $password Password for new user (may not be blank) - * @param UserHelper::ACCESS_LEVEL_* $access_level Access level for new user - * @return bool true if user has been created - */ - static function user_add(string $login, string $password, int $access_level) : bool { - $login = clean($login); - - if ($login && - $password && - !self::find_user_by_login($login) && - self::map_access_level((int)$access_level) != self::ACCESS_LEVEL_KEEP_CURRENT) { - - $user = ORM::for_table('ttrss_users')->create(); - - $user->salt = self::get_salt(); - $user->login = mb_strtolower($login); - $user->pwd_hash = self::hash_password($password, $user->salt); - $user->access_level = $access_level; - $user->created = Db::NOW(); - - return $user->save(); - } - - return false; - } - - /** - * @param int $uid User ID to modify - * @param string $new_password set password to this value if its not blank - * @param UserHelper::ACCESS_LEVEL_* $access_level set user access level to this value if it is set (default ACCESS_LEVEL_KEEP_CURRENT) - * @return bool true if user record has been saved - * - * NOTE: $access_level is of mixed type because of intellephense - */ - static function user_modify(int $uid, string $new_password = '', $access_level = self::ACCESS_LEVEL_KEEP_CURRENT) : bool { - $user = ORM::for_table('ttrss_users')->find_one($uid); - - if ($user) { - if ($new_password != '') { - $new_salt = self::get_salt(); - $pwd_hash = self::hash_password($new_password, $new_salt, self::HASH_ALGOS[0]); - - $user->pwd_hash = $pwd_hash; - $user->salt = $new_salt; - } - - if ($access_level != self::ACCESS_LEVEL_KEEP_CURRENT) { - $user->access_level = (int)$access_level; - } - - return $user->save(); - } - - return false; - } - - /** - * @param int $uid user ID to delete (this won't delete built-in admin user with UID 1) - * @return bool true if user has been deleted - */ - static function user_delete(int $uid) : bool { - if ($uid != 1) { - - $user = ORM::for_table('ttrss_users')->find_one($uid); - - if ($user) { - // TODO: is it still necessary to split those queries? - - ORM::for_table('ttrss_tags') - ->where('owner_uid', $uid) - ->delete_many(); - - ORM::for_table('ttrss_feeds') - ->where('owner_uid', $uid) - ->delete_many(); - - return $user->delete(); - } - } - - return false; - } - - /** - * @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only - * @param string $password password to compare hash against - * @return bool - */ - static function user_has_password(?int $owner_uid, string $password) : bool { - if ($owner_uid) { - $authenticator = new Auth_Internal(); - - return $authenticator->check_password($owner_uid, $password); - } else { - /** @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 && - method_exists($authenticator, "check_password") && - $authenticator->check_password($_SESSION["uid"], $password)) { - - return true; - } - } - - return false; - } - -} diff --git a/composer.json b/composer.json index d4a1f66b1..2458104f9 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,11 @@ "url": "https://dev.tt-rss.org/fox/idiorm.git" } ], + "autoload": { + "psr-4": { + "": "classes/" + } + }, "require": { "spomky-labs/otphp": "^10.0", "chillerlan/php-qrcode": "^4.3.3", diff --git a/include/autoload.php b/include/autoload.php index 4422a435c..d019940b7 100644 --- a/include/autoload.php +++ b/include/autoload.php @@ -1,17 +1,2 @@ { + xhr.post("backend.php", {op: "RPC", method: "setpref", key: "COMBINED_DISPLAY_MODE", value: value}, () => { this.setInitParam("combined_display_mode", !this.getInitParam("combined_display_mode")); @@ -306,7 +306,7 @@ const App = { if (App.isCombinedMode()) { const value = expand ? "true" : "false"; - xhr.post("backend.php", {op: "rpc", method: "setpref", key: "CDM_EXPANDED", value: value}, () => { + xhr.post("backend.php", {op: "RPC", method: "setpref", key: "CDM_EXPANDED", value: value}, () => { this.setInitParam("cdm_expanded", !this.getInitParam("cdm_expanded")); Headlines.renderAgain(); }); @@ -440,7 +440,7 @@ const App = { } }, hotkeyHelp: function() { - xhr.post("backend.php", {op: "rpc", method: "hotkeyHelp"}, (reply) => { + xhr.post("backend.php", {op: "RPC", method: "hotkeyHelp"}, (reply) => { const dialog = new fox.SingleUseDialog({ title: __("Keyboard shortcuts"), content: reply, @@ -621,7 +621,7 @@ const App = { try { xhr.post("backend.php", - {op: "rpc", method: "log", + {op: "RPC", method: "log", file: params.filename ? params.filename : error.fileName, line: params.lineno ? params.lineno : error.lineNumber, msg: message, @@ -703,7 +703,7 @@ const App = { this.initHotkeyActions(); const params = { - op: "rpc", + op: "RPC", method: "sanityCheck", clientTzOffset: new Date().getTimezoneOffset() * 60, hasSandbox: "sandbox" in document.createElement("iframe"), @@ -737,7 +737,7 @@ const App = { return errorMsg == ""; }, updateRuntimeInfo: function() { - xhr.json("backend.php", {op: "rpc", method: "getruntimeinfo"}, () => { + xhr.json("backend.php", {op: "RPC", method: "getruntimeinfo"}, () => { // handled by xhr.json() }); }, @@ -858,7 +858,7 @@ const App = { checkForUpdates: function() { console.log('checking for updates...'); - xhr.json("backend.php", {op: 'rpc', method: 'checkforupdates'}) + xhr.json("backend.php", {op: 'RPC', method: 'checkforupdates'}) .then((reply) => { console.log('update reply', reply); @@ -965,7 +965,7 @@ const App = { if (article_id) Article.view(article_id); - xhr.post("backend.php", {op: "rpc", method: "setWidescreen", wide: wide ? 1 : 0}); + xhr.post("backend.php", {op: "RPC", method: "setWidescreen", wide: wide ? 1 : 0}); }, initHotkeyActions: function() { if (this.is_prefs) { @@ -1149,7 +1149,7 @@ const App = { if (!Feeds.activeIsCat() && parseInt(Feeds.getActive()) > 0) { /* global __csrf_token */ - App.postOpenWindow("backend.php", {op: "feeds", method: "updatedebugger", + App.postOpenWindow("backend.php", {op: "Feeds", method: "updatedebugger", feed_id: Feeds.getActive(), csrf_token: __csrf_token}); } else { @@ -1158,7 +1158,7 @@ const App = { }; this.hotkey_actions["feed_debug_viewfeed"] = () => { - App.postOpenWindow("backend.php", {op: "feeds", method: "view", + App.postOpenWindow("backend.php", {op: "Feeds", method: "view", feed: Feeds.getActive(), timestamps: 1, debug: 1, cat: Feeds.activeIsCat(), csrf_token: __csrf_token}); }; @@ -1177,13 +1177,13 @@ const App = { Headlines.reverse(); }; this.hotkey_actions["feed_toggle_grid"] = () => { - xhr.json("backend.php", {op: "rpc", method: "togglepref", key: "CDM_ENABLE_GRID"}, (reply) => { + xhr.json("backend.php", {op: "RPC", method: "togglepref", key: "CDM_ENABLE_GRID"}, (reply) => { App.setInitParam("cdm_enable_grid", reply.value); Headlines.renderAgain(); }) }; this.hotkey_actions["feed_toggle_vgroup"] = () => { - xhr.post("backend.php", {op: "rpc", method: "togglepref", key: "VFEED_GROUP_BY_FEED"}, () => { + xhr.post("backend.php", {op: "RPC", method: "togglepref", key: "VFEED_GROUP_BY_FEED"}, () => { Feeds.reloadCurrent(); }) }; @@ -1274,7 +1274,7 @@ const App = { CommonDialogs.subscribeToFeed(); break; case "qmcDigest": - window.location.href = "backend.php?op=digest"; + window.location.href = "backend.php?op=Digest"; break; case "qmcEditFeed": if (Feeds.activeIsCat()) diff --git a/js/Article.js b/js/Article.js index 5f3a8c2e9..85cee6322 100644 --- a/js/Article.js +++ b/js/Article.js @@ -123,7 +123,7 @@ const Article = { Article.setActive(0); }, displayUrl: function (id) { - const query = {op: "article", method: "getmetadatabyid", id: id}; + const query = {op: "Article", method: "getmetadatabyid", id: id}; xhr.json("backend.php", query, (reply) => { if (reply && reply.link) { @@ -136,7 +136,7 @@ const Article = { openInNewWindow: function (id) { /* global __csrf_token */ App.postOpenWindow("backend.php", - { "op": "article", "method": "redirect", "id": id, "csrf_token": __csrf_token }); + { "op": "Article", "method": "redirect", "id": id, "csrf_token": __csrf_token }); Headlines.toggleUnread(id, 0); }, @@ -395,7 +395,7 @@ const Article = { const tmph = dojo.connect(dialog, 'onShow', function () { dojo.disconnect(tmph); - xhr.json("backend.php", {op: "article", method: "printArticleTags", id: id}, (reply) => { + xhr.json("backend.php", {op: "Article", method: "printArticleTags", id: id}, (reply) => { dijit.getEnclosingWidget(App.byId("tags_str")) .attr('value', reply.tags.join(", ")) @@ -404,7 +404,7 @@ const Article = { App.byId('tags_str').onkeyup = (e) => { const last_tag = e.target.value.split(',').pop().trim(); - xhr.json("backend.php", {op: 'article', method: 'completeTags', search: last_tag}, (data) => { + xhr.json("backend.php", {op: 'Article', method: 'completeTags', search: last_tag}, (data) => { App.byId("tags_choices").innerHTML = `${data.map((tag) => `${tag}` ) .join(', ')}` diff --git a/js/CommonDialogs.js b/js/CommonDialogs.js index a141c29be..e7190e07c 100644 --- a/js/CommonDialogs.js +++ b/js/CommonDialogs.js @@ -28,7 +28,7 @@ const CommonDialogs = { }, subscribeToFeed: function() { xhr.json("backend.php", - {op: "feeds", method: "subscribeToFeed"}, + {op: "Feeds", method: "subscribeToFeed"}, (reply) => { const dialog = new fox.SingleUseDialog({ title: __("Subscribe to feed"), @@ -215,7 +215,7 @@ const CommonDialogs = { }, showFeedsWithErrors: function() { - xhr.json("backend.php", {op: "pref-feeds", method: "feedsWithErrors"}, (reply) => { + xhr.json("backend.php", {op: "Pref_Feeds", method: "feedsWithErrors"}, (reply) => { const dialog = new fox.SingleUseDialog({ id: "errorFeedsDlg", @@ -231,7 +231,7 @@ const CommonDialogs = { Notify.progress("Removing selected feeds...", true); const query = { - op: "pref-feeds", method: "remove", + op: "Pref_Feeds", method: "remove", ids: sel_rows.toString() }; @@ -305,7 +305,7 @@ const CommonDialogs = { if (caption != undefined && caption.trim().length > 0) { - const query = {op: "pref-labels", method: "add", caption: caption.trim()}; + const query = {op: "Pref_Labels", method: "add", caption: caption.trim()}; Notify.progress("Loading, please wait...", true); @@ -325,7 +325,7 @@ const CommonDialogs = { if (typeof title == "undefined" || confirm(msg)) { Notify.progress("Removing feed..."); - const query = {op: "pref-feeds", quiet: 1, method: "remove", ids: feed_id}; + const query = {op: "Pref_Feeds", quiet: 1, method: "remove", ids: feed_id}; xhr.post("backend.php", query, () => { if (App.isPrefs()) { @@ -348,7 +348,7 @@ const CommonDialogs = { if (feed_id <= 0) return alert(__("You can't edit this kind of feed.")); - const query = {op: "pref-feeds", method: "editfeed", id: feed_id}; + const query = {op: "Pref_Feeds", method: "editfeed", id: feed_id}; console.log("editFeed", query); @@ -378,7 +378,7 @@ const CommonDialogs = { const fd = new FormData(); fd.append('icon_file', icon_file) fd.append('feed_id', feed_id); - fd.append('op', 'pref-feeds'); + fd.append('op', 'Pref_Feeds'); fd.append('method', 'uploadIcon'); fd.append('csrf_token', App.getInitParam("csrf_token")); @@ -427,7 +427,7 @@ const CommonDialogs = { if (confirm(__("Remove stored feed icon?"))) { Notify.progress("Removing feed icon...", true); - xhr.post("backend.php", {op: "pref-feeds", method: "removeicon", feed_id: id}, () => { + xhr.post("backend.php", {op: "Pref_Feeds", method: "removeicon", feed_id: id}, () => { Notify.info("Feed icon removed."); if (App.isPrefs()) @@ -470,7 +470,7 @@ const CommonDialogs = { const tmph = dojo.connect(dialog, 'onShow', function () { dojo.disconnect(tmph); - xhr.json("backend.php", {op: "pref-feeds", method: "editfeed", id: feed_id}, (reply) => { + xhr.json("backend.php", {op: "Pref_Feeds", method: "editfeed", id: feed_id}, (reply) => { const feed = reply.feed; const is_readonly = reply.user.access_level == App.UserAccessLevels.ACCESS_LEVEL_READONLY; @@ -493,7 +493,7 @@ const CommonDialogs = {
${App.FormFields.hidden_tag("id", feed_id)} - ${App.FormFields.hidden_tag("op", "pref-feeds")} + ${App.FormFields.hidden_tag("op", "Pref_Feeds")} ${App.FormFields.hidden_tag("method", "editSave")}
@@ -621,7 +621,7 @@ const CommonDialogs = { Notify.progress("Loading, please wait...", true); - xhr.json("backend.php", {op: "pref-feeds", method: "getsharedurl", id: feed, is_cat: is_cat, search: search}, (reply) => { + xhr.json("backend.php", {op: "Pref_Feeds", method: "getsharedurl", id: feed, is_cat: is_cat, search: search}, (reply) => { try { const dialog = new fox.SingleUseDialog({ title: __("Show as feed"), @@ -630,7 +630,7 @@ const CommonDialogs = { Notify.progress("Trying to change address...", true); - const query = {op: "pref-feeds", method: "regenFeedKey", id: feed, is_cat: is_cat}; + const query = {op: "Pref_Feeds", method: "regenFeedKey", id: feed, is_cat: is_cat}; xhr.json("backend.php", query, (reply) => { const new_link = reply.link; diff --git a/js/CommonFilters.js b/js/CommonFilters.js index 1a0ce1606..8be9e2613 100644 --- a/js/CommonFilters.js +++ b/js/CommonFilters.js @@ -115,7 +115,7 @@ const Filters = { insertRule: function(parentNode, replaceNode) { const rule = dojo.formToJson("filter_new_rule_form"); - xhr.post("backend.php", {op: "pref-filters", method: "printrulename", rule: rule}, (reply) => { + xhr.post("backend.php", {op: "Pref_Filters", method: "printrulename", rule: rule}, (reply) => { try { const li = document.createElement('li'); li.addClassName("rule"); @@ -147,7 +147,7 @@ const Filters = { const action = dojo.formToJson(form); - xhr.post("backend.php", { op: "pref-filters", method: "printactionname", action: action }, (reply) => { + xhr.post("backend.php", { op: "Pref_Filters", method: "printactionname", action: action }, (reply) => { try { const li = document.createElement('li'); li.addClassName("action"); @@ -200,7 +200,7 @@ const Filters = { console.log(rule, dialog.filter_info); - xhr.json("backend.php", {op: "pref-filters", method: "editrule", ids: rule.feed_id.join(",")}, function (editrule) { + xhr.json("backend.php", {op: "Pref_Filters", method: "editrule", ids: rule.feed_id.join(",")}, function (editrule) { edit_rule_dialog.attr('content', `
@@ -326,7 +326,7 @@ const Filters = { dijit.byId("filterDlg_actionSelect").attr('value', action.action_id); - /*xhr.post("backend.php", {op: 'pref-filters', method: 'newaction', action: actionStr}, (reply) => { + /*xhr.post("backend.php", {op: 'Pref_Filters', method: 'newaction', action: actionStr}, (reply) => { edit_action_dialog.attr('content', reply); setTimeout(() => { @@ -365,7 +365,7 @@ const Filters = { Notify.progress("Removing filter..."); - const query = {op: "pref-filters", method: "remove", ids: this.attr('value').id}; + const query = {op: "Pref_Filters", method: "remove", ids: this.attr('value').id}; xhr.post("backend.php", query, () => { const tree = dijit.byId("filterTree"); @@ -411,7 +411,7 @@ const Filters = { const tmph = dojo.connect(dialog, 'onShow', function () { dojo.disconnect(tmph); - xhr.json("backend.php", {op: "pref-filters", method: "edit", id: filter_id}, function (filter) { + xhr.json("backend.php", {op: "Pref_Filters", method: "edit", id: filter_id}, function (filter) { dialog.filter_info = filter; @@ -425,7 +425,7 @@ const Filters = { ` - ${App.FormFields.hidden_tag("op", "pref-filters")} + ${App.FormFields.hidden_tag("op", "Pref_Filters")} ${App.FormFields.hidden_tag("id", filter_id)} ${App.FormFields.hidden_tag("method", filter_id ? "editSave" : "add")} ${App.FormFields.hidden_tag("csrf_token", App.getInitParam('csrf_token'))} @@ -541,7 +541,7 @@ const Filters = { dialog.editRule(null, dojo.toJson(rule)); } else { - const query = {op: "article", method: "getmetadatabyid", id: Article.getActive()}; + const query = {op: "Article", method: "getmetadatabyid", id: Article.getActive()}; xhr.json("backend.php", query, (reply) => { let title; diff --git a/js/FeedTree.js b/js/FeedTree.js index 3eaa61263..df026a7bc 100755 --- a/js/FeedTree.js +++ b/js/FeedTree.js @@ -104,7 +104,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co menu.addChild(new dijit.MenuItem({ label: __("Open site"), onClick: function() { - App.postOpenWindow("backend.php", {op: "feeds", method: "opensite", + App.postOpenWindow("backend.php", {op: "Feeds", method: "opensite", feed_id: this.getParent().row_id, csrf_token: __csrf_token}); }})); @@ -114,7 +114,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co label: __("Debug feed"), onClick: function() { /* global __csrf_token */ - App.postOpenWindow("backend.php", {op: "feeds", method: "updatedebugger", + App.postOpenWindow("backend.php", {op: "Feeds", method: "updatedebugger", feed_id: this.getParent().row_id, csrf_token: __csrf_token}); }})); } diff --git a/js/Feeds.js b/js/Feeds.js index 7a5678084..a6eecaf81 100644 --- a/js/Feeds.js +++ b/js/Feeds.js @@ -160,7 +160,7 @@ const Feeds = { }, // null = get all data, [] would give empty response for specific type requestCounters: function(feed_ids = null, label_ids = null) { - xhr.json("backend.php", {op: "rpc", + xhr.json("backend.php", {op: "RPC", method: "getAllCounters", "feed_ids[]": feed_ids, "feed_id_count": feed_ids ? feed_ids.length : -1, @@ -179,7 +179,7 @@ const Feeds = { } const store = new dojo.data.ItemFileWriteStore({ - url: "backend.php?op=pref_feeds&method=getfeedtree&mode=2" + url: "backend.php?op=Pref_Feeds&method=getfeedtree&mode=2" }); // noinspection JSUnresolvedFunction @@ -347,7 +347,7 @@ const Feeds = { toggleUnread: function() { const hide = !App.getInitParam("hide_read_feeds"); - xhr.post("backend.php", {op: "rpc", method: "setpref", key: "HIDE_READ_FEEDS", value: hide}, () => { + xhr.post("backend.php", {op: "RPC", method: "setpref", key: "HIDE_READ_FEEDS", value: hide}, () => { this.hideOrShowFeeds(hide); App.setInitParam("hide_read_feeds", hide); }); @@ -386,7 +386,7 @@ const Feeds = { }, 10 * 1000); } - let query = {...{op: "feeds", method: "view", feed: feed}, ...dojo.formToObject("toolbar-main")}; + let query = {...{op: "Feeds", method: "view", feed: feed}, ...dojo.formToObject("toolbar-main")}; if (method) query.m = method; @@ -435,7 +435,7 @@ const Feeds = { Notify.progress("Marking all feeds as read..."); - xhr.json("backend.php", {op: "feeds", method: "catchupAll"}, () => { + xhr.json("backend.php", {op: "Feeds", method: "catchupAll"}, () => { this.reloadCurrent(); }); @@ -473,7 +473,7 @@ const Feeds = { } const catchup_query = { - op: 'rpc', method: 'catchupFeed', feed_id: feed, + op: 'RPC', method: 'catchupFeed', feed_id: feed, is_cat: is_cat, mode: mode, search_query: this.last_search_query[0], search_lang: this.last_search_query[1] }; @@ -612,7 +612,7 @@ const Feeds = { }, search: function() { xhr.json("backend.php", - {op: "feeds", method: "search"}, + {op: "Feeds", method: "search"}, (reply) => { try { const dialog = new fox.SingleUseDialog({ @@ -686,7 +686,7 @@ const Feeds = { updateRandom: function() { console.log("in update_random_feed"); - xhr.json("backend.php", {op: "rpc", method: "updaterandomfeed"}, () => { + xhr.json("backend.php", {op: "RPC", method: "updaterandomfeed"}, () => { // }); }, diff --git a/js/Headlines.js b/js/Headlines.js index 5706377f8..6a439f744 100755 --- a/js/Headlines.js +++ b/js/Headlines.js @@ -160,26 +160,26 @@ const Headlines = { if (ops.tmark.length != 0) promises.push(xhr.post("backend.php", - {op: "rpc", method: "markSelected", "ids[]": ops.tmark, cmode: 2})); + {op: "RPC", method: "markSelected", "ids[]": ops.tmark, cmode: 2})); if (ops.tpub.length != 0) promises.push(xhr.post("backend.php", - {op: "rpc", method: "publishSelected", "ids[]": ops.tpub, cmode: 2})); + {op: "RPC", method: "publishSelected", "ids[]": ops.tpub, cmode: 2})); if (ops.read.length != 0) promises.push(xhr.post("backend.php", - {op: "rpc", method: "catchupSelected", "ids[]": ops.read, cmode: 0})); + {op: "RPC", method: "catchupSelected", "ids[]": ops.read, cmode: 0})); if (ops.unread.length != 0) promises.push(xhr.post("backend.php", - {op: "rpc", method: "catchupSelected", "ids[]": ops.unread, cmode: 1})); + {op: "RPC", method: "catchupSelected", "ids[]": ops.unread, cmode: 1})); const scores = Object.keys(ops.rescore); if (scores.length != 0) { scores.forEach((score) => { promises.push(xhr.post("backend.php", - {op: "article", method: "setScore", "ids[]": ops.rescore[score], score: score})); + {op: "Article", method: "setScore", "ids[]": ops.rescore[score], score: score})); }); } @@ -1132,7 +1132,7 @@ const Headlines = { } const query = { - op: "article", method: "removeFromLabel", + op: "Article", method: "removeFromLabel", ids: ids.toString(), lid: id }; @@ -1149,7 +1149,7 @@ const Headlines = { } const query = { - op: "article", method: "assignToLabel", + op: "Article", method: "assignToLabel", ids: ids.toString(), lid: id }; @@ -1181,7 +1181,7 @@ const Headlines = { return; } - const query = {op: "rpc", method: "delete", ids: rows.toString()}; + const query = {op: "RPC", method: "delete", ids: rows.toString()}; xhr.json("backend.php", query, () => { Feeds.reloadCurrent(); @@ -1586,7 +1586,7 @@ const Headlines = { menu.addChild(new dijit.MenuItem({ label: __("Open site"), onClick: function() { - App.postOpenWindow("backend.php", {op: "feeds", method: "opensite", + App.postOpenWindow("backend.php", {op: "Feeds", method: "opensite", feed_id: this.getParent().currentTarget.getAttribute("data-feed-id"), csrf_token: __csrf_token}); }})); @@ -1596,7 +1596,7 @@ const Headlines = { label: __("Debug feed"), onClick: function() { /* global __csrf_token */ - App.postOpenWindow("backend.php", {op: "feeds", method: "updatedebugger", + App.postOpenWindow("backend.php", {op: "Feeds", method: "updatedebugger", feed_id: this.getParent().currentTarget.getAttribute("data-feed-id"), csrf_token: __csrf_token}); }})); diff --git a/js/PrefFeedStore.js b/js/PrefFeedStore.js index ee983af54..348cbd995 100644 --- a/js/PrefFeedStore.js +++ b/js/PrefFeedStore.js @@ -8,7 +8,7 @@ define(["dojo/_base/declare", "dojo/data/ItemFileWriteStore"], function (declare dojo.xhrPost({ url: "backend.php", - content: {op: "pref-feeds", method: "savefeedorder", + content: {op: "Pref_Feeds", method: "savefeedorder", payload: newFileContentString}, error: saveFailedCallback, load: saveCompleteCallback}); diff --git a/js/PrefFeedTree.js b/js/PrefFeedTree.js index 85b262b6d..f1729382c 100644 --- a/js/PrefFeedTree.js +++ b/js/PrefFeedTree.js @@ -150,7 +150,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b const searchElem = App.byId("feed_search"); const search = (searchElem) ? searchElem.value : ""; - xhr.post("backend.php", { op: "pref-feeds", search: search }, (reply) => { + xhr.post("backend.php", { op: "Pref_Feeds", search: search }, (reply) => { dijit.byId('feedsTab').attr('content', reply); Notify.close(); }); @@ -185,14 +185,14 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b resetFeedOrder: function() { Notify.progress("Loading, please wait..."); - xhr.post("backend.php", {op: "pref-feeds", method: "feedsortreset"}, () => { + xhr.post("backend.php", {op: "Pref_Feeds", method: "feedsortreset"}, () => { this.reload(); }); }, resetCatOrder: function() { Notify.progress("Loading, please wait..."); - xhr.post("backend.php", {op: "pref-feeds", method: "catsortreset"}, () => { + xhr.post("backend.php", {op: "Pref_Feeds", method: "catsortreset"}, () => { this.reload(); }); }, @@ -200,7 +200,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b if (confirm(__("Remove category %s? Any nested feeds would be placed into Uncategorized.").replace("%s", item.name))) { Notify.progress("Removing category..."); - xhr.post("backend.php", {op: "pref-feeds", method: "removeCat", ids: id}, () => { + xhr.post("backend.php", {op: "Pref_Feeds", method: "removeCat", ids: id}, () => { Notify.close(); this.reload(); }); @@ -215,7 +215,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b Notify.progress("Unsubscribing from selected feeds...", true); const query = { - op: "pref-feeds", method: "remove", + op: "Pref_Feeds", method: "remove", ids: sel_rows.toString() }; @@ -231,14 +231,14 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b return false; }, checkErrorFeeds: function() { - xhr.json("backend.php", {op: "pref-feeds", method: "feedsWithErrors"}, (reply) => { + xhr.json("backend.php", {op: "Pref_Feeds", method: "feedsWithErrors"}, (reply) => { if (reply.length > 0) { Element.show(dijit.byId("pref_feeds_errors_btn").domNode); } }); }, checkInactiveFeeds: function() { - xhr.json("backend.php", {op: "pref-feeds", method: "inactivefeeds"}, (reply) => { + xhr.json("backend.php", {op: "Pref_Feeds", method: "inactivefeeds"}, (reply) => { if (reply.length > 0) { Element.show(dijit.byId("pref_feeds_inactive_btn").domNode); } @@ -264,7 +264,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b Notify.progress("Removing selected categories..."); const query = { - op: "pref-feeds", method: "removeCat", + op: "Pref_Feeds", method: "removeCat", ids: sel_rows.toString() }; @@ -316,7 +316,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b Notify.progress("Loading, please wait..."); - xhr.post("backend.php", {op: "pref-feeds", method: "editfeeds", ids: rows.toString()}, (reply) => { + xhr.post("backend.php", {op: "Pref_Feeds", method: "editfeeds", ids: rows.toString()}, (reply) => { Notify.close(); try { @@ -393,7 +393,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b Notify.progress("Loading, please wait..."); - xhr.post("backend.php", { op: 'pref-feeds', method: 'renamecat', id: id, title: new_name }, () => { + xhr.post("backend.php", { op: 'Pref_Feeds', method: 'renamecat', id: id, title: new_name }, () => { this.reload(); }); } @@ -404,14 +404,14 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b if (title) { Notify.progress("Creating category..."); - xhr.post("backend.php", {op: "pref-feeds", method: "addCat", cat: title}, () => { + xhr.post("backend.php", {op: "Pref_Feeds", method: "addCat", cat: title}, () => { Notify.close(); this.reload(); }); } }, batchSubscribe: function() { - xhr.json("backend.php", {op: 'pref-feeds', method: 'batchSubscribe'}, (reply) => { + xhr.json("backend.php", {op: 'Pref_Feeds', method: 'batchSubscribe'}, (reply) => { const dialog = new fox.SingleUseDialog({ id: "batchSubDlg", title: __("Batch subscribe"), @@ -431,7 +431,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b }, content: ` - ${App.FormFields.hidden_tag("op", "pref-feeds")} + ${App.FormFields.hidden_tag("op", "Pref_Feeds")} ${App.FormFields.hidden_tag("method", "batchaddfeeds")}
@@ -484,7 +484,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b }); }, showInactiveFeeds: function() { - xhr.json("backend.php", {op: 'pref-feeds', method: 'inactivefeeds'}, function (reply) { + xhr.json("backend.php", {op: 'Pref_Feeds', method: 'inactivefeeds'}, function (reply) { const dialog = new fox.SingleUseDialog({ id: "inactiveFeedsDlg", @@ -500,7 +500,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b Notify.progress("Removing selected feeds...", true); const query = { - op: "pref-feeds", method: "remove", + op: "Pref_Feeds", method: "remove", ids: sel_rows.toString() }; diff --git a/js/PrefFilterStore.js b/js/PrefFilterStore.js index a41d84129..f1192374a 100644 --- a/js/PrefFilterStore.js +++ b/js/PrefFilterStore.js @@ -9,7 +9,7 @@ define(["dojo/_base/declare", "dojo/data/ItemFileWriteStore"], function (declare dojo.xhrPost({ url: "backend.php", content: { - op: "pref-filters", method: "savefilterorder", + op: "Pref_Filters", method: "savefilterorder", payload: newFileContentString }, error: saveFailedCallback, diff --git a/js/PrefFilterTree.js b/js/PrefFilterTree.js index 149261abd..eded7e383 100644 --- a/js/PrefFilterTree.js +++ b/js/PrefFilterTree.js @@ -107,7 +107,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio let search = ""; if (user_search) { search = user_search.value; } - xhr.post("backend.php", { op: "pref-filters", search: search }, (reply) => { + xhr.post("backend.php", { op: "Pref_Filters", search: search }, (reply) => { dijit.byId('filtersTab').attr('content', reply); Notify.close(); }); @@ -125,7 +125,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio resetFilterOrder: function() { Notify.progress("Loading, please wait..."); - xhr.post("backend.php", {op: "pref-filters", method: "filtersortreset"}, () => { + xhr.post("backend.php", {op: "Pref_Filters", method: "filtersortreset"}, () => { this.reload(); }); }, @@ -140,7 +140,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio if (confirm(__("Combine selected filters?"))) { Notify.progress("Joining filters..."); - xhr.post("backend.php", {op: "pref-filters", method: "join", ids: rows.toString()}, () => { + xhr.post("backend.php", {op: "Pref_Filters", method: "join", ids: rows.toString()}, () => { this.reload(); }); } @@ -153,7 +153,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio Notify.progress("Removing selected filters..."); const query = { - op: "pref-filters", method: "remove", + op: "Pref_Filters", method: "remove", ids: sel_rows.toString() }; diff --git a/js/PrefHelpers.js b/js/PrefHelpers.js index c0fff66c9..7a4c99340 100644 --- a/js/PrefHelpers.js +++ b/js/PrefHelpers.js @@ -19,7 +19,7 @@ const Helpers = { alert("No passwords selected."); } else if (confirm(__("Remove selected app passwords?"))) { - xhr.post("backend.php", {op: "pref-prefs", method: "deleteAppPasswords", "ids[]": rows}, (reply) => { + xhr.post("backend.php", {op: "Pref_Prefs", method: "deleteAppPasswords", "ids[]": rows}, (reply) => { this.updateContent(reply); Notify.close(); }); @@ -31,7 +31,7 @@ const Helpers = { const title = prompt("Password description:") if (title) { - xhr.post("backend.php", {op: "pref-prefs", method: "generateAppPassword", title: title}, (reply) => { + xhr.post("backend.php", {op: "Pref_Prefs", method: "generateAppPassword", title: title}, (reply) => { this.updateContent(reply); Notify.close(); }); @@ -45,7 +45,7 @@ const Helpers = { if (confirm(__("This will invalidate all previously generated feed URLs. Continue?"))) { Notify.progress("Clearing URLs..."); - xhr.post("backend.php", {op: "pref-feeds", method: "clearKeys"}, () => { + xhr.post("backend.php", {op: "Pref_Feeds", method: "clearKeys"}, () => { Notify.info("Generated URLs cleared."); }); } @@ -71,7 +71,7 @@ const Helpers = { const tmph = dojo.connect(dialog, 'onShow', function () { dojo.disconnect(tmph); - xhr.json("backend.php", {op: "pref-prefs", method: "previewDigest"}, (reply) => { + xhr.json("backend.php", {op: "Pref_Prefs", method: "previewDigest"}, (reply) => { dialog.domNode.querySelector('.digest-preview').innerHTML = reply[0]; }); }); @@ -91,7 +91,7 @@ const Helpers = { }, update: function() { xhr.post("backend.php", { - op: "pref-system", + op: "Pref_System", severity: dijit.byId("severity").attr('value'), page: Helpers.EventLog.log_page }, (reply) => { @@ -114,7 +114,7 @@ const Helpers = { Notify.progress("Loading, please wait..."); - xhr.post("backend.php", {op: "pref-system", method: "clearLog"}, () => { + xhr.post("backend.php", {op: "Pref_System", method: "clearLog"}, () => { Helpers.EventLog.refresh(); }); } @@ -135,7 +135,7 @@ const Helpers = { const new_title = prompt(__("Name for cloned profile:")); if (new_title) { - xhr.post("backend.php", {op: "pref-prefs", method: "cloneprofile", "new_title": new_title, "old_profile": sel_rows[0]}, () => { + xhr.post("backend.php", {op: "Pref_Prefs", method: "cloneprofile", "new_title": new_title, "old_profile": sel_rows[0]}, () => { Notify.close(); dialog.refresh(); }); @@ -153,7 +153,7 @@ const Helpers = { if (confirm(__("Remove selected profiles? Active and default profiles will not be removed."))) { Notify.progress("Removing selected profiles...", true); - xhr.post("backend.php", {op: "pref-prefs", method: "remprofiles", "ids[]": sel_rows}, () => { + xhr.post("backend.php", {op: "Pref_Prefs", method: "remprofiles", "ids[]": sel_rows}, () => { Notify.close(); dialog.refresh(); }); @@ -167,7 +167,7 @@ const Helpers = { if (this.validate()) { Notify.progress("Creating profile...", true); - const query = {op: "pref-prefs", method: "addprofile", title: dialog.attr('value').newprofile}; + const query = {op: "Pref_Prefs", method: "addprofile", title: dialog.attr('value').newprofile}; xhr.post("backend.php", query, () => { Notify.close(); @@ -177,7 +177,7 @@ const Helpers = { } }, refresh: function() { - xhr.json("backend.php", {op: 'pref-prefs', method: 'getprofiles'}, (reply) => { + xhr.json("backend.php", {op: 'Pref_Prefs', method: 'getprofiles'}, (reply) => { dialog.attr('content', `
@@ -210,7 +210,7 @@ const Helpers = { profile-id='${profile.id}'>${profile.title} @@ -242,7 +242,7 @@ const Helpers = { if (confirm(__("Activate selected profile?"))) { Notify.progress("Loading, please wait..."); - xhr.post("backend.php", {op: "pref-prefs", method: "activateprofile", id: sel_rows.toString()}, () => { + xhr.post("backend.php", {op: "Pref_Prefs", method: "activateprofile", id: sel_rows.toString()}, () => { window.location.reload(); }); } @@ -312,7 +312,7 @@ const Helpers = { const tmph = dojo.connect(dialog, 'onShow', function () { dojo.disconnect(tmph); - xhr.json("backend.php", {op: "pref-prefs", method: "customizeCSS"}, (reply) => { + xhr.json("backend.php", {op: "Pref_Prefs", method: "customizeCSS"}, (reply) => { const editor = dijit.getEnclosingWidget(dialog.domNode.querySelector(".user-css-editor")); @@ -327,14 +327,14 @@ const Helpers = { }, confirmReset: function() { if (confirm(__("Reset to defaults?"))) { - xhr.post("backend.php", {op: "pref-prefs", method: "resetconfig"}, (reply) => { + xhr.post("backend.php", {op: "Pref_Prefs", method: "resetconfig"}, (reply) => { Helpers.Prefs.refresh(); Notify.info(reply); }); } }, refresh: function() { - xhr.post("backend.php", { op: "pref-prefs" }, (reply) => { + xhr.post("backend.php", { op: "Pref_Prefs" }, (reply) => { dijit.byId('prefsTab').attr('content', reply); Notify.close(); }); @@ -360,7 +360,7 @@ const Helpers = { this.render_contents(); }, reload: function() { - xhr.json("backend.php", {op: "pref-prefs", method: "getPluginsList"}, (reply) => { + xhr.json("backend.php", {op: "Pref_Prefs", method: "getPluginsList"}, (reply) => { this._list_of_plugins = reply; this.render_contents(); }, (e) => { @@ -444,7 +444,7 @@ const Helpers = { if (confirm(__("Clear stored data for %s?").replace("%s", name))) { Notify.progress("Loading, please wait..."); - xhr.post("backend.php", {op: "pref-prefs", method: "clearPluginData", name: name}, () => { + xhr.post("backend.php", {op: "Pref_Prefs", method: "clearPluginData", name: name}, () => { Helpers.Prefs.refresh(); }); } @@ -455,7 +455,7 @@ const Helpers = { if (confirm(msg)) { Notify.progress("Loading, please wait..."); - xhr.json("backend.php", {op: "pref-prefs", method: "uninstallPlugin", plugin: plugin}, (reply) => { + xhr.json("backend.php", {op: "Pref_Prefs", method: "uninstallPlugin", plugin: plugin}, (reply) => { if (reply && reply.status == 1) Helpers.Plugins.reload(); else { @@ -504,7 +504,7 @@ const Helpers = { const container = install_dialog.domNode.querySelector(".contents"); - xhr.json("backend.php", {op: "pref-prefs", method: "installPlugin", plugin: plugin}, (reply) => { + xhr.json("backend.php", {op: "Pref_Prefs", method: "installPlugin", plugin: plugin}, (reply) => { if (!reply) { container.innerHTML = `
  • ${__("Operation failed: check event log.")}
  • `; } else { @@ -603,7 +603,7 @@ const Helpers = { const container = dialog.domNode.querySelector(".contents"); container.innerHTML = `
  • ${__("Looking for plugins...")}
  • `; - xhr.json("backend.php", {op: "pref-prefs", method: "getAvailablePlugins"}, (reply) => { + xhr.json("backend.php", {op: "Pref_Prefs", method: "getAvailablePlugins"}, (reply) => { dialog.entries = reply; dialog.render_contents(); }); @@ -656,7 +656,7 @@ const Helpers = { container.innerHTML = `
  • ${__("Updating, please wait...")}
  • `; let enable_update_btn = false; - xhr.json("backend.php", {op: "pref-prefs", method: "updateLocalPlugins", plugins: dialog.plugins_to_update.join(",")}, (reply) => { + xhr.json("backend.php", {op: "Pref_Prefs", method: "updateLocalPlugins", plugins: dialog.plugins_to_update.join(",")}, (reply) => { if (!reply) { container.innerHTML = `
  • ${__("Operation failed: check event log.")}
  • `; @@ -717,7 +717,7 @@ const Helpers = { //container.innerHTML = `
  • ${__("Checking: %s...").replace("%s", name)}
  • `; - xhr.json("backend.php", {op: "pref-prefs", method: "checkForPluginUpdates", name: name}, (reply) => { + xhr.json("backend.php", {op: "Pref_Prefs", method: "checkForPluginUpdates", name: name}, (reply) => { if (!reply) { container.innerHTML += `
  • ${__("%s: Operation failed: check event log.").replace("%s", name)}
  • `; @@ -834,7 +834,7 @@ const Helpers = { }, export: function() { console.log("export"); - window.open("backend.php?op=opml&method=export&" + dojo.formToQuery("opmlExportForm")); + window.open("backend.php?op=OPML&method=export&" + dojo.formToQuery("opmlExportForm")); }, } }; diff --git a/js/PrefLabelTree.js b/js/PrefLabelTree.js index 39e3f8315..582e5a9b9 100644 --- a/js/PrefLabelTree.js +++ b/js/PrefLabelTree.js @@ -55,13 +55,13 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f return rv; }, reload: function() { - xhr.post("backend.php", { op: "pref-labels" }, (reply) => { + xhr.post("backend.php", { op: "Pref_Labels" }, (reply) => { dijit.byId('labelsTab').attr('content', reply); Notify.close(); }); }, editLabel: function(id) { - xhr.json("backend.php", {op: "pref-labels", method: "edit", id: id}, (reply) => { + xhr.json("backend.php", {op: "Pref_Labels", method: "edit", id: id}, (reply) => { const fg_color = reply['fg_color']; const bg_color = reply['bg_color'] ? reply['bg_color'] : '#fff7d5'; @@ -91,7 +91,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f } const query = { - op: "pref-labels", method: "colorset", kind: kind, + op: "Pref_Labels", method: "colorset", kind: kind, ids: id, fg: fg, bg: bg, color: color }; @@ -131,7 +131,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
    ${App.FormFields.hidden_tag('id', id)} - ${App.FormFields.hidden_tag('op', 'pref-labels')} + ${App.FormFields.hidden_tag('op', 'Pref_Labels')} ${App.FormFields.hidden_tag('method', 'save')} ${App.FormFields.hidden_tag('fg_color', fg_color, {}, 'labelEdit_fgColor')} @@ -189,7 +189,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f if (confirm(__("Reset selected labels to default colors?"))) { const query = { - op: "pref-labels", method: "colorreset", + op: "Pref_Labels", method: "colorreset", ids: labels.toString() }; @@ -210,7 +210,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f Notify.progress("Removing selected labels..."); const query = { - op: "pref-labels", method: "remove", + op: "Pref_Labels", method: "remove", ids: sel_rows.toString() }; diff --git a/js/PrefUsers.js b/js/PrefUsers.js index a6081f35f..e8f4a7489 100644 --- a/js/PrefUsers.js +++ b/js/PrefUsers.js @@ -8,7 +8,7 @@ const Users = { const user_search = App.byId("user_search"); const search = user_search ? user_search.value : ""; - xhr.post("backend.php", { op: "pref-users", sort: sort, search: search }, (reply) => { + xhr.post("backend.php", { op: "Pref_Users", sort: sort, search: search }, (reply) => { dijit.byId('usersTab').attr('content', reply); Notify.close(); resolve(); @@ -21,7 +21,7 @@ const Users = { if (login) { Notify.progress("Adding user..."); - xhr.post("backend.php", {op: "pref-users", method: "add", login: login}, (reply) => { + xhr.post("backend.php", {op: "Pref_Users", method: "add", login: login}, (reply) => { Users.reload().then(() => { Notify.info(reply); }) @@ -30,7 +30,7 @@ const Users = { } }, edit: function(id) { - xhr.json('backend.php', {op: 'pref-users', method: 'edit', id: id}, (reply) => { + xhr.json('backend.php', {op: 'Pref_Users', method: 'edit', id: id}, (reply) => { const user = reply.user; const admin_disabled = (user.id == 1); @@ -53,7 +53,7 @@ const Users = { ${App.FormFields.hidden_tag('id', user.id.toString())} - ${App.FormFields.hidden_tag('op', 'pref-users')} + ${App.FormFields.hidden_tag('op', 'Pref_Users')} ${App.FormFields.hidden_tag('method', 'editSave')}
    @@ -104,7 +104,7 @@ const Users = {