diff options
Diffstat (limited to 'classes')
-rwxr-xr-x | classes/api.php | 96 | ||||
-rwxr-xr-x | classes/article.php | 26 | ||||
-rw-r--r-- | classes/backend.php | 86 | ||||
-rw-r--r-- | classes/counters.php | 22 | ||||
-rwxr-xr-x | classes/db.php | 9 | ||||
-rw-r--r-- | classes/debug.php | 26 | ||||
-rw-r--r-- | classes/digest.php | 14 | ||||
-rw-r--r-- | classes/diskcache.php | 307 | ||||
-rwxr-xr-x | classes/feeds.php | 209 | ||||
-rw-r--r-- | classes/handler.php | 4 | ||||
-rwxr-xr-x | classes/handler/public.php | 100 | ||||
-rw-r--r-- | classes/labels.php | 16 | ||||
-rwxr-xr-x | classes/logger.php | 4 | ||||
-rw-r--r-- | classes/mailer.php | 2 | ||||
-rw-r--r-- | classes/opml.php | 42 | ||||
-rwxr-xr-x | classes/pluginhost.php | 31 | ||||
-rwxr-xr-x | classes/pref/feeds.php | 37 | ||||
-rwxr-xr-x | classes/pref/filters.php | 57 | ||||
-rw-r--r-- | classes/pref/prefs.php | 126 | ||||
-rw-r--r-- | classes/pref/system.php | 7 | ||||
-rw-r--r-- | classes/pref/users.php | 33 | ||||
-rwxr-xr-x | classes/rpc.php | 316 | ||||
-rwxr-xr-x | classes/rssutils.php | 440 | ||||
-rw-r--r-- | classes/sanitizer.php | 217 | ||||
-rw-r--r-- | classes/templator.php | 21 | ||||
-rw-r--r-- | classes/timehelper.php | 88 | ||||
-rw-r--r-- | classes/urlhelper.php | 489 | ||||
-rw-r--r-- | classes/userhelper.php | 141 |
28 files changed, 2330 insertions, 636 deletions
diff --git a/classes/api.php b/classes/api.php index 339e9eef1..3bba4fa8d 100755 --- a/classes/api.php +++ b/classes/api.php @@ -1,7 +1,7 @@ <?php class API extends Handler { - const API_LEVEL = 14; + const API_LEVEL = 15; const STATUS_OK = 0; const STATUS_ERR = 1; @@ -74,10 +74,10 @@ class API extends Handler { } if (get_pref("ENABLE_API_ACCESS", $uid)) { - if (authenticate_user($login, $password, false, Auth_Base::AUTH_SERVICE_API)) { // try login with normal password + if (UserHelper::authenticate($login, $password, false, Auth_Base::AUTH_SERVICE_API)) { // try login with normal password $this->wrap(self::STATUS_OK, array("session_id" => session_id(), "api_level" => self::API_LEVEL)); - } else if (authenticate_user($login, $password_base64, false, Auth_Base::AUTH_SERVICE_API)) { // else try with base64_decoded password + } else if (UserHelper::authenticate($login, $password_base64, false, Auth_Base::AUTH_SERVICE_API)) { // else try with base64_decoded password $this->wrap(self::STATUS_OK, array("session_id" => session_id(), "api_level" => self::API_LEVEL)); } else { // else we are not logged in @@ -91,7 +91,7 @@ class API extends Handler { } function logout() { - logout_user(); + Pref_Users::logout_user(); $this->wrap(self::STATUS_OK, array("status" => "OK")); } @@ -117,10 +117,10 @@ class API extends Handler { function getFeeds() { $cat_id = clean($_REQUEST["cat_id"]); - $unread_only = API::param_to_bool(clean($_REQUEST["unread_only"])); + $unread_only = self::param_to_bool(clean($_REQUEST["unread_only"])); $limit = (int) clean($_REQUEST["limit"]); $offset = (int) clean($_REQUEST["offset"]); - $include_nested = API::param_to_bool(clean($_REQUEST["include_nested"])); + $include_nested = self::param_to_bool(clean($_REQUEST["include_nested"])); $feeds = $this->api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested); @@ -128,9 +128,9 @@ class API extends Handler { } function getCategories() { - $unread_only = API::param_to_bool(clean($_REQUEST["unread_only"])); - $enable_nested = API::param_to_bool(clean($_REQUEST["enable_nested"])); - $include_empty = API::param_to_bool(clean($_REQUEST['include_empty'])); + $unread_only = self::param_to_bool(clean($_REQUEST["unread_only"])); + $enable_nested = self::param_to_bool(clean($_REQUEST["enable_nested"])); + $include_empty = self::param_to_bool(clean($_REQUEST['include_empty'])); // TODO do not return empty categories, return Uncategorized and standard virtual cats @@ -160,9 +160,9 @@ class API extends Handler { $unread += Feeds::getCategoryChildrenUnread($line["id"]); if ($unread || !$unread_only) { - array_push($cats, array("id" => $line["id"], + array_push($cats, array("id" => (int) $line["id"], "title" => $line["title"], - "unread" => $unread, + "unread" => (int) $unread, "order_id" => (int) $line["order_id"], )); } @@ -174,9 +174,9 @@ class API extends Handler { $unread = getFeedUnread($cat_id, true); if ($unread || !$unread_only) { - array_push($cats, array("id" => $cat_id, + array_push($cats, array("id" => (int) $cat_id, "title" => Feeds::getCategoryTitle($cat_id), - "unread" => $unread)); + "unread" => (int) $unread)); } } } @@ -196,39 +196,25 @@ class API extends Handler { $offset = (int)clean($_REQUEST["skip"]); $filter = clean($_REQUEST["filter"]); - $is_cat = API::param_to_bool(clean($_REQUEST["is_cat"])); - $show_excerpt = API::param_to_bool(clean($_REQUEST["show_excerpt"])); - $show_content = API::param_to_bool(clean($_REQUEST["show_content"])); + $is_cat = self::param_to_bool(clean($_REQUEST["is_cat"])); + $show_excerpt = self::param_to_bool(clean($_REQUEST["show_excerpt"])); + $show_content = self::param_to_bool(clean($_REQUEST["show_content"])); /* all_articles, unread, adaptive, marked, updated */ $view_mode = clean($_REQUEST["view_mode"]); - $include_attachments = API::param_to_bool(clean($_REQUEST["include_attachments"])); + $include_attachments = self::param_to_bool(clean($_REQUEST["include_attachments"])); $since_id = (int)clean($_REQUEST["since_id"]); - $include_nested = API::param_to_bool(clean($_REQUEST["include_nested"])); + $include_nested = self::param_to_bool(clean($_REQUEST["include_nested"])); $sanitize_content = !isset($_REQUEST["sanitize"]) || - API::param_to_bool($_REQUEST["sanitize"]); - $force_update = API::param_to_bool(clean($_REQUEST["force_update"])); - $has_sandbox = API::param_to_bool(clean($_REQUEST["has_sandbox"])); + self::param_to_bool($_REQUEST["sanitize"]); + $force_update = self::param_to_bool(clean($_REQUEST["force_update"])); + $has_sandbox = self::param_to_bool(clean($_REQUEST["has_sandbox"])); $excerpt_length = (int)clean($_REQUEST["excerpt_length"]); $check_first_id = (int)clean($_REQUEST["check_first_id"]); - $include_header = API::param_to_bool(clean($_REQUEST["include_header"])); + $include_header = self::param_to_bool(clean($_REQUEST["include_header"])); $_SESSION['hasSandbox'] = $has_sandbox; - $skip_first_id_check = false; - - $override_order = false; - switch (clean($_REQUEST["order_by"])) { - case "title": - $override_order = "ttrss_entries.title, date_entered, updated"; - break; - case "date_reverse": - $override_order = "score DESC, date_entered, updated"; - $skip_first_id_check = true; - break; - case "feed_dates": - $override_order = "updated DESC"; - break; - } + list($override_order, $skip_first_id_check) = Feeds::order_to_override_query(clean($_REQUEST["order_by"])); /* do not rely on params below */ @@ -313,7 +299,7 @@ class API extends Handler { $article_ids = explode(",", clean($_REQUEST["article_id"])); $sanitize_content = !isset($_REQUEST["sanitize"]) || - API::param_to_bool($_REQUEST["sanitize"]); + self::param_to_bool($_REQUEST["sanitize"]); if ($article_ids) { @@ -342,9 +328,9 @@ class API extends Handler { "title" => $line["title"], "link" => $line["link"], "labels" => Article::get_article_labels($line['id']), - "unread" => API::param_to_bool($line["unread"]), - "marked" => API::param_to_bool($line["marked"]), - "published" => API::param_to_bool($line["published"]), + "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"]), @@ -357,9 +343,9 @@ class API extends Handler { ); if ($sanitize_content) { - $article["content"] = sanitize( + $article["content"] = Sanitizer::sanitize( $line["content"], - API::param_to_bool($line['hide_images']), + self::param_to_bool($line['hide_images']), false, $line["site_url"], false, $line["id"]); } else { $article["content"] = $line["content"]; @@ -411,8 +397,12 @@ class API extends Handler { 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($feed_id, $is_cat); + Feeds::catchup_feed($feed_id, $is_cat, $_SESSION["uid"], $mode); $this->wrap(self::STATUS_OK, array("status" => "OK")); } @@ -463,7 +453,7 @@ class API extends Handler { $article_ids = explode(",", clean($_REQUEST["article_ids"])); $label_id = (int) clean($_REQUEST['label_id']); - $assign = API::param_to_bool(clean($_REQUEST['assign'])); + $assign = self::param_to_bool(clean($_REQUEST['assign'])); $label = Labels::find_caption(Labels::feed_to_label_id($label_id), $_SESSION["uid"]); @@ -670,7 +660,7 @@ class API extends Handler { if ($row = $sth->fetch()) { $last_updated = strtotime($row["last_updated"]); - $cache_images = API::param_to_bool($row["cache_images"]); + $cache_images = self::param_to_bool($row["cache_images"]); if (!$cache_images && time() - $last_updated > 120) { RSSUtils::update_rss_feed($feed_id, true); @@ -740,9 +730,9 @@ class API extends Handler { $headline_row = array( "id" => (int)$line["id"], "guid" => $line["guid"], - "unread" => API::param_to_bool($line["unread"]), - "marked" => API::param_to_bool($line["marked"]), - "published" => API::param_to_bool($line["published"]), + "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"], @@ -762,9 +752,9 @@ class API extends Handler { if ($show_content) { if ($sanitize_content) { - $headline_row["content"] = sanitize( + $headline_row["content"] = Sanitizer::sanitize( $line["content"], - API::param_to_bool($line['hide_images']), + self::param_to_bool($line['hide_images']), false, $line["site_url"], false, $line["id"]); } else { $headline_row["content"] = $line["content"]; @@ -782,7 +772,7 @@ class API extends Handler { $headline_row["comments_count"] = (int)$line["num_comments"]; $headline_row["comments_link"] = $line["comments"]; - $headline_row["always_display_attachments"] = API::param_to_bool($line["always_display_enclosures"]); + $headline_row["always_display_attachments"] = self::param_to_bool($line["always_display_enclosures"]); $headline_row["author"] = $line["author"]; @@ -841,7 +831,7 @@ class API extends Handler { } function getFeedTree() { - $include_empty = API::param_to_bool(clean($_REQUEST['include_empty'])); + $include_empty = self::param_to_bool(clean($_REQUEST['include_empty'])); $pf = new Pref_Feeds($_REQUEST); diff --git a/classes/article.php b/classes/article.php index 74dbdae53..430109283 100755 --- a/classes/article.php +++ b/classes/article.php @@ -1,12 +1,6 @@ <?php class Article extends Handler_Protected { - function csrf_ignore($method) { - $csrf_ignored = array("redirect", "editarticletags"); - - return array_search($method, $csrf_ignored) !== false; - } - function redirect() { $id = clean($_REQUEST['id']); @@ -60,7 +54,7 @@ class Article extends Handler_Protected { if (!$title) $title = $url; if (!$title && !$url) return false; - if (filter_var($url, FILTER_VALIDATE_URL) === FALSE) return false; + if (filter_var($url, FILTER_VALIDATE_URL) === false) return false; $pdo = Db::pdo(); @@ -94,7 +88,7 @@ class Article extends Handler_Protected { ":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 = ?"); @@ -165,7 +159,7 @@ class Article extends Handler_Protected { $param = clean($_REQUEST['param']); - $tags = Article::get_article_tags($param); + $tags = self::get_article_tags($param); $tags_str = join(", ", $tags); @@ -267,7 +261,7 @@ class Article extends Handler_Protected { $this->pdo->commit(); - $tags = Article::get_article_tags($id); + $tags = self::get_article_tags($id); $tags_str = $this->format_tags_string($tags, $id); $tags_str_full = join(", ", $tags); @@ -350,7 +344,7 @@ class Article extends Handler_Protected { static function format_article_enclosures($id, $always_display_enclosures, $article_content, $hide_images = false) { - $result = Article::get_article_enclosures($id); + $result = self::get_article_enclosures($id); $rv = ''; foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FORMAT_ENCLOSURES) as $plugin) { @@ -393,7 +387,7 @@ class Article extends Handler_Protected { # $entry .= " <a target=\"_blank\" href=\"" . htmlspecialchars($url) . "\" rel=\"noopener noreferrer\">" . # $filename . " (" . $ctype . ")" . "</a>"; - $entry = "<div onclick=\"popupOpenUrl('".htmlspecialchars($url)."')\" + $entry = "<div onclick=\"Article.popupOpenUrl('".htmlspecialchars($url)."')\" dojoType=\"dijit.MenuItem\">$filename ($ctype)</div>"; array_push($entries_html, $entry); @@ -473,7 +467,7 @@ class Article extends Handler_Protected { else $filename = ""; - $rv .= "<div onclick='popupOpenUrl(\"".htmlspecialchars($entry["url"])."\")' + $rv .= "<div onclick='Article.popupOpenUrl(\"".htmlspecialchars($entry["url"])."\")' dojoType=\"dijit.MenuItem\">".$filename . $title."</div>"; }; @@ -583,7 +577,7 @@ class Article extends Handler_Protected { return "<div class='article-note $note_class'> <i class='material-icons'>note</i> - <div $onclick class='body'>$note</div> + <div $onclick class='body'>$note</div> </div>"; return $str; @@ -658,7 +652,7 @@ class Article extends Handler_Protected { } static function getLastArticleId() { - $pdo = DB::pdo(); + $pdo = Db::pdo(); $sth = $pdo->prepare("SELECT ref_id AS id FROM ttrss_user_entries WHERE owner_uid = ? ORDER BY ref_id DESC LIMIT 1"); @@ -763,7 +757,7 @@ class Article extends Handler_Protected { if (!$article_image) foreach ($enclosures as $enc) { - if (strpos($enc["content_type"], "image/") !== FALSE) { + if (strpos($enc["content_type"], "image/") !== false) { $article_image = $enc["content_url"]; break; } diff --git a/classes/backend.php b/classes/backend.php index 122e28c65..16c20660a 100644 --- a/classes/backend.php +++ b/classes/backend.php @@ -1,12 +1,6 @@ <?php -class Backend extends Handler { - function loading() { - header("Content-type: text/html"); - print __("Loading, please wait...") . " " . - "<img src='images/indicator_tiny.gif'>"; - } - - function digestTest() { +class Backend extends Handler_Protected { + /* function digestTest() { if (isset($_SESSION['uid'])) { header("Content-type: text/html"); @@ -19,80 +13,14 @@ class Backend extends Handler { } else { print error_json(6); } - } - - private function display_main_help() { - $info = get_hotkeys_info(); - $imap = get_hotkeys_map(); - $omap = array(); - - foreach ($imap[1] as $sequence => $action) { - if (!isset($omap[$action])) $omap[$action] = array(); - - array_push($omap[$action], $sequence); - } - - print "<ul class='panel panel-scrollable hotkeys-help' style='height : 300px'>"; - - print "<h2>" . __("Keyboard Shortcuts") . "</h2>"; - - foreach ($info as $section => $hotkeys) { - - print "<li><hr></li>"; - print "<li><h3>" . $section . "</h3></li>"; - - foreach ($hotkeys as $action => $description) { - - if (is_array($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); - } - - print "<li>"; - print "<div class='hk'><code>$sequence</code></div>"; - print "<div class='desc'>$description</div>"; - print "</li>"; - } - } - } - } - - print "</ul>"; - - - } + } */ function help() { - $topic = clean_filename($_REQUEST["topic"]); // only one for now + $topic = basename(clean($_REQUEST["topic"])); // only one for now if ($topic == "main") { - $info = get_hotkeys_info(); - $imap = get_hotkeys_map(); + $info = RPC::get_hotkeys_info(); + $imap = RPC::get_hotkeys_map(); $omap = array(); foreach ($imap[1] as $sequence => $action) { @@ -114,7 +42,7 @@ class Backend extends Handler { if (is_array($omap[$action])) { foreach ($omap[$action] as $sequence) { - if (strpos($sequence, "|") !== FALSE) { + if (strpos($sequence, "|") !== false) { $sequence = substr($sequence, strpos($sequence, "|")+1, strlen($sequence)); diff --git a/classes/counters.php b/classes/counters.php index d8ed27621..be634c52a 100644 --- a/classes/counters.php +++ b/classes/counters.php @@ -2,12 +2,12 @@ class Counters { static function getAllCounters() { - $data = Counters::getGlobalCounters(); + $data = self::getGlobalCounters(); - $data = array_merge($data, Counters::getVirtCounters()); - $data = array_merge($data, Counters::getLabelCounters()); - $data = array_merge($data, Counters::getFeedCounters()); - $data = array_merge($data, Counters::getCategoryCounters()); + $data = array_merge($data, self::getVirtCounters()); + $data = array_merge($data, self::getLabelCounters()); + $data = array_merge($data, self::getFeedCounters()); + $data = array_merge($data, self::getCategoryCounters()); return $data; } @@ -23,7 +23,7 @@ class Counters { $marked = 0; while ($line = $sth->fetch()) { - list ($tmp_unread, $tmp_marked) = Counters::getCategoryChildrenCounters($line["id"], $owner_uid); + list ($tmp_unread, $tmp_marked) = self::getCategoryChildrenCounters($line["id"], $owner_uid); $unread += $tmp_unread + Feeds::getCategoryUnread($line["id"], $owner_uid); $marked += $tmp_marked + Feeds::getCategoryMarked($line["id"], $owner_uid); @@ -42,7 +42,7 @@ class Counters { array_push($ret, $cv); - $pdo = DB::pdo(); + $pdo = Db::pdo(); $sth = $pdo->prepare("SELECT fc.id, SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count, @@ -68,7 +68,7 @@ class Counters { while ($line = $sth->fetch()) { if ($line["num_children"] > 0) { - list ($child_counter, $child_marked_counter) = Counters::getCategoryChildrenCounters($line["id"], $_SESSION["uid"]); + list ($child_counter, $child_marked_counter) = self::getCategoryChildrenCounters($line["id"], $_SESSION["uid"]); } else { $child_counter = 0; $child_marked_counter = 0; @@ -112,7 +112,7 @@ class Counters { $id = $line["id"]; $last_error = htmlspecialchars($line["last_error"]); - $last_updated = make_local_datetime($line['last_updated'], false); + $last_updated = TimeHelper::make_local_datetime($line['last_updated'], false); if (Feeds::feedHasIcon($id)) { $has_img = filemtime(Feeds::getIconFile($id)); @@ -234,8 +234,8 @@ class Counters { 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 - WHERE ttrss_labels2.owner_uid = :uid AND u1.owner_uid = :uid + 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']]); diff --git a/classes/db.php b/classes/db.php index ac493f6d6..70b97d0ee 100755 --- a/classes/db.php +++ b/classes/db.php @@ -113,4 +113,13 @@ class Db return self::$instance->pdo; } + + public static function sql_random_function() { + if (DB_TYPE == "mysql") { + return "RAND()"; + } else { + return "RANDOM()"; + } + } + } diff --git a/classes/debug.php b/classes/debug.php index c62f0c9f5..3061c6893 100644 --- a/classes/debug.php +++ b/classes/debug.php @@ -11,40 +11,40 @@ class Debug { private static $loglevel = 0; public static function set_logfile($logfile) { - Debug::$logfile = $logfile; + self::$logfile = $logfile; } public static function enabled() { - return Debug::$enabled; + return self::$enabled; } public static function set_enabled($enable) { - Debug::$enabled = $enable; + self::$enabled = $enable; } public static function set_quiet($quiet) { - Debug::$quiet = $quiet; + self::$quiet = $quiet; } public static function set_loglevel($level) { - Debug::$loglevel = $level; + self::$loglevel = $level; } public static function get_loglevel() { - return Debug::$loglevel; + return self::$loglevel; } public static function log($message, $level = 0) { - if (!Debug::$enabled || Debug::$loglevel < $level) return false; + if (!self::$enabled || self::$loglevel < $level) return false; $ts = strftime("%H:%M:%S", time()); if (function_exists('posix_getpid')) { $ts = "$ts/" . posix_getpid(); } - if (Debug::$logfile) { - $fp = fopen(Debug::$logfile, 'a+'); + if (self::$logfile) { + $fp = fopen(self::$logfile, 'a+'); if ($fp) { $locked = false; @@ -60,7 +60,7 @@ class Debug { if (!$locked) { fclose($fp); - user_error("Unable to lock debugging log file: " . Debug::$logfile, E_USER_WARNING); + user_error("Unable to lock debugging log file: " . self::$logfile, E_USER_WARNING); return; } } @@ -73,14 +73,14 @@ class Debug { fclose($fp); - if (Debug::$quiet) + if (self::$quiet) return; } else { - user_error("Unable to open debugging log file: " . Debug::$logfile, E_USER_WARNING); + user_error("Unable to open debugging log file: " . self::$logfile, E_USER_WARNING); } } print "[$ts] $message\n"; } -}
\ No newline at end of file +} diff --git a/classes/digest.php b/classes/digest.php index c9e9f24e7..5128b4186 100644 --- a/classes/digest.php +++ b/classes/digest.php @@ -90,16 +90,14 @@ class Digest static function prepare_headlines_digest($user_id, $days = 1, $limit = 1000) { - require_once "lib/MiniTemplator.class.php"; + $tpl = new Templator(); + $tpl_t = new Templator(); - $tpl = new MiniTemplator; - $tpl_t = new MiniTemplator; - - $tpl->readTemplateFromFile("templates/digest_template_html.txt"); - $tpl_t->readTemplateFromFile("templates/digest_template.txt"); + $tpl->readTemplateFromFile("digest_template_html.txt"); + $tpl_t->readTemplateFromFile("digest_template.txt"); $user_tz_string = get_pref('USER_TIMEZONE', $user_id); - $local_ts = convert_timestamp(time(), 'UTC', $user_tz_string); + $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)); @@ -161,7 +159,7 @@ class Digest array_push($affected_ids, $line["ref_id"]); - $updated = make_local_datetime($line['last_updated'], false, + $updated = TimeHelper::make_local_datetime($line['last_updated'], false, $user_id); if (get_pref('ENABLE_FEED_CATS', $user_id)) { diff --git a/classes/diskcache.php b/classes/diskcache.php index 7e4a8335d..6a8aa89c4 100644 --- a/classes/diskcache.php +++ b/classes/diskcache.php @@ -2,8 +2,196 @@ class DiskCache { private $dir; + // https://stackoverflow.com/a/53662733 + private $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 function __construct($dir) { - $this->dir = CACHE_DIR . "/" . clean_filename($dir); + $this->dir = CACHE_DIR . "/" . basename(clean($dir)); } public function getDir() { @@ -39,9 +227,7 @@ class DiskCache { } public function getFullPath($filename) { - $filename = clean_filename($filename); - - return $this->dir . "/" . $filename; + return $this->dir . "/" . basename(clean($filename)); } public function put($filename, $data) { @@ -66,54 +252,79 @@ class DiskCache { return null; } + public function getFakeExtension($filename) { + $mimetype = $this->getMimeType($filename); + + if ($mimetype) + return isset($this->mimeMap[$mimetype]) ? $this->mimeMap[$mimetype] : ""; + else + return ""; + } + public function send($filename) { - header("Content-Disposition: inline; filename=\"$filename\""); + $fake_extension = $this->getFakeExtension($filename); + + if ($fake_extension) + $fake_extension = ".$fake_extension"; + + header("Content-Disposition: inline; filename=\"${filename}${fake_extension}\""); - return send_local_file($this->getFullPath($filename)); + return $this->send_local_file($this->getFullPath($filename)); } public function getUrl($filename) { - return get_self_url_prefix() . "/public.php?op=cached_url&file=" . basename($this->dir) . "/" . $filename; + return get_self_url_prefix() . "/public.php?op=cached_url&file=" . basename($this->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 rewriteUrls($str) { $res = trim($str); if (!$res) return ''; $doc = new DOMDocument(); - if ($doc->loadHTML('<?xml encoding="UTF-8">' . $res)) { + if (@$doc->loadHTML('<?xml encoding="UTF-8">' . $res)) { $xpath = new DOMXPath($doc); $cache = new DiskCache("images"); - $entries = $xpath->query('(//img[@src]|//picture/source[@src]|//video[@poster]|//video/source[@src]|//audio/source[@src])'); + $entries = $xpath->query('(//img[@src]|//source[@src|@srcset]|//video[@poster|@src])'); $need_saving = false; foreach ($entries as $entry) { + foreach (array('src', 'poster') as $attr) { + if ($entry->hasAttribute($attr)) { + $url = $entry->getAttribute($attr); + $cached_filename = sha1($url); - if ($entry->hasAttribute('src') || $entry->hasAttribute('poster')) { + if ($cache->exists($cached_filename)) { + $url = $cache->getUrl($cached_filename); - // should be already absolutized because this is called after sanitize() - $src = $entry->hasAttribute('poster') ? $entry->getAttribute('poster') : $entry->getAttribute('src'); - $cached_filename = sha1($src); + $entry->setAttribute($attr, $url); + $entry->removeAttribute("srcset"); - if ($cache->exists($cached_filename)) { + $need_saving = true; + } + } + } - $src = $cache->getUrl(sha1($src)); + if ($entry->hasAttribute("srcset")) { + $matches = RSSUtils::decode_srcset($entry->getAttribute('srcset')); - if ($entry->hasAttribute('poster')) - $entry->setAttribute('poster', $src); - else { - $entry->setAttribute('src', $src); - $entry->removeAttribute("srcset"); - } + for ($i = 0; $i < count($matches); $i++) { + $cached_filename = sha1($matches[$i]["url"]); + + if ($cache->exists($cached_filename)) { + $matches[$i]["url"] = $cache->getUrl($cached_filename); - $need_saving = true; + $need_saving = true; + } } + + $entry->setAttribute("srcset", RSSUtils::encode_srcset($matches)); } } @@ -148,4 +359,56 @@ class DiskCache { } } } + + /* this is essentially a wrapper for readfile() which allows plugins to hook + output with httpd-specific "fast" implementation i.e. X-Sendfile or whatever else + + hook function should return true if request was handled (or at least attempted to) + + note that this can be called without user context so the plugin to handle this + should be loaded systemwide in config.php */ + function send_local_file($filename) { + if (file_exists($filename)) { + + if (is_writable($filename)) touch($filename); + + $mimetype = mime_content_type($filename); + + // this is hardly ideal but 1) only media is cached in images/ and 2) seemingly only mp4 + // video files are detected as octet-stream by mime_content_type() + + 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|video)\//", $mimetype) || in_array($mimetype, $mimetype_blacklist)) { + http_response_code(400); + header("Content-type: text/plain"); + + print "Stored file has disallowed content type ($mimetype)"; + return false; + } + + $tmppluginhost = new PluginHost(); + + $tmppluginhost->load(PLUGINS, PluginHost::KIND_SYSTEM); + $tmppluginhost->load_data(); + + foreach ($tmppluginhost->get_hooks(PluginHost::HOOK_SEND_LOCAL_FILE) as $plugin) { + if ($plugin->hook_send_local_file($filename)) return true; + } + + header("Content-type: $mimetype"); + + $stamp = gmdate("D, d M Y H:i:s", filemtime($filename)) . " GMT"; + header("Last-Modified: $stamp", true); + + return readfile($filename); + } else { + return false; + } + } } diff --git a/classes/feeds.php b/classes/feeds.php index 77add790e..c1b7e8022 100755 --- a/classes/feeds.php +++ b/classes/feeds.php @@ -8,7 +8,7 @@ class Feeds extends Handler_Protected { private $params; function csrf_ignore($method) { - $csrf_ignored = array("index", "quickaddfeed", "search"); + $csrf_ignored = array("index"); return array_search($method, $csrf_ignored) !== false; } @@ -204,14 +204,14 @@ class Feeds extends Handler_Protected { } $vfeed_group_enabled = get_pref("VFEED_GROUP_BY_FEED") && - !(in_array($feed, Feeds::NEVER_GROUP_FEEDS) && !$cat_view); + !(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 ? - make_local_datetime($qfh_ret[4], false) : __("Never"); + $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]; @@ -305,7 +305,7 @@ class Feeds extends Handler_Protected { $line["buttons"] .= $p->hook_article_button($line); } - $line["content"] = sanitize($line["content"], + $line["content"] = Sanitizer::sanitize($line["content"], $line['hide_images'], false, $line["site_url"], $highlight_words, $line["id"]); foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_RENDER_ARTICLE_CDM) as $p) { @@ -343,12 +343,12 @@ class Feeds extends Handler_Protected { } } - $line["updated_long"] = make_local_datetime($line["updated"],true); - $line["updated"] = make_local_datetime($line["updated"], false, false, false, true); + $line["updated_long"] = TimeHelper::make_local_datetime($line["updated"],true); + $line["updated"] = TimeHelper::make_local_datetime($line["updated"], false, false, false, true); $line['imported'] = T_sprintf("Imported at %s", - make_local_datetime($line["date_entered"], false)); + TimeHelper::make_local_datetime($line["date_entered"], false)); if ($line["tag_cache"]) $tags = explode(",", $line["tag_cache"]); @@ -357,7 +357,7 @@ class Feeds extends Handler_Protected { $line["tags_str"] = Article::format_tags_string($tags, $id); - if (feeds::feedHasIcon($feed_id)) { + if (self::feedHasIcon($feed_id)) { $line['feed_icon'] = "<img class=\"icon\" src=\"".ICONS_URL."/$feed_id.ico\" alt=\"\">"; } else { $line['feed_icon'] = "<i class='icon-no-feed material-icons'>rss_feed</i>"; @@ -426,7 +426,7 @@ class Feeds extends Handler_Protected { $sth->execute([$_SESSION['uid']]); $row = $sth->fetch(); - $last_updated = make_local_datetime($row["last_updated"], false); + $last_updated = TimeHelper::make_local_datetime($row["last_updated"], false); $reply['content'] .= sprintf(__("Feeds last updated at %s"), $last_updated); @@ -529,21 +529,7 @@ class Feeds extends Handler_Protected { $reply['headlines'] = []; - $override_order = false; - $skip_first_id_check = false; - - switch ($order_by) { - case "title": - $override_order = "ttrss_entries.title, date_entered, updated"; - break; - case "date_reverse": - $override_order = "score DESC, date_entered, updated"; - $skip_first_id_check = true; - break; - case "feed_dates": - $override_order = "updated DESC"; - break; - } + 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, @@ -564,7 +550,7 @@ class Feeds extends Handler_Protected { "disable_cache" => (bool) $disable_cache]; // this is parsed by handleRpcJson() on first viewfeed() to set cdm expanded, etc - $reply['runtime-info'] = make_runtime_info(); + $reply['runtime-info'] = RPC::make_runtime_info(); $reply_json = json_encode($reply); @@ -594,7 +580,7 @@ class Feeds extends Handler_Protected { $sth->execute([$_SESSION['uid']]); $row = $sth->fetch(); - $last_updated = make_local_datetime($row["last_updated"], false); + $last_updated = TimeHelper::make_local_datetime($row["last_updated"], false); $reply['headlines']['content'] .= sprintf(__("Feeds last updated at %s"), $last_updated); @@ -701,12 +687,12 @@ class Feeds extends Handler_Protected { print "<section>"; print "<label> <label class='checkbox'><input type='checkbox' name='need_auth' dojoType='dijit.form.CheckBox' id='feedDlg_loginCheck' - onclick='displayIfChecked(this, \"feedDlg_loginContainer\")'> + onclick='App.displayIfChecked(this, \"feedDlg_loginContainer\")'> ".__('This feed requires authentication.')."</label>"; print "</section>"; print "<footer>"; - print "<button dojoType='dijit.form.Button' class='alt-primary' type='submit' + print "<button dojoType='dijit.form.Button' class='alt-primary' type='submit' onclick=\"return dijit.byId('feedAddDlg').execute()\">".__('Subscribe')."</button>"; print "<button dojoType='dijit.form.Button' onclick=\"return dijit.byId('feedAddDlg').hide()\">".__('Cancel')."</button>"; @@ -765,7 +751,7 @@ class Feeds extends Handler_Protected { $feed_id = (int)$_REQUEST["feed_id"]; @$do_update = $_REQUEST["action"] == "do_update"; - $csrf_token = $_REQUEST["csrf_token"]; + $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']]); @@ -813,7 +799,7 @@ class Feeds extends Handler_Protected { <div class="container"> <h1>Feed Debugger: <?php echo "$feed_id: " . $this->getFeedTitle($feed_id) ?></h1> <div class="content"> - <form method="GET" action=""> + <form method="post" action=""> <input type="hidden" name="op" value="feeds"> <input type="hidden" name="method" value="update_debugger"> <input type="hidden" name="xdebug" value="1"> @@ -865,7 +851,7 @@ class Feeds extends Handler_Protected { // fall back in case of no plugins if (!$search_qpart) { - list($search_qpart, $search_words) = Feeds::search_to_sql($search[0], $search[1]); + list($search_qpart, $search_words) = self::search_to_sql($search[0], $search[1], $owner_uid); } } else { $search_qpart = "true"; @@ -905,7 +891,7 @@ class Feeds extends Handler_Protected { if ($feed >= 0) { if ($feed > 0) { - $children = Feeds::getChildCategories($feed, $owner_uid); + $children = self::getChildCategories($feed, $owner_uid); array_push($children, $feed); $children = array_map("intval", $children); @@ -1035,7 +1021,7 @@ class Feeds extends Handler_Protected { $match_part = ""; if ($is_cat) { - return Feeds::getCategoryUnread($n_feed, $owner_uid); + return self::getCategoryUnread($n_feed, $owner_uid); } else if ($n_feed == -6) { return 0; } else if ($feed != "0" && $n_feed == 0) { @@ -1081,7 +1067,7 @@ class Feeds extends Handler_Protected { $label_id = Labels::feed_to_label_id($feed); - return Feeds::getLabelUnread($label_id, $owner_uid); + return self::getLabelUnread($label_id, $owner_uid); } if ($match_part) { @@ -1138,11 +1124,11 @@ class Feeds extends Handler_Protected { $pdo = Db::pdo(); - $url = Feeds::fix_url($url); + $url = UrlHelper::validate($url); - if (!$url || !Feeds::validate_feed_url($url)) return array("code" => 2); + if (!$url) return array("code" => 2); - $contents = @fetch_file_contents($url, false, $auth_login, $auth_pass); + $contents = @UrlHelper::fetch($url, false, $auth_login, $auth_pass); foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_SUBSCRIBE_FEED) as $plugin) { $contents = $plugin->hook_subscribe_feed($contents, $url, $auth_login, $auth_pass); @@ -1156,8 +1142,8 @@ class Feeds extends Handler_Protected { return array("code" => 5, "message" => $fetch_last_error); } - if (mb_strpos($fetch_last_content_type, "html") !== FALSE && Feeds::is_html($contents)) { - $feedUrls = Feeds::get_feeds_from_html($url, $contents); + if (mb_strpos($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); @@ -1248,7 +1234,7 @@ class Feeds extends Handler_Protected { $pdo = Db::pdo(); if ($cat) { - return Feeds::getCategoryTitle($id); + return self::getCategoryTitle($id); } else if ($id == -1) { return __("Starred articles"); } else if ($id == -2) { @@ -1337,7 +1323,7 @@ class Feeds extends Handler_Protected { return 0; } else if ($cat == -2) { - $sth = $pdo->prepare("SELECT COUNT(DISTINCT article_id) AS unread + $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]); @@ -1360,8 +1346,8 @@ class Feeds extends Handler_Protected { $unread = 0; while ($line = $sth->fetch()) { - $unread += Feeds::getCategoryUnread($line["id"], $owner_uid); - $unread += Feeds::getCategoryChildrenUnread($line["id"], $owner_uid); + $unread += self::getCategoryUnread($line["id"], $owner_uid); + $unread += self::getCategoryChildrenUnread($line["id"], $owner_uid); } return $unread; @@ -1373,8 +1359,8 @@ class Feeds extends Handler_Protected { $pdo = Db::pdo(); - $sth = $pdo->prepare("SELECT SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count - FROM ttrss_user_entries ue + $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]); @@ -1464,11 +1450,11 @@ class Feeds extends Handler_Protected { // fall back in case of no plugins if (!$search_query_part) { - list($search_query_part, $search_words) = Feeds::search_to_sql($search, $search_language); + list($search_query_part, $search_words) = self::search_to_sql($search, $search_language, $owner_uid); } if (DB_TYPE == "pgsql") { - $test_sth = $pdo->prepare("select $search_query_part + $test_sth = $pdo->prepare("select $search_query_part FROM ttrss_entries, ttrss_user_entries WHERE id = ref_id limit 1"); try { @@ -1502,7 +1488,7 @@ class Feeds extends Handler_Protected { $unread = getFeedUnread($feed, $cat_view); if ($cat_view && $feed > 0 && $include_children) - $unread += Feeds::getCategoryChildrenUnread($feed); + $unread += self::getCategoryChildrenUnread($feed); if ($unread > 0) { $view_query_part = " unread = true AND "; @@ -1546,7 +1532,7 @@ class Feeds extends Handler_Protected { if ($feed > 0) { if ($include_children) { # sub-cats - $subcats = Feeds::getChildCategories($feed, $owner_uid); + $subcats = self::getChildCategories($feed, $owner_uid); array_push($subcats, $feed); $subcats = array_map("intval", $subcats); @@ -1665,7 +1651,7 @@ class Feeds extends Handler_Protected { $feed_title = T_sprintf("Search results: %s", $search); } else { if ($cat_view) { - $feed_title = Feeds::getCategoryTitle($feed); + $feed_title = self::getCategoryTitle($feed); } else { if (is_numeric($feed) && $feed > 0) { $ssth = $pdo->prepare("SELECT title,site_url,last_error,last_updated @@ -1678,7 +1664,7 @@ class Feeds extends Handler_Protected { $last_error = $row["last_error"]; $last_updated = $row["last_updated"]; } else { - $feed_title = Feeds::getFeedTitle($feed); + $feed_title = self::getFeedTitle($feed); } } } @@ -1702,7 +1688,7 @@ class Feeds extends Handler_Protected { // proper override_order applied above if ($vfeed_query_part && !$ignore_vfeed_group && get_pref('VFEED_GROUP_BY_FEED', $owner_uid)) { - if (!(in_array($feed, Feeds::NEVER_GROUP_BY_DATE) && !$cat_view)) { + 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 { @@ -1883,7 +1869,7 @@ class Feeds extends Handler_Protected { while ($line = $sth->fetch()) { array_push($rv, $line["parent_cat"]); - $rv = array_merge($rv, Feeds::getParentCategories($line["parent_cat"], $owner_uid)); + $rv = array_merge($rv, self::getParentCategories($line["parent_cat"], $owner_uid)); } return $rv; @@ -1900,7 +1886,7 @@ class Feeds extends Handler_Protected { while ($line = $sth->fetch()) { array_push($rv, $line["id"]); - $rv = array_merge($rv, Feeds::getChildCategories($line["id"], $owner_uid)); + $rv = array_merge($rv, self::getChildCategories($line["id"], $owner_uid)); } return $rv; @@ -1938,13 +1924,13 @@ class Feeds extends Handler_Protected { } static function get_feeds_from_html($url, $content) { - $url = Feeds::fix_url($url); + $url = UrlHelper::validate($url); $baseUrl = substr($url, 0, strrpos($url, '/') + 1); $feedUrls = []; $doc = new DOMDocument(); - if ($doc->loadHTML($content)) { + 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"]'); @@ -1969,56 +1955,6 @@ class Feeds extends Handler_Protected { return preg_match("/<html|DOCTYPE html/i", substr($content, 0, 8192)) !== 0; } - static function validate_feed_url($url) { - $parts = parse_url($url); - - return ($parts['scheme'] == 'http' || $parts['scheme'] == 'feed' || $parts['scheme'] == 'https'); - } - - /** - * Fixes incomplete URLs by prepending "http://". - * Also replaces feed:// with http://, and - * prepends a trailing slash if the url is a domain name only. - * - * @param string $url Possibly incomplete URL - * - * @return string Fixed URL. - */ - static function fix_url($url) { - - // support schema-less urls - if (strpos($url, '//') === 0) { - $url = 'https:' . $url; - } - - if (strpos($url, '://') === false) { - $url = 'http://' . $url; - } else if (substr($url, 0, 5) == 'feed:') { - $url = 'http:' . substr($url, 5); - } - - //prepend slash if the URL has no slash in it - // "http://www.example" -> "http://www.example/" - if (strpos($url, '/', strpos($url, ':') + 3) === false) { - $url .= '/'; - } - - //convert IDNA hostname to punycode if possible - if (function_exists("idn_to_ascii")) { - $parts = parse_url($url); - if (mb_detect_encoding($parts['host']) != 'ASCII') - { - $parts['host'] = idn_to_ascii($parts['host']); - $url = build_url($parts); - } - } - - if ($url != "http:///") - return $url; - else - return ''; - } - static function add_feed_category($feed_cat, $parent_cat_id = false, $order_id = 0) { if (!$feed_cat) return false; @@ -2096,7 +2032,7 @@ class Feeds extends Handler_Protected { */ static function purge_feed($feed_id, $purge_interval) { - if (!$purge_interval) $purge_interval = Feeds::feed_purge_interval($feed_id); + if (!$purge_interval) $purge_interval = self::feed_purge_interval($feed_id); $pdo = Db::pdo(); @@ -2161,7 +2097,7 @@ class Feeds extends Handler_Protected { static function feed_purge_interval($feed_id) { - $pdo = DB::pdo(); + $pdo = Db::pdo(); $sth = $pdo->prepare("SELECT purge_interval, owner_uid FROM ttrss_feeds WHERE id = ?"); @@ -2181,7 +2117,7 @@ class Feeds extends Handler_Protected { } } - static function search_to_sql($search, $search_language) { + static function search_to_sql($search, $search_language, $owner_uid) { $keywords = str_getcsv(trim($search), " "); $query_keywords = array(); @@ -2193,7 +2129,7 @@ class Feeds extends Handler_Protected { if ($search_language) $search_language = $pdo->quote(mb_strtolower($search_language)); else - $search_language = $pdo->quote("english"); + $search_language = $pdo->quote(mb_strtolower(get_pref('DEFAULT_SEARCH_LANGUAGE', $owner_uid))); foreach ($keywords as $k) { if (strpos($k, "-") === 0) { @@ -2267,6 +2203,24 @@ class Feeds extends Handler_Protected { 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 = ".$pdo->quote($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") @@ -2285,7 +2239,7 @@ class Feeds extends Handler_Protected { $user_tz_string = get_pref('USER_TIMEZONE', $_SESSION['uid']); $orig_ts = strtotime(substr($k, 1)); - $k = date("Y-m-d", convert_timestamp($orig_ts, $user_tz_string, 'UTC')); + $k = date("Y-m-d", TimeHelper::convert_timestamp($orig_ts, $user_tz_string, 'UTC')); //$k = date("Y-m-d", strtotime(substr($k, 1))); @@ -2323,9 +2277,38 @@ class Feeds extends Handler_Protected { } - $search_query_part = implode("AND", $query_keywords); + if (count($query_keywords) > 0) + $search_query_part = implode("AND", $query_keywords); + else + $search_query_part = "false"; return array($search_query_part, $search_words); } + + static function order_to_override_query($order) { + $query = ""; + $skip_first_id = false; + + foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE) as $p) { + list ($query, $skip_first_id) = $p->hook_headlines_custom_sort_override($order); + + if ($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 index 5b1109492..09557c284 100644 --- a/classes/handler.php +++ b/classes/handler.php @@ -9,7 +9,7 @@ class Handler implements IHandler { } function csrf_ignore($method) { - return true; + return false; } function before($method) { @@ -20,4 +20,4 @@ class Handler implements IHandler { return true; } -}
\ No newline at end of file +} diff --git a/classes/handler/public.php b/classes/handler/public.php index 8c2700012..4bd9c06f9 100755 --- a/classes/handler/public.php +++ b/classes/handler/public.php @@ -5,8 +5,6 @@ class Handler_Public extends Handler { $limit, $offset, $search, $view_mode = false, $format = 'atom', $order = false, $orig_guid = false, $start_ts = false) { - require_once "lib/MiniTemplator.class.php"; - $note_style = "background-color : #fff7d5; border-width : 1px; ". "padding : 5px; border-style : dashed; border-color : #e7d796;". @@ -14,24 +12,16 @@ class Handler_Public extends Handler { if (!$limit) $limit = 60; - $date_sort_field = "date_entered DESC, updated DESC"; + list($override_order, $skip_first_id_check) = Feeds::order_to_override_query($order); - if ($feed == -2 && !$is_cat) { - $date_sort_field = "last_published DESC"; - } else if ($feed == -1 && !$is_cat) { - $date_sort_field = "last_marked DESC"; - } + if (!$override_order) { + $override_order = "date_entered DESC, updated DESC"; - switch ($order) { - case "title": - $date_sort_field = "ttrss_entries.title, date_entered, updated"; - break; - case "date_reverse": - $date_sort_field = "date_entered, updated"; - break; - case "feed_dates": - $date_sort_field = "updated DESC"; - break; + if ($feed == -2 && !$is_cat) { + $override_order = "last_published DESC"; + } else if ($feed == -1 && !$is_cat) { + $override_order = "last_marked DESC"; + } } $params = array( @@ -41,7 +31,7 @@ class Handler_Public extends Handler { "view_mode" => $view_mode, "cat_view" => $is_cat, "search" => $search, - "override_order" => $date_sort_field, + "override_order" => $override_order, "include_children" => true, "ignore_vfeed_group" => true, "offset" => $offset, @@ -80,9 +70,9 @@ class Handler_Public extends Handler { if (!$feed_site_url) $feed_site_url = get_self_url_prefix(); if ($format == 'atom') { - $tpl = new MiniTemplator; + $tpl = new Templator(); - $tpl->readTemplateFromFile("templates/generated_feed.txt"); + $tpl->readTemplateFromFile("generated_feed.txt"); $tpl->setVariable('FEED_TITLE', $feed_title, true); $tpl->setVariable('VERSION', get_version(), true); @@ -91,7 +81,7 @@ class Handler_Public extends Handler { $tpl->setVariable('SELF_URL', htmlspecialchars(get_self_url_prefix()), true); while ($line = $result->fetch()) { - $line["content_preview"] = sanitize(truncate_string(strip_tags($line["content"]), 100, '...')); + $line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...')); foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_QUERY_HEADLINES) as $p) { $line = $p->hook_query_headlines($line); @@ -108,7 +98,7 @@ class Handler_Public extends Handler { $tpl->setVariable('ARTICLE_TITLE', htmlspecialchars($line['title']), true); $tpl->setVariable('ARTICLE_EXCERPT', $line["content_preview"], true); - $content = sanitize($line["content"], false, $owner_uid, + $content = Sanitizer::sanitize($line["content"], false, $owner_uid, $feed_site_url, false, $line["id"]); $content = DiskCache::rewriteUrls($content); @@ -190,7 +180,7 @@ class Handler_Public extends Handler { while ($line = $result->fetch()) { - $line["content_preview"] = sanitize(truncate_string(strip_tags($line["content_preview"]), 100, '...')); + $line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content_preview"]), 100, '...')); foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_QUERY_HEADLINES) as $p) { $line = $p->hook_query_headlines($line, 100); @@ -206,7 +196,7 @@ class Handler_Public extends Handler { $article['link'] = $line['link']; $article['title'] = $line['title']; $article['excerpt'] = $line["content_preview"]; - $article['content'] = sanitize($line["content"], false, $owner_uid, $feed_site_url, false, $line["id"]); + $article['content'] = Sanitizer::sanitize($line["content"], false, $owner_uid, $feed_site_url, false, $line["id"]); $article['updated'] = date('c', strtotime($line["updated"])); if ($line['note']) $article['note'] = $line['note']; @@ -293,15 +283,20 @@ class Handler_Public extends Handler { } function logout() { - logout_user(); - header("Location: index.php"); + if (validate_csrf($_POST["csrf_token"])) { + Pref_Users::logout_user(); + header("Location: index.php"); + } else { + header("Content-Type: text/json"); + print error_json(6); + } } function share() { $uuid = clean($_REQUEST["key"]); if ($uuid) { - $sth = $this->pdo->prepare("SELECT ref_id, owner_uid + $sth = $this->pdo->prepare("SELECT ref_id, owner_uid FROM ttrss_user_entries WHERE uuid = ?"); $sth->execute([$uuid]); @@ -348,7 +343,7 @@ class Handler_Public extends Handler { $line["tags"] = Article::get_article_tags($id, $owner_uid, $line["tag_cache"]); unset($line["tag_cache"]); - $line["content"] = sanitize($line["content"], + $line["content"] = Sanitizer::sanitize($line["content"], $line['hide_images'], $owner_uid, $line["site_url"], false, $line["id"]); @@ -376,7 +371,7 @@ class Handler_Public extends Handler { } body.css_loading * { display : none; - } + } </style> <link rel='shortcut icon' type='image/png' href='images/favicon.png'> <link rel='icon' type='image/png' sizes='72x72' href='images/favicon-72px.png'>"; @@ -419,7 +414,7 @@ class Handler_Public extends Handler { $rv .= "<div class='row'>"; # row //$entry_author = $line["author"] ? " - " . $line["author"] : ""; - $parsed_updated = make_local_datetime($line["updated"], true, + $parsed_updated = TimeHelper::make_local_datetime($line["updated"], true, $owner_uid, true); $rv .= "<div>".$line['author']."</div>"; @@ -475,7 +470,7 @@ class Handler_Public extends Handler { if (!$format) $format = 'atom'; if (SINGLE_USER_MODE) { - authenticate_user("admin", null); + UserHelper::authenticate("admin", null); } $owner_id = false; @@ -513,7 +508,7 @@ class Handler_Public extends Handler { function sharepopup() { if (SINGLE_USER_MODE) { - login_sequence(); + UserHelper::login_sequence(); } header('Content-Type: text/html; charset=utf-8'); @@ -678,14 +673,15 @@ class Handler_Public extends Handler { $login = clean($_POST["login"]); $password = clean($_POST["password"]); $remember_me = clean($_POST["remember_me"]); + $safe_mode = checkbox_to_sql_bool(clean($_POST["safe_mode"])); if ($remember_me) { - session_set_cookie_params(SESSION_COOKIE_LIFETIME); + @session_set_cookie_params(SESSION_COOKIE_LIFETIME); } else { - session_set_cookie_params(0); + @session_set_cookie_params(0); } - if (authenticate_user($login, $password)) { + if (UserHelper::authenticate($login, $password)) { $_POST["password"] = ""; if (get_schema_version() >= 120) { @@ -694,6 +690,7 @@ class Handler_Public extends Handler { $_SESSION["ref_schema_version"] = get_schema_version(true); $_SESSION["bw_limit"] = !!clean($_POST["bw_limit"]); + $_SESSION["safe_mode"] = $safe_mode; if (clean($_POST["profile"])) { @@ -732,12 +729,13 @@ class Handler_Public extends Handler { function subscribe() { if (SINGLE_USER_MODE) { - login_sequence(); + UserHelper::login_sequence(); } if ($_SESSION["uid"]) { $feed_url = trim(clean($_REQUEST["feed_url"])); + $csrf_token = clean($_POST["csrf_token"]); header('Content-Type: text/html; charset=utf-8'); ?> @@ -784,13 +782,14 @@ class Handler_Public extends Handler { <div class='content'> <?php - if (!$feed_url) { + if (!$feed_url || !validate_csrf($csrf_token)) { ?> <form method="post"> <input type="hidden" name="op" value="subscribe"> + <?php print_hidden("csrf_token", $_SESSION["csrf_token"]) ?> <fieldset> <label>Feed or site URL:</label> - <input style="width: 300px" dojoType="dijit.form.ValidationTextBox" required="1" name="feed_url"> + <input style="width: 300px" dojoType="dijit.form.ValidationTextBox" required="1" name="feed_url" value="<?php echo htmlspecialchars($feed_url) ?>"> </fieldset> <button class="alt-primary" dojoType="dijit.form.Button" type="submit"> @@ -830,6 +829,7 @@ class Handler_Public extends Handler { print "<form action='public.php'>"; print "<input type='hidden' name='op' value='subscribe'>"; + print_hidden("csrf_token", $_SESSION["csrf_token"]); print "<fieldset>"; print "<label style='display : inline'>" . __("Multiple feed URLs found:") . "</label>"; @@ -878,7 +878,7 @@ class Handler_Public extends Handler { print "</div></div></body></html>"; } else { - render_login_form(); + $this->render_login_form(); } } @@ -942,7 +942,7 @@ class Handler_Public extends Handler { if ($timestamp && $resetpass_token && $timestamp >= time() - 15*60*60 && - $resetpass_token == $hash) { + $resetpass_token === $hash) { $sth = $this->pdo->prepare("UPDATE ttrss_users SET resetpass_token = NULL WHERE id = ?"); @@ -1030,11 +1030,9 @@ class Handler_Public extends Handler { $resetpass_link = get_self_url_prefix() . "/public.php?op=forgotpass&hash=" . $resetpass_token . "&login=" . urlencode($login); - require_once "lib/MiniTemplator.class.php"; - - $tpl = new MiniTemplator; + $tpl = new Templator(); - $tpl->readTemplateFromFile("templates/resetpass_link_template.txt"); + $tpl->readTemplateFromFile("resetpass_link_template.txt"); $tpl->setVariable('LOGIN', $login); $tpl->setVariable('RESETPASS_LINK', $resetpass_link); @@ -1094,7 +1092,7 @@ class Handler_Public extends Handler { if (!SINGLE_USER_MODE && $_SESSION["access_level"] < 10) { $_SESSION["login_error_msg"] = __("Your access level is insufficient to run this script."); - render_login_form(); + $this->render_login_form(); exit; } @@ -1246,7 +1244,7 @@ class Handler_Public extends Handler { public function pluginhandler() { $host = new PluginHost(); - $plugin_name = clean_filename($_REQUEST["plugin"]); + $plugin_name = basename(clean($_REQUEST["plugin"])); $method = clean($_REQUEST["pmethod"]); $host->load($plugin_name, PluginHost::KIND_USER, 0); @@ -1274,5 +1272,13 @@ class Handler_Public extends Handler { print error_json(14); } } + + static function render_login_form() { + header('Cache-Control: public'); + + require_once "login_form.php"; + exit; + } + } ?> diff --git a/classes/labels.php b/classes/labels.php index 19d060617..1f27ee25c 100644 --- a/classes/labels.php +++ b/classes/labels.php @@ -12,7 +12,7 @@ class Labels static function find_id($label, $owner_uid) { $pdo = Db::pdo(); - $sth = $pdo->prepare("SELECT id FROM ttrss_labels2 WHERE caption = ? + $sth = $pdo->prepare("SELECT id FROM ttrss_labels2 WHERE LOWER(caption) = LOWER(?) AND owner_uid = ? LIMIT 1"); $sth->execute([$label, $owner_uid]); @@ -57,7 +57,7 @@ class Labels $pdo = Db::pdo(); if ($force) - Labels::clear_cache($id); + self::clear_cache($id); if (!$labels) $labels = Article::get_article_labels($id); @@ -82,7 +82,7 @@ class Labels static function remove_article($id, $label, $owner_uid) { - $label_id = Labels::find_id($label, $owner_uid); + $label_id = self::find_id($label, $owner_uid); if (!$label_id) return; @@ -95,12 +95,12 @@ class Labels $sth->execute([$label_id, $id]); - Labels::clear_cache($id); + self::clear_cache($id); } static function add_article($id, $label, $owner_uid) { - $label_id = Labels::find_id($label, $owner_uid); + $label_id = self::find_id($label, $owner_uid); if (!$label_id) return; @@ -123,7 +123,7 @@ class Labels $sth->execute([$label_id, $id]); } - Labels::clear_cache($id); + self::clear_cache($id); } @@ -186,7 +186,7 @@ class Labels } $sth = $pdo->prepare("SELECT id FROM ttrss_labels2 - WHERE caption = ? AND owner_uid = ?"); + WHERE LOWER(caption) = LOWER(?) AND owner_uid = ?"); $sth->execute([$caption, $owner_uid]); if (!$sth->fetch()) { @@ -202,4 +202,4 @@ class Labels return $result; } -}
\ No newline at end of file +} diff --git a/classes/logger.php b/classes/logger.php index 732f1fd5d..cdc6b240a 100755 --- a/classes/logger.php +++ b/classes/logger.php @@ -30,9 +30,9 @@ class Logger { return false; } - function log($string, $context = "") { + function log($errno, $errstr, $context = "") { if ($this->adapter) - return $this->adapter->log_error(E_USER_NOTICE, $string, '', 0, $context); + return $this->adapter->log_error($errno, $errstr, '', 0, $context); else return false; } diff --git a/classes/mailer.php b/classes/mailer.php index 2919eec79..16be16523 100644 --- a/classes/mailer.php +++ b/classes/mailer.php @@ -20,7 +20,7 @@ class Mailer { $to_combined = $to_name ? "$to_name <$to_address>" : $to_address; if (defined('_LOG_SENT_MAIL') && _LOG_SENT_MAIL) - Logger::get()->log("Sending mail from $from_combined to $to_combined [$subject]: $message"); + Logger::get()->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 diff --git a/classes/opml.php b/classes/opml.php index 48db9a8a3..37e653a39 100644 --- a/classes/opml.php +++ b/classes/opml.php @@ -8,7 +8,7 @@ class Opml extends Handler_Protected { } function export() { - $output_name = "tt-rss_".date("Y-m-d").".opml"; + $output_name = sprintf("tt-rss_%s_%s.opml", $_SESSION["name"], date("Y-m-d")); $include_settings = $_REQUEST["include_settings"] == "1"; $owner_uid = $_SESSION["uid"]; @@ -62,7 +62,7 @@ class Opml extends Handler_Protected { $ttrss_specific_qpart = ""; if ($cat_id) { - $sth = $this->pdo->prepare("SELECT title,order_id + $sth = $this->pdo->prepare("SELECT title,order_id FROM ttrss_feed_categories WHERE id = ? AND owner_uid = ?"); $sth->execute([$cat_id, $owner_uid]); @@ -90,7 +90,7 @@ class Opml extends Handler_Protected { $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 + $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"); @@ -105,8 +105,9 @@ class Opml extends Handler_Protected { 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\" ttrssUpdateInterval=\"$update_interval\""; + $ttrss_specific_qpart = "ttrssSortOrder=\"$order_id\" ttrssPurgeInterval=\"$purge_interval\" ttrssUpdateInterval=\"$update_interval\""; } else { $ttrss_specific_qpart = ""; } @@ -125,15 +126,16 @@ class Opml extends Handler_Protected { return $out; } - function opml_export($name, $owner_uid, $hide_private_feeds = false, $include_settings = true) { + function opml_export($filename, $owner_uid, $hide_private_feeds = false, $include_settings = true, $file_output = false) { if (!$owner_uid) return; - if (!isset($_REQUEST["debug"])) { - header("Content-type: application/xml+opml"); - header("Content-Disposition: attachment; filename=" . $name ); - } else { - header("Content-type: text/xml"); - } + 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\"?".">"; @@ -288,7 +290,10 @@ class Opml extends Handler_Protected { 'return str_repeat("\t", intval(strlen($matches[0])/2));'), $res); */ - print $res; + if ($file_output) + return file_put_contents($filename, $res) > 0; + else + print $res; } // Import @@ -323,11 +328,14 @@ class Opml extends Handler_Protected { $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) VALUES - (?, ?, ?, ?, ?, ?, ?)"); + (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]); + $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)); @@ -602,7 +610,7 @@ class Opml extends Handler_Protected { if (is_file($tmp_file)) { $doc = new DOMDocument(); libxml_disable_entity_loader(false); - $doc->load($tmp_file); + $loaded = $doc->load($tmp_file); libxml_disable_entity_loader(true); unlink($tmp_file); } else if (!$doc) { @@ -610,7 +618,7 @@ class Opml extends Handler_Protected { return; } - if ($doc) { + if ($loaded) { $this->pdo->beginTransaction(); $this->opml_import_category($doc, false, $owner_uid, false); $this->pdo->commit(); diff --git a/classes/pluginhost.php b/classes/pluginhost.php index 6158880f2..3ff658918 100755 --- a/classes/pluginhost.php +++ b/classes/pluginhost.php @@ -1,6 +1,9 @@ <?php class PluginHost { private $pdo; + /* separate handle for plugin data so transaction while saving wouldn't clash with possible main + tt-rss code transactions; only initialized when first needed */ + private $pdo_data; private $hooks = array(); private $plugins = array(); private $handlers = array(); @@ -62,6 +65,9 @@ class PluginHost { const HOOK_ARTICLE_IMAGE = 42; const HOOK_FEED_TREE = 43; const HOOK_IFRAME_WHITELISTED = 44; + const HOOK_ENCLOSURE_IMPORTED = 45; + const HOOK_HEADLINES_CUSTOM_SORT_MAP = 46; + const HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE = 47; const KIND_ALL = 1; const KIND_SYSTEM = 2; @@ -73,7 +79,6 @@ class PluginHost { function __construct() { $this->pdo = Db::pdo(); - $this->storage = array(); } @@ -150,7 +155,7 @@ class PluginHost { foreach (array_keys($this->hooks[$type]) as $prio) { $key = array_search($sender, $this->hooks[$type][$prio]); - if ($key !== FALSE) { + if ($key !== false) { unset($this->hooks[$type][$prio][$key]); } } @@ -188,7 +193,7 @@ class PluginHost { foreach ($plugins as $class) { $class = trim($class); - $class_file = strtolower(clean_filename($class)); + $class_file = strtolower(basename(clean($class))); if (!is_dir(__DIR__."/../plugins/$class_file") && !is_dir(__DIR__."/../plugins.local/$class_file")) continue; @@ -213,7 +218,7 @@ class PluginHost { if (file_exists($vendor_dir)) { spl_autoload_register(function($class) use ($vendor_dir) { - if (strpos($class, '\\') !== FALSE) { + if (strpos($class, '\\') !== false) { list ($namespace, $class_name) = explode('\\', $class, 2); if ($namespace && $class_name) { @@ -230,8 +235,8 @@ class PluginHost { $plugin_api = $plugin->api_version(); - if ($plugin_api < PluginHost::API_VERSION) { - user_error("Plugin $class is not compatible with current API version (need: " . PluginHost::API_VERSION . ", got: $plugin_api)", E_USER_WARNING); + if ($plugin_api < self::API_VERSION) { + user_error("Plugin $class is not compatible with current API version (need: " . self::API_VERSION . ", got: $plugin_api)", E_USER_WARNING); continue; } @@ -361,9 +366,13 @@ class PluginHost { private function save_data($plugin) { if ($this->owner_uid) { - $this->pdo->beginTransaction(); - $sth = $this->pdo->prepare("SELECT id FROM ttrss_plugin_storage WHERE + if (!$this->pdo_data) + $this->pdo_data = Db::instance()->pdo_connect(); + + $this->pdo_data->beginTransaction(); + + $sth = $this->pdo_data->prepare("SELECT id FROM ttrss_plugin_storage WHERE owner_uid= ? AND name = ?"); $sth->execute([$this->owner_uid, $plugin]); @@ -373,18 +382,18 @@ class PluginHost { $content = serialize($this->storage[$plugin]); if ($sth->fetch()) { - $sth = $this->pdo->prepare("UPDATE ttrss_plugin_storage SET content = ? + $sth = $this->pdo_data->prepare("UPDATE ttrss_plugin_storage SET content = ? WHERE owner_uid= ? AND name = ?"); $sth->execute([(string)$content, $this->owner_uid, $plugin]); } else { - $sth = $this->pdo->prepare("INSERT INTO ttrss_plugin_storage + $sth = $this->pdo_data->prepare("INSERT INTO ttrss_plugin_storage (name,owner_uid,content) VALUES (?, ?, ?)"); $sth->execute([$plugin, $this->owner_uid, (string)$content]); } - $this->pdo->commit(); + $this->pdo_data->commit(); } } diff --git a/classes/pref/feeds.php b/classes/pref/feeds.php index 6d7295beb..8b9099007 100755 --- a/classes/pref/feeds.php +++ b/classes/pref/feeds.php @@ -101,7 +101,7 @@ class Pref_Feeds extends Handler_Protected { $feed['unread'] = -1; $feed['error'] = $feed_line['last_error']; $feed['icon'] = Feeds::getFeedIcon($feed_line['id']); - $feed['param'] = make_local_datetime( + $feed['param'] = TimeHelper::make_local_datetime( $feed_line['last_updated'], true); $feed['updates_disabled'] = (int)($feed_line['update_interval'] < 0); @@ -268,7 +268,7 @@ class Pref_Feeds extends Handler_Protected { $feed['checkbox'] = false; $feed['error'] = $feed_line['last_error']; $feed['icon'] = Feeds::getFeedIcon($feed_line['id']); - $feed['param'] = make_local_datetime( + $feed['param'] = TimeHelper::make_local_datetime( $feed_line['last_updated'], true); $feed['unread'] = -1; $feed['type'] = 'feed'; @@ -303,7 +303,7 @@ class Pref_Feeds extends Handler_Protected { $feed['checkbox'] = false; $feed['error'] = $feed_line['last_error']; $feed['icon'] = Feeds::getFeedIcon($feed_line['id']); - $feed['param'] = make_local_datetime( + $feed['param'] = TimeHelper::make_local_datetime( $feed_line['last_updated'], true); $feed['unread'] = -1; $feed['type'] = 'feed'; @@ -449,7 +449,7 @@ class Pref_Feeds extends Handler_Protected { if ($row = $sth->fetch()) { @unlink(ICONS_DIR . "/$feed_id.ico"); - $sth = $this->pdo->prepare("UPDATE ttrss_feeds SET favicon_avg_color = NULL + $sth = $this->pdo->prepare("UPDATE ttrss_feeds SET favicon_avg_color = NULL, favicon_last_checked = '1970-01-01' where id = ?"); $sth->execute([$feed_id]); } @@ -554,7 +554,7 @@ class Pref_Feeds extends Handler_Protected { $last_error = $row["last_error"]; if ($last_error) { - print " <i class=\"material-icons\" + print " <i class=\"material-icons\" title=\"".htmlspecialchars($last_error)."\">error</i>"; } @@ -676,7 +676,7 @@ class Pref_Feeds extends Handler_Protected { $auth_checked = $auth_enabled ? 'checked' : ''; print "<label class='checkbox'> <input type='checkbox' $auth_checked name='need_auth' dojoType='dijit.form.CheckBox' id='feedEditDlg_loginCheck' - onclick='displayIfChecked(this, \"feedEditDlg_loginContainer\")'> + onclick='App.displayIfChecked(this, \"feedEditDlg_loginContainer\")'> ".__('This feed requires authentication.')."</label>"; print '</div><div dojoType="dijit.layout.ContentPane" title="'.__('Options').'">'; @@ -772,6 +772,7 @@ class Pref_Feeds extends Handler_Protected { <input style='display: none' id='icon_file' size='10' name='icon_file' type='file'> </label> <input type='hidden' name='op' value='pref-feeds'> + <input type='hidden' name='csrf_token' value='".$_SESSION['csrf_token']."'> <input type='hidden' name='feed_id' value='$feed_id'> <input type='hidden' name='method' value='uploadicon'> <button dojoType='dijit.form.Button' onclick=\"return CommonDialogs.uploadFeedIcon();\" @@ -1150,7 +1151,7 @@ class Pref_Feeds extends Handler_Protected { $ids = explode(",", clean($_REQUEST["ids"])); foreach ($ids as $id) { - Pref_Feeds::remove_feed($id, $_SESSION["uid"]); + self::remove_feed($id, $_SESSION["uid"]); } return; @@ -1172,7 +1173,7 @@ class Pref_Feeds extends Handler_Protected { function index() { print "<div dojoType='dijit.layout.AccordionContainer' region='center'>"; - print "<div style='padding : 0px' dojoType='dijit.layout.AccordionPane' + print "<div style='padding : 0px' dojoType='dijit.layout.AccordionPane' title=\"<i class='material-icons'>rss_feed</i> ".__('Feeds')."\">"; $sth = $this->pdo->prepare("SELECT COUNT(id) AS num_errors @@ -1307,7 +1308,7 @@ class Pref_Feeds extends Handler_Protected { print "</div>"; # feeds pane - print "<div dojoType='dijit.layout.AccordionPane' + print "<div dojoType='dijit.layout.AccordionPane' title='<i class=\"material-icons\">import_export</i> ".__('OPML')."'>"; print "<h3>" . __("Using OPML you can export and import your feeds, filters, labels and Tiny Tiny RSS settings.") . "</h3>"; @@ -1325,6 +1326,7 @@ class Pref_Feeds extends Handler_Protected { <input style='display : none' id='opml_file' name='opml_file' type='file'> </label> <input type='hidden' name='op' value='dlg'> + <input type='hidden' name='csrf_token' value='".$_SESSION['csrf_token']."'> <input type='hidden' name='method' value='importOpml'> <button dojoType='dijit.form.Button' class='alt-primary' onclick=\"return Helpers.OPML.import();\" type=\"submit\">" . __('Import OPML') . "</button>"; @@ -1360,7 +1362,7 @@ class Pref_Feeds extends Handler_Protected { print "</div>"; # pane - print "<div dojoType=\"dijit.layout.AccordionPane\" + print "<div dojoType=\"dijit.layout.AccordionPane\" title=\"<i class='material-icons'>share</i> ".__('Published & shared articles / Generated feeds')."\">"; print "<h3>" . __('Published articles can be subscribed by anyone who knows the following URL:') . "</h3>"; @@ -1476,7 +1478,7 @@ class Pref_Feeds extends Handler_Protected { htmlspecialchars($line["title"])."</a>"; print "</td><td class='text-muted' align='right'>"; - print make_local_datetime($line['last_article'], false); + print TimeHelper::make_local_datetime($line['last_article'], false); print "</td>"; print "</tr>"; @@ -1637,6 +1639,8 @@ class Pref_Feeds extends Handler_Protected { } function batchSubscribe() { + print "<form onsubmit='return false'>"; + print_hidden("op", "pref-feeds"); print_hidden("method", "batchaddfeeds"); @@ -1645,7 +1649,7 @@ class Pref_Feeds extends Handler_Protected { print "<textarea style='font-size : 12px; width : 98%; height: 200px;' - dojoType='dijit.form.SimpleTextarea' name='feeds'></textarea>"; + dojoType='fox.form.ValidationTextArea' required='1' name='feeds'></textarea>"; if (get_pref('ENABLE_FEED_CATS')) { print "<fieldset>"; @@ -1670,14 +1674,17 @@ class Pref_Feeds extends Handler_Protected { print "<fieldset class='narrow'> <label class='checkbox'><input type='checkbox' name='need_auth' dojoType='dijit.form.CheckBox' - onclick='displayIfChecked(this, \"feedDlg_loginContainer\")'> ". + onclick='App.displayIfChecked(this, \"feedDlg_loginContainer\")'> ". __('Feeds require authentication.')."</label></div>"; print "</fieldset>"; print "<footer> - <button dojoType='dijit.form.Button' type='submit' class='alt-primary'>".__('Subscribe')."</button> + <button dojoType='dijit.form.Button' onclick=\"return dijit.byId('batchSubDlg').execute()\" type='submit' class='alt-primary'>". + __('Subscribe')."</button> <button dojoType='dijit.form.Button' onclick=\"return dijit.byId('batchSubDlg').hide()\">".__('Cancel')."</button> </footer>"; + + print "</form>"; } function batchAddFeeds() { @@ -1696,7 +1703,7 @@ class Pref_Feeds extends Handler_Protected { foreach ($feeds as $feed) { $feed = trim($feed); - if (Feeds::validate_feed_url($feed)) { + if (UrlHelper::validate($feed)) { $this->pdo->beginTransaction(); diff --git a/classes/pref/filters.php b/classes/pref/filters.php index a3a0ce77f..1113f251e 100755 --- a/classes/pref/filters.php +++ b/classes/pref/filters.php @@ -3,7 +3,7 @@ class Pref_Filters extends Handler_Protected { function csrf_ignore($method) { $csrf_ignored = array("index", "getfiltertree", "edit", "newfilter", "newrule", - "newaction", "savefilterorder"); + "newaction", "savefilterorder", "testfilterdlg"); return array_search($method, $csrf_ignored) !== false; } @@ -159,22 +159,19 @@ class Pref_Filters extends Handler_Protected { print json_encode($rv); } - function testFilter() { + function testFilterDlg() { + ?> + <div> + <img id='prefFilterLoadingIndicator' src='images/indicator_tiny.gif'> + <span id='prefFilterProgressMsg'>Looking for articles...</span> + </div> - if (isset($_REQUEST["offset"])) return $this->testFilterDo(); - - //print __("Articles matching this filter:"); - - print "<div><img id='prefFilterLoadingIndicator' src='images/indicator_tiny.gif'> <span id='prefFilterProgressMsg'>Looking for articles...</span></div>"; - - print "<ul class='panel panel-scrollable list list-unstyled' id='prefFilterTestResultList'>"; - print "</ul>"; - - print "<footer class='text-center'>"; - print "<button dojoType='dijit.form.Button' onclick=\"dijit.byId('filterTestDlg').hide()\">". - __('Close this window')."</button>"; - print "</footer>"; + <ul class='panel panel-scrollable list list-unstyled' id='prefFilterTestResultList'></ul> + <footer class='text-center'> + <button dojoType='dijit.form.Button' onclick="dijit.byId('filterTestDlg').hide()"><?php echo __('Close this window') ?></button> + </footer> + <?php } private function getfilterrules_list($filter_id) { @@ -241,6 +238,7 @@ class Pref_Filters extends Handler_Protected { $root = array(); $root['id'] = 'root'; $root['name'] = __('Filters'); + $root['enabled'] = true; $root['items'] = array(); $filter_search = $_SESSION["prefs_filter_search"]; @@ -304,8 +302,8 @@ class Pref_Filters extends Handler_Protected { $filter['name'] = $name[0]; $filter['param'] = $name[1]; $filter['checkbox'] = false; - $filter['last_triggered'] = $line["last_triggered"] ? make_local_datetime($line["last_triggered"], false) : null; - $filter['enabled'] = $line["enabled"]; + $filter['last_triggered'] = $line["last_triggered"] ? TimeHelper::make_local_datetime($line["last_triggered"], false) : null; + $filter['enabled'] = sql_bool_to_bool($line["enabled"]); $filter['rules'] = $this->getfilterrules_list($line['id']); if (!$filter_search || $match_ok) { @@ -600,10 +598,6 @@ class Pref_Filters extends Handler_Protected { } function editSave() { - if (clean($_REQUEST["savemode"] && $_REQUEST["savemode"]) == "test") { - return $this->testFilter(); - } - $filter_id = clean($_REQUEST["id"]); $enabled = checkbox_to_sql_bool(clean($_REQUEST["enabled"])); $match_any_rule = checkbox_to_sql_bool(clean($_REQUEST["match_any_rule"])); @@ -714,10 +708,6 @@ class Pref_Filters extends Handler_Protected { } function add() { - if (clean($_REQUEST["savemode"] && $_REQUEST["savemode"]) == "test") { - return $this->testFilter(); - } - $enabled = checkbox_to_sql_bool(clean($_REQUEST["enabled"])); $match_any_rule = checkbox_to_sql_bool(clean($_REQUEST["match_any_rule"])); $title = clean($_REQUEST["title"]); @@ -975,19 +965,18 @@ class Pref_Filters extends Handler_Protected { print "<section>"; - print "<input dojoType=\"dijit.form.ValidationTextBox\" - required=\"true\" id=\"filterDlg_regExp\" - onchange='Filters.filterDlgCheckRegExp(this)' - onblur='Filters.filterDlgCheckRegExp(this)' - onfocus='Filters.filterDlgCheckRegExp(this)' - style=\"font-size : 16px; width : 500px\" - name=\"reg_exp\" value=\"$reg_exp\"/>"; + print "<textarea dojoType='fox.form.ValidationTextArea' + required='true' id='filterDlg_regExp' + ValidRegExp='true' + rows='4' + style='font-size : 14px; width : 490px; word-break: break-all' + name='reg_exp'>$reg_exp</textarea>"; print "<div dojoType='dijit.Tooltip' id='filterDlg_regExp_tip' connectId='filterDlg_regExp' position='below'></div>"; print "<fieldset>"; - print "<label class='checkbox'><input id=\"filterDlg_inverse\" dojoType=\"dijit.form.CheckBox\" - name=\"inverse\" $inverse_checked/> ". + print "<label class='checkbox'><input id='filterDlg_inverse' dojoType='dijit.form.CheckBox' + name='inverse' $inverse_checked/> ". __("Inverse regular expression matching")."</label>"; print "</fieldset>"; diff --git a/classes/pref/prefs.php b/classes/pref/prefs.php index ac16b5971..d7b486cbb 100644 --- a/classes/pref/prefs.php +++ b/classes/pref/prefs.php @@ -8,7 +8,7 @@ class Pref_Prefs extends Handler_Protected { private $profile_blacklist = []; function csrf_ignore($method) { - $csrf_ignored = array("index", "updateself", "customizecss", "editprefprofiles"); + $csrf_ignored = array("index", "updateself", "customizecss", "editprefprofiles", "otpqrcode"); return array_search($method, $csrf_ignored) !== false; } @@ -125,8 +125,14 @@ class Pref_Prefs extends Handler_Protected { $old_pw = clean($_POST["old_password"]); $new_pw = clean($_POST["new_password"]); + $new_unclean_pw = $_POST["new_password"]; $con_pw = clean($_POST["confirm_password"]); + if ($new_unclean_pw != $new_pw) { + print "ERROR: ".format_error("New password contains disallowed characters."); + return; + } + if ($old_pw == $new_pw) { print "ERROR: ".format_error("New password must be different from the old one."); return; @@ -213,11 +219,9 @@ class Pref_Prefs extends Handler_Protected { if ($old_email != $email) { $mailer = new Mailer(); - require_once "lib/MiniTemplator.class.php"; + $tpl = new Templator(); - $tpl = new MiniTemplator; - - $tpl->readTemplateFromFile("templates/mail_change_template.txt"); + $tpl->readTemplateFromFile("mail_change_template.txt"); $tpl->setVariable('LOGIN', $row["login"]); $tpl->setVariable('NEWMAIL', $email); @@ -253,7 +257,7 @@ class Pref_Prefs extends Handler_Protected { AND owner_uid = :uid"); $sth->execute([":profile" => $_SESSION['profile'], ":uid" => $_SESSION['uid']]); - initialize_user_prefs($_SESSION["uid"], $_SESSION["profile"]); + $this->initialize_user_prefs($_SESSION["uid"], $_SESSION["profile"]); echo __("Your preferences are now set to default values."); } @@ -382,12 +386,12 @@ class Pref_Prefs extends Handler_Protected { print "<fieldset>"; print "<label>" . __("New password:") . "</label>"; - print "<input dojoType='dijit.form.ValidationTextBox' type='password' required='1' name='new_password'>"; + print "<input dojoType='dijit.form.ValidationTextBox' type='password' regexp='^[^<>]+' required='1' name='new_password'>"; print "</fieldset>"; print "<fieldset>"; print "<label>" . __("Confirm password:") . "</label>"; - print "<input dojoType='dijit.form.ValidationTextBox' type='password' required='1' name='confirm_password'>"; + print "<input dojoType='dijit.form.ValidationTextBox' type='password' regexp='^[^<>]+' required='1' name='confirm_password'>"; print "</fieldset>"; print_hidden("op", "pref-prefs"); @@ -479,8 +483,8 @@ class Pref_Prefs extends Handler_Protected { if (function_exists("imagecreatefromstring")) { print "<h3>" . __("Scan the following code by the Authenticator application or copy the key manually") . "</h3>"; - $csrf_token = $_SESSION["csrf_token"]; - print "<img alt='otp qr-code' src='backend.php?op=pref-prefs&method=otpqrcode&csrf_token=$csrf_token'>"; + $csrf_token_hash = sha1($_SESSION["csrf_token"]); + print "<img alt='otp qr-code' src='backend.php?op=pref-prefs&method=otpqrcode&csrf_token_hash=$csrf_token_hash'>"; } else { print_error("PHP GD functions are required to generate QR codes."); print "<h3>" . __("Use the following OTP key with a compatible Authenticator application") . "</h3>"; @@ -586,9 +590,9 @@ class Pref_Prefs extends Handler_Protected { if ($profile) { print_notice(__("Some preferences are only available in default profile.")); - initialize_user_prefs($_SESSION["uid"], $profile); + $this->initialize_user_prefs($_SESSION["uid"], $profile); } else { - initialize_user_prefs($_SESSION["uid"]); + $this->initialize_user_prefs($_SESSION["uid"]); } $prefs_available = []; @@ -854,6 +858,10 @@ class Pref_Prefs extends Handler_Protected { print_warning("Your PHP configuration has open_basedir restrictions enabled. Some plugins relying on CURL for functionality may not work correctly."); } + if ($_SESSION["safe_mode"]) { + print_error("You have logged in using safe mode, no user plugins will be actually enabled until you login again."); + } + $feed_handler_whitelist = [ "Af_Comics" ]; $feed_handlers = array_merge( @@ -862,7 +870,7 @@ class Pref_Prefs extends Handler_Protected { PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FETCH_FEED)); $feed_handlers = array_filter($feed_handlers, function($plugin) use ($feed_handler_whitelist) { - return in_array(get_class($plugin), $feed_handler_whitelist) === FALSE; }); + return in_array(get_class($plugin), $feed_handler_whitelist) === false; }); if (count($feed_handlers) > 0) { print_error( @@ -1006,21 +1014,28 @@ class Pref_Prefs extends Handler_Protected { } function otpqrcode() { - require_once "lib/phpqrcode/phpqrcode.php"; + $csrf_token_hash = clean($_REQUEST["csrf_token_hash"]); - $sth = $this->pdo->prepare("SELECT login - FROM ttrss_users - WHERE id = ?"); - $sth->execute([$_SESSION['uid']]); + if (sha1($_SESSION["csrf_token"]) === $csrf_token_hash) { + require_once "lib/phpqrcode/phpqrcode.php"; - if ($row = $sth->fetch()) { - $secret = $this->otpsecret(); - $login = $row['login']; + $sth = $this->pdo->prepare("SELECT login + FROM ttrss_users + WHERE id = ?"); + $sth->execute([$_SESSION['uid']]); - if ($secret) { - QRcode::png("otpauth://totp/".urlencode($login). - "?secret=$secret&issuer=".urlencode("Tiny Tiny RSS")); + if ($row = $sth->fetch()) { + $secret = $this->otpsecret(); + $login = $row['login']; + + if ($secret) { + QRcode::png("otpauth://totp/".urlencode($login). + "?secret=$secret&issuer=".urlencode("Tiny Tiny RSS")); + } } + } else { + header("Content-Type: text/json"); + print error_json(6); } } @@ -1087,11 +1102,9 @@ class Pref_Prefs extends Handler_Protected { if ($row = $sth->fetch()) { $mailer = new Mailer(); - require_once "lib/MiniTemplator.class.php"; - - $tpl = new MiniTemplator; + $tpl = new Templator(); - $tpl->readTemplateFromFile("templates/otp_disabled_template.txt"); + $tpl->readTemplateFromFile("otp_disabled_template.txt"); $tpl->setVariable('LOGIN', $row["login"]); $tpl->setVariable('TTRSS_HOST', SELF_URL_PATH); @@ -1307,11 +1320,11 @@ class Pref_Prefs extends Handler_Protected { print "<td>" . htmlspecialchars($row["title"]) . "</td>"; print "<td align='right' class='text-muted'>"; - print make_local_datetime($row['created'], false); + print TimeHelper::make_local_datetime($row['created'], false); print "</td>"; print "<td align='right' class='text-muted'>"; - print make_local_datetime($row['last_used'], false); + print TimeHelper::make_local_datetime($row['last_used'], false); print "</td>"; print "</tr>"; @@ -1353,4 +1366,57 @@ class Pref_Prefs extends Handler_Protected { $this->appPasswordList(); } + + static function initialize_user_prefs($uid, $profile = false) { + + if (get_schema_version() < 63) $profile_qpart = ""; + + $pdo = Db::pdo(); + $in_nested_tr = false; + + try { + $pdo->beginTransaction(); + } catch (Exception $e) { + $in_nested_tr = true; + } + + $sth = $pdo->query("SELECT pref_name,def_value FROM ttrss_prefs"); + + if (!is_numeric($profile) || !$profile || get_schema_version() < 63) $profile = null; + + $u_sth = $pdo->prepare("SELECT pref_name + FROM ttrss_user_prefs WHERE owner_uid = :uid AND + (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); + $u_sth->execute([':uid' => $uid, ':profile' => $profile]); + + $active_prefs = array(); + + while ($line = $u_sth->fetch()) { + array_push($active_prefs, $line["pref_name"]); + } + + while ($line = $sth->fetch()) { + if (array_search($line["pref_name"], $active_prefs) === false) { +// print "adding " . $line["pref_name"] . "<br>"; + + if (get_schema_version() < 63) { + $i_sth = $pdo->prepare("INSERT INTO ttrss_user_prefs + (owner_uid,pref_name,value) VALUES + (?, ?, ?)"); + $i_sth->execute([$uid, $line["pref_name"], $line["def_value"]]); + + } else { + $i_sth = $pdo->prepare("INSERT INTO ttrss_user_prefs + (owner_uid,pref_name,value, profile) VALUES + (?, ?, ?, ?)"); + $i_sth->execute([$uid, $line["pref_name"], $line["def_value"], $profile]); + } + + } + } + + if (!$in_nested_tr) $pdo->commit(); + + } + } diff --git a/classes/pref/system.php b/classes/pref/system.php index d0f8a8273..7e9aa44a1 100644 --- a/classes/pref/system.php +++ b/classes/pref/system.php @@ -26,7 +26,7 @@ class Pref_System extends Handler_Protected { function index() { print "<div dojoType=\"dijit.layout.AccordionContainer\" region=\"center\">"; - print "<div dojoType=\"dijit.layout.AccordionPane\" + print "<div dojoType=\"dijit.layout.AccordionPane\" title=\"<i class='material-icons'>report</i> ".__('Event Log')."\">"; if (LOG_DESTINATION == "sql") { @@ -66,8 +66,7 @@ class Pref_System extends Handler_Protected { print "<td class='login'>" . $line["login"] . "</td>"; print "<td class='timestamp'>" . - make_local_datetime( - $line["created_at"], false) . "</td>"; + TimeHelper::make_local_datetime($line["created_at"], false) . "</td>"; print "</tr>"; } @@ -81,7 +80,7 @@ class Pref_System extends Handler_Protected { print "</div>"; - print "<div dojoType=\"dijit.layout.AccordionPane\" + print "<div dojoType=\"dijit.layout.AccordionPane\" title=\"<i class='material-icons'>info</i> ".__('PHP Information')."\">"; ob_start(); diff --git a/classes/pref/users.php b/classes/pref/users.php index 851d4fa9e..5ec7aa2e6 100644 --- a/classes/pref/users.php +++ b/classes/pref/users.php @@ -137,10 +137,10 @@ class Pref_Users extends Handler_Protected { if ($row = $sth->fetch()) { print "<table width='100%'>"; - $last_login = make_local_datetime( + $last_login = TimeHelper::make_local_datetime( $row["last_login"], true); - $created = make_local_datetime( + $created = TimeHelper::make_local_datetime( $row["created"], true); $stored_articles = $row["stored_articles"]; @@ -259,7 +259,7 @@ class Pref_Users extends Handler_Protected { print T_sprintf("Added user %s with password %s", $login, $tmp_user_pwd); - initialize_user($new_uid); + $this->initialize_user($new_uid); } else { @@ -304,7 +304,7 @@ class Pref_Users extends Handler_Protected { function resetPass() { $uid = clean($_REQUEST["id"]); - Pref_Users::resetUserPassword($uid); + self::resetUserPassword($uid); } function index() { @@ -399,8 +399,8 @@ class Pref_Users extends Handler_Protected { print "<tr data-row-id='$uid' onclick='Users.edit($uid)'>"; $line["login"] = htmlspecialchars($line["login"]); - $line["created"] = make_local_datetime($line["created"], false); - $line["last_login"] = make_local_datetime($line["last_login"], false); + $line["created"] = TimeHelper::make_local_datetime($line["created"], false); + $line["last_login"] = TimeHelper::make_local_datetime($line["last_login"], false); print "<td align='center'><input onclick='Tables.onRowChecked(this); event.stopPropagation();' dojoType='dijit.form.CheckBox' type='checkbox'></td>"; @@ -443,4 +443,25 @@ class Pref_Users extends Handler_Protected { return $default; } + // this is called after user is created to initialize default feeds, labels + // or whatever else + // user preferences are checked on every login, not here + static function initialize_user($uid) { + + $pdo = Db::pdo(); + + $sth = $pdo->prepare("insert into ttrss_feeds (owner_uid,title,feed_url) + values (?, 'Tiny Tiny RSS: Forum', + 'https://tt-rss.org/forum/rss.php')"); + $sth->execute([$uid]); + } + + static function logout_user() { + @session_destroy(); + if (isset($_COOKIE[session_name()])) { + setcookie(session_name(), '', time()-42000, '/'); + } + session_commit(); + } + } diff --git a/classes/rpc.php b/classes/rpc.php index 208551075..6b41a51b8 100755 --- a/classes/rpc.php +++ b/classes/rpc.php @@ -2,7 +2,7 @@ class RPC extends Handler_Protected { function csrf_ignore($method) { - $csrf_ignored = array("sanitycheck", "completelabels", "saveprofile"); + $csrf_ignored = array("completelabels", "saveprofile"); return array_search($method, $csrf_ignored) !== false; } @@ -52,7 +52,7 @@ class RPC extends Handler_Protected { $profile_id = $row['id']; if ($profile_id) { - initialize_user_prefs($_SESSION["uid"], $profile_id); + Pref_Prefs::initialize_user_prefs($_SESSION["uid"], $profile_id); } } } @@ -279,7 +279,7 @@ class RPC extends Handler_Protected { ]; if ($seq % 2 == 0) - $reply['runtime-info'] = make_runtime_info(); + $reply['runtime-info'] = $this->make_runtime_info(); print json_encode($reply); } @@ -323,8 +323,8 @@ class RPC extends Handler_Protected { $reply['error'] = sanity_check(); if ($reply['error']['code'] == 0) { - $reply['init-params'] = make_init_params(); - $reply['runtime-info'] = make_runtime_info(); + $reply['init-params'] = $this->make_init_params(); + $reply['runtime-info'] = $this->make_runtime_info(); } print json_encode($reply); @@ -435,8 +435,10 @@ class RPC extends Handler_Protected { ) OR ( ttrss_feeds.update_interval > 0 AND ttrss_feeds.last_updated < NOW() - CAST((ttrss_feeds.update_interval || ' minutes') AS INTERVAL) - ) OR ttrss_feeds.last_updated IS NULL - OR last_updated = '1970-01-01 00:00:00')"; + ) OR ( + ttrss_feeds.update_interval >= 0 + AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) + ))"; } else { $update_limit_qpart = "AND (( ttrss_feeds.update_interval = 0 @@ -444,8 +446,10 @@ class RPC extends Handler_Protected { ) OR ( ttrss_feeds.update_interval > 0 AND ttrss_feeds.last_updated < DATE_SUB(NOW(), INTERVAL ttrss_feeds.update_interval MINUTE) - ) OR ttrss_feeds.last_updated IS NULL - OR last_updated = '1970-01-01 00:00:00')"; + ) OR ( + ttrss_feeds.update_interval >= 0 + AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) + ))"; } // Test if feed is currently being updated by another process. @@ -455,7 +459,7 @@ class RPC extends Handler_Protected { $updstart_thresh_qpart = "AND (ttrss_feeds.last_update_started IS NULL OR ttrss_feeds.last_update_started < DATE_SUB(NOW(), INTERVAL 5 MINUTE))"; } - $random_qpart = sql_random_function(); + $random_qpart = Db::sql_random_function(); $pdo = Db::pdo(); @@ -508,7 +512,7 @@ class RPC extends Handler_Protected { } function updaterandomfeed() { - RPC::updaterandomfeed_real(); + self::updaterandomfeed_real(); } private function markArticlesById($ids, $cmode) { @@ -572,7 +576,7 @@ class RPC extends Handler_Protected { function log() { $msg = clean($_REQUEST['msg']); - $file = clean_filename($_REQUEST['file']); + $file = basename(clean($_REQUEST['file'])); $line = (int) clean($_REQUEST['line']); $context = clean($_REQUEST['context']); @@ -596,7 +600,7 @@ class RPC extends Handler_Protected { get_version($git_commit, $git_timestamp); if (defined('CHECK_FOR_UPDATES') && CHECK_FOR_UPDATES && $_SESSION["access_level"] >= 10 && $git_timestamp) { - $content = @fetch_file_contents(["url" => "https://srv.tt-rss.org/version.json"]); + $content = @UrlHelper::fetch(["url" => "https://tt-rss.org/version.json"]); if ($content) { $content = json_decode($content, true); @@ -614,4 +618,290 @@ class RPC extends Handler_Protected { print json_encode($rv); } + private function make_init_params() { + $params = array(); + + foreach (array("ON_CATCHUP_SHOW_NEXT_FEED", "HIDE_READ_FEEDS", + "ENABLE_FEED_CATS", "FEEDS_SORT_BY_UNREAD", "CONFIRM_FEED_CATCHUP", + "CDM_AUTO_CATCHUP", "FRESH_ARTICLE_MAX_AGE", + "HIDE_READ_SHOWS_SPECIAL", "COMBINED_DISPLAY_MODE") as $param) { + + $params[strtolower($param)] = (int) get_pref($param); + } + + $params["check_for_updates"] = CHECK_FOR_UPDATES; + $params["icons_url"] = ICONS_URL; + $params["cookie_lifetime"] = SESSION_COOKIE_LIFETIME; + $params["default_view_mode"] = get_pref("_DEFAULT_VIEW_MODE"); + $params["default_view_limit"] = (int) get_pref("_DEFAULT_VIEW_LIMIT"); + $params["default_view_order_by"] = get_pref("_DEFAULT_VIEW_ORDER_BY"); + $params["bw_limit"] = (int) $_SESSION["bw_limit"]; + $params["is_default_pw"] = Pref_Prefs::isdefaultpassword(); + $params["label_base_index"] = (int) LABEL_BASE_INDEX; + + $theme = get_pref( "USER_CSS_THEME", false, false); + $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"] = get_self_url_prefix(); + $params["max_feed_id"] = (int) $max_feed_id; + $params["num_feeds"] = (int) $num_feeds; + + $params["hotkeys"] = $this->get_hotkeys_map(); + + $params["widescreen"] = (int) $_COOKIE["ttrss_widescreen"]; + + $params['simple_update'] = defined('SIMPLE_UPDATE_MODE') && SIMPLE_UPDATE_MODE; + + $params["icon_indicator_white"] = $this->image_to_base64("images/indicator_white.gif"); + + $params["labels"] = Labels::get_all_labels($_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(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('CDM_EXPANDED'); + $data["labels"] = Labels::get_all_labels($_SESSION["uid"]); + + if (LOG_DESTINATION == 'sql' && $_SESSION['access_level'] >= 10) { + if (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 != 1024 AND $log_interval"); + $sth->execute(); + + if ($row = $sth->fetch()) { + $data['recent_log_events'] = $row['cid']; + } + } + + if (file_exists(LOCK_DIRECTORY . "/update_daemon.lock")) { + + $data['daemon_is_running'] = (int) file_is_locked("update_daemon.lock"); + + if (time() - $_SESSION["daemon_stamp_check"] > 30) { + + $stamp = (int) @file_get_contents(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_tagcloud" => __("Tag cloud"), + "goto_prefs" => __("Preferences")), + __("Other") => array( + "create_label" => __("Create label"), + "create_filter" => __("Create filter"), + "collapse_sidebar" => __("Un/collapse sidebar"), + "help_dialog" => __("Show help dialog")) + ); + + foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_HOTKEY_INFO) as $plugin) { + $hotkeys = $plugin->hook_hotkey_info($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 t" => "goto_tagcloud", + "g P" => "goto_prefs", + "r" => "select_article_cursor", + "c l" => "create_label", + "c f" => "create_filter", + "c s" => "collapse_sidebar", + "?" => "help_dialog", + ); + + foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_HOTKEY_MAP) as $plugin) { + $hotkeys = $plugin->hook_hotkey_map($hotkeys); + } + + $prefixes = array(); + + foreach (array_keys($hotkeys) as $hotkey) { + $pair = explode(" ", $hotkey, 2); + + if (count($pair) > 1 && !in_array($pair[0], $prefixes)) { + array_push($prefixes, $pair[0]); + } + } + + return array($prefixes, $hotkeys); + } + } diff --git a/classes/rssutils.php b/classes/rssutils.php index 831ac1baf..3954f76dc 100755 --- a/classes/rssutils.php +++ b/classes/rssutils.php @@ -3,7 +3,12 @@ class RSSUtils { static function calculate_article_hash($article, $pluginhost) { $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(",", $v) : $v); @@ -24,7 +29,29 @@ class RSSUtils { $pdo->query("DELETE FROM ttrss_feedbrowser_cache"); } - static function update_daemon_common($limit = DAEMON_FEED_LIMIT) { + static function cleanup_feed_icons() { + $pdo = Db::pdo(); + $sth = $pdo->prepare("SELECT id FROM ttrss_feeds WHERE id = ?"); + + // check icon files once every CACHE_MAX_DAYS days + $icon_files = array_filter(glob(ICONS_DIR . "/*.ico"), + function($f) { return filemtime($f) < time() - 86400*CACHE_MAX_DAYS; }); + + foreach ($icon_files as $icon) { + $feed_id = basename($icon, ".ico"); + + $sth->execute([$feed_id]); + + if ($sth->fetch()) { + @touch($icon); + } else { + Debug::log("Removing orphaned feed icon: $icon"); + unlink($icon); + } + } + } + + static function update_daemon_common($limit = DAEMON_FEED_LIMIT, $options = []) { $schema_version = get_schema_version(); if ($schema_version != SCHEMA_VERSION) { @@ -47,33 +74,31 @@ class RSSUtils { $update_limit_qpart = "AND (( ttrss_feeds.update_interval = 0 AND ttrss_user_prefs.value != '-1' - AND ttrss_feeds.last_updated < NOW() - CAST((ttrss_user_prefs.value || ' minutes') AS INTERVAL) + AND last_updated < NOW() - CAST((ttrss_user_prefs.value || ' minutes') AS INTERVAL) ) OR ( ttrss_feeds.update_interval > 0 - AND ttrss_feeds.last_updated < NOW() - CAST((ttrss_feeds.update_interval || ' minutes') AS INTERVAL) - ) OR (ttrss_feeds.last_updated IS NULL - AND ttrss_user_prefs.value != '-1') - OR (last_updated = '1970-01-01 00:00:00' + AND last_updated < NOW() - CAST((ttrss_feeds.update_interval || ' minutes') AS INTERVAL) + ) OR ((last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) + AND ttrss_feeds.update_interval >= 0 AND ttrss_user_prefs.value != '-1'))"; } else { $update_limit_qpart = "AND (( ttrss_feeds.update_interval = 0 AND ttrss_user_prefs.value != '-1' - AND ttrss_feeds.last_updated < DATE_SUB(NOW(), INTERVAL CONVERT(ttrss_user_prefs.value, SIGNED INTEGER) MINUTE) + AND last_updated < DATE_SUB(NOW(), INTERVAL CONVERT(ttrss_user_prefs.value, SIGNED INTEGER) MINUTE) ) OR ( ttrss_feeds.update_interval > 0 - AND ttrss_feeds.last_updated < DATE_SUB(NOW(), INTERVAL ttrss_feeds.update_interval MINUTE) - ) OR (ttrss_feeds.last_updated IS NULL - AND ttrss_user_prefs.value != '-1') - OR (last_updated = '1970-01-01 00:00:00' + AND last_updated < DATE_SUB(NOW(), INTERVAL ttrss_feeds.update_interval MINUTE) + ) OR ((last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) + AND ttrss_feeds.update_interval >= 0 AND ttrss_user_prefs.value != '-1'))"; } // Test if feed is currently being updated by another process. if (DB_TYPE == "pgsql") { - $updstart_thresh_qpart = "AND (ttrss_feeds.last_update_started IS NULL OR ttrss_feeds.last_update_started < NOW() - INTERVAL '10 minutes')"; + $updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < NOW() - INTERVAL '10 minutes')"; } else { - $updstart_thresh_qpart = "AND (ttrss_feeds.last_update_started IS NULL OR ttrss_feeds.last_update_started < DATE_SUB(NOW(), INTERVAL 10 MINUTE))"; + $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) : ""; @@ -119,7 +144,11 @@ class RSSUtils { $batch_owners = array(); // since we have the data cached, we can deal with other feeds with the same url - $usth = $pdo->prepare("SELECT DISTINCT ttrss_feeds.id,last_updated,ttrss_feeds.owner_uid + $usth = $pdo->prepare("SELECT + DISTINCT ttrss_feeds.id, + last_updated, + ttrss_feeds.owner_uid, + ttrss_feeds.title FROM ttrss_feeds, ttrss_users, ttrss_user_prefs WHERE ttrss_user_prefs.owner_uid = ttrss_feeds.owner_uid AND ttrss_users.id = ttrss_user_prefs.owner_uid AND @@ -134,31 +163,70 @@ class RSSUtils { Debug::log("Base feed: $feed"); $usth->execute([$feed]); - //update_rss_feed($line["id"], true); if ($tline = $usth->fetch()) { - Debug::log(" => " . $tline["last_updated"] . ", " . $tline["id"] . " " . $tline["owner_uid"]); + Debug::log(sprintf("=> %s (ID: %d, UID: %d), last updated: %s", $tline["title"], $tline["id"], $tline["owner_uid"], + $tline["last_updated"] ? $tline["last_updated"] : "never")); - if (array_search($tline["owner_uid"], $batch_owners) === FALSE) + if (array_search($tline["owner_uid"], $batch_owners) === false) array_push($batch_owners, $tline["owner_uid"]); $fstarted = microtime(true); - try { - RSSUtils::update_rss_feed($tline["id"], true, false); - } catch (PDOException $e) { - Logger::get()->log_error(E_USER_NOTICE, $e->getMessage(), $e->getFile(), $e->getLine(), $e->getTraceAsString()); + $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(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) { + $esth = $pdo->prepare("SELECT last_error FROM ttrss_feeds WHERE id = ?"); + $esth->execute([$tline["id"]]); + + if ($erow = $esth->fetch()) { + $error_message = $erow["last_error"]; + } else { + $error_message = "N/A"; + } + + Debug::log("!! Last error: $error_message"); + + Logger::get()->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))); + } + } else { try { - $pdo->rollback(); + if (!self::update_rss_feed($tline["id"], true)) { + global $fetch_last_error; + + Logger::get()->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($fetch_last_error))); + } + + Debug::log(sprintf("<= %.4f (sec) (not using a separate process)", microtime(true) - $fstarted)); + } 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 + Logger::get()->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 + } } } - Debug::log(sprintf(" %.4f (sec)", microtime(true) - $fstarted)); - ++$nf; } } @@ -171,7 +239,7 @@ class RSSUtils { foreach ($batch_owners as $owner_uid) { Debug::log("Running housekeeping tasks for user $owner_uid..."); - RSSUtils::housekeeping_user($owner_uid); + self::housekeeping_user($owner_uid); } // Send feed digests by email if needed. @@ -209,7 +277,7 @@ class RSSUtils { } if (!$basic_info) { - $feed_data = fetch_file_contents($fetch_url, false, + $feed_data = UrlHelper::fetch($fetch_url, false, $auth_login, $auth_pass, false, FEED_FETCH_TIMEOUT, 0); @@ -259,8 +327,6 @@ class RSSUtils { */ static function update_rss_feed($feed, $no_cache = false) { - reset_fetch_domain_quota(); - Debug::log("start", Debug::$LOG_VERBOSE); $pdo = Db::pdo(); @@ -281,7 +347,7 @@ class RSSUtils { // this is not optimal currently as it fetches stuff separately TODO: optimize if ($title == "[Unknown]" || !$title || !$site_url) { Debug::log("setting basic feed info for $feed [$title, $site_url]..."); - RSSUtils::set_basic_feed_info($feed); + self::set_basic_feed_info($feed); } $sth = $pdo->prepare("SELECT id,update_interval,auth_login, @@ -391,7 +457,7 @@ class RSSUtils { Debug::log("fetching [$fetch_url] (force_refetch: $force_refetch)...", Debug::$LOG_VERBOSE); - $feed_data = fetch_file_contents([ + $feed_data = UrlHelper::fetch([ "url" => $fetch_url, "login" => $auth_login, "pass" => $auth_pass, @@ -401,7 +467,11 @@ class RSSUtils { $feed_data = trim($feed_data); + global $fetch_effective_url; + global $fetch_effective_ip_addr; + Debug::log("fetch done.", Debug::$LOG_VERBOSE); + Debug::log("effective URL (after redirects): " . clean($fetch_effective_url) . " (IP: $fetch_effective_ip_addr)", Debug::$LOG_VERBOSE); Debug::log("source last modified: " . $fetch_last_modified, Debug::$LOG_VERBOSE); if ($feed_data && $fetch_last_modified != $stored_last_modified) { @@ -427,18 +497,24 @@ class RSSUtils { Debug::log("unable to fetch: $fetch_last_error [$fetch_last_error_code]", Debug::$LOG_VERBOSE); // If-Modified-Since - if ($fetch_last_error_code != 304) { - $error_message = $fetch_last_error; - } else { + if ($fetch_last_error_code == 304) { Debug::log("source claims data not modified, nothing to do.", Debug::$LOG_VERBOSE); $error_message = ""; - } - $sth = $pdo->prepare("UPDATE ttrss_feeds SET last_error = ?, + $sth = $pdo->prepare("UPDATE ttrss_feeds SET last_error = ?, + last_successful_update = NOW(), + last_updated = NOW() WHERE id = ?"); + + } else { + $error_message = $fetch_last_error; + + $sth = $pdo->prepare("UPDATE ttrss_feeds SET last_error = ?, last_updated = NOW() WHERE id = ?"); + } + $sth->execute([$error_message, $feed]); - return; + return $error_message == ""; } Debug::log("running HOOK_FEED_FETCHED handlers...", Debug::$LOG_VERBOSE); @@ -469,7 +545,7 @@ class RSSUtils { foreach ($pluginhost->get_hooks(PluginHost::HOOK_FEED_PARSED) as $plugin) { Debug::log("... " . get_class($plugin), Debug::$LOG_VERBOSE); $start = microtime(true); - $plugin->hook_feed_parsed($rss); + $plugin->hook_feed_parsed($rss, $feed); Debug::log(sprintf("=== %.4f (sec)", microtime(true) - $start), Debug::$LOG_VERBOSE); } @@ -511,7 +587,7 @@ class RSSUtils { Debug::log("checking favicon...", Debug::$LOG_VERBOSE); - RSSUtils::check_feed_favicon($site_url, $feed); + self::check_feed_favicon($site_url, $feed); $favicon_modified_new = @filemtime($favicon_file); if ($favicon_modified_new > $favicon_modified) @@ -540,7 +616,7 @@ class RSSUtils { Debug::log("loading filters & labels...", Debug::$LOG_VERBOSE); - $filters = RSSUtils::load_filters($feed, $owner_uid); + $filters = self::load_filters($feed, $owner_uid); if (Debug::get_loglevel() >= Debug::$LOG_EXTENDED) { print_r($filters); @@ -579,18 +655,18 @@ class RSSUtils { $entry_guid = strip_tags($item->get_id()); if (!$entry_guid) $entry_guid = strip_tags($item->get_link()); - if (!$entry_guid) $entry_guid = RSSUtils::make_guid_from_title($item->get_title()); + 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("$owner_uid,$entry_guid"); + $entry_guid_hashed = json_encode(["ver" => 2, "uid" => $owner_uid, "hash" => 'SHA1:' . sha1($entry_guid)]); $entry_guid = "$owner_uid,$entry_guid"; - $entry_guid_hashed = 'SHA1:' . sha1($entry_guid); - - Debug::log("guid $entry_guid / $entry_guid_hashed", Debug::$LOG_VERBOSE); + Debug::log("guid $entry_guid (hash: $entry_guid_hashed compat: $entry_guid_hashed_compat)", Debug::$LOG_VERBOSE); $entry_timestamp = (int)$item->get_date(); @@ -632,8 +708,8 @@ class RSSUtils { Debug::log("done collecting data.", Debug::$LOG_VERBOSE); $sth = $pdo->prepare("SELECT id, content_hash, lang FROM ttrss_entries - WHERE guid = ? OR guid = ?"); - $sth->execute([$entry_guid, $entry_guid_hashed]); + WHERE guid IN (?, ?, ?)"); + $sth->execute([$entry_guid, $entry_guid_hashed, $entry_guid_hashed_compat]); if ($row = $sth->fetch()) { $base_entry_id = $row["id"]; @@ -648,6 +724,38 @@ class RSSUtils { $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) { + + foreach ($pluginhost->get_hooks(PluginHost::HOOK_ENCLOSURE_IMPORTED) as $plugin) { + $e = $plugin->hook_enclosure_imported($e, $feed); + } + + $e_item = array( + rewrite_relative_url($site_url, $e->link), + $e->type, $e->length, $e->title, $e->width, $e->height); + + // Yet another episode of "mysql utf8_general_ci is gimped" + if (DB_TYPE == "mysql" && 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" => $owner_uid, // read only "guid" => $entry_guid, // read only "guid_hashed" => $entry_guid_hashed, // read only @@ -662,6 +770,7 @@ class RSSUtils { "language" => $entry_language, "timestamp" => $entry_timestamp, "num_comments" => $num_comments, + "enclosures" => $enclosures, "feed" => array("id" => $feed, "fetch_url" => $fetch_url, "site_url" => $site_url, @@ -669,7 +778,7 @@ class RSSUtils { ); $entry_plugin_data = ""; - $entry_current_hash = RSSUtils::calculate_article_hash($article, $pluginhost); + $entry_current_hash = self::calculate_article_hash($article, $pluginhost); Debug::log("article hash: $entry_current_hash [stored=$entry_stored_hash]", Debug::$LOG_VERBOSE); @@ -715,7 +824,7 @@ class RSSUtils { 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] = RSSUtils::strip_utf8mb4($v); + $article[$k] = self::strip_utf8mb4($v); } } } @@ -725,7 +834,7 @@ class RSSUtils { $matched_rules = []; $matched_filters = []; - $article_filters = RSSUtils::get_article_filters($filters, $article["title"], + $article_filters = self::get_article_filters($filters, $article["title"], $article["content"], $article["link"], $article["author"], $article["tags"], $matched_rules, $matched_filters); @@ -765,7 +874,7 @@ class RSSUtils { } } - $plugin_filter_names = RSSUtils::find_article_filters($article_filters, "plugin"); + $plugin_filter_names = self::find_article_filters($article_filters, "plugin"); $plugin_filter_actions = $pluginhost->get_filter_actions(); if (count($plugin_filter_names) > 0) { @@ -804,6 +913,7 @@ class RSSUtils { $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(); @@ -825,11 +935,11 @@ class RSSUtils { Debug::log("force catchup: $entry_force_catchup", Debug::$LOG_VERBOSE); if ($cache_images) - RSSUtils::cache_media($entry_content, $site_url); + self::cache_media($entry_content, $site_url); $csth = $pdo->prepare("SELECT id FROM ttrss_entries - WHERE guid = ? OR guid = ?"); - $csth->execute([$entry_guid, $entry_guid_hashed]); + WHERE guid IN (?, ?, ?)"); + $csth->execute([$entry_guid, $entry_guid_hashed, $entry_guid_hashed_compat]); if (!$row = $csth->fetch()) { @@ -874,7 +984,7 @@ class RSSUtils { } - $csth->execute([$entry_guid, $entry_guid_hashed]); + $csth->execute([$entry_guid, $entry_guid_hashed, $entry_guid_hashed_compat]); $entry_ref_id = 0; $entry_int_id = 0; @@ -886,13 +996,13 @@ class RSSUtils { $ref_id = $row['id']; $entry_ref_id = $ref_id; - if (RSSUtils::find_article_filter($article_filters, "filter")) { + if (self::find_article_filter($article_filters, "filter")) { Debug::log("article is filtered out, nothing to do.", Debug::$LOG_VERBOSE); $pdo->commit(); continue; } - $score = RSSUtils::calculate_article_score($article_filters) + $entry_score_modifier; + $score = self::calculate_article_score($article_filters) + $entry_score_modifier; Debug::log("initial score: $score [including plugin modifier: $entry_score_modifier]", Debug::$LOG_VERBOSE); @@ -912,7 +1022,7 @@ class RSSUtils { Debug::log("user record not found, creating...", Debug::$LOG_VERBOSE); - if ($score >= -500 && !RSSUtils::find_article_filter($article_filters, 'catchup') && !$entry_force_catchup) { + if ($score >= -500 && !self::find_article_filter($article_filters, 'catchup') && !$entry_force_catchup) { $unread = 1; $last_read_qpart = null; } else { @@ -920,13 +1030,13 @@ class RSSUtils { $last_read_qpart = date("Y-m-d H:i"); // we can't use NOW() here because it gets quoted } - if (RSSUtils::find_article_filter($article_filters, 'mark') || $score > 1000) { + if (self::find_article_filter($article_filters, 'mark') || $score > 1000) { $marked = 1; } else { $marked = 0; } - if (RSSUtils::find_article_filter($article_filters, 'publish')) { + if (self::find_article_filter($article_filters, 'publish')) { $published = 1; } else { $published = 0; @@ -999,7 +1109,7 @@ class RSSUtils { if ($mark_unread_on_update && !$entry_force_catchup && - !RSSUtils::find_article_filter($article_filters, 'catchup')) { + !self::find_article_filter($article_filters, 'catchup')) { Debug::log("article updated, marking unread as requested.", Debug::$LOG_VERBOSE); @@ -1019,38 +1129,11 @@ class RSSUtils { Debug::log("assigning labels [filters]...", Debug::$LOG_VERBOSE); - RSSUtils::assign_article_to_label_filters($entry_ref_id, $article_filters, + self::assign_article_to_label_filters($entry_ref_id, $article_filters, $owner_uid, $article_labels); - Debug::log("looking for enclosures...", Debug::$LOG_VERBOSE); - - // enclosures - - $enclosures = array(); - - $encs = $item->get_enclosures(); - - if (is_array($encs)) { - foreach ($encs as $e) { - $e_item = array( - rewrite_relative_url($site_url, $e->link), - $e->type, $e->length, $e->title, $e->width, $e->height); - - // Yet another episode of "mysql utf8_general_ci is gimped" - if (DB_TYPE == "mysql" && MYSQL_CHARSET != "UTF8MB4") { - for ($i = 0; $i < count($e_item); $i++) { - if (is_string($e_item[$i])) { - $e_item[$i] = RSSUtils::strip_utf8mb4($e_item[$i]); - } - } - } - - array_push($enclosures, $e_item); - } - } - if ($cache_images) - RSSUtils::cache_enclosures($enclosures, $site_url); + self::cache_enclosures($enclosures, $site_url); if (Debug::get_loglevel() >= Debug::$LOG_EXTENDED) { Debug::log("article enclosures:", Debug::$LOG_VERBOSE); @@ -1155,8 +1238,11 @@ class RSSUtils { Feeds::purge_feed($feed, 0); - $sth = $pdo->prepare("UPDATE ttrss_feeds - SET last_updated = NOW(), last_unconditional = NOW(), last_error = '' WHERE id = ?"); + $sth = $pdo->prepare("UPDATE ttrss_feeds SET + last_updated = NOW(), + last_unconditional = NOW(), + last_successful_update = NOW(), + last_error = '' WHERE id = ?"); $sth->execute([$feed]); } else { @@ -1171,8 +1257,10 @@ class RSSUtils { } } - $sth = $pdo->prepare("UPDATE ttrss_feeds SET last_error = ?, - last_updated = NOW(), last_unconditional = NOW() WHERE id = ?"); + $sth = $pdo->prepare("UPDATE ttrss_feeds SET + last_error = ?, + last_updated = NOW(), + last_unconditional = NOW() WHERE id = ?"); $sth->execute([$error_msg, $feed]); unset($rss); @@ -1186,6 +1274,7 @@ class RSSUtils { return true; } + /* TODO: move to DiskCache? */ static function cache_enclosures($enclosures, $site_url) { $cache = new DiskCache("images"); @@ -1204,7 +1293,7 @@ class RSSUtils { global $fetch_last_error_code; global $fetch_last_error; - $file_content = fetch_file_contents(array("url" => $src, + $file_content = UrlHelper::fetch(array("url" => $src, "http_referrer" => $src, "max_size" => MAX_CACHE_FILE_SIZE)); @@ -1221,41 +1310,56 @@ class RSSUtils { } } + /* TODO: move to DiskCache? */ + static function cache_media_url($cache, $url, $site_url) { + $url = rewrite_relative_url($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); + + global $fetch_last_error_code; + global $fetch_last_error; + + $file_content = UrlHelper::fetch(array("url" => $url, + "http_referrer" => $url, + "max_size" => MAX_CACHE_FILE_SIZE)); + + if ($file_content) { + $cache->put($local_filename, $file_content); + } else { + Debug::log("cache_media: failed with $fetch_last_error_code: $fetch_last_error"); + } + } else if ($cache->isWritable($local_filename)) { + $cache->touch($local_filename); + } + } + + /* TODO: move to DiskCache? */ static function cache_media($html, $site_url) { $cache = new DiskCache("images"); - if ($cache->isWritable()) { + if ($html && $cache->isWritable()) { $doc = new DOMDocument(); - if ($doc->loadHTML($html)) { + if (@$doc->loadHTML($html)) { $xpath = new DOMXPath($doc); - $entries = $xpath->query('(//img[@src])|(//video/source[@src])|(//audio/source[@src])'); + $entries = $xpath->query('(//img[@src]|//source[@src|@srcset]|//video[@poster|@src])'); foreach ($entries as $entry) { - if ($entry->hasAttribute('src') && strpos($entry->getAttribute('src'), "data:") !== 0) { - $src = rewrite_relative_url($site_url, $entry->getAttribute('src')); - - $local_filename = sha1($src); - - Debug::log("cache_media: checking $src", Debug::$LOG_VERBOSE); - - if (!$cache->exists($local_filename)) { - Debug::log("cache_media: downloading: $src to $local_filename", Debug::$LOG_VERBOSE); - - global $fetch_last_error_code; - global $fetch_last_error; + 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); + } + } - $file_content = fetch_file_contents(array("url" => $src, - "http_referrer" => $src, - "max_size" => MAX_CACHE_FILE_SIZE)); + if ($entry->hasAttribute("srcset")) { + $matches = self::decode_srcset($entry->getAttribute('srcset')); - if ($file_content) { - $cache->put($local_filename, $file_content); - } else { - Debug::log("cache_media: failed with $fetch_last_error_code: $fetch_last_error"); - } - } else if ($cache->isWritable($local_filename)) { - $cache->touch($local_filename); + for ($i = 0; $i < count($matches); $i++) { + self::cache_media_url($cache, $matches[$i]["url"], $site_url); } } } @@ -1343,6 +1447,7 @@ class RSSUtils { foreach ($filter["rules"] as $rule) { $match = false; $reg_exp = str_replace('/', '\/', $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"]; if (!$reg_exp) @@ -1457,7 +1562,7 @@ class RSSUtils { static function assign_article_to_label_filters($id, $filters, $owner_uid, $article_labels) { foreach ($filters as $f) { if ($f["type"] == "label") { - if (!RSSUtils::labels_contains_caption($article_labels, $f["param"])) { + if (!self::labels_contains_caption($article_labels, $f["param"])) { Labels::add_article($id, $f["param"], $owner_uid); } } @@ -1477,10 +1582,48 @@ class RSSUtils { $pdo->query("DELETE FROM ttrss_cat_counters_cache"); } + static function disable_failed_feeds() { + if (defined('DAEMON_UNSUCCESSFUL_DAYS_LIMIT') && DAEMON_UNSUCCESSFUL_DAYS_LIMIT > 0) { + + $pdo = Db::pdo(); + + $pdo->beginTransaction(); + + $days = (int) DAEMON_UNSUCCESSFUL_DAYS_LIMIT; + + if (DB_TYPE == "pgsql") { + $interval_query = "last_successful_update < NOW() - INTERVAL '$days days' AND last_updated > NOW() - INTERVAL '1 days'"; + } else if (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::get()->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"], DAEMON_UNSUCCESSFUL_DAYS_LIMIT)); + + Debug::log(sprintf("Auto-disabling feed %d (%s) (failed to update for %d days).", $row["id"], + clean($row["title"]), 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($owner_uid) { $tmph = new PluginHost(); - load_user_plugins($owner_uid, $tmph); + UserHelper::load_user_plugins($owner_uid, $tmph); $tmph->run_hooks(PluginHost::HOOK_HOUSE_KEEPING, "hook_house_keeping", ""); } @@ -1488,13 +1631,15 @@ class RSSUtils { static function housekeeping_common() { DiskCache::expire(); - RSSUtils::expire_lock_files(); - RSSUtils::expire_error_log(); - RSSUtils::expire_feed_archive(); - RSSUtils::cleanup_feed_browser(); + 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(); - RSSUtils::cleanup_counters_cache(); + self::cleanup_counters_cache(); PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING, "hook_house_keeping", ""); } @@ -1505,11 +1650,11 @@ class RSSUtils { $icon_file = ICONS_DIR . "/$feed.ico"; if (!file_exists($icon_file)) { - $favicon_url = RSSUtils::get_favicon_url($site_url); + $favicon_url = self::get_favicon_url($site_url); if ($favicon_url) { // Limiting to "image" type misses those served with text/plain - $contents = fetch_file_contents($favicon_url); // , "image"); + $contents = UrlHelper::fetch($favicon_url); // , "image"); if ($contents) { // Crude image type matching. @@ -1682,10 +1827,10 @@ class RSSUtils { $favicon_url = false; - if ($html = @fetch_file_contents($url)) { + if ($html = @UrlHelper::fetch($url)) { $doc = new DOMDocument(); - if ($doc->loadHTML($html)) { + if (@$doc->loadHTML($html)) { $xpath = new DOMXPath($doc); $base = $xpath->query('/html/head/base[@href]'); @@ -1710,4 +1855,37 @@ class RSSUtils { return $favicon_url; } + // https://community.tt-rss.org/t/problem-with-img-srcset/3519 + static function decode_srcset($srcset) { + $matches = []; + + preg_match_all( + '/(?:\A|,)\s*(?P<url>(?!,)\S+(?<!,))\s*(?P<size>\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; + } + + static function encode_srcset($matches) { + $tokens = []; + + foreach ($matches as $m) { + array_push($tokens, trim($m["url"]) . " " . trim($m["size"])); + } + + return implode(",", $tokens); + } + + static function function_enabled($func) { + return !in_array($func, + explode(',', (string)ini_get('disable_functions'))); + } } diff --git a/classes/sanitizer.php b/classes/sanitizer.php new file mode 100644 index 000000000..9f3bfada0 --- /dev/null +++ b/classes/sanitizer.php @@ -0,0 +1,217 @@ +<?php +class Sanitizer { + private static function strip_harmful_tags($doc, $allowed_elements, $disallowed_attributes) { + $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($entry) { + @$src = parse_url($entry->getAttribute("src"), PHP_URL_HOST); + + if ($src) { + foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_IFRAME_WHITELISTED) as $plugin) { + if ($plugin->hook_iframe_whitelisted($src)) + return true; + } + } + + return false; + } + + public static function sanitize($str, $force_remove_images = false, $owner = false, $site_url = false, $highlight_words = false, $article_id = false) { + if (!$owner) $owner = $_SESSION["uid"]; + + $res = trim($str); if (!$res) return ''; + + $doc = new DOMDocument(); + $doc->loadHTML('<?xml encoding="UTF-8">' . $res); + $xpath = new DOMXPath($doc); + + $rewrite_base_url = $site_url ? $site_url : get_self_url_prefix(); + + $entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src])'); + + foreach ($entries as $entry) { + + if ($entry->hasAttribute('href')) { + $entry->setAttribute('href', + rewrite_relative_url($rewrite_base_url, $entry->getAttribute('href'))); + + $entry->setAttribute('rel', 'noopener noreferrer'); + $entry->setAttribute("target", "_blank"); + } + + if ($entry->hasAttribute('src')) { + $entry->setAttribute('src', + rewrite_relative_url($rewrite_base_url, $entry->getAttribute('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"] = rewrite_relative_url($rewrite_base_url, $matches[$i]["url"]); + } + + $entry->setAttribute("srcset", RSSUtils::encode_srcset($matches)); + } + + if ($entry->hasAttribute('src') && + ($owner && get_pref("STRIP_IMAGES", $owner)) || $force_remove_images || $_SESSION["bw_limit"]) { + + $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 (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']) $allowed_elements[] = 'iframe'; + + $disallowed_attributes = array('id', 'style', 'class', 'width', 'height', 'allow'); + + foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_SANITIZE) as $plugin) { + $retval = $plugin->hook_sanitize($doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id); + if (is_array($retval)) { + $doc = $retval[0]; + $allowed_elements = $retval[1]; + $disallowed_attributes = $retval[2]; + } else { + $doc = $retval; + } + } + + $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 ($highlight_words && 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, $pos))); + $word = mb_substr($text, $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 <body>...</body> */ + + $res_frag = array(); + if (preg_match('/<body>(.*)<\/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 @@ +<?php +require_once "lib/MiniTemplator.class.php"; + +class Templator extends MiniTemplator { + + /* this reads tt-rss template from templates.local/ or templates/ if only base filename is given */ + function readTemplateFromFile ($fileName) { + if (strpos($fileName, "/") === false) { + + $fileName = basename($fileName); + + if (file_exists("templates.local/$fileName")) + return parent::readTemplateFromFile("templates.local/$fileName"); + else + return parent::readTemplateFromFile("templates/$fileName"); + + } else { + return parent::readTemplateFromFile($fileName); + } + } +} diff --git a/classes/timehelper.php b/classes/timehelper.php new file mode 100644 index 000000000..ce9e35f3e --- /dev/null +++ b/classes/timehelper.php @@ -0,0 +1,88 @@ +<?php +class TimeHelper { + + static function smart_date_time($timestamp, $tz_offset = 0, $owner_uid = false, $eta_min = false) { + if (!$owner_uid) $owner_uid = $_SESSION['uid']; + + if ($eta_min && time() + $tz_offset - $timestamp < 3600) { + return T_sprintf("%d min", date("i", time() + $tz_offset - $timestamp)); + } else if (date("Y.m.d", $timestamp) == date("Y.m.d", time() + $tz_offset)) { + $format = get_pref('SHORT_DATE_FORMAT', $owner_uid); + if (strpos((strtolower($format)), "a") === false) + return date("G:i", $timestamp); + else + return date("g:i a", $timestamp); + } else if (date("Y", $timestamp) == date("Y", time() + $tz_offset)) { + $format = get_pref('SHORT_DATE_FORMAT', $owner_uid); + return date($format, $timestamp); + } else { + $format = get_pref('LONG_DATE_FORMAT', $owner_uid); + return date($format, $timestamp); + } + } + + static function make_local_datetime($timestamp, $long, $owner_uid = false, + $no_smart_dt = false, $eta_min = false) { + + if (!$owner_uid) $owner_uid = $_SESSION['uid']; + if (!$timestamp) $timestamp = '1970-01-01 0:00'; + + global $utc_tz; + global $user_tz; + + if (!$utc_tz) $utc_tz = new DateTimeZone('UTC'); + + $timestamp = substr($timestamp, 0, 19); + + # We store date in UTC internally + $dt = new DateTime($timestamp, $utc_tz); + + $user_tz_string = get_pref('USER_TIMEZONE', $owner_uid); + + if ($user_tz_string != 'Automatic') { + + try { + if (!$user_tz) $user_tz = new DateTimeZone($user_tz_string); + } catch (Exception $e) { + $user_tz = $utc_tz; + } + + $tz_offset = $user_tz->getOffset($dt); + } else { + $tz_offset = (int) -$_SESSION["clientTzOffset"]; + } + + $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('LONG_DATE_FORMAT', $owner_uid); + else + $format = get_pref('SHORT_DATE_FORMAT', $owner_uid); + + return date($format, $user_timestamp); + } + } + + static function convert_timestamp($timestamp, $source_tz, $dest_tz) { + + 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 $dt->format('U') + $dest_tz->getOffset($dt); + } + +} diff --git a/classes/urlhelper.php b/classes/urlhelper.php new file mode 100644 index 000000000..d7b7d004a --- /dev/null +++ b/classes/urlhelper.php @@ -0,0 +1,489 @@ +<?php +class UrlHelper { + static function build_url($parts) { + $tmp = $parts['scheme'] . "://" . $parts['host'] . $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. + * + * @param string $url Base URL (i.e. from where the document is) + * @param string $rel_url Possibly relative URL in the document + * + * @return string Absolute URL + */ + public static function rewrite_relative($url, $rel_url) { + + $rel_parts = parse_url($rel_url); + + if ($rel_parts['host'] && $rel_parts['scheme']) { + return self::validate($rel_url); + } else if (strpos($rel_url, "//") === 0) { + # protocol-relative URL (rare but they exist) + return self::validate("https:" . $rel_url); + } else if (strpos($rel_url, "magnet:") === 0) { + # allow magnet links + return $rel_url; + } else { + $parts = parse_url($url); + + $rel_parts['host'] = $parts['host']; + $rel_parts['scheme'] = $parts['scheme']; + + if (strpos($rel_parts['path'], '/') !== 0) + $rel_parts['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 + static function validate($url, $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 (!$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') { + $tokens['host'] = idn_to_ascii($tokens['host']); + } + } + + // 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']) { + $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; + } + + static function resolve_redirects($url, $timeout, $nest = 0) { + + // too many redirects + if ($nest > 10) + return false; + + if (version_compare(PHP_VERSION, '7.1.0', '>=')) { + $context_options = array( + 'http' => array( + 'header' => array( + 'Connection: close' + ), + 'method' => 'HEAD', + 'timeout' => $timeout, + 'protocol_version'=> 1.1) + ); + + if (defined('_HTTP_PROXY')) { + $context_options['http']['request_fulluri'] = true; + $context_options['http']['proxy'] = _HTTP_PROXY; + } + + $context = stream_context_create($context_options); + + $headers = get_headers($url, 0, $context); + } else { + $headers = get_headers($url, 0); + } + + 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); + } + } + + return $url; + } + + // request failed? + return false; + } + + // 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*/) { + + global $fetch_last_error; + global $fetch_last_error_code; + global $fetch_last_error_content; + global $fetch_last_content_type; + global $fetch_last_modified; + global $fetch_effective_url; + global $fetch_effective_ip_addr; + global $fetch_curl_used; + global $fetch_domain_hits; + + $fetch_last_error = false; + $fetch_last_error_code = -1; + $fetch_last_error_content = ""; + $fetch_last_content_type = ""; + $fetch_curl_used = false; + $fetch_last_modified = ""; + $fetch_effective_url = ""; + $fetch_effective_ip_addr = ""; + + if (!is_array($fetch_domain_hits)) + $fetch_domain_hits = []; + + 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"]; + $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"] : 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); + + $url = self::validate($url, true); + + if (!$url) { + $fetch_last_error = "Requested URL failed extended validation."; + return false; + } + + $url_host = parse_url($url, PHP_URL_HOST); + $ip_addr = gethostbyname($url_host); + + if (!$ip_addr || strpos($ip_addr, "127.") === 0) { + $fetch_last_error = "URL hostname failed to resolve or resolved to a loopback address ($ip_addr)"; + return false; + } + + $fetch_domain_hits[$url_host] += 1; + + /*if ($fetch_domain_hits[$url_host] > MAX_FETCH_REQUESTS_PER_HOST) { + user_error("Exceeded fetch request quota for $url_host: " . $fetch_domain_hits[$url_host], E_USER_WARNING); + #return false; + }*/ + + if (!defined('NO_CURL') && function_exists('curl_init') && !ini_get("open_basedir")) { + + $fetch_curl_used = true; + + $ch = curl_init($url); + + $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 : FILE_FETCH_CONNECT_TIMEOUT); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout ? $timeout : FILE_FETCH_TIMEOUT); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, !ini_get("open_basedir") && $followlocation); + curl_setopt($ch, CURLOPT_MAXREDIRS, 20); + curl_setopt($ch, CURLOPT_BINARYTRANSFER, true); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_ANY); + curl_setopt($ch, CURLOPT_USERAGENT, $useragent ? $useragent : + SELF_USER_AGENT); + curl_setopt($ch, CURLOPT_ENCODING, ""); + + 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) { + Debug::log("[curl progressfunction] $downloaded $max_size", Debug::$LOG_EXTENDED); + + return ($downloaded > $max_size) ? 1 : 0; // if max size is set, abort when exceeding it + }); + + } + + if (!ini_get("open_basedir")) { + curl_setopt($ch, CURLOPT_COOKIEJAR, "/dev/null"); + } + + if (defined('_HTTP_PROXY')) { + curl_setopt($ch, CURLOPT_PROXY, _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); + + $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") { + $fetch_last_modified = $value; + } + } + + if (substr(strtolower($header), 0, 7) == 'http/1.') { + $fetch_last_error_code = (int) substr($header, 9, 3); + $fetch_last_error = $header; + } + } + + if (curl_errno($ch) === 23 || curl_errno($ch) === 61) { + curl_setopt($ch, CURLOPT_ENCODING, 'none'); + $contents = @curl_exec($ch); + } + + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $fetch_last_content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + + $fetch_effective_url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); + + if (!self::validate($fetch_effective_url, true)) { + $fetch_last_error = "URL received after redirection failed extended validation."; + + return false; + } + + $fetch_effective_ip_addr = gethostbyname(parse_url($fetch_effective_url, PHP_URL_HOST)); + + if (!$fetch_effective_ip_addr || strpos($fetch_effective_ip_addr, "127.") === 0) { + $fetch_last_error = "URL hostname received after redirection failed to resolve or resolved to a loopback address ($fetch_effective_ip_addr)"; + + return false; + } + + $fetch_last_error_code = $http_code; + + if ($http_code != 200 || $type && strpos($fetch_last_content_type, "$type") === false) { + + if (curl_errno($ch) != 0) { + $fetch_last_error .= "; " . curl_errno($ch) . " " . curl_error($ch); + } + + $fetch_last_error_content = $contents; + curl_close($ch); + return false; + } + + if (!$contents) { + $fetch_last_error = curl_errno($ch) . " " . curl_error($ch); + curl_close($ch); + return false; + } + + curl_close($ch); + + $is_gzipped = RSSUtils::is_gzipped($contents); + + if ($is_gzipped) { + $tmp = @gzdecode($contents); + + if ($tmp) $contents = $tmp; + } + + return $contents; + } else { + + $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 : 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 (defined('_HTTP_PROXY')) { + $context_options['http']['request_fulluri'] = true; + $context_options['http']['proxy'] = _HTTP_PROXY; + } + + $context = stream_context_create($context_options); + + $old_error = error_get_last(); + + $fetch_effective_url = self::resolve_redirects($url, $timeout ? $timeout : FILE_FETCH_CONNECT_TIMEOUT); + + if (!self::validate($fetch_effective_url, true)) { + $fetch_last_error = "URL received after redirection failed extended validation."; + + return false; + } + + $fetch_effective_ip_addr = gethostbyname(parse_url($fetch_effective_url, PHP_URL_HOST)); + + if (!$fetch_effective_ip_addr || strpos($fetch_effective_ip_addr, "127.") === 0) { + $fetch_last_error = "URL hostname received after redirection failed to resolve or resolved to a loopback address ($fetch_effective_ip_addr)"; + + return false; + } + + $data = @file_get_contents($url, false, $context); + + if (isset($http_response_header) && is_array($http_response_header)) { + foreach ($http_response_header as $header) { + if (strstr($header, ": ") !== false) { + list ($key, $value) = explode(": ", $header); + + $key = strtolower($key); + + if ($key == 'content-type') { + $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') { + $fetch_last_modified = $value; + } else if ($key == 'location') { + $fetch_effective_url = $value; + } + } + + if (substr(strtolower($header), 0, 7) == 'http/1.') { + $fetch_last_error_code = (int) substr($header, 9, 3); + $fetch_last_error = $header; + } + } + } + + if ($fetch_last_error_code != 200) { + $error = error_get_last(); + + if ($error['message'] != $old_error['message']) { + $fetch_last_error .= "; " . $error["message"]; + } + + $fetch_last_error_content = $data; + + return false; + } + + $is_gzipped = RSSUtils::is_gzipped($data); + + if ($is_gzipped) { + $tmp = @gzdecode($data); + + if ($tmp) $data = $tmp; + } + + return $data; + } + } + +} diff --git a/classes/userhelper.php b/classes/userhelper.php new file mode 100644 index 000000000..fd0b0ac57 --- /dev/null +++ b/classes/userhelper.php @@ -0,0 +1,141 @@ +<?php +class UserHelper { + + static function authenticate($login, $password, $check_only = false, $service = false) { + + if (!SINGLE_USER_MODE) { + $user_id = false; + $auth_module = false; + + foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_AUTH_USER) as $plugin) { + + $user_id = (int) $plugin->authenticate($login, $password, $service); + + if ($user_id) { + $auth_module = strtolower(get_class($plugin)); + break; + } + } + + if ($user_id && !$check_only) { + + session_start(); + session_regenerate_id(true); + + $_SESSION["uid"] = $user_id; + $_SESSION["auth_module"] = $auth_module; + + $pdo = Db::pdo(); + $sth = $pdo->prepare("SELECT login,access_level,pwd_hash FROM ttrss_users + WHERE id = ?"); + $sth->execute([$user_id]); + $row = $sth->fetch(); + + $_SESSION["name"] = $row["login"]; + $_SESSION["access_level"] = $row["access_level"]; + $_SESSION["csrf_token"] = bin2hex(get_random_bytes(16)); + + $usth = $pdo->prepare("UPDATE ttrss_users SET last_login = NOW() WHERE id = ?"); + $usth->execute([$user_id]); + + $_SESSION["ip_address"] = $_SERVER["REMOTE_ADDR"]; + $_SESSION["user_agent"] = sha1($_SERVER['HTTP_USER_AGENT']); + $_SESSION["pwd_hash"] = $row["pwd_hash"]; + + Pref_Prefs::initialize_user_prefs($_SESSION["uid"]); + + return true; + } + + return false; + + } else { + + $_SESSION["uid"] = 1; + $_SESSION["name"] = "admin"; + $_SESSION["access_level"] = 10; + + $_SESSION["hide_hello"] = true; + $_SESSION["hide_logout"] = true; + + $_SESSION["auth_module"] = false; + + if (!$_SESSION["csrf_token"]) + $_SESSION["csrf_token"] = bin2hex(get_random_bytes(16)); + + $_SESSION["ip_address"] = $_SERVER["REMOTE_ADDR"]; + + Pref_Prefs::initialize_user_prefs($_SESSION["uid"]); + + return true; + } + } + + static function load_user_plugins($owner_uid, $pluginhost = false) { + + if (!$pluginhost) $pluginhost = PluginHost::getInstance(); + + if ($owner_uid && SCHEMA_VERSION >= 100 && !$_SESSION["safe_mode"]) { + $plugins = get_pref("_ENABLED_PLUGINS", $owner_uid); + + $pluginhost->load($plugins, PluginHost::KIND_USER, $owner_uid); + + if (get_schema_version() > 100) { + $pluginhost->load_data(); + } + } + } + + static function login_sequence() { + $pdo = Db::pdo(); + + if (SINGLE_USER_MODE) { + @session_start(); + self::authenticate("admin", null); + startup_gettext(); + self::load_user_plugins($_SESSION["uid"]); + } else { + if (!validate_session()) $_SESSION["uid"] = false; + + if (!$_SESSION["uid"]) { + + if (AUTH_AUTO_LOGIN && self::authenticate(null, null)) { + $_SESSION["ref_schema_version"] = get_schema_version(true); + } else { + self::authenticate(null, null, true); + } + + if (!$_SESSION["uid"]) { + Pref_Users::logout_user(); + + Handler_Public::render_login_form(); + exit; + } + + } else { + /* bump login timestamp */ + $sth = $pdo->prepare("UPDATE ttrss_users SET last_login = NOW() WHERE id = ?"); + $sth->execute([$_SESSION['uid']]); + + $_SESSION["last_login_update"] = time(); + } + + if ($_SESSION["uid"]) { + startup_gettext(); + self::load_user_plugins($_SESSION["uid"]); + } + } + } + + static function print_user_stylesheet() { + $value = get_pref('USER_STYLESHEET'); + + if ($value) { + print "<style type='text/css' id='user_css_style'>"; + print str_replace("<br/>", "\n", $value); + print "</style>"; + } + + } + +} |