diff options
author | Andrew Dolgov <[email protected]> | 2021-03-02 15:16:38 +0300 |
---|---|---|
committer | Andrew Dolgov <[email protected]> | 2021-03-02 15:16:38 +0300 |
commit | 9ad4cbeecaed32e4106a7fef30bbe3d14195f78a (patch) | |
tree | 687b365d08a17cb2fb737dfc22f78197c62dde1e /classes/handler | |
parent | d6629ed18863f797d34ebdc65815d7af21cb8332 (diff) |
wip separate handlersexp-separate-handlers
Diffstat (limited to 'classes/handler')
-rw-r--r-- | classes/handler/api.php | 884 | ||||
-rw-r--r-- | classes/handler/article.php | 166 | ||||
-rw-r--r-- | classes/handler/feeds.php | 305 | ||||
-rw-r--r-- | classes/handler/opml.php | 661 | ||||
-rw-r--r-- | classes/handler/pluginhandler.php | 29 | ||||
-rw-r--r-- | classes/handler/rpc.php | 786 |
6 files changed, 2831 insertions, 0 deletions
diff --git a/classes/handler/api.php b/classes/handler/api.php new file mode 100644 index 000000000..e860584b4 --- /dev/null +++ b/classes/handler/api.php @@ -0,0 +1,884 @@ +<?php +class Handler_API extends Handler { + + const API_LEVEL = 15; + + const STATUS_OK = 0; + const STATUS_ERR = 1; + + const E_API_DISABLED = "API_DISABLED"; + const E_NOT_LOGGED_IN = "NOT_LOGGED_IN"; + const E_LOGIN_ERROR = "LOGIN_ERROR"; + const E_INCORRECT_USAGE = "INCORRECT_USAGE"; + const E_UNKNOWN_METHOD = "UNKNOWN_METHOD"; + const E_OPERATION_FAILED = "E_OPERATION_FAILED"; + + private $seq; + + private static function _param_to_bool($p) { + return $p && ($p !== "f" && $p !== "false"); + } + + private function _wrap($status, $reply) { + print json_encode([ + "seq" => $this->seq, + "status" => $status, + "content" => $reply + ]); + } + + function before($method) { + 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() { + $rv = array("version" => Config::get_version()); + $this->_wrap(self::STATUS_OK, $rv); + } + + function getApiLevel() { + $rv = array("level" => self::API_LEVEL); + $this->_wrap(self::STATUS_OK, $rv); + } + + function login() { + + 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)) { + $this->_wrap(self::STATUS_OK, array("session_id" => session_id(), + "api_level" => self::API_LEVEL)); + } else { + $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR)); + } + } else { + $this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED)); + } + } else { + $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR)); + return; + } + } + + function logout() { + UserHelper::logout(); + $this->_wrap(self::STATUS_OK, array("status" => "OK")); + } + + function isLoggedIn() { + $this->_wrap(self::STATUS_OK, array("status" => $_SESSION["uid"] != '')); + } + + function getUnread() { + $feed_id = clean($_REQUEST["feed_id"]); + $is_cat = clean($_REQUEST["is_cat"]); + + if ($feed_id) { + $this->_wrap(self::STATUS_OK, array("unread" => getFeedUnread($feed_id, $is_cat))); + } else { + $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_global_unread())); + } + } + + /* Method added for ttrss-reader for Android */ + function getCounters() { + $this->_wrap(self::STATUS_OK, Counters::get_all()); + } + + function getFeeds() { + $cat_id = clean($_REQUEST["cat_id"]); + $unread_only = self::_param_to_bool(clean($_REQUEST["unread_only"] ?? 0)); + $limit = (int) clean($_REQUEST["limit"] ?? 0); + $offset = (int) clean($_REQUEST["offset"] ?? 0); + $include_nested = self::_param_to_bool(clean($_REQUEST["include_nested"] ?? false)); + + $feeds = $this->_api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested); + + $this->_wrap(self::STATUS_OK, $feeds); + } + + function getCategories() { + $unread_only = self::_param_to_bool(clean($_REQUEST["unread_only"] ?? false)); + $enable_nested = self::_param_to_bool(clean($_REQUEST["enable_nested"] ?? false)); + $include_empty = self::_param_to_bool(clean($_REQUEST['include_empty'] ?? false)); + + // TODO do not return empty categories, return Uncategorized and standard virtual cats + + if ($enable_nested) + $nested_qpart = "parent_cat IS NULL"; + else + $nested_qpart = "true"; + + $sth = $this->pdo->prepare("SELECT + id, title, order_id, (SELECT COUNT(id) FROM + ttrss_feeds WHERE + ttrss_feed_categories.id IS NOT NULL AND cat_id = ttrss_feed_categories.id) AS num_feeds, + (SELECT COUNT(id) FROM + ttrss_feed_categories AS c2 WHERE + c2.parent_cat = ttrss_feed_categories.id) AS num_cats + FROM ttrss_feed_categories + WHERE $nested_qpart AND owner_uid = ?"); + $sth->execute([$_SESSION['uid']]); + + $cats = array(); + + while ($line = $sth->fetch()) { + if ($include_empty || $line["num_feeds"] > 0 || $line["num_cats"] > 0) { + $unread = getFeedUnread($line["id"], true); + + if ($enable_nested) + $unread += Feeds::_get_cat_children_unread($line["id"]); + + if ($unread || !$unread_only) { + array_push($cats, array("id" => (int) $line["id"], + "title" => $line["title"], + "unread" => (int) $unread, + "order_id" => (int) $line["order_id"], + )); + } + } + } + + foreach (array(-2,-1,0) as $cat_id) { + if ($include_empty || !$this->_is_cat_empty($cat_id)) { + $unread = getFeedUnread($cat_id, true); + + if ($unread || !$unread_only) { + array_push($cats, array("id" => $cat_id, + "title" => Feeds::_get_cat_title($cat_id), + "unread" => (int) $unread)); + } + } + } + + $this->_wrap(self::STATUS_OK, $cats); + } + + function getHeadlines() { + $feed_id = clean($_REQUEST["feed_id"]); + if ($feed_id !== "") { + + if (is_numeric($feed_id)) $feed_id = (int) $feed_id; + + $limit = (int)clean($_REQUEST["limit"]); + + if (!$limit || $limit >= 200) $limit = 200; + + $offset = (int)clean($_REQUEST["skip"]); + $filter = clean($_REQUEST["filter"] ?? ""); + $is_cat = self::_param_to_bool(clean($_REQUEST["is_cat"] ?? false)); + $show_excerpt = self::_param_to_bool(clean($_REQUEST["show_excerpt"] ?? false)); + $show_content = self::_param_to_bool(clean($_REQUEST["show_content"])); + /* all_articles, unread, adaptive, marked, updated */ + $view_mode = clean($_REQUEST["view_mode"] ?? null); + $include_attachments = self::_param_to_bool(clean($_REQUEST["include_attachments"] ?? false)); + $since_id = (int)clean($_REQUEST["since_id"] ?? 0); + $include_nested = self::_param_to_bool(clean($_REQUEST["include_nested"] ?? false)); + $sanitize_content = !isset($_REQUEST["sanitize"]) || + self::_param_to_bool($_REQUEST["sanitize"]); + $force_update = self::_param_to_bool(clean($_REQUEST["force_update"] ?? false)); + $has_sandbox = self::_param_to_bool(clean($_REQUEST["has_sandbox"] ?? false)); + $excerpt_length = (int)clean($_REQUEST["excerpt_length"] ?? 0); + $check_first_id = (int)clean($_REQUEST["check_first_id"] ?? 0); + $include_header = self::_param_to_bool(clean($_REQUEST["include_header"] ?? false)); + + $_SESSION['hasSandbox'] = $has_sandbox; + + list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query(clean($_REQUEST["order_by"] ?? null)); + + /* 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) { + $this->_wrap(self::STATUS_OK, array($headlines_header, $headlines)); + } else { + $this->_wrap(self::STATUS_OK, $headlines); + } + } else { + $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); + } + } + + function updateArticle() { + $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"; + }; + + 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 && $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(array_merge($article_ids, [$_SESSION['uid']])); + + $num_updated = $sth->rowCount(); + + $this->_wrap(self::STATUS_OK, array("status" => "OK", + "updated" => $num_updated)); + + } else { + $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); + } + + } + + function getArticle() { + + $article_ids = explode(",", clean($_REQUEST["article_id"])); + $sanitize_content = !isset($_REQUEST["sanitize"]) || + self::_param_to_bool($_REQUEST["sanitize"]); + + if (count($article_ids) > 0) { + + $article_qmarks = arr_qmarks($article_ids); + + $sth = $this->pdo->prepare("SELECT id,guid,title,link,content,feed_id,comments,int_id, + marked,unread,published,score,note,lang, + ".SUBSTRING_FOR_DATE."(updated,1,16) as updated, + author,(SELECT title FROM ttrss_feeds WHERE id = feed_id) AS feed_title, + (SELECT site_url FROM ttrss_feeds WHERE id = feed_id) AS site_url, + (SELECT hide_images FROM ttrss_feeds WHERE id = feed_id) AS hide_images + FROM ttrss_entries,ttrss_user_entries + WHERE id IN ($article_qmarks) AND ref_id = id AND owner_uid = ?"); + + $sth->execute(array_merge($article_ids, [$_SESSION['uid']])); + + $articles = array(); + + while ($line = $sth->fetch()) { + + $article = array( + "id" => $line["id"], + "guid" => $line["guid"], + "title" => $line["title"], + "link" => $line["link"], + "labels" => Article::_get_labels($line['id']), + "unread" => self::_param_to_bool($line["unread"]), + "marked" => self::_param_to_bool($line["marked"]), + "published" => self::_param_to_bool($line["published"]), + "comments" => $line["comments"], + "author" => $line["author"], + "updated" => (int) strtotime($line["updated"]), + "feed_id" => $line["feed_id"], + "attachments" => Article::_get_enclosures($line['id']), + "score" => (int)$line["score"], + "feed_title" => $line["feed_title"], + "note" => $line["note"], + "lang" => $line["lang"] + ); + + if ($sanitize_content) { + $article["content"] = Sanitizer::sanitize( + $line["content"], + self::_param_to_bool($line['hide_images']), + false, $line["site_url"], false, $line["id"]); + } else { + $article["content"] = $line["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); + + } + + $this->_wrap(self::STATUS_OK, $articles); + // @phpstan-ignore-next-line + } else { + $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); + } + } + + function getConfig() { + $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"); + + $sth = $this->pdo->prepare("SELECT COUNT(*) AS cf FROM + ttrss_feeds WHERE owner_uid = ?"); + $sth->execute([$_SESSION['uid']]); + $row = $sth->fetch(); + + $config["num_feeds"] = $row["cf"]; + + $this->_wrap(self::STATUS_OK, $config); + } + + function updateFeed() { + $feed_id = (int) clean($_REQUEST["feed_id"]); + + if (!ini_get("open_basedir")) { + RSSUtils::update_rss_feed($feed_id); + } + + $this->_wrap(self::STATUS_OK, array("status" => "OK")); + } + + function catchupFeed() { + $feed_id = clean($_REQUEST["feed_id"]); + $is_cat = clean($_REQUEST["is_cat"]); + @$mode = clean($_REQUEST["mode"]); + + if (!in_array($mode, ["all", "1day", "1week", "2week"])) + $mode = "all"; + + Feeds::_catchup($feed_id, $is_cat, $_SESSION["uid"], $mode); + + $this->_wrap(self::STATUS_OK, array("status" => "OK")); + } + + function getPref() { + $pref_name = clean($_REQUEST["pref_name"]); + + $this->_wrap(self::STATUS_OK, array("value" => get_pref($pref_name))); + } + + function getLabels() { + $article_id = (int)clean($_REQUEST['article_id']); + + $rv = array(); + + $sth = $this->pdo->prepare("SELECT id, caption, fg_color, bg_color + FROM ttrss_labels2 + WHERE owner_uid = ? ORDER BY caption"); + $sth->execute([$_SESSION['uid']]); + + if ($article_id) + $article_labels = Article::_get_labels($article_id); + else + $article_labels = array(); + + while ($line = $sth->fetch()) { + + $checked = false; + foreach ($article_labels as $al) { + if (Labels::feed_to_label_id($al[0]) == $line['id']) { + $checked = true; + break; + } + } + + array_push($rv, array( + "id" => (int)Labels::label_to_feed_id($line['id']), + "caption" => $line['caption'], + "fg_color" => $line['fg_color'], + "bg_color" => $line['bg_color'], + "checked" => $checked)); + } + + $this->_wrap(self::STATUS_OK, $rv); + } + + function setArticleLabel() { + + $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($id, $label, $_SESSION["uid"]); + else + Labels::remove_article($id, $label, $_SESSION["uid"]); + + ++$num_updated; + + } + } + + $this->_wrap(self::STATUS_OK, array("status" => "OK", + "updated" => $num_updated)); + + } + + function index($method) { + $plugin = PluginHost::getInstance()->get_api_method(strtolower($method)); + + if ($plugin && method_exists($plugin, $method)) { + $reply = $plugin->$method(); + + $this->_wrap($reply[0], $reply[1]); + + } else { + $this->_wrap(self::STATUS_ERR, array("error" => self::E_UNKNOWN_METHOD, "method" => $method)); + } + } + + function shareToPublished() { + $title = strip_tags(clean($_REQUEST["title"])); + $url = strip_tags(clean($_REQUEST["url"])); + $content = strip_tags(clean($_REQUEST["content"])); + + if (Article::_create_published_article($title, $url, $content, "", $_SESSION["uid"])) { + $this->_wrap(self::STATUS_OK, array("status" => 'OK')); + } else { + $this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED)); + } + } + + private static function _api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested = false) { + + $feeds = array(); + + $pdo = Db::pdo(); + + $limit = (int) $limit; + $offset = (int) $offset; + $cat_id = (int) $cat_id; + + /* Labels */ + + /* API only: -4 All feeds, including virtual feeds */ + if ($cat_id == -4 || $cat_id == -2) { + $counters = Counters::get_labels(); + + foreach (array_values($counters) as $cv) { + + $unread = $cv["counter"]; + + if ($unread || !$unread_only) { + + $row = array( + "id" => (int) $cv["id"], + "title" => $cv["description"], + "unread" => $cv["counter"], + "cat_id" => -2, + ); + + array_push($feeds, $row); + } + } + } + + /* Virtual feeds */ + + if ($cat_id == -4 || $cat_id == -1) { + foreach (array(-1, -2, -3, -4, -6, 0) as $i) { + $unread = getFeedUnread($i); + + if ($unread || !$unread_only) { + $title = Feeds::_get_title($i); + + $row = array( + "id" => $i, + "title" => $title, + "unread" => $unread, + "cat_id" => -1, + ); + array_push($feeds, $row); + } + + } + } + + /* Child cats */ + + if ($include_nested && $cat_id) { + $sth = $pdo->prepare("SELECT + id, title, order_id FROM ttrss_feed_categories + WHERE parent_cat = ? AND owner_uid = ? ORDER BY order_id, title"); + + $sth->execute([$cat_id, $_SESSION['uid']]); + + while ($line = $sth->fetch()) { + $unread = getFeedUnread($line["id"], true) + + Feeds::_get_cat_children_unread($line["id"]); + + if ($unread || !$unread_only) { + $row = array( + "id" => (int) $line["id"], + "title" => $line["title"], + "unread" => $unread, + "is_cat" => true, + "order_id" => (int) $line["order_id"] + ); + array_push($feeds, $row); + } + } + } + + /* Real feeds */ + + if ($limit) { + $limit_qpart = "LIMIT $limit OFFSET $offset"; + } else { + $limit_qpart = ""; + } + + /* API only: -3 All feeds, excluding virtual feeds (e.g. Labels and such) */ + if ($cat_id == -4 || $cat_id == -3) { + $sth = $pdo->prepare("SELECT + id, feed_url, cat_id, title, order_id, ". + SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated + FROM ttrss_feeds WHERE owner_uid = ? + ORDER BY order_id, title " . $limit_qpart); + $sth->execute([$_SESSION['uid']]); + + } else { + + $sth = $pdo->prepare("SELECT + id, feed_url, cat_id, title, order_id, ". + SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated + FROM ttrss_feeds WHERE + (cat_id = :cat OR (:cat = 0 AND cat_id IS NULL)) + AND owner_uid = :uid + ORDER BY order_id, title " . $limit_qpart); + $sth->execute([":uid" => $_SESSION['uid'], ":cat" => $cat_id]); + } + + while ($line = $sth->fetch()) { + + $unread = getFeedUnread($line["id"]); + + $has_icon = Feeds::_has_icon($line['id']); + + if ($unread || !$unread_only) { + + $row = array( + "feed_url" => $line["feed_url"], + "title" => $line["title"], + "id" => (int)$line["id"], + "unread" => (int)$unread, + "has_icon" => $has_icon, + "cat_id" => (int)$line["cat_id"], + "last_updated" => (int) strtotime($line["last_updated"]), + "order_id" => (int) $line["order_id"], + ); + + array_push($feeds, $row); + } + } + + return $feeds; + } + + private static function _api_get_headlines($feed_id, $limit, $offset, + $filter, $is_cat, $show_excerpt, $show_content, $view_mode, $order, + $include_attachments, $since_id, + $search = "", $include_nested = false, $sanitize_content = true, + $force_update = false, $excerpt_length = 100, $check_first_id = false, $skip_first_id_check = false) { + + $pdo = Db::pdo(); + + if ($force_update && $feed_id > 0 && is_numeric($feed_id)) { + // Update the feed if required with some basic flood control + + $sth = $pdo->prepare( + "SELECT cache_images,".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated + FROM ttrss_feeds WHERE id = ?"); + $sth->execute([$feed_id]); + + if ($row = $sth->fetch()) { + $last_updated = strtotime($row["last_updated"]); + $cache_images = self::_param_to_bool($row["cache_images"]); + + if (!$cache_images && time() - $last_updated > 120) { + RSSUtils::update_rss_feed($feed_id, true); + } else { + $sth = $pdo->prepare("UPDATE ttrss_feeds SET last_updated = '1970-01-01', last_update_started = '1970-01-01' + WHERE id = ?"); + $sth->execute([$feed_id]); + } + } + } + + $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']), + false, $line["site_url"], false, $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"]; + + 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"]); + + $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() { + $feed_id = (int) clean($_REQUEST["feed_id"]); + + $sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE + id = ? AND owner_uid = ?"); + $sth->execute([$feed_id, $_SESSION['uid']]); + + if ($row = $sth->fetch()) { + Pref_Feeds::remove_feed($feed_id, $_SESSION["uid"]); + $this->_wrap(self::STATUS_OK, array("status" => "OK")); + } else { + $this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED)); + } + } + + function subscribeToFeed() { + $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); + + $this->_wrap(self::STATUS_OK, array("status" => $rc)); + } else { + $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); + } + } + + function getFeedTree() { + $include_empty = self::_param_to_bool(clean($_REQUEST['include_empty'])); + + $pf = new Pref_Feeds($_REQUEST); + + $_REQUEST['mode'] = 2; + $_REQUEST['force_show_empty'] = $include_empty; + + $this->_wrap(self::STATUS_OK, + array("categories" => $pf->_makefeedtree())); + } + + // only works for labels or uncategorized for the time being + private function _is_cat_empty($id) { + + if ($id == -2) { + $sth = $this->pdo->prepare("SELECT COUNT(id) AS count FROM ttrss_labels2 + WHERE owner_uid = ?"); + $sth->execute([$_SESSION['uid']]); + $row = $sth->fetch(); + + return $row["count"] == 0; + + } else if ($id == 0) { + $sth = $this->pdo->prepare("SELECT COUNT(id) AS count FROM ttrss_feeds + WHERE cat_id IS NULL AND owner_uid = ?"); + $sth->execute([$_SESSION['uid']]); + $row = $sth->fetch(); + + return $row["count"] == 0; + + } + + return false; + } + + +} diff --git a/classes/handler/article.php b/classes/handler/article.php new file mode 100644 index 000000000..269fdf75d --- /dev/null +++ b/classes/handler/article.php @@ -0,0 +1,166 @@ +<?php +class Handler_Article extends Handler_Protected { + function redirect() { + $article = ORM::for_table('ttrss_entries') + ->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."; + } + + function printArticleTags() { + $id = (int) clean($_REQUEST['id'] ?? 0); + + print json_encode(["id" => $id, + "tags" => Article::_get_tags($id)]); + } + + function setScore() { + $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(array_merge([$score], $ids, [$_SESSION['uid']])); + + print json_encode(["id" => $ids, "score" => $score]); + } + + function setArticleTags() { + + $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" => Article::_get_tags($id)]); + } + + + /*function completeTags() { + $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%"]); + + print "<ul>"; + while ($line = $sth->fetch()) { + print "<li>" . $line["tag_name"] . "</li>"; + } + print "</ul>"; + }*/ + + function assigntolabel() { + return $this->_label_ops(true); + } + + function removefromlabel() { + return $this->_label_ops(false); + } + + private function _label_ops($assign) { + $reply = array(); + + $ids = explode(",", clean($_REQUEST["ids"])); + $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" => Article::_get_labels($id)]); + } + } + + $reply["message"] = "UPDATE_COUNTERS"; + + print json_encode($reply); + } + + function getmetadatabyid() { + $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([]); + } + } +} diff --git a/classes/handler/feeds.php b/classes/handler/feeds.php new file mode 100644 index 000000000..0d262a554 --- /dev/null +++ b/classes/handler/feeds.php @@ -0,0 +1,305 @@ +<?php +require_once "colors.php"; + +class Handler_Feeds extends Handler_Protected { + function csrf_ignore($method) { + $csrf_ignored = array("index"); + + return array_search($method, $csrf_ignored) !== false; + } + + function catchupAll() { + $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() { + $reply = array(); + + $feed = $_REQUEST["feed"]; + $method = $_REQUEST["m"] ?? ""; + $view_mode = $_REQUEST["view_mode"] ?? ""; + $limit = 30; + $cat_view = $_REQUEST["cat"] == "true"; + $next_unread_feed = $_REQUEST["nuf"] ?? 0; + $offset = $_REQUEST["skip"] ?? 0; + $order_by = $_REQUEST["order_by"] ?? ""; + $check_first_id = $_REQUEST["fid"] ?? 0; + + if (is_numeric($feed)) $feed = (int) $feed; + + /* Feed -5 is a special case: it is used to display auxiliary information + * when there's nothing to load - e.g. no stuff in fresh feed */ + + if ($feed == -5) { + 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) = Feeds::_order_to_override_query($order_by); + + $ret = Feeds::_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); + } + + private function _generate_dashboard_feed() { + $reply = array(); + + $reply['headlines']['id'] = -5; + $reply['headlines']['is_cat'] = false; + + $reply['headlines']['toolbar'] = ''; + + $reply['headlines']['content'] = "<div class='whiteBox'>".__('No feed selected.'); + + $reply['headlines']['content'] .= "<p><span class=\"text-muted\">"; + + $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); + + $sth = $this->pdo->prepare("SELECT COUNT(id) AS num_errors + FROM ttrss_feeds WHERE last_error != '' AND owner_uid = ?"); + $sth->execute([$_SESSION['uid']]); + $row = $sth->fetch(); + + $num_errors = $row["num_errors"]; + + if ($num_errors > 0) { + $reply['headlines']['content'] .= "<br/>"; + $reply['headlines']['content'] .= "<a class=\"text-muted\" href=\"#\" onclick=\"CommonDialogs.showFeedsWithErrors()\">". + __('Some feeds have update errors (click for details)')."</a>"; + } + $reply['headlines']['content'] .= "</span></p>"; + + $reply['headlines-info'] = array("count" => 0, + "unread" => 0, + "disable_cache" => true); + + return $reply; + } + + private function _generate_error_feed($error) { + $reply = array(); + + $reply['headlines']['id'] = -7; + $reply['headlines']['is_cat'] = false; + + $reply['headlines']['toolbar'] = ''; + $reply['headlines']['content'] = "<div class='whiteBox'>". $error . "</div>"; + + $reply['headlines-info'] = array("count" => 0, + "unread" => 0, + "disable_cache" => true); + + return $reply; + } + + function subscribeToFeed() { + print json_encode([ + "cat_select" => \Controls\select_feeds_cats("cat") + ]); + } + + function search() { + 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 updatedebugger() { + header("Content-type: text/html"); + + $xdebug = isset($_REQUEST["xdebug"]) ? (int)$_REQUEST["xdebug"] : 1; + + Debug::set_enabled(true); + Debug::set_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; + } + ?> + <!DOCTYPE html> + <html> + <head> + <title>Feed Debugger</title> + <style type='text/css'> + @media (prefers-color-scheme: dark) { + body { + background : #222; + } + } + body.css_loading * { + display : none; + } + </style> + <script> + dojoConfig = { + async: true, + cacheBust: "<?= get_scripts_timestamp(); ?>", + packages: [ + { name: "fox", location: "../../js" }, + ] + }; + </script> + <?= javascript_tag("js/utility.js") ?> + <?= javascript_tag("js/common.js") ?> + <?= javascript_tag("lib/dojo/dojo.js") ?> + <?= javascript_tag("lib/dojo/tt-rss-layer.js") ?> + </head> + <body class="flat ttrss_utility feed_debugger css_loading"> + <script type="text/javascript"> + require(['dojo/parser', "dojo/ready", 'dijit/form/Button','dijit/form/CheckBox', 'fox/form/Select', 'dijit/form/Form', + 'dijit/form/Select','dijit/form/TextBox','dijit/form/ValidationTextBox'],function(parser, ready){ + ready(function() { + parser.parse(); + }); + }); + </script> + + <div class="container"> + <h1>Feed Debugger: <?= "$feed_id: " . $this->_get_title($feed_id) ?></h1> + <div class="content"> + <form method="post" action="" dojoType="dijit.form.Form"> + <?= \Controls\hidden_tag("op", "feeds") ?> + <?= \Controls\hidden_tag("method", "updatedebugger") ?> + <?= \Controls\hidden_tag("csrf_token", $csrf_token) ?> + <?= \Controls\hidden_tag("action", "do_update") ?> + <?= \Controls\hidden_tag("feed_id", (string)$feed_id) ?> + + <fieldset> + <label> + <?= \Controls\select_hash("xdebug", $xdebug, + [Debug::$LOG_VERBOSE => "LOG_VERBOSE", Debug::$LOG_EXTENDED => "LOG_EXTENDED"]); + ?></label> + </fieldset> + + <fieldset> + <label class="checkbox"><?= \Controls\checkbox_tag("force_refetch", isset($_REQUEST["force_refetch"])) ?> Force refetch</label> + </fieldset> + + <fieldset class="narrow"> + <label class="checkbox"><?= \Controls\checkbox_tag("force_rehash", isset($_REQUEST["force_rehash"])) ?> Force rehash</label> + </fieldset> + + <?= \Controls\submit_tag("Continue") ?> + </form> + + <hr> + + <pre><?php + + if ($do_update) { + RSSUtils::update_rss_feed($feed_id, true); + } + + ?></pre> + </div> + </div> + </body> + </html> + <?php + + } + + function add() { + $feed = clean($_REQUEST['feed']); + $cat = 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)); + } + +} + diff --git a/classes/handler/opml.php b/classes/handler/opml.php new file mode 100644 index 000000000..a07e8ebff --- /dev/null +++ b/classes/handler/opml.php @@ -0,0 +1,661 @@ +<?php +class Handler_OPML extends Handler_Protected { + + function csrf_ignore($method) { + $csrf_ignored = array("export", "import"); + + return array_search($method, $csrf_ignored) !== false; + } + + function export() { + $output_name = sprintf("tt-rss_%s_%s.opml", $_SESSION["name"], date("Y-m-d")); + $include_settings = $_REQUEST["include_settings"] == "1"; + $owner_uid = $_SESSION["uid"]; + + $rc = $this->opml_export($output_name, $owner_uid, false, $include_settings); + + return $rc; + } + + function import() { + $owner_uid = $_SESSION["uid"]; + + header('Content-Type: text/html; charset=utf-8'); + + print "<html> + <head> + ".stylesheet_tag("themes/light.css")." + <title>".__("OPML Utility")."</title> + <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\"/> + </head> + <body class='claro ttrss_utility'> + <h1>".__('OPML Utility')."</h1><div class='content'>"; + + Feeds::_add_cat("Imported feeds", $owner_uid); + + $this->opml_notice(__("Importing OPML...")); + + $this->opml_import($owner_uid); + + print "<br><form method=\"GET\" action=\"prefs.php\"> + <input type=\"submit\" value=\"".__("Return to preferences")."\"> + </form>"; + + print "</div></body></html>"; + + + } + + // Export + + private function opml_export_category($owner_uid, $cat_id, $hide_private_feeds = false, $include_settings = true) { + + $cat_id = (int) $cat_id; + + 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 .= "<outline text=\"$cat_title\" $ttrss_specific_qpart>\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 .= "<outline type=\"rss\" text=\"$title\" xmlUrl=\"$url\" $ttrss_specific_qpart $html_url_qpart/>\n"; + } + + if ($cat_title) $out .= "</outline>\n"; + + return $out; + } + + function opml_export($filename, $owner_uid, $hide_private_feeds = false, $include_settings = true, $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 = "<?xml version=\"1.0\" encoding=\"utf-8\"?".">"; + + $out .= "<opml version=\"1.0\">"; + $out .= "<head> + <dateCreated>" . date("r", time()) . "</dateCreated> + <title>Tiny Tiny RSS Feed Export</title> + </head>"; + $out .= "<body>"; + + $out .= $this->opml_export_category($owner_uid, 0, $hide_private_feeds, $include_settings); + + # export tt-rss settings + + if ($include_settings) { + $out .= "<outline text=\"tt-rss-prefs\" schema-version=\"".Db_Updater::SCHEMA_VERSION."\">"; + + $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 .= "<outline pref-name=\"$name\" value=\"$value\"/>"; + } + + $out .= "</outline>"; + + $out .= "<outline text=\"tt-rss-labels\" schema-version=\"".Db_Updater::SCHEMA_VERSION."\">"; + + $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 .= "<outline label-name=\"$name\" label-fg-color=\"$fg_color\" label-bg-color=\"$bg_color\"/>"; + + } + + $out .= "</outline>"; + + $out .= "<outline text=\"tt-rss-filters\" schema-version=\"".Db_Updater::SCHEMA_VERSION."\">"; + + $sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2 + WHERE owner_uid = ? ORDER BY id"); + $sth->execute([$owner_uid]); + + while ($line = $sth->fetch()) { + $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 .= "<outline filter-type=\"2\"><![CDATA[$filter]]></outline>"; + + } + + + $out .= "</outline>"; + } + + $out .= "</body></opml>"; + + // 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; + else + print $res; + } + + // Import + + private function opml_import_feed($node, $cat_id, $owner_uid) { + $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)); + + 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)); + } + } + } + + private function opml_import_label($node, $owner_uid) { + $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, $_SESSION['uid'])) { + $this->opml_notice(T_sprintf("Adding label %s", htmlspecialchars($label_name))); + Labels::create($label_name, $fg_color, $bg_color, $owner_uid); + } else { + $this->opml_notice(T_sprintf("Duplicate label: %s", htmlspecialchars($label_name))); + } + } + } + + private function opml_import_preference($node) { + $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)); + + set_pref($pref_name, $pref_value); + } + } + + private function opml_import_filter($node) { + $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, $_SESSION['uid']]); + + $sth = $this->pdo->prepare("SELECT MAX(id) AS id FROM ttrss_filters2 WHERE + owner_uid = ?"); + $sth->execute([$_SESSION['uid']]); + + $row = $sth->fetch(); + $filter_id = $row['id']; + + if ($filter_id) { + $this->opml_notice(T_sprintf("Adding filter %s...", $title)); + + foreach ($filter["rules"] as $rule) { + $feed_id = null; + $cat_id = null; + + if ($rule["match"]) { + + $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 { + + 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 { + + 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($doc, $root_node, $owner_uid, $parent_id) { + $default_cat_id = (int) $this->get_feed_category('Imported feeds', false); + + 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, $parent_id); + + if ($cat_id === false) { + $order_id = (int) $root_node->attributes->getNamedItem('ttrssSortOrder')->nodeValue; + + Feeds::_add_cat($cat_title, $_SESSION['uid'], $parent_id ? $parent_id : null, (int)$order_id); + $cat_id = $this->get_feed_category($cat_title, $parent_id); + } + + } 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"))); + + foreach ($outlines as $node) { + if ($node->hasAttributes() && strtolower($node->tagName) == "outline") { + $attrs = $node->attributes; + $node_cat_title = $attrs->getNamedItem('text')->nodeValue; + + if (!$node_cat_title) + $node_cat_title = $attrs->getNamedItem('title')->nodeValue; + + $node_feed_url = $attrs->getNamedItem('xmlUrl')->nodeValue; + + if ($node_cat_title && !$node_feed_url) { + $this->opml_import_category($doc, $node, $owner_uid, $cat_id); + } 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); + break; + case "tt-rss-labels": + $this->opml_import_label($node, $owner_uid); + break; + case "tt-rss-filters": + $this->opml_import_filter($node); + break; + default: + $this->opml_import_feed($node, $dst_cat_id, $owner_uid); + } + } + } + } + } + + function opml_import($owner_uid) { + if (!$owner_uid) return; + + $doc = false; + + if ($_FILES['opml_file']['error'] != 0) { + print_error(T_sprintf("Upload failed with error code %d", + $_FILES['opml_file']['error'])); + return; + } + + if (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; + } + } else { + print_error(__('Error: please upload OPML file.')); + return; + } + + $loaded = false; + + if (is_file($tmp_file)) { + $doc = new DOMDocument(); + libxml_disable_entity_loader(false); + $loaded = $doc->load($tmp_file); + libxml_disable_entity_loader(true); + unlink($tmp_file); + } else if (empty($doc)) { + print_error(__('Error: unable to find moved OPML file.')); + return; + } + + if ($loaded) { + $this->pdo->beginTransaction(); + $this->opml_import_category($doc, false, $owner_uid, false); + $this->pdo->commit(); + } else { + print_error(__('Error while parsing document.')); + } + } + + private function opml_notice($msg) { + print "$msg<br/>"; + } + + static function get_publish_url(){ + return Config::get_self_url() . + "/public.php?op=publishOpml&key=" . + Feeds::_get_access_key('OPML:Publish', false, $_SESSION["uid"]); + } + + function get_feed_category($feed_cat, $parent_cat_id = false) { + + $parent_cat_id = (int) $parent_cat_id; + + $sth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories + WHERE title = :title + AND (parent_cat = :parent OR (:parent = 0 AND parent_cat IS NULL)) + AND owner_uid = :uid"); + + $sth->execute([':title' => $feed_cat, ':parent' => $parent_cat_id, ':uid' => $_SESSION['uid']]); + + if ($row = $sth->fetch()) { + return $row['id']; + } else { + return false; + } + } + + +} diff --git a/classes/handler/pluginhandler.php b/classes/handler/pluginhandler.php new file mode 100644 index 000000000..1bb88c149 --- /dev/null +++ b/classes/handler/pluginhandler.php @@ -0,0 +1,29 @@ +<?php +class Handler_PluginHandler extends Handler_Protected { + function csrf_ignore($method) { + return true; + } + + function catchall($method) { + $plugin_name = clean($_REQUEST["plugin"]); + $plugin = PluginHost::getInstance()->get_plugin($plugin_name); + $csrf_token = ($_POST["csrf_token"] ?? ""); + + if ($plugin) { + if (method_exists($plugin, $method)) { + if (validate_csrf($csrf_token) || $plugin->csrf_ignore($method)) { + $plugin->$method(); + } else { + user_error("Rejected ${plugin_name}->${method}(): invalid CSRF token.", E_USER_WARNING); + print Errors::to_json(Errors::E_UNAUTHORIZED); + } + } else { + user_error("Rejected ${plugin_name}->${method}(): unknown method.", E_USER_WARNING); + print Errors::to_json(Errors::E_UNKNOWN_METHOD); + } + } else { + user_error("Rejected ${plugin_name}->${method}(): unknown plugin.", E_USER_WARNING); + print Errors::to_json(Errors::E_UNKNOWN_PLUGIN); + } + } +} diff --git a/classes/handler/rpc.php b/classes/handler/rpc.php new file mode 100644 index 000000000..6fec9f56f --- /dev/null +++ b/classes/handler/rpc.php @@ -0,0 +1,786 @@ +<?php +class Handler_RPC extends Handler_Protected { + + /*function csrf_ignore($method) { + $csrf_ignored = array("completelabels"); + + return array_search($method, $csrf_ignored) !== false; + }*/ + + private function _translations_as_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() { + $key = clean($_REQUEST["key"]); + set_pref($key, !get_pref($key)); + $value = get_pref($key); + + print json_encode(array("param" =>$key, "value" => $value)); + } + + function setpref() { + // 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() { + $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() { + $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(array_merge($ids, [$_SESSION['uid']])); + + Article::_purge_orphans(); + + print json_encode(array("message" => "UPDATE_COUNTERS")); + } + + function publ() { + $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() { + $reply = [ + 'runtime-info' => $this->_make_runtime_info() + ]; + + print json_encode($reply); + } + + function getAllCounters() { + @$seq = (int) $_REQUEST['seq']; + + $feed_id_count = (int)$_REQUEST["feed_id_count"]; + $label_id_count = (int)$_REQUEST["label_id_count"]; + + // 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 + ]; + + print json_encode($reply); + } + + /* GET["cmode"] = 0 - mark as read, 1 - as unread, 2 - toggle */ + function catchupSelected() { + $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() { + $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() { + $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() { + $_SESSION["hasSandbox"] = clean($_REQUEST["hasSandbox"]) === "true"; + $_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 (Db_Updater::is_update_required()) { + $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); + } + } + + /*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 "<ul>"; + while ($line = $sth->fetch()) { + print "<li>" . $line["caption"] . "</li>"; + } + print "</ul>"; + }*/ + + function catchupFeed() { + $feed_id = clean($_REQUEST['feed_id']); + $is_cat = clean($_REQUEST['is_cat']) == "true"; + $mode = clean($_REQUEST['mode'] ?? ''); + $search_query = clean($_REQUEST['search_query']); + $search_lang = clean($_REQUEST['search_lang']); + + Feeds::_catchup($feed_id, $is_cat, false, $mode, [$search_query, $search_lang]); + + // 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() { + $wide = (int) clean($_REQUEST["wide"]); + + set_pref(Prefs::WIDESCREEN_MODE, $wide); + + print json_encode(["wide" => $wide]); + } + + static function updaterandomfeed_real() { + + $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 + $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")); + } + + } + + function updaterandomfeed() { + self::updaterandomfeed_real(); + } + + private function markArticlesById($ids, $cmode) { + + $ids_qmarks = arr_qmarks($ids); + + if ($cmode == 0) { + $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET + marked = false, last_marked = NOW() + WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); + } else if ($cmode == 1) { + $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(array_merge($ids, [$_SESSION['uid']])); + } + + private function publishArticlesById($ids, $cmode) { + + $ids_qmarks = arr_qmarks($ids); + + if ($cmode == 0) { + $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET + published = false, last_published = NOW() + WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); + } else if ($cmode == 1) { + $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(array_merge($ids, [$_SESSION['uid']])); + } + + function log() { + $msg = clean($_REQUEST['msg']); + $file = basename(clean($_REQUEST['file'])); + $line = (int) clean($_REQUEST['line']); + $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")); + } + } + + function checkforupdates() { + $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"] >= 10 && $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(); + } + + print json_encode($rv); + } + + private function _make_init_params() { + $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] 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(Config::ICONS_URL); + $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"]; + $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["labels"] = Labels::get_all($_SESSION["uid"]); + + return $params; + } + + private function image_to_base64($filename) { + if (file_exists($filename)) { + $ext = pathinfo($filename, PATHINFO_EXTENSION); + + return "data:image/$ext;base64," . base64_encode((string)file_get_contents($filename)); + } else { + return ""; + } + } + + static function _make_runtime_info() { + $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'] >= 10) { + 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 '%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; + } + } + } + + return $data; + } + + static function get_hotkeys_info() { + $hotkeys = array( + __("Navigation") => array( + "next_feed" => __("Open next feed"), + "prev_feed" => __("Open previous 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_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 + static function get_hotkeys_map() { + $hotkeys = array( + "k" => "next_feed", + "j" => "prev_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 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 D" => "feed_debug_update", + "f G" => "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() { + $info = self::get_hotkeys_info(); + $imap = self::get_hotkeys_map(); + $omap = array(); + + foreach ($imap[1] as $sequence => $action) { + if (!isset($omap[$action])) $omap[$action] = array(); + + array_push($omap[$action], $sequence); + } + + ?> + <ul class='panel panel-scrollable hotkeys-help' style='height : 300px'> + <?php + + foreach ($info as $section => $hotkeys) { + ?> + <li><h3><?= $section ?></h3></li> + <?php + + foreach ($hotkeys as $action => $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); + } + + ?> + <li> + <div class='hk'><code><?= $sequence ?></code></div> + <div class='desc'><?= $description ?></div> + </li> + <?php + } + } + } + } + ?> + </ul> + <footer class='text-center'> + <?= \Controls\submit_tag(__('Close this window')) ?> + </footer> + <?php + } +} |