diff options
48 files changed, 1026 insertions, 342 deletions
diff --git a/api/index.php b/api/index.php index 3fbf6bf57..363f2ae13 100644 --- a/api/index.php +++ b/api/index.php @@ -22,8 +22,6 @@ ini_set('session.use_cookies', 0); ini_set("session.gc_maxlifetime", 86400); - define('AUTH_DISABLE_OTP', true); - if (defined('ENABLE_GZIP_OUTPUT') && ENABLE_GZIP_OUTPUT && function_exists("ob_gzhandler")) { diff --git a/classes/api.php b/classes/api.php index 44c9841ce..6fb87d04f 100755 --- a/classes/api.php +++ b/classes/api.php @@ -74,10 +74,10 @@ class API extends Handler { } if (get_pref("ENABLE_API_ACCESS", $uid)) { - if (authenticate_user($login, $password)) { // try login with normal password + if (authenticate_user($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)) { // else try with base64_decoded password + } else if (authenticate_user($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 @@ -535,6 +535,7 @@ class API extends Handler { /* Labels */ + /* API only: -4 All feeds, including virtual feeds */ if ($cat_id == -4 || $cat_id == -2) { $counters = Counters::getLabelCounters(true); @@ -582,7 +583,7 @@ class API extends Handler { if ($include_nested && $cat_id) { $sth = $pdo->prepare("SELECT id, title, order_id FROM ttrss_feed_categories - WHERE parent_cat = ? AND owner_uid = ? ORDER BY id, title"); + WHERE parent_cat = ? AND owner_uid = ? ORDER BY order_id, title"); $sth->execute([$cat_id, $_SESSION['uid']]); @@ -611,12 +612,13 @@ class API extends Handler { $limit_qpart = ""; } + /* API only: -3 All feeds, excluding virtual feeds (e.g. Labels and such) */ if ($cat_id == -4 || $cat_id == -3) { $sth = $pdo->prepare("SELECT id, feed_url, cat_id, title, order_id, ". SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated FROM ttrss_feeds WHERE owner_uid = ? - ORDER BY cat_id, title " . $limit_qpart); + ORDER BY order_id, title " . $limit_qpart); $sth->execute([$_SESSION['uid']]); } else { @@ -627,7 +629,7 @@ class API extends Handler { FROM ttrss_feeds WHERE (cat_id = :cat OR (:cat = 0 AND cat_id IS NULL)) AND owner_uid = :uid - ORDER BY cat_id, title " . $limit_qpart); + ORDER BY order_id, title " . $limit_qpart); $sth->execute([":uid" => $_SESSION['uid'], ":cat" => $cat_id]); } diff --git a/classes/article.php b/classes/article.php index 943528f2a..fc81838ed 100755 --- a/classes/article.php +++ b/classes/article.php @@ -305,19 +305,9 @@ class Article extends Handler_Protected { post_int_id = ? AND owner_uid = ?"); $sth->execute([$int_id, $_SESSION['uid']]); - foreach ($tags as $tag) { - $tag = Article::sanitize_tag($tag); - - if (!Article::tag_is_valid($tag)) { - continue; - } - - if (preg_match("/^[0-9]*$/", $tag)) { - continue; - } - - // print "<!-- $id : $int_id : $tag -->"; + $tags = FeedItem_Common::normalize_categories($tags); + foreach ($tags as $tag) { if ($tag != '') { $sth = $this->pdo->prepare("INSERT INTO ttrss_tags (post_int_id, owner_uid, tag_name) @@ -331,7 +321,6 @@ class Article extends Handler_Protected { /* update tag cache */ - sort($tags_to_cache); $tags_str = join(",", $tags_to_cache); $sth = $this->pdo->prepare("UPDATE ttrss_user_entries @@ -802,27 +791,6 @@ class Article extends Handler_Protected { return $rv; } - static function sanitize_tag($tag) { - $tag = trim($tag); - - $tag = mb_strtolower($tag, 'utf-8'); - - $tag = preg_replace('/[,\'\"\+\>\<]/', "", $tag); - - if (DB_TYPE == "mysql") { - $tag = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $tag); - } - - return $tag; - } - - static function tag_is_valid($tag) { - if (!$tag || is_numeric($tag) || mb_strlen($tag) > 250) - return false; - - return true; - } - static function get_article_image($enclosures, $content, $site_url) { $article_image = ""; diff --git a/classes/auth/base.php b/classes/auth/base.php index dbc77f8cd..4cbc23589 100644 --- a/classes/auth/base.php +++ b/classes/auth/base.php @@ -2,6 +2,8 @@ class Auth_Base { private $pdo; + const AUTH_SERVICE_API = '_api'; + function __construct() { $this->pdo = Db::pdo(); } @@ -9,14 +11,14 @@ class Auth_Base { /** * @SuppressWarnings(unused) */ - function check_password($owner_uid, $password) { + function check_password($owner_uid, $password, $service = '') { return false; } /** * @SuppressWarnings(unused) */ - function authenticate($login, $password) { + function authenticate($login, $password, $service = '') { return false; } diff --git a/classes/backend.php b/classes/backend.php index 5bd724728..122e28c65 100644 --- a/classes/backend.php +++ b/classes/backend.php @@ -88,7 +88,7 @@ class Backend extends Handler { } function help() { - $topic = basename(clean($_REQUEST["topic"])); // only one for now + $topic = clean_filename($_REQUEST["topic"]); // only one for now if ($topic == "main") { $info = get_hotkeys_info(); diff --git a/classes/digest.php b/classes/digest.php index f2533d160..c9e9f24e7 100644 --- a/classes/digest.php +++ b/classes/digest.php @@ -103,9 +103,11 @@ class Digest $tpl->setVariable('CUR_DATE', date('Y/m/d', $local_ts)); $tpl->setVariable('CUR_TIME', date('G:i', $local_ts)); + $tpl->setVariable('TTRSS_HOST', SELF_URL_PATH); $tpl_t->setVariable('CUR_DATE', date('Y/m/d', $local_ts)); $tpl_t->setVariable('CUR_TIME', date('G:i', $local_ts)); + $tpl_t->setVariable('TTRSS_HOST', SELF_URL_PATH); $affected_ids = array(); diff --git a/classes/feeditem/atom.php b/classes/feeditem/atom.php index a962b59f2..a03080981 100755 --- a/classes/feeditem/atom.php +++ b/classes/feeditem/atom.php @@ -103,20 +103,20 @@ class FeedItem_Atom extends FeedItem_Common { function get_categories() { $categories = $this->elem->getElementsByTagName("category"); - $cats = array(); + $cats = []; foreach ($categories as $cat) { if ($cat->hasAttribute("term")) - array_push($cats, trim($cat->getAttribute("term"))); + array_push($cats, $cat->getAttribute("term")); } $categories = $this->xpath->query("dc:subject", $this->elem); foreach ($categories as $cat) { - array_push($cats, clean(trim($cat->nodeValue))); + array_push($cats, $cat->nodeValue); } - return $cats; + return $this->normalize_categories($cats); } function get_enclosures() { diff --git a/classes/feeditem/common.php b/classes/feeditem/common.php index 3193ed273..f208f4a48 100755 --- a/classes/feeditem/common.php +++ b/classes/feeditem/common.php @@ -162,4 +162,35 @@ abstract class FeedItem_Common extends FeedItem { } } + static function normalize_categories($cats) { + + $tmp = []; + + foreach ($cats as $rawcat) { + $tmp = array_merge($tmp, explode(",", $rawcat)); + } + + $tmp = array_map(function($srccat) { + $cat = clean(trim(mb_strtolower($srccat))); + + // we don't support numeric tags + if (is_numeric($cat)) + $cat = 't:' . $cat; + + $cat = preg_replace('/[,\'\"]/', "", $cat); + + if (DB_TYPE == "mysql") { + $cat = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $cat); + } + + if (mb_strlen($cat) > 250) + $cat = mb_substr($cat, 0, 250); + + return $cat; + }, $tmp); + + asort($tmp); + + return array_unique($tmp); + } } diff --git a/classes/feeditem/rss.php b/classes/feeditem/rss.php index 916c73ec4..1f7953c51 100755 --- a/classes/feeditem/rss.php +++ b/classes/feeditem/rss.php @@ -97,19 +97,19 @@ class FeedItem_RSS extends FeedItem_Common { function get_categories() { $categories = $this->elem->getElementsByTagName("category"); - $cats = array(); + $cats = []; foreach ($categories as $cat) { - array_push($cats, trim($cat->nodeValue)); + array_push($cats, $cat->nodeValue); } $categories = $this->xpath->query("dc:subject", $this->elem); foreach ($categories as $cat) { - array_push($cats, clean(trim($cat->nodeValue))); + array_push($cats, $cat->nodeValue); } - return $cats; + return $this->normalize_categories($cats); } function get_enclosures() { diff --git a/classes/feeds.php b/classes/feeds.php index b89f4e4ca..bae571a3f 100755 --- a/classes/feeds.php +++ b/classes/feeds.php @@ -2,6 +2,8 @@ require_once "colors.php"; class Feeds extends Handler_Protected { + const NEVER_GROUP_FEEDS = [ -6, 0 ]; + const NEVER_GROUP_BY_DATE = [ -2, -1, -3 ]; private $params; @@ -199,7 +201,8 @@ class Feeds extends Handler_Protected { $qfh_ret = $this->queryFeedHeadlines($params); } - $vfeed_group_enabled = get_pref("VFEED_GROUP_BY_FEED") && $feed != -6; + $vfeed_group_enabled = get_pref("VFEED_GROUP_BY_FEED") && + !(in_array($feed, Feeds::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]; @@ -1438,7 +1441,7 @@ class Feeds extends Handler_Protected { $start_ts = isset($params["start_ts"]) ? $params["start_ts"] : false; $check_first_id = isset($params["check_first_id"]) ? $params["check_first_id"] : false; $skip_first_id_check = isset($params["skip_first_id_check"]) ? $params["skip_first_id_check"] : false; - $order_by = isset($params["order_by"]) ? $params["order_by"] : false; + //$order_by = isset($params["order_by"]) ? $params["order_by"] : false; $ext_tables_part = ""; $limit_query_part = ""; @@ -1693,12 +1696,18 @@ class Feeds extends Handler_Protected { if (is_numeric($feed)) { // proper override_order applied above if ($vfeed_query_part && !$ignore_vfeed_group && get_pref('VFEED_GROUP_BY_FEED', $owner_uid)) { - $yyiw_desc = $order_by == "date_reverse" ? "" : "desc"; + + if (!(in_array($feed, Feeds::NEVER_GROUP_BY_DATE) && !$cat_view)) { + $yyiw_desc = $order_by == "date_reverse" ? "" : "desc"; + $yyiw_order_qpart = "yyiw $yyiw_desc, "; + } else { + $yyiw_order_qpart = ""; + } if (!$override_order) { - $order_by = "yyiw $yyiw_desc, ttrss_feeds.title, ".$order_by; + $order_by = "$yyiw_order_qpart ttrss_feeds.title, $order_by"; } else { - $order_by = "yyiw $yyiw_desc, ttrss_feeds.title, ".$override_order; + $order_by = "$yyiw_order_qpart ttrss_feeds.title, $override_order"; } } diff --git a/classes/handler/public.php b/classes/handler/public.php index 06c01df57..b81fb03b8 100755 --- a/classes/handler/public.php +++ b/classes/handler/public.php @@ -509,7 +509,7 @@ class Handler_Public extends Handler { <!DOCTYPE html> <html> <head> - <title><?php echo __("Share with Tiny Tiny RSS") ?> ?></title> + <title><?php echo __("Share with Tiny Tiny RSS") ?></title> <?php echo stylesheet_tag("css/default.css"); echo javascript_tag("lib/prototype.js"); @@ -996,6 +996,7 @@ class Handler_Public extends Handler { $tpl->setVariable('LOGIN', $login); $tpl->setVariable('RESETPASS_LINK', $resetpass_link); + $tpl->setVariable('TTRSS_HOST', SELF_URL_PATH); $tpl->addBlock('message'); @@ -1203,27 +1204,30 @@ class Handler_Public extends Handler { public function pluginhandler() { $host = new PluginHost(); - $plugin = basename(clean($_REQUEST["plugin"])); + $plugin_name = clean_filename($_REQUEST["plugin"]); $method = clean($_REQUEST["pmethod"]); - $host->load($plugin, PluginHost::KIND_USER, 0); + $host->load($plugin_name, PluginHost::KIND_USER, 0); $host->load_data(); - $pclass = $host->get_plugin($plugin); + $plugin = $host->get_plugin($plugin_name); - if ($pclass) { - if (method_exists($pclass, $method)) { - if ($pclass->is_public_method($method)) { - $pclass->$method(); + if ($plugin) { + if (method_exists($plugin, $method)) { + if ($plugin->is_public_method($method)) { + $plugin->$method(); } else { + user_error("PluginHandler[PUBLIC]: Requested private method '$method' of plugin '$plugin_name'.", E_USER_WARNING); header("Content-Type: text/json"); print error_json(6); } } else { + user_error("PluginHandler[PUBLIC]: Requested unknown method '$method' of plugin '$plugin_name'.", E_USER_WARNING); header("Content-Type: text/json"); print error_json(13); } } else { + user_error("PluginHandler[PUBLIC]: Requested method '$method' of unknown plugin '$plugin_name'.", E_USER_WARNING); header("Content-Type: text/json"); print error_json(14); } diff --git a/classes/iauthmodule.php b/classes/iauthmodule.php index 9ec674078..2d0c98709 100644 --- a/classes/iauthmodule.php +++ b/classes/iauthmodule.php @@ -1,4 +1,4 @@ <?php interface IAuthModule { - function authenticate($login, $password); + function authenticate($login, $password); // + optional third parameter: $service } diff --git a/classes/logger/sql.php b/classes/logger/sql.php index 989539e5d..1b44b1e5f 100755 --- a/classes/logger/sql.php +++ b/classes/logger/sql.php @@ -15,6 +15,17 @@ class Logger_SQL { // limit context length, DOMDocument dumps entire XML in here sometimes, which may be huge $context = mb_substr($context, 0, 8192); + $server_params = [ + "IP" => "REMOTE_ADDR", + "Request URI" => "REQUEST_URI", + "User agent" => "HTTP_USER_AGENT", + ]; + + foreach ($server_params as $n => $p) { + if (isset($_SERVER[$p])) + $context .= "\n$n: " . $_SERVER[$p]; + } + // passed error message may contain invalid unicode characters, failing to insert an error here // would break the execution entirely by generating an actual fatal error instead of a E_WARNING etc $errstr = UConverter::transcode($errstr, 'UTF-8', 'UTF-8'); diff --git a/classes/pluginhandler.php b/classes/pluginhandler.php index d10343e09..9682e440f 100644 --- a/classes/pluginhandler.php +++ b/classes/pluginhandler.php @@ -5,15 +5,18 @@ class PluginHandler extends Handler_Protected { } function catchall($method) { - $plugin = PluginHost::getInstance()->get_plugin(clean($_REQUEST["plugin"])); + $plugin_name = clean($_REQUEST["plugin"]); + $plugin = PluginHost::getInstance()->get_plugin($plugin_name); if ($plugin) { if (method_exists($plugin, $method)) { $plugin->$method(); } else { + user_error("PluginHandler: Requested unknown method '$method' of plugin '$plugin_name'.", E_USER_WARNING); print error_json(13); } } else { + user_error("PluginHandler: Requested method '$method' of unknown plugin '$plugin_name'.", E_USER_WARNING); print error_json(14); } } diff --git a/classes/pluginhost.php b/classes/pluginhost.php index d09ecca17..6158880f2 100755 --- a/classes/pluginhost.php +++ b/classes/pluginhost.php @@ -60,6 +60,8 @@ class PluginHost { const HOOK_FILTER_TRIGGERED = 40; const HOOK_GET_FULL_TEXT = 41; const HOOK_ARTICLE_IMAGE = 42; + const HOOK_FEED_TREE = 43; + const HOOK_IFRAME_WHITELISTED = 44; const KIND_ALL = 1; const KIND_SYSTEM = 2; @@ -128,28 +130,44 @@ class PluginHost { } } - function add_hook($type, $sender) { + function add_hook($type, $sender, $priority = 50) { + $priority = (int) $priority; + if (!is_array($this->hooks[$type])) { - $this->hooks[$type] = array(); + $this->hooks[$type] = []; + } + + if (!is_array($this->hooks[$type][$priority])) { + $this->hooks[$type][$priority] = []; } - array_push($this->hooks[$type], $sender); + array_push($this->hooks[$type][$priority], $sender); + ksort($this->hooks[$type]); } function del_hook($type, $sender) { if (is_array($this->hooks[$type])) { - $key = array_Search($sender, $this->hooks[$type]); - if ($key !== FALSE) { - unset($this->hooks[$type][$key]); + foreach (array_keys($this->hooks[$type]) as $prio) { + $key = array_search($sender, $this->hooks[$type][$prio]); + + if ($key !== FALSE) { + unset($this->hooks[$type][$prio][$key]); + } } } } function get_hooks($type) { if (isset($this->hooks[$type])) { - return $this->hooks[$type]; + $tmp = []; + + foreach (array_keys($this->hooks[$type]) as $prio) { + $tmp = array_merge($tmp, $this->hooks[$type][$prio]); + } + + return $tmp; } else { - return array(); + return []; } } function load_all($kind, $owner_uid = false, $skip_init = false) { @@ -170,7 +188,7 @@ class PluginHost { foreach ($plugins as $class) { $class = trim($class); - $class_file = strtolower(basename($class)); + $class_file = strtolower(clean_filename($class)); if (!is_dir(__DIR__."/../plugins/$class_file") && !is_dir(__DIR__."/../plugins.local/$class_file")) continue; @@ -475,4 +493,34 @@ class PluginHost { function get_owner_uid() { return $this->owner_uid; } + + // handled by classes/pluginhandler.php, requires valid session + function get_method_url($sender, $method, $params) { + return get_self_url_prefix() . "/backend.php?" . + http_build_query( + array_merge( + [ + "op" => "pluginhandler", + "plugin" => strtolower(get_class($sender)), + "method" => $method + ], + $params)); + } + + // WARNING: endpoint in public.php, exposed to unauthenticated users + function get_public_method_url($sender, $method, $params) { + if ($sender->is_public_method($method)) { + return get_self_url_prefix() . "/public.php?" . + http_build_query( + array_merge( + [ + "op" => "pluginhandler", + "plugin" => strtolower(get_class($sender)), + "pmethod" => $method + ], + $params)); + } else { + user_error("get_public_method_url: requested method '$method' of '" . get_class($sender) . "' is private."); + } + } } diff --git a/classes/pref/feeds.php b/classes/pref/feeds.php index c55affd77..f672a0375 100755 --- a/classes/pref/feeds.php +++ b/classes/pref/feeds.php @@ -312,7 +312,7 @@ class Pref_Feeds extends Handler_Protected { array_push($root['items'], $feed); } - $root['param'] = vsprintf(_ngettext('(%d feed)', '(%d feeds)', count($cat['items'])), count($cat['items'])); + $root['param'] = vsprintf(_ngettext('(%d feed)', '(%d feeds)', count($root['items'])), count($root['items'])); } $fl = array(); diff --git a/classes/pref/prefs.php b/classes/pref/prefs.php index e7e7a365e..e66052657 100644 --- a/classes/pref/prefs.php +++ b/classes/pref/prefs.php @@ -122,6 +122,11 @@ class Pref_Prefs extends Handler_Protected { $new_pw = clean($_POST["new_password"]); $con_pw = clean($_POST["confirm_password"]); + if ($old_pw == $new_pw) { + print "ERROR: ".format_error("New password must be different from the old one."); + return; + } + if ($old_pw == "") { print "ERROR: ".format_error("Old password cannot be blank."); return; @@ -194,6 +199,37 @@ class Pref_Prefs extends Handler_Protected { $full_name = clean($_POST["full_name"]); $active_uid = $_SESSION["uid"]; + $sth = $this->pdo->prepare("SELECT email, login, full_name FROM ttrss_users WHERE id = ?"); + $sth->execute([$active_uid]); + + if ($row = $sth->fetch()) { + $old_email = $row["email"]; + + if ($old_email != $email) { + $mailer = new Mailer(); + + require_once "lib/MiniTemplator.class.php"; + + $tpl = new MiniTemplator; + + $tpl->readTemplateFromFile("templates/mail_change_template.txt"); + + $tpl->setVariable('LOGIN', $row["login"]); + $tpl->setVariable('NEWMAIL', $email); + $tpl->setVariable('TTRSS_HOST', SELF_URL_PATH); + + $tpl->addBlock('message'); + + $tpl->generateOutputToString($message); + + $mailer->mail(["to_name" => $row["login"], + "to_address" => $row["email"], + "subject" => "[tt-rss] Mail address change notification", + "message" => $message]); + + } + } + $sth = $this->pdo->prepare("UPDATE ttrss_users SET email = ?, full_name = ? WHERE id = ?"); $sth->execute([$email, $full_name, $active_uid]); @@ -359,6 +395,30 @@ class Pref_Prefs extends Handler_Protected { print "</form>"; print "</div>"; # content pane + + if ($_SESSION["auth_module"] == "auth_internal") { + + print "<div dojoType='dijit.layout.ContentPane' title=\"" . __('App passwords') . "\">"; + + print_notice("You can create separate passwords for the API clients. Using one is required if you enable OTP."); + + print "<div id='app_passwords_holder'>"; + $this->appPasswordList(); + print "</div>"; + + print "<hr>"; + + print "<button style='float : left' class='alt-primary' dojoType='dijit.form.Button' + onclick=\"Helpers.AppPasswords.generate()\">" . + __('Generate new password') . "</button> "; + + print "<button style='float : left' class='alt-danger' dojoType='dijit.form.Button' + onclick=\"Helpers.AppPasswords.removeSelected()\">" . + __('Remove selected passwords') . "</button>"; + + print "</div>"; # content pane + } + print "<div dojoType='dijit.layout.ContentPane' title=\"".__('One time passwords / Authenticator')."\">"; if ($_SESSION["auth_module"] == "auth_internal") { @@ -403,17 +463,30 @@ class Pref_Prefs extends Handler_Protected { print "</form>"; - } else if (function_exists("imagecreatefromstring")) { + } else { print_warning("You will need a compatible Authenticator to use this. Changing your password would automatically disable OTP."); - print_notice("Scan the following code by the Authenticator application:"); + print_notice("You will need to generate app passwords for the API clients if you enable OTP."); - $csrf_token = $_SESSION["csrf_token"]; + if (function_exists("imagecreatefromstring")) { + print "<h3>" . __("Scan the following code by the Authenticator application or copy the key manually") . "</h3>"; - print "<img alt='otp qr-code' src='backend.php?op=pref-prefs&method=otpqrcode&csrf_token=$csrf_token'>"; + $csrf_token = $_SESSION["csrf_token"]; + print "<img alt='otp qr-code' src='backend.php?op=pref-prefs&method=otpqrcode&csrf_token=$csrf_token'>"; + } 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>"; + } print "<form dojoType='dijit.form.Form' id='changeOtpForm'>"; + $otp_secret = $this->otpsecret(); + + print "<fieldset>"; + print "<label>".__("OTP Key:")."</label>"; + print "<input dojoType='dijit.form.ValidationTextBox' disabled='disabled' value='$otp_secret' size='32'>"; + print "</fieldset>"; + print_hidden("op", "pref-prefs"); print_hidden("method", "otpenable"); @@ -454,8 +527,6 @@ class Pref_Prefs extends Handler_Protected { print "</form>"; - } else { - print_notice("PHP GD functions are required for OTP support."); } } @@ -773,6 +844,24 @@ 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."); } + $feed_handler_whitelist = [ "Af_Comics" ]; + + $feed_handlers = array_merge( + PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FEED_FETCHED), + PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FEED_PARSED), + 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; }); + + if (count($feed_handlers) > 0) { + print_error( + T_sprintf("The following plugins use per-feed content hooks. This may cause excessive data usage and origin server load resulting in a ban of your instance: <b>%s</b>" , + implode(", ", array_map(function($plugin) { return get_class($plugin); }, $feed_handlers)) + ) . " (<a href='https://tt-rss.org/wiki/FeedHandlerPlugins' target='_blank'>".__("More info...")."</a>)" + ); + } + print "<h2>".__("System plugins")."</h2>"; print_notice("System plugins are enabled in <strong>config.php</strong> for all users."); @@ -886,27 +975,41 @@ class Pref_Prefs extends Handler_Protected { $_SESSION["prefs_show_advanced"] = !$_SESSION["prefs_show_advanced"]; } - function otpqrcode() { - require_once "lib/phpqrcode/phpqrcode.php"; - - $sth = $this->pdo->prepare("SELECT login,salt,otp_enabled + function otpsecret() { + $sth = $this->pdo->prepare("SELECT salt, otp_enabled FROM ttrss_users WHERE id = ?"); $sth->execute([$_SESSION['uid']]); if ($row = $sth->fetch()) { - - $base32 = new \OTPHP\Base32(); - - $login = $row["login"]; $otp_enabled = sql_bool_to_bool($row["otp_enabled"]); if (!$otp_enabled) { - $secret = $base32->encode(sha1($row["salt"])); + $base32 = new \OTPHP\Base32(); + $secret = $base32->encode(mb_substr(sha1($row["salt"]), 0, 12), false); + + return $secret; + } + } + + return false; + } + + function otpqrcode() { + require_once "lib/phpqrcode/phpqrcode.php"; + $sth = $this->pdo->prepare("SELECT login + FROM ttrss_users + WHERE id = ?"); + $sth->execute([$_SESSION['uid']]); + + 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")); - } } } @@ -920,16 +1023,12 @@ class Pref_Prefs extends Handler_Protected { if ($authenticator->check_password($_SESSION["uid"], $password)) { - $sth = $this->pdo->prepare("SELECT salt - FROM ttrss_users - WHERE id = ?"); - $sth->execute([$_SESSION['uid']]); + $secret = $this->otpsecret(); - if ($row = $sth->fetch()) { + if ($secret) { $base32 = new \OTPHP\Base32(); - $secret = $base32->encode(sha1($row["salt"])); $topt = new \OTPHP\TOTP($secret); $otp_check = $topt->now(); @@ -972,6 +1071,31 @@ class Pref_Prefs extends Handler_Protected { if ($authenticator->check_password($_SESSION["uid"], $password)) { + $sth = $this->pdo->prepare("SELECT email, login FROM ttrss_users WHERE id = ?"); + $sth->execute([$_SESSION['uid']]); + + if ($row = $sth->fetch()) { + $mailer = new Mailer(); + + require_once "lib/MiniTemplator.class.php"; + + $tpl = new MiniTemplator; + + $tpl->readTemplateFromFile("templates/otp_disabled_template.txt"); + + $tpl->setVariable('LOGIN', $row["login"]); + $tpl->setVariable('TTRSS_HOST', SELF_URL_PATH); + + $tpl->addBlock('message'); + + $tpl->generateOutputToString($message); + + $mailer->mail(["to_name" => $row["login"], + "to_address" => $row["email"], + "subject" => "[tt-rss] OTP change notification", + "message" => $message]); + } + $sth = $this->pdo->prepare("UPDATE ttrss_users SET otp_enabled = false WHERE id = ?"); $sth->execute([$_SESSION['uid']]); @@ -1130,4 +1254,87 @@ class Pref_Prefs extends Handler_Protected { } return ""; } + + private function appPasswordList() { + print "<div dojoType='fox.Toolbar'>"; + print "<div dojoType='fox.form.DropDownButton'>" . + "<span>" . __('Select') . "</span>"; + print "<div dojoType='dijit.Menu' style='display: none'>"; + print "<div onclick=\"Tables.select('app-password-list', true)\" + dojoType=\"dijit.MenuItem\">" . __('All') . "</div>"; + print "<div onclick=\"Tables.select('app-password-list', false)\" + dojoType=\"dijit.MenuItem\">" . __('None') . "</div>"; + print "</div></div>"; + print "</div>"; #toolbar + + print "<div class='panel panel-scrollable'>"; + print "<table width='100%' id='app-password-list'>"; + print "<tr>"; + print "<th width='2%'></th>"; + print "<th align='left'>".__("Description")."</th>"; + print "<th align='right'>".__("Created")."</th>"; + print "<th align='right'>".__("Last used")."</th>"; + print "</tr>"; + + $sth = $this->pdo->prepare("SELECT id, title, created, last_used + FROM ttrss_app_passwords WHERE owner_uid = ?"); + $sth->execute([$_SESSION['uid']]); + + while ($row = $sth->fetch()) { + + $row_id = $row["id"]; + + print "<tr data-row-id='$row_id'>"; + + print "<td align='center'> + <input onclick='Tables.onRowChecked(this)' dojoType='dijit.form.CheckBox' type='checkbox'></td>"; + print "<td>" . htmlspecialchars($row["title"]) . "</td>"; + + print "<td align='right' class='text-muted'>"; + print make_local_datetime($row['created'], false); + print "</td>"; + + print "<td align='right' class='text-muted'>"; + print make_local_datetime($row['last_used'], false); + print "</td>"; + + print "</tr>"; + } + + print "</table>"; + print "</div>"; + } + + private function encryptAppPassword($password) { + $salt = substr(bin2hex(get_random_bytes(24)), 0, 24); + + return "SSHA-512:".hash('sha512', $salt . $password). ":$salt"; + } + + function deleteAppPassword() { + $ids = explode(",", clean($_REQUEST['ids'])); + $ids_qmarks = arr_qmarks($ids); + + $sth = $this->pdo->prepare("DELETE FROM ttrss_app_passwords WHERE id IN ($ids_qmarks) AND owner_uid = ?"); + $sth->execute(array_merge($ids, [$_SESSION['uid']])); + + $this->appPasswordList(); + } + + function generateAppPassword() { + $title = clean($_REQUEST['title']); + $new_password = make_password(16); + $new_password_hash = $this->encryptAppPassword($new_password); + + print_warning(T_sprintf("Generated password <strong>%s</strong> for %s. Please remember it for future reference.", $new_password, $title)); + + $sth = $this->pdo->prepare("INSERT INTO ttrss_app_passwords + (title, pwd_hash, service, created, owner_uid) + VALUES + (?, ?, ?, NOW(), ?)"); + + $sth->execute([$title, $new_password_hash, Auth_Base::AUTH_SERVICE_API, $_SESSION['uid']]); + + $this->appPasswordList(); + } } diff --git a/classes/rpc.php b/classes/rpc.php index 8736cbb65..84c9cfe92 100755 --- a/classes/rpc.php +++ b/classes/rpc.php @@ -572,7 +572,7 @@ class RPC extends Handler_Protected { function log() { $msg = clean($_REQUEST['msg']); - $file = basename(clean($_REQUEST['file'])); + $file = clean_filename($_REQUEST['file']); $line = (int) clean($_REQUEST['line']); $context = clean($_REQUEST['context']); diff --git a/classes/rssutils.php b/classes/rssutils.php index fe4c0a8a3..66008899b 100755 --- a/classes/rssutils.php +++ b/classes/rssutils.php @@ -259,6 +259,8 @@ class RSSUtils { */ static function update_rss_feed($feed, $no_cache = false) { + reset_fetch_domain_quota(); + Debug::log("start", Debug::$LOG_VERBOSE); $pdo = Db::pdo(); @@ -335,8 +337,19 @@ class RSSUtils { $force_refetch = isset($_REQUEST["force_refetch"]); $feed_data = ""; + Debug::log("running HOOK_FETCH_FEED handlers...", Debug::$LOG_VERBOSE); + foreach ($pluginhost->get_hooks(PluginHost::HOOK_FETCH_FEED) as $plugin) { + Debug::log("... " . get_class($plugin), Debug::$LOG_VERBOSE); + $start = microtime(true); $feed_data = $plugin->hook_fetch_feed($feed_data, $fetch_url, $owner_uid, $feed, 0, $auth_login, $auth_pass); + Debug::log(sprintf("=== %.4f (sec)", microtime(true) - $start), Debug::$LOG_VERBOSE); + } + + if ($feed_data) { + Debug::log("feed data has been modified by a plugin.", Debug::$LOG_VERBOSE); + } else { + Debug::log("feed data has not been modified by a plugin.", Debug::$LOG_VERBOSE); } // try cache @@ -428,8 +441,20 @@ class RSSUtils { return; } + Debug::log("running HOOK_FEED_FETCHED handlers...", Debug::$LOG_VERBOSE); + $feed_data_checksum = md5($feed_data); + foreach ($pluginhost->get_hooks(PluginHost::HOOK_FEED_FETCHED) as $plugin) { + Debug::log("... " . get_class($plugin), Debug::$LOG_VERBOSE); + $start = microtime(true); $feed_data = $plugin->hook_feed_fetched($feed_data, $fetch_url, $owner_uid, $feed); + Debug::log(sprintf("=== %.4f (sec)", microtime(true) - $start), Debug::$LOG_VERBOSE); + } + + if (md5($feed_data) != $feed_data_checksum) { + Debug::log("feed data has been modified by a plugin.", Debug::$LOG_VERBOSE); + } else { + Debug::log("feed data has not been modified by a plugin.", Debug::$LOG_VERBOSE); } $rss = new FeedParser($feed_data); @@ -437,8 +462,16 @@ class RSSUtils { if (!$rss->error()) { + Debug::log("running HOOK_FEED_PARSED handlers...", Debug::$LOG_VERBOSE); + // We use local pluginhost here because we need to load different per-user feed plugins - $pluginhost->run_hooks(PluginHost::HOOK_FEED_PARSED, "hook_feed_parsed", $rss); + + 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); + Debug::log(sprintf("=== %.4f (sec)", microtime(true) - $start), Debug::$LOG_VERBOSE); + } Debug::log("language: $feed_language", Debug::$LOG_VERBOSE); Debug::log("processing feed data...", Debug::$LOG_VERBOSE); @@ -559,18 +592,10 @@ class RSSUtils { Debug::log("guid $entry_guid / $entry_guid_hashed", Debug::$LOG_VERBOSE); - $entry_timestamp = strip_tags($item->get_date()); + $entry_timestamp = (int)$item->get_date(); Debug::log("orig date: " . $item->get_date(), Debug::$LOG_VERBOSE); - if ($entry_timestamp == -1 || !$entry_timestamp || $entry_timestamp > time()) { - $entry_timestamp = time(); - } - - $entry_timestamp_fmt = strftime("%Y/%m/%d %H:%M:%S", $entry_timestamp); - - Debug::log("date $entry_timestamp [$entry_timestamp_fmt]", Debug::$LOG_VERBOSE); - $entry_title = strip_tags($item->get_title()); $entry_link = rewrite_relative_url($site_url, clean($item->get_link())); @@ -599,31 +624,10 @@ class RSSUtils { $entry_guid = mb_substr($entry_guid, 0, 245); Debug::log("author $entry_author", Debug::$LOG_VERBOSE); - Debug::log("num_comments: $num_comments", Debug::$LOG_VERBOSE); Debug::log("looking for tags...", Debug::$LOG_VERBOSE); - // parse <category> entries into tags - - $additional_tags = array(); - - $additional_tags_src = $item->get_categories(); - - if (is_array($additional_tags_src)) { - foreach ($additional_tags_src as $tobj) { - array_push($additional_tags, $tobj); - } - } - - $entry_tags = array_unique($additional_tags); - - for ($i = 0; $i < count($entry_tags); $i++) { - $entry_tags[$i] = mb_strtolower($entry_tags[$i], 'utf-8'); - - // we don't support numeric tags, let's prefix them - if (is_numeric($entry_tags[$i])) $entry_tags[$i] = 't:' . $entry_tags[$i]; - } - - Debug::log("tags found: " . join(",", $entry_tags), Debug::$LOG_VERBOSE); + $entry_tags = $item->get_categories(); + Debug::log("tags found: " . join(", ", $entry_tags), Debug::$LOG_VERBOSE); Debug::log("done collecting data.", Debug::$LOG_VERBOSE); @@ -656,7 +660,8 @@ class RSSUtils { "force_catchup" => false, // ugly hack for the time being "score_modifier" => 0, // no previous value, plugin should recalculate score modifier based on content if needed "language" => $entry_language, - "num_comments" => $num_comments, // read only + "timestamp" => $entry_timestamp, + "num_comments" => $num_comments, "feed" => array("id" => $feed, "fetch_url" => $fetch_url, "site_url" => $site_url, @@ -734,7 +739,7 @@ class RSSUtils { if (count($matched_filter_ids) > 0) { $filter_ids_qmarks = arr_qmarks($matched_filter_ids); - $fsth = $pdo->prepare("UPDATE ttrss_filters2 SET last_triggered = NOW() WHERE + $fsth = $pdo->prepare("UPDATE ttrss_filters2 SET last_triggered = NOW() WHERE id IN ($filter_ids_qmarks) AND owner_uid = ?"); $fsth->execute(array_merge($matched_filter_ids, [$owner_uid])); @@ -797,6 +802,17 @@ class RSSUtils { $article_labels = $article["labels"]; $entry_score_modifier = (int) $article["score_modifier"]; $entry_language = $article["language"]; + $entry_timestamp = $article["timestamp"]; + $num_comments = $article["num_comments"]; + + if ($entry_timestamp == -1 || !$entry_timestamp || $entry_timestamp > time()) { + $entry_timestamp = time(); + } + + $entry_timestamp_fmt = strftime("%Y/%m/%d %H:%M:%S", $entry_timestamp); + + Debug::log("date $entry_timestamp [$entry_timestamp_fmt]", Debug::$LOG_VERBOSE); + Debug::log("num_comments: $num_comments", Debug::$LOG_VERBOSE); if (Debug::get_loglevel() >= Debug::$LOG_EXTENDED) { Debug::log("article labels:", Debug::$LOG_VERBOSE); @@ -1071,9 +1087,7 @@ class RSSUtils { $manual_tags = trim_array(explode(",", $f["param"])); foreach ($manual_tags as $tag) { - if (Article::tag_is_valid($tag)) { - array_push($entry_tags, $tag); - } + array_push($entry_tags, $tag); } } } @@ -1086,19 +1100,17 @@ class RSSUtils { $filtered_tags = array(); $tags_to_cache = array(); - if ($entry_tags && is_array($entry_tags)) { - foreach ($entry_tags as $tag) { - if (array_search($tag, $boring_tags) === false) { - array_push($filtered_tags, $tag); - } + foreach ($entry_tags as $tag) { + if (array_search($tag, $boring_tags) === false) { + array_push($filtered_tags, $tag); } } $filtered_tags = array_unique($filtered_tags); - if (Debug::get_loglevel() >= Debug::$LOG_EXTENDED) { - Debug::log("filtered article tags:", Debug::$LOG_VERBOSE); - print_r($filtered_tags); + if (Debug::get_loglevel() >= Debug::$LOG_VERBOSE) { + Debug::log("filtered tags: " . implode(", ", $filtered_tags), Debug::$LOG_VERBOSE); + } // Save article tags in the database @@ -1113,12 +1125,9 @@ class RSSUtils { (owner_uid,tag_name,post_int_id) VALUES (?, ?, ?)"); - foreach ($filtered_tags as $tag) { - - $tag = Article::sanitize_tag($tag); - - if (!Article::tag_is_valid($tag)) continue; + $filtered_tags = FeedItem_Common::normalize_categories($filtered_tags); + foreach ($filtered_tags as $tag) { $tsth->execute([$tag, $entry_int_id, $owner_uid]); if (!$tsth->fetch()) { @@ -1129,9 +1138,6 @@ class RSSUtils { } /* update the cache */ - - $tags_to_cache = array_unique($tags_to_cache); - $tags_str = join(",", $tags_to_cache); $tsth = $pdo->prepare("UPDATE ttrss_user_entries @@ -1194,10 +1200,18 @@ class RSSUtils { Debug::log("cache_enclosures: downloading: $src to $local_filename", Debug::$LOG_VERBOSE); if (!$cache->exists($local_filename)) { - $file_content = fetch_file_contents(array("url" => $src, "max_size" => MAX_CACHE_FILE_SIZE)); + + global $fetch_last_error_code; + global $fetch_last_error; + + $file_content = fetch_file_contents(array("url" => $src, + "http_referrer" => $src, + "max_size" => MAX_CACHE_FILE_SIZE)); if ($file_content) { $cache->put($local_filename, $file_content); + } else { + Debug::log("cache_enclosures: failed with $fetch_last_error_code: $fetch_last_error"); } } else if (is_writable($local_filename)) { $cache->touch($local_filename); @@ -1228,10 +1242,17 @@ class RSSUtils { if (!$cache->exists($local_filename)) { Debug::log("cache_media: downloading: $src to $local_filename", Debug::$LOG_VERBOSE); - $file_content = fetch_file_contents(array("url" => $src, "max_size" => MAX_CACHE_FILE_SIZE)); + global $fetch_last_error_code; + global $fetch_last_error; + + $file_content = fetch_file_contents(array("url" => $src, + "http_referrer" => $src, + "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); @@ -1548,7 +1569,8 @@ class RSSUtils { } static function is_gzipped($feed_data) { - return mb_strpos($feed_data, "\x1f" . "\x8b" . "\x08", 0, "US-ASCII") === 0; + return strpos(substr($feed_data, 0, 3), + "\x1f" . "\x8b" . "\x08", 0) === 0; } static function load_filters($feed_id, $owner_uid) { diff --git a/include/functions.php b/include/functions.php index c326ac468..0f5464990 100644 --- a/include/functions.php +++ b/include/functions.php @@ -1,6 +1,6 @@ <?php define('EXPECTED_CONFIG_VERSION', 26); - define('SCHEMA_VERSION', 138); + define('SCHEMA_VERSION', 139); define('LABEL_BASE_INDEX', -1024); define('PLUGIN_FEED_BASE_INDEX', -128); @@ -63,6 +63,11 @@ define_default('MAX_CONDITIONAL_INTERVAL', 3600*12); // max interval between forced unconditional updates for servers // not complying with http if-modified-since (seconds) + define_default('MAX_FETCH_REQUESTS_PER_HOST', 25); + // a maximum amount of allowed HTTP requests per destination host + // during a single update (i.e. within PHP process lifetime) + // this is used to not cause excessive load on the origin server on + // e.g. feed subscription when all articles are being processes /* tunables end here */ @@ -159,6 +164,12 @@ Debug::log($msg); } + function reset_fetch_domain_quota() { + global $fetch_domain_hits; + + $fetch_domain_hits = []; + } + // TODO: max_size currently only works for CURL transfers // TODO: multiple-argument way is deprecated, first parameter is a hash now function fetch_file_contents($options /* previously: 0: $url , 1: $type = false, 2: $login = false, 3: $pass = false, @@ -171,6 +182,7 @@ global $fetch_last_modified; global $fetch_effective_url; global $fetch_curl_used; + global $fetch_domain_hits; $fetch_last_error = false; $fetch_last_error_code = -1; @@ -180,6 +192,9 @@ $fetch_last_modified = ""; $fetch_effective_url = ""; + if (!is_array($fetch_domain_hits)) + $fetch_domain_hits = []; + if (!is_array($options)) { // falling back on compatibility shim @@ -215,6 +230,7 @@ $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); @@ -222,6 +238,14 @@ if (strpos($url, "//") === 0) $url = 'http:' . $url; + $url_host = parse_url($url, PHP_URL_HOST); + $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; @@ -250,7 +274,9 @@ curl_setopt($ch, CURLOPT_USERAGENT, $useragent ? $useragent : SELF_USER_AGENT); curl_setopt($ch, CURLOPT_ENCODING, ""); - //curl_setopt($ch, CURLOPT_REFERER, $url); + + if ($http_referrer) + curl_setopt($ch, CURLOPT_REFERER, $http_referrer); if ($max_size) { curl_setopt($ch, CURLOPT_NOPROGRESS, false); @@ -378,6 +404,9 @@ 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; @@ -509,7 +538,7 @@ return ""; } - function authenticate_user($login, $password, $check_only = false) { + function authenticate_user($login, $password, $check_only = false, $service = false) { if (!SINGLE_USER_MODE) { $user_id = false; @@ -517,7 +546,7 @@ foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_AUTH_USER) as $plugin) { - $user_id = (int) $plugin->authenticate($login, $password); + $user_id = (int) $plugin->authenticate($login, $password, $service); if ($user_id) { $auth_module = strtolower(get_class($plugin)); @@ -593,7 +622,7 @@ } function clean_filename($filename) { - return basename(preg_replace("/\.\.|[\/\\\]/", "", $filename)); + return basename(preg_replace("/\.\.|[\/\\\]/", "", clean($filename))); } function make_password($length = 12) { @@ -1013,10 +1042,10 @@ __("Navigation") => array( "next_feed" => __("Open next feed"), "prev_feed" => __("Open previous feed"), - "next_article" => __("Open next article"), - "prev_article" => __("Open previous article"), - "next_article_noscroll" => __("Open next article (don't scroll long articles)"), - "prev_article_noscroll" => __("Open previous article (don't scroll long articles)"), + "next_article" => __("Open next article (scroll long articles)"), + "prev_article" => __("Open previous article (scroll long articles)"), + "next_article_noscroll" => __("Open next article"), + "prev_article_noscroll" => __("Open previous article"), "next_article_noexpand" => __("Move to next article (don't expand or mark read)"), "prev_article_noexpand" => __("Move to previous article (don't expand or mark read)"), "search_dialog" => __("Show search dialog")), @@ -1221,13 +1250,11 @@ } function iframe_whitelisted($entry) { - $whitelist = array("youtube.com", "youtu.be", "vimeo.com", "player.vimeo.com"); - @$src = parse_url($entry->getAttribute("src"), PHP_URL_HOST); if ($src) { - foreach ($whitelist as $w) { - if ($src == $w || $src == "www.$w") + foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_IFRAME_WHITELISTED) as $plugin) { + if ($plugin->hook_iframe_whitelisted($src)) return true; } } diff --git a/include/login_form.php b/include/login_form.php index 6c6aaf8cf..941321fc0 100755 --- a/include/login_form.php +++ b/include/login_form.php @@ -91,11 +91,12 @@ function bwLimitChange(elem) { dojoType="dijit.form.TextBox" class="input input-text" value="<?php echo $_SESSION["fake_password"] ?>"/> - - <?php if (strpos(PLUGINS, "auth_internal") !== FALSE) { ?> - <a href="public.php?op=forgotpass"><?php echo __("I forgot my password") ?></a> - <?php } ?> </fieldset> + <?php if (strpos(PLUGINS, "auth_internal") !== FALSE) { ?> + <fieldset class="align-right"> + <a href="public.php?op=forgotpass"><?php echo __("I forgot my password") ?></a> + </fieldset> + <?php } ?> <fieldset> <label><?php echo __("Profile:") ?></label> @@ -140,6 +140,11 @@ <div id="feedlistLoading"> <img src='images/indicator_tiny.gif'/> <?php echo __("Loading, please wait..."); ?></div> + <?php + foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FEED_TREE) as $p) { + echo $p->hook_feed_tree(); + } + ?> <div id="feedTree"></div> </div> diff --git a/install/index.php b/install/index.php index 815422712..3db53107d 100755 --- a/install/index.php +++ b/install/index.php @@ -437,7 +437,7 @@ if (!$res) { print_notice("Query: $line"); - print_error("Error: " . implode(", ", $this->pdo->errorInfo())); + print_error("Error: " . implode(", ", $pdo->errorInfo())); } } } diff --git a/js/Article.js b/js/Article.js index b933ed716..970234818 100644 --- a/js/Article.js +++ b/js/Article.js @@ -32,7 +32,7 @@ define(["dojo/_base/declare"], function (declare) { if (ids.length > 0) { const score = prompt(__("Please enter new score for selected articles:")); - if (parseInt(score) != undefined) { + if (!isNaN(parseInt(score))) { ids.each((id) => { const row = $("RROW-" + id); @@ -66,7 +66,7 @@ define(["dojo/_base/declare"], function (declare) { const score_old = row.getAttribute("data-score"); const score = prompt(__("Please enter new score for this article:"), score_old); - if (parseInt(score) != undefined) { + if (!isNaN(parseInt(score))) { row.setAttribute("data-score", score); const pic = row.select(".icon-score")[0]; @@ -340,4 +340,4 @@ define(["dojo/_base/declare"], function (declare) { } return Article; -});
\ No newline at end of file +}); diff --git a/js/PrefHelpers.js b/js/PrefHelpers.js index a3d122029..6a62cb593 100644 --- a/js/PrefHelpers.js +++ b/js/PrefHelpers.js @@ -1,5 +1,43 @@ define(["dojo/_base/declare"], function (declare) { Helpers = { + AppPasswords: { + getSelected: function() { + return Tables.getSelected("app-password-list"); + }, + updateContent: function(data) { + $("app_passwords_holder").innerHTML = data; + dojo.parser.parse("app_passwords_holder"); + }, + removeSelected: function() { + const rows = this.getSelected(); + + if (rows.length == 0) { + alert("No passwords selected."); + } else { + if (confirm(__("Remove selected app passwords?"))) { + + xhrPost("backend.php", {op: "pref-prefs", method: "deleteAppPassword", ids: rows.toString()}, (transport) => { + this.updateContent(transport.responseText); + Notify.close(); + }); + + Notify.progress("Loading, please wait..."); + } + } + }, + generate: function() { + const title = prompt("Password description:") + + if (title) { + xhrPost("backend.php", {op: "pref-prefs", method: "generateAppPassword", title: title}, (transport) => { + this.updateContent(transport.responseText); + Notify.close(); + }); + + Notify.progress("Loading, please wait..."); + } + }, + }, clearFeedAccessKeys: function() { if (confirm(__("This will invalidate all previously generated feed URLs. Continue?"))) { Notify.progress("Clearing URLs..."); diff --git a/plugins/af_comics/filters/af_comics_comicpress.php b/plugins/af_comics/filters/af_comics_comicpress.php index 19c335660..7755bcb1a 100755 --- a/plugins/af_comics/filters/af_comics_comicpress.php +++ b/plugins/af_comics/filters/af_comics_comicpress.php @@ -18,9 +18,8 @@ class Af_Comics_ComicPress extends Af_ComicFilter { // lol at people who block clients by user agent // oh noes my ad revenue Q_Q - $res = fetch_file_contents($article["link"], false, false, false, - false, false, 0, - "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)"); + $res = fetch_file_contents(["url" => $article["link"], + "useragent" => "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)"]); $doc = new DOMDocument(); @@ -30,10 +29,34 @@ class Af_Comics_ComicPress extends Af_ComicFilter { if ($basenode) { $article["content"] = $doc->saveHTML($basenode); + return true; } - } - return true; + // buni-specific + $webtoon_link = $xpath->query("//a[contains(@href,'www.webtoons.com')]")->item(0); + + if ($webtoon_link) { + + $res = fetch_file_contents(["url" => $webtoon_link->getAttribute("href"), + "useragent" => "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)"]); + + if (@$doc->loadHTML($res)) { + $xpath = new DOMXPath($doc); + $basenode = $xpath->query('//div[@id="_viewerBox"]')->item(0); + + if ($basenode) { + $imgs = $xpath->query("//img[@data-url]", $basenode); + + foreach ($imgs as $img) { + $img->setAttribute("src", $img->getAttribute("data-url")); + } + + $article["content"] = $doc->saveHTML($basenode); + return true; + } + } + } + } } return false; diff --git a/plugins/af_comics/init.php b/plugins/af_comics/init.php index d24287c74..c0e97297d 100755 --- a/plugins/af_comics/init.php +++ b/plugins/af_comics/init.php @@ -114,9 +114,6 @@ class Af_Comics extends Plugin { $tpl->setVariable('FEED_URL', htmlspecialchars($fetch_url), true); $tpl->setVariable('SELF_URL', $site_url, true); - $tpl->setVariable('ARTICLE_UPDATED_ATOM', date('c'), true); - $tpl->setVariable('ARTICLE_UPDATED_RFC822', date(DATE_RFC822), true); - if ($body) { $doc = new DOMDocument(); @@ -126,13 +123,22 @@ class Af_Comics extends Plugin { $node = $xpath->query('//picture[contains(@class, "item-comic-image")]/img')->item(0); if ($node) { - $node->removeAttribute("width"); - $node->removeAttribute("data-srcset"); - $node->removeAttribute("srcset"); + $title = $xpath->query('//h1')->item(0); + + if ($title) { + $title = clean(trim($title->nodeValue)); + } else { + $title = date('l, F d, Y'); + } + + foreach (['srcset', 'sizes', 'data-srcset', 'width'] as $attr ) { + $node->removeAttribute($attr); + } $tpl->setVariable('ARTICLE_ID', $article_link, true); $tpl->setVariable('ARTICLE_LINK', $article_link, true); - $tpl->setVariable('ARTICLE_TITLE', date('l, F d, Y'), true); + $tpl->setVariable('ARTICLE_UPDATED_ATOM', date('c', mktime(11, 0, 0)), true); + $tpl->setVariable('ARTICLE_TITLE', htmlspecialchars($title), true); $tpl->setVariable('ARTICLE_EXCERPT', '', true); $tpl->setVariable('ARTICLE_CONTENT', $doc->saveHTML($node), true); @@ -141,15 +147,12 @@ class Af_Comics extends Plugin { $tpl->setVariable('ARTICLE_SOURCE_TITLE', $feed_title, true); $tpl->addBlock('entry'); - } } } $tpl->addBlock('feed'); - $tmp_data = ''; - if ($tpl->generateOutputToString($tmp_data)) $feed_data = $tmp_data; } diff --git a/plugins/af_zz_imgproxy/init.php b/plugins/af_proxy_http/init.php index ddc30936f..80100160d 100755..100644 --- a/plugins/af_zz_imgproxy/init.php +++ b/plugins/af_proxy_http/init.php @@ -1,5 +1,5 @@ <?php -class Af_Zz_ImgProxy extends Plugin { +class Af_Proxy_Http extends Plugin { /* @var PluginHost $host */ private $host; @@ -9,7 +9,7 @@ class Af_Zz_ImgProxy extends Plugin { function about() { return array(1.0, - "Load insecure images via built-in proxy", + "Loads media served over plain HTTP via built-in secure proxy", "fox"); } @@ -23,8 +23,8 @@ class Af_Zz_ImgProxy extends Plugin { $this->host = $host; $this->cache = new DiskCache("images"); - $host->add_hook($host::HOOK_RENDER_ARTICLE, $this); - $host->add_hook($host::HOOK_RENDER_ARTICLE_CDM, $this); + $host->add_hook($host::HOOK_RENDER_ARTICLE, $this, 150); + $host->add_hook($host::HOOK_RENDER_ARTICLE_CDM, $this, 150); $host->add_hook($host::HOOK_ENCLOSURE_ENTRY, $this); $host->add_hook($host::HOOK_PREFS_TAB, $this); @@ -141,8 +141,7 @@ class Af_Zz_ImgProxy extends Plugin { } } - return get_self_url_prefix() . "/public.php?op=pluginhandler&plugin=af_zz_imgproxy&pmethod=imgproxy&url=" . - urlencode($url); + return $this->host->get_public_method_url($this, "imgproxy", ["url" => $url]); } } } @@ -210,7 +209,7 @@ class Af_Zz_ImgProxy extends Plugin { if ($args != "prefFeeds") return; print "<div dojoType=\"dijit.layout.AccordionPane\" - title=\"<i class='material-icons'>extension</i> ".__('Image proxy settings (af_zz_imgproxy)')."\">"; + title=\"<i class='material-icons'>extension</i> ".__('Image proxy settings (af_proxy_http)')."\">"; print "<form dojoType=\"dijit.form.Form\">"; @@ -230,7 +229,7 @@ class Af_Zz_ImgProxy extends Plugin { print_hidden("op", "pluginhandler"); print_hidden("method", "save"); - print_hidden("plugin", "af_zz_imgproxy"); + print_hidden("plugin", "af_proxy_http"); $proxy_all = $this->host->get($this, "proxy_all"); print_checkbox("proxy_all", $proxy_all); diff --git a/plugins/af_readability/init.php b/plugins/af_readability/init.php index 7f3c6db4d..a487707c8 100755 --- a/plugins/af_readability/init.php +++ b/plugins/af_readability/init.php @@ -29,7 +29,8 @@ class Af_Readability extends Plugin { { $this->host = $host; - if (version_compare(PHP_VERSION, '5.6.0', '<')) { + if (version_compare(PHP_VERSION, '7.0.0', '<')) { + user_error("af_readability requires PHP 7.0", E_USER_WARNING); return; } @@ -51,8 +52,8 @@ class Af_Readability extends Plugin { print "<div dojoType='dijit.layout.AccordionPane' title=\"<i class='material-icons'>extension</i> ".__('Readability settings (af_readability)')."\">"; - if (version_compare(PHP_VERSION, '5.6.0', '<')) { - print_error("This plugin requires PHP version 5.6."); + if (version_compare(PHP_VERSION, '7.0.0', '<')) { + print_error("This plugin requires PHP 7.0."); } else { print "<h2>" . __("Global settings") . "</h2>"; @@ -88,7 +89,7 @@ class Af_Readability extends Plugin { print "</label>"; print "</fieldset>"; - print print_button("submit", __("Save"), "class='alt-primary'"); + print_button("submit", __("Save"), "class='alt-primary'"); print "</form>"; $enabled_feeds = $this->host->get($this, "enabled_feeds"); @@ -179,7 +180,11 @@ class Af_Readability extends Plugin { // this is the worst hack yet :( if (strtolower($tmpdoc->encoding) != 'utf-8') { $tmp = preg_replace("/<meta.*?charset.*?\/?>/i", "", $tmp); - $tmp = mb_convert_encoding($tmp, 'utf-8', $tmpdoc->encoding); + if (empty($tmpdoc->encoding)) { + $tmp = mb_convert_encoding($tmp, 'utf-8'); + } else { + $tmp = mb_convert_encoding($tmp, 'utf-8', $tmpdoc->encoding); + } } try { diff --git a/plugins/af_readability/vendor/andreskrey/Readability/Configuration.php b/plugins/af_readability/vendor/andreskrey/Readability/Configuration.php index 6c17bc757..0632399c6 100644 --- a/plugins/af_readability/vendor/andreskrey/Readability/Configuration.php +++ b/plugins/af_readability/vendor/andreskrey/Readability/Configuration.php @@ -167,32 +167,6 @@ class Configuration } /** - * @deprecated Use getCharThreshold. Will be removed in version 2.0 - * - * @return int - */ - public function getWordThreshold() - { - @trigger_error('getWordThreshold was replaced with getCharThreshold and will be removed in version 3.0', E_USER_DEPRECATED); - - return $this->charThreshold; - } - - /** - * @param int $charThreshold - * - * @return $this - */ - public function setWordThreshold($charThreshold) - { - @trigger_error('setWordThreshold was replaced with setCharThreshold and will be removed in version 3.0', E_USER_DEPRECATED); - - $this->charThreshold = $charThreshold; - - return $this; - } - - /** * @return bool */ public function getArticleByLine() diff --git a/plugins/af_readability/vendor/andreskrey/Readability/Nodes/DOM/DOMNodeList.php b/plugins/af_readability/vendor/andreskrey/Readability/Nodes/DOM/DOMNodeList.php new file mode 100644 index 000000000..5149c0b98 --- /dev/null +++ b/plugins/af_readability/vendor/andreskrey/Readability/Nodes/DOM/DOMNodeList.php @@ -0,0 +1,82 @@ +<?php + +namespace andreskrey\Readability\Nodes\DOM; + +/** + * Class DOMNodeList. + * + * This is a fake DOMNodeList class that allows adding items to the list. The original class is static and the nodes + * are defined automagically when instantiating it. This fake version behaves exactly the same way but adds the function + * add() that allows to insert new DOMNodes into the DOMNodeList. + * + * It cannot extend the original DOMNodeList class because the functionality behind the property ->length is hidden + * from the user and cannot be extended, changed, or tweaked. + */ +class DOMNodeList implements \Countable, \IteratorAggregate +{ + /** + * @var array + */ + protected $items = []; + + /** + * @var int + */ + protected $length = 0; + + /** + * To allow access to length in the same way that DOMNodeList allows. + * + * {@inheritdoc} + */ + public function __get($name) + { + switch ($name) { + case 'length': + return $this->length; + default: + trigger_error(sprintf('Undefined property: %s::%s', static::class, $name)); + } + } + + /** + * @param DOMNode|DOMElement|DOMComment $node + * + * @return DOMNodeList + */ + public function add($node) + { + $this->items[] = $node; + $this->length++; + + return $this; + } + + /** + * @param int $offset + * + * @return DOMNode|DOMElement|DOMComment + */ + public function item(int $offset) + { + return $this->items[$offset]; + } + + /** + * @return int|void + */ + public function count(): int + { + return $this->length; + } + + /** + * To make it compatible with iterator_to_array() function. + * + * {@inheritdoc} + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->items); + } +} diff --git a/plugins/af_readability/vendor/andreskrey/Readability/Nodes/NodeTrait.php b/plugins/af_readability/vendor/andreskrey/Readability/Nodes/NodeTrait.php index d7060ccbb..5198bbb5f 100644 --- a/plugins/af_readability/vendor/andreskrey/Readability/Nodes/NodeTrait.php +++ b/plugins/af_readability/vendor/andreskrey/Readability/Nodes/NodeTrait.php @@ -181,11 +181,11 @@ trait NodeTrait /** * Override for native hasAttribute. * - * @see getAttribute - * * @param $attributeName * * @return bool + * + * @see getAttribute */ public function hasAttribute($attributeName) { @@ -317,10 +317,14 @@ trait NodeTrait * * @param bool $filterEmptyDOMText Filter empty DOMText nodes? * + * @deprecated Use NodeUtility::filterTextNodes, function will be removed in version 3.0 + * * @return array */ public function getChildren($filterEmptyDOMText = false) { + @trigger_error('getChildren was replaced with NodeUtility::filterTextNodes and will be removed in version 3.0', E_USER_DEPRECATED); + $ret = iterator_to_array($this->childNodes); if ($filterEmptyDOMText) { // Array values is used to discard the key order. Needs to be 0 to whatever without skipping any number @@ -418,12 +422,12 @@ trait NodeTrait public function hasSingleTagInsideElement($tag) { // There should be exactly 1 element child with given tag - if (count($children = $this->getChildren(true)) !== 1 || $children[0]->nodeName !== $tag) { + if (count($children = NodeUtility::filterTextNodes($this->childNodes)) !== 1 || $children->item(0)->nodeName !== $tag) { return false; } // And there should be no text nodes with real content - return array_reduce($children, function ($carry, $child) { + return array_reduce(iterator_to_array($children), function ($carry, $child) { if (!$carry === false) { return false; } @@ -443,7 +447,7 @@ trait NodeTrait { $result = false; if ($this->hasChildNodes()) { - foreach ($this->getChildren() as $child) { + foreach ($this->childNodes as $child) { if (in_array($child->nodeName, $this->divToPElements)) { $result = true; } else { @@ -500,18 +504,22 @@ trait NodeTrait ); } + /** + * In the original JS project they check if the node has the style display=none, which unfortunately + * in our case we have no way of knowing that. So we just check for the attribute hidden or "display: none". + * + * Might be a good idea to check for classes or other attributes like 'aria-hidden' + * + * @return bool + */ public function isProbablyVisible() { - /* - * In the original JS project they check if the node has the style display=none, which unfortunately - * in our case we have no way of knowing that. So we just check for the attribute hidden or "display: none". - * - * Might be a good idea to check for classes or other attributes like 'aria-hidden' - */ - return !preg_match('/display:( )?none/', $this->getAttribute('style')) && !$this->hasAttribute('hidden'); } + /** + * @return bool + */ public function isWhitespace() { return ($this->nodeType === XML_TEXT_NODE && mb_strlen(trim($this->textContent)) === 0) || @@ -557,4 +565,23 @@ trait NodeTrait $count -= ($count - $nodes->length); } } + + /** + * Mimics JS's firstElementChild property. PHP only has firstChild which could be any type of DOMNode. Use this + * function to get the first one that is an DOMElement node. + * + * @return \DOMElement|null + */ + public function getFirstElementChild() + { + if ($this->childNodes instanceof \Traversable) { + foreach ($this->childNodes as $node) { + if ($node instanceof \DOMElement) { + return $node; + } + } + } + + return null; + } } diff --git a/plugins/af_readability/vendor/andreskrey/Readability/Nodes/NodeUtility.php b/plugins/af_readability/vendor/andreskrey/Readability/Nodes/NodeUtility.php index 7a1f18ee4..cbf78bae0 100644 --- a/plugins/af_readability/vendor/andreskrey/Readability/Nodes/NodeUtility.php +++ b/plugins/af_readability/vendor/andreskrey/Readability/Nodes/NodeUtility.php @@ -5,6 +5,7 @@ namespace andreskrey\Readability\Nodes; use andreskrey\Readability\Nodes\DOM\DOMDocument; use andreskrey\Readability\Nodes\DOM\DOMElement; use andreskrey\Readability\Nodes\DOM\DOMNode; +use andreskrey\Readability\Nodes\DOM\DOMNodeList; /** * Class NodeUtility. @@ -157,4 +158,23 @@ class NodeUtility return ($originalNode) ? $originalNode->nextSibling : $originalNode; } + + /** + * Remove all empty DOMNodes from DOMNodeLists. + * + * @param \DOMNodeList $list + * + * @return DOMNodeList + */ + public static function filterTextNodes(\DOMNodeList $list) + { + $newList = new DOMNodeList(); + foreach ($list as $node) { + if ($node->nodeType !== XML_TEXT_NODE || mb_strlen(trim($node->nodeValue))) { + $newList->add($node); + } + } + + return $newList; + } } diff --git a/plugins/af_readability/vendor/andreskrey/Readability/Readability.php b/plugins/af_readability/vendor/andreskrey/Readability/Readability.php index 7b7eed6bf..6bcbf78d7 100644 --- a/plugins/af_readability/vendor/andreskrey/Readability/Readability.php +++ b/plugins/af_readability/vendor/andreskrey/Readability/Readability.php @@ -57,6 +57,13 @@ class Readability protected $author = null; /** + * Website name. + * + * @var string|null + */ + protected $siteName = null; + + /** * Direction of the text. * * @var string|null @@ -287,10 +294,10 @@ class Readability $values = []; // property is a space-separated list of values - $propertyPattern = '/\s*(dc|dcterm|og|twitter)\s*:\s*(author|creator|description|title|image)\s*/i'; + $propertyPattern = '/\s*(dc|dcterm|og|twitter)\s*:\s*(author|creator|description|title|image|site_name)(?!:)\s*/i'; // name is a single value - $namePattern = '/^\s*(?:(dc|dcterm|og|twitter|weibo:(article|webpage))\s*[\.:]\s*)?(author|creator|description|title|image)\s*$/i'; + $namePattern = '/^\s*(?:(dc|dcterm|og|twitter|weibo:(article|webpage))\s*[\.:]\s*)?(author|creator|description|title|image|site_name)(?!:)\s*$/i'; // Find description tags. foreach ($this->dom->getElementsByTagName('meta') as $meta) { @@ -332,7 +339,6 @@ class Readability * This could be easily replaced with an ugly set of isset($values['key']) or a bunch of ??s. * Will probably replace it with ??s after dropping support of PHP5.6 */ - $key = current(array_intersect([ 'dc:title', 'dcterm:title', @@ -373,11 +379,18 @@ class Readability // get main image $key = current(array_intersect([ + 'image', 'og:image', 'twitter:image' ], array_keys($values))); $this->setImage(isset($values[$key]) ? $values[$key] : null); + + $key = current(array_intersect([ + 'og:site_name' + ], array_keys($values))); + + $this->setSiteName(isset($values[$key]) ? $values[$key] : null); } /** @@ -722,7 +735,7 @@ class Readability */ if ($node->hasSingleTagInsideElement('p') && $node->getLinkDensity() < 0.25) { $this->logger->debug(sprintf('[Get Nodes] Found DIV with a single P node, removing DIV. Node content is: \'%s\'', substr($node->nodeValue, 0, 128))); - $pNode = $node->getChildren(true)[0]; + $pNode = NodeUtility::filterTextNodes($node->childNodes)->item(0); $node->parentNode->replaceChild($pNode, $node); $node = $pNode; $elementsToScore[] = $node; @@ -1082,7 +1095,7 @@ class Readability // If the top candidate is the only child, use parent instead. This will help sibling // joining logic when adjacent content is actually located in parent's sibling node. $parentOfTopCandidate = $topCandidate->parentNode; - while ($parentOfTopCandidate->nodeName !== 'body' && count($parentOfTopCandidate->getChildren(true)) === 1) { + while ($parentOfTopCandidate->nodeName !== 'body' && count(NodeUtility::filterTextNodes($parentOfTopCandidate->childNodes)) === 1) { $topCandidate = $parentOfTopCandidate; $parentOfTopCandidate = $topCandidate->parentNode; } @@ -1102,14 +1115,16 @@ class Readability $siblingScoreThreshold = max(10, $topCandidate->contentScore * 0.2); // Keep potential top candidate's parent node to try to get text direction of it later. $parentOfTopCandidate = $topCandidate->parentNode; - $siblings = $parentOfTopCandidate->getChildren(); + $siblings = $parentOfTopCandidate->childNodes; $hasContent = false; $this->logger->info('[Rating] Adding top candidate siblings...'); - /** @var DOMElement $sibling */ - foreach ($siblings as $sibling) { + /* @var DOMElement $sibling */ + // Can't foreach here because down there we might change the tag name and that causes the foreach to skip items + for ($i = 0; $i < $siblings->length; $i++) { + $sibling = $siblings[$i]; $append = false; if ($sibling === $topCandidate) { @@ -1147,7 +1162,6 @@ class Readability * We have a node that isn't a common block level element, like a form or td tag. * Turn it into a div so it doesn't get filtered out later by accident. */ - $sibling = NodeUtility::setNodeTag($sibling, 'div'); } @@ -1266,11 +1280,11 @@ class Readability // Remove single-cell tables foreach ($article->shiftingAwareGetElementsByTagName('table') as $table) { /** @var DOMNode $table */ - $tbody = $table->hasSingleTagInsideElement('tbody') ? $table->childNodes[0] : $table; + $tbody = $table->hasSingleTagInsideElement('tbody') ? $table->getFirstElementChild() : $table; if ($tbody->hasSingleTagInsideElement('tr')) { - $row = $tbody->firstChild; + $row = $tbody->getFirstElementChild(); if ($row->hasSingleTagInsideElement('td')) { - $cell = $row->firstChild; + $cell = $row->getFirstElementChild(); $cell = NodeUtility::setNodeTag($cell, (array_reduce(iterator_to_array($cell->childNodes), function ($carry, $node) { return $node->isPhrasingContent() && $carry; }, true)) ? 'p' : 'div'); @@ -1597,7 +1611,7 @@ class Readability $node->removeAttribute('class'); } - for ($node = $node->firstChild; $node !== null; $node = $node->nextSibling) { + for ($node = $node->getFirstElementChild(); $node !== null; $node = $node->nextSibling) { $this->_cleanClasses($node); } } @@ -1757,6 +1771,22 @@ class Readability } /** + * @return string|null + */ + public function getSiteName() + { + return $this->siteName; + } + + /** + * @param string $siteName + */ + protected function setSiteName($siteName) + { + $this->siteName = $siteName; + } + + /** * @return null|string */ public function getDirection() diff --git a/plugins/af_youtube_embed/init.php b/plugins/af_youtube_embed/init.php index 16dcc926c..0a6160752 100644 --- a/plugins/af_youtube_embed/init.php +++ b/plugins/af_youtube_embed/init.php @@ -4,7 +4,7 @@ class Af_Youtube_Embed extends Plugin { function about() { return array(1.0, - "Embed videos in Youtube RSS feeds", + "Embed videos in Youtube RSS feeds (and whitelist Youtube iframes)", "fox"); } @@ -12,6 +12,11 @@ class Af_Youtube_Embed extends Plugin { $this->host = $host; $host->add_hook($host::HOOK_RENDER_ENCLOSURE, $this); + $host->add_hook($host::HOOK_IFRAME_WHITELISTED, $this); + } + + function hook_iframe_whitelisted($src) { + return in_array($src, ["www.youtube.com", "youtube.com", "youtu.be"]); } /** diff --git a/plugins/auth_internal/init.php b/plugins/auth_internal/init.php index 8200ddc02..bcba7970a 100644 --- a/plugins/auth_internal/init.php +++ b/plugins/auth_internal/init.php @@ -10,81 +10,99 @@ class Auth_Internal extends Plugin implements IAuthModule { true); } - /* @var PluginHost $host */ - function init($host) { + /* @var PluginHost $host */ + function init($host) { $this->host = $host; $this->pdo = Db::pdo(); $host->add_hook($host::HOOK_AUTH_USER, $this); } - function authenticate($login, $password) { + function authenticate($login, $password, $service = '') { $pwd_hash1 = encrypt_password($password); $pwd_hash2 = encrypt_password($password, $login); $otp = $_REQUEST["otp"]; if (get_schema_version() > 96) { - if (!defined('AUTH_DISABLE_OTP') || !AUTH_DISABLE_OTP) { - $sth = $this->pdo->prepare("SELECT otp_enabled,salt FROM ttrss_users WHERE - login = ?"); - $sth->execute([$login]); + $sth = $this->pdo->prepare("SELECT otp_enabled,salt FROM ttrss_users WHERE + login = ?"); + $sth->execute([$login]); - if ($row = $sth->fetch()) { + if ($row = $sth->fetch()) { + $otp_enabled = $row['otp_enabled']; + + if ($otp_enabled) { + + // only allow app password checking if OTP is enabled + if ($service && get_schema_version() > 138) { + return $this->check_app_password($login, $password, $service); + } - $base32 = new \OTPHP\Base32(); - - $otp_enabled = $row['otp_enabled']; - $secret = $base32->encode(sha1($row['salt'])); - - $topt = new \OTPHP\TOTP($secret); - $otp_check = $topt->now(); - - if ($otp_enabled) { - if ($otp) { - if ($otp != $otp_check) { - return false; - } - } else { - $return = urlencode($_REQUEST["return"]); - ?> - <!DOCTYPE html> - <html> - <head> - <title>Tiny Tiny RSS</title> - <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> - </head> - <?php echo stylesheet_tag("css/default.css") ?> - <body class="ttrss_utility otp"> - <h1><?php echo __("Authentication") ?></h1> - <div class="content"> - <form action="public.php?return=<?php echo $return ?>" - method="POST" class="otpform"> - <input type="hidden" name="op" value="login"> - <input type="hidden" name="login" value="<?php echo htmlspecialchars($login) ?>"> - <input type="hidden" name="password" value="<?php echo htmlspecialchars($password) ?>"> - <input type="hidden" name="bw_limit" value="<?php echo htmlspecialchars($_POST["bw_limit"]) ?>"> - <input type="hidden" name="remember_me" value="<?php echo htmlspecialchars($_POST["remember_me"]) ?>"> - <input type="hidden" name="profile" value="<?php echo htmlspecialchars($_POST["profile"]) ?>"> - - <fieldset> - <label><?php echo __("Please enter your one time password:") ?></label> - <input autocomplete="off" size="6" name="otp" value=""/> - <input type="submit" value="Continue"/> - </fieldset> - </form></div> - <script type="text/javascript"> - document.forms[0].otp.focus(); - </script> - <?php - exit; + if ($otp) { + $base32 = new \OTPHP\Base32(); + + $secret = $base32->encode(mb_substr(sha1($row["salt"]), 0, 12), false); + $secret_legacy = $base32->encode(sha1($row["salt"])); + + $totp = new \OTPHP\TOTP($secret); + $otp_check = $totp->now(); + + $totp_legacy = new \OTPHP\TOTP($secret_legacy); + $otp_check_legacy = $totp_legacy->now(); + + if ($otp != $otp_check && $otp != $otp_check_legacy) { + return false; } + } else { + $return = urlencode($_REQUEST["return"]); + ?> + <!DOCTYPE html> + <html> + <head> + <title>Tiny Tiny RSS</title> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + </head> + <?php echo stylesheet_tag("css/default.css") ?> + <body class="ttrss_utility otp"> + <h1><?php echo __("Authentication") ?></h1> + <div class="content"> + <form action="public.php?return=<?php echo $return ?>" + method="POST" class="otpform"> + <input type="hidden" name="op" value="login"> + <input type="hidden" name="login" value="<?php echo htmlspecialchars($login) ?>"> + <input type="hidden" name="password" value="<?php echo htmlspecialchars($password) ?>"> + <input type="hidden" name="bw_limit" value="<?php echo htmlspecialchars($_POST["bw_limit"]) ?>"> + <input type="hidden" name="remember_me" value="<?php echo htmlspecialchars($_POST["remember_me"]) ?>"> + <input type="hidden" name="profile" value="<?php echo htmlspecialchars($_POST["profile"]) ?>"> + + <fieldset> + <label><?php echo __("Please enter your one time password:") ?></label> + <input autocomplete="off" size="6" name="otp" value=""/> + <input type="submit" value="Continue"/> + </fieldset> + </form></div> + <script type="text/javascript"> + document.forms[0].otp.focus(); + </script> + <?php + exit; } } } } + // check app passwords first but allow regular password as a fallback for the time being + // if OTP is not enabled + + if ($service && get_schema_version() > 138) { + $user_id = $this->check_app_password($login, $password, $service); + + if ($user_id) + return $user_id; + } + if (get_schema_version() > 87) { $sth = $this->pdo->prepare("SELECT salt FROM ttrss_users WHERE login = ?"); @@ -96,7 +114,7 @@ class Auth_Internal extends Plugin implements IAuthModule { if ($salt == "") { $sth = $this->pdo->prepare("SELECT id FROM ttrss_users WHERE - login = ? AND (pwd_hash = ? OR pwd_hash = ?)"); + login = ? AND (pwd_hash = ? OR pwd_hash = ?)"); $sth->execute([$login, $pwd_hash1, $pwd_hash2]); @@ -111,7 +129,7 @@ class Auth_Internal extends Plugin implements IAuthModule { $pwd_hash = encrypt_password($password, $salt, true); $sth = $this->pdo->prepare("UPDATE ttrss_users SET - pwd_hash = ?, salt = ? WHERE login = ?"); + pwd_hash = ?, salt = ? WHERE login = ?"); $sth->execute([$pwd_hash, $salt, $login]); @@ -125,8 +143,8 @@ class Auth_Internal extends Plugin implements IAuthModule { $pwd_hash = encrypt_password($password, $salt, true); $sth = $this->pdo->prepare("SELECT id - FROM ttrss_users WHERE - login = ? AND pwd_hash = ?"); + FROM ttrss_users WHERE + login = ? AND pwd_hash = ?"); $sth->execute([$login, $pwd_hash]); if ($row = $sth->fetch()) { @@ -136,8 +154,8 @@ class Auth_Internal extends Plugin implements IAuthModule { } else { $sth = $this->pdo->prepare("SELECT id - FROM ttrss_users WHERE - login = ? AND (pwd_hash = ? OR pwd_hash = ?)"); + FROM ttrss_users WHERE + login = ? AND (pwd_hash = ? OR pwd_hash = ?)"); $sth->execute([$login, $pwd_hash1, $pwd_hash2]); @@ -147,22 +165,22 @@ class Auth_Internal extends Plugin implements IAuthModule { } } else { $sth = $this->pdo->prepare("SELECT id - FROM ttrss_users WHERE - login = ? AND (pwd_hash = ? OR pwd_hash = ?)"); + FROM ttrss_users WHERE + login = ? AND (pwd_hash = ? OR pwd_hash = ?)"); $sth->execute([$login, $pwd_hash1, $pwd_hash2]); if ($row = $sth->fetch()) { return $row['id']; } - } + } return false; } function check_password($owner_uid, $password) { - $sth = $this->pdo->prepare("SELECT salt,login FROM ttrss_users WHERE + $sth = $this->pdo->prepare("SELECT salt,login,otp_enabled FROM ttrss_users WHERE id = ?"); $sth->execute([$owner_uid]); @@ -176,7 +194,7 @@ class Auth_Internal extends Plugin implements IAuthModule { $password_hash2 = encrypt_password($password, $login); $sth = $this->pdo->prepare("SELECT id FROM ttrss_users WHERE - id = ? AND (pwd_hash = ? OR pwd_hash = ?)"); + id = ? AND (pwd_hash = ? OR pwd_hash = ?)"); $sth->execute([$owner_uid, $password_hash1, $password_hash2]); @@ -186,7 +204,7 @@ class Auth_Internal extends Plugin implements IAuthModule { $password_hash = encrypt_password($password, $salt, true); $sth = $this->pdo->prepare("SELECT id FROM ttrss_users WHERE - id = ? AND pwd_hash = ?"); + id = ? AND pwd_hash = ?"); $sth->execute([$owner_uid, $password_hash]); @@ -194,7 +212,7 @@ class Auth_Internal extends Plugin implements IAuthModule { } } - return false; + return false; } function change_password($owner_uid, $old_password, $new_password) { @@ -211,15 +229,66 @@ class Auth_Internal extends Plugin implements IAuthModule { $_SESSION["pwd_hash"] = $new_password_hash; + $sth = $this->pdo->prepare("SELECT email, login FROM ttrss_users WHERE id = ?"); + $sth->execute([$owner_uid]); + + if ($row = $sth->fetch()) { + $mailer = new Mailer(); + + require_once "lib/MiniTemplator.class.php"; + + $tpl = new MiniTemplator; + + $tpl->readTemplateFromFile("templates/password_change_template.txt"); + + $tpl->setVariable('LOGIN', $row["login"]); + $tpl->setVariable('TTRSS_HOST', SELF_URL_PATH); + + $tpl->addBlock('message'); + + $tpl->generateOutputToString($message); + + $mailer->mail(["to_name" => $row["login"], + "to_address" => $row["email"], + "subject" => "[tt-rss] Password change notification", + "message" => $message]); + + } + return __("Password has been changed."); } else { return "ERROR: ".__('Old password is incorrect.'); } } + private function check_app_password($login, $password, $service) { + $sth = $this->pdo->prepare("SELECT p.id, p.pwd_hash, u.id AS uid + FROM ttrss_app_passwords p, ttrss_users u + WHERE p.owner_uid = u.id AND u.login = ? AND service = ?"); + $sth->execute([$login, $service]); + + while ($row = $sth->fetch()) { + list ($algo, $hash, $salt) = explode(":", $row["pwd_hash"]); + + if ($algo == "SSHA-512") { + $test_hash = hash('sha512', $salt . $password); + + if ($test_hash == $hash) { + $usth = $this->pdo->prepare("UPDATE ttrss_app_passwords SET last_used = NOW() WHERE id = ?"); + $usth->execute([$row['id']]); + + return $row['uid']; + } + } else { + user_error("Got unknown algo of app password for user $login: $algo"); + } + } + + return false; + } + function api_version() { return 2; } } -?> diff --git a/schema/ttrss_schema_mysql.sql b/schema/ttrss_schema_mysql.sql index fcff9c9bb..60b121e37 100644 --- a/schema/ttrss_schema_mysql.sql +++ b/schema/ttrss_schema_mysql.sql @@ -33,6 +33,7 @@ drop table if exists ttrss_cat_counters_cache; drop table if exists ttrss_feeds; drop table if exists ttrss_archived_feeds; drop table if exists ttrss_feed_categories; +drop table if exists ttrss_app_passwords; drop table if exists ttrss_users; drop table if exists ttrss_themes; drop table if exists ttrss_sessions; @@ -57,6 +58,14 @@ create table ttrss_users (id integer primary key not null auto_increment, insert into ttrss_users (login,pwd_hash,access_level) values ('admin', 'SHA1:5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8', 10); +create table ttrss_app_passwords (id integer not null primary key auto_increment, + title varchar(250) not null, + pwd_hash text not null, + service varchar(100) not null, + created datetime not null, + last_used datetime default null, + owner_uid integer not null references ttrss_users(id) on delete cascade) ENGINE=InnoDB DEFAULT CHARSET=UTF8; + create table ttrss_feed_categories(id integer not null primary key auto_increment, owner_uid integer not null, title varchar(200) not null, @@ -137,7 +146,7 @@ create table ttrss_feeds (id integer not null auto_increment primary key, unique(feed_url(255), owner_uid)) ENGINE=InnoDB DEFAULT CHARSET=UTF8; insert into ttrss_feeds (owner_uid, title, feed_url) values - (1, 'Tiny Tiny RSS: Forum', 'http://tt-rss.org/forum/rss.php'); + ((select id from ttrss_users where login = 'admin'), 'Tiny Tiny RSS: Forum', 'https://tt-rss.org/forum/rss.php'); create table ttrss_entries (id integer not null primary key auto_increment, title text not null, @@ -287,7 +296,7 @@ create table ttrss_tags (id integer primary key auto_increment, create table ttrss_version (schema_version int not null) ENGINE=InnoDB DEFAULT CHARSET=UTF8; -insert into ttrss_version values (138); +insert into ttrss_version values (139); create table ttrss_enclosures (id integer primary key auto_increment, content_url text not null, diff --git a/schema/ttrss_schema_pgsql.sql b/schema/ttrss_schema_pgsql.sql index ac1b38315..c818596b8 100644 --- a/schema/ttrss_schema_pgsql.sql +++ b/schema/ttrss_schema_pgsql.sql @@ -30,6 +30,7 @@ drop table if exists ttrss_cat_counters_cache; drop table if exists ttrss_archived_feeds; drop table if exists ttrss_feeds; drop table if exists ttrss_feed_categories; +drop table if exists ttrss_app_passwords; drop table if exists ttrss_users; drop table if exists ttrss_themes; drop table if exists ttrss_sessions; @@ -55,6 +56,14 @@ create table ttrss_users (id serial not null primary key, insert into ttrss_users (login,pwd_hash,access_level) values ('admin', 'SHA1:5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8', 10); +create table ttrss_app_passwords (id serial not null primary key, + title varchar(250) not null, + pwd_hash text not null, + service varchar(100) not null, + created timestamp not null, + last_used timestamp default null, + owner_uid integer not null references ttrss_users(id) on delete cascade); + create table ttrss_feed_categories(id serial not null primary key, owner_uid integer not null references ttrss_users(id) on delete cascade, collapsed boolean not null default false, @@ -106,7 +115,7 @@ create index ttrss_feeds_owner_uid_index on ttrss_feeds(owner_uid); create index ttrss_feeds_cat_id_idx on ttrss_feeds(cat_id); insert into ttrss_feeds (owner_uid, title, feed_url) values - (1, 'Tiny Tiny RSS: Forum', 'http://tt-rss.org/forum/rss.php'); + ((select id from ttrss_users where login = 'admin'), 'Tiny Tiny RSS: Forum', 'https://tt-rss.org/forum/rss.php'); create table ttrss_archived_feeds (id integer not null primary key, owner_uid integer not null references ttrss_users(id) on delete cascade, @@ -269,7 +278,7 @@ create index ttrss_tags_post_int_id_idx on ttrss_tags(post_int_id); create table ttrss_version (schema_version int not null); -insert into ttrss_version values (138); +insert into ttrss_version values (139); create table ttrss_enclosures (id serial not null primary key, content_url text not null, diff --git a/schema/versions/mysql/139.sql b/schema/versions/mysql/139.sql new file mode 100644 index 000000000..88dfb69c7 --- /dev/null +++ b/schema/versions/mysql/139.sql @@ -0,0 +1,13 @@ +begin; + +create table ttrss_app_passwords (id integer not null primary key auto_increment, + title varchar(250) not null, + pwd_hash text not null, + service varchar(100) not null, + created datetime not null, + last_used datetime default null, + owner_uid integer not null references ttrss_users(id) on delete cascade) ENGINE=InnoDB DEFAULT CHARSET=UTF8; + +update ttrss_version set schema_version = 139; + +commit; diff --git a/schema/versions/pgsql/139.sql b/schema/versions/pgsql/139.sql new file mode 100644 index 000000000..b2617390c --- /dev/null +++ b/schema/versions/pgsql/139.sql @@ -0,0 +1,13 @@ +begin; + +create table ttrss_app_passwords (id serial not null primary key, + title varchar(250) not null, + pwd_hash text not null, + service varchar(100) not null, + created timestamp not null, + last_used timestamp default null, + owner_uid integer not null references ttrss_users(id) on delete cascade); + +update ttrss_version set schema_version = 139; + +commit; diff --git a/templates/digest_template.txt b/templates/digest_template.txt index aa56fb3ae..841f8ee98 100644 --- a/templates/digest_template.txt +++ b/templates/digest_template.txt @@ -11,4 +11,5 @@ ${FEED_TITLE} <!-- $EndBlock feed --> -- To unsubscribe, visit your configuration options or contact instance owner. +Sent by tt-rss mailer daemon at ${TTRSS_HOST}. <!-- $EndBlock digest --> diff --git a/templates/digest_template_html.txt b/templates/digest_template_html.txt index ede93d917..f38d98a21 100644 --- a/templates/digest_template_html.txt +++ b/templates/digest_template_html.txt @@ -14,5 +14,7 @@ <!-- $EndBlock feed --> <hr> -<em>To unsubscribe, visit your configuration options or contact instance owner.</em> + +<em>To unsubscribe, visit your configuration options or contact instance owner.</em><br/> +<em>Sent by tt-rss mailer daemon at ${TTRSS_HOST}.</em> <!-- $EndBlock digest --> diff --git a/templates/email_article_template.txt b/templates/email_article_template.txt index b6bc63921..c955100a0 100644 --- a/templates/email_article_template.txt +++ b/templates/email_article_template.txt @@ -12,5 +12,5 @@ Thought I'd share the following with you: <!-- $EndBlock article --> -- -This message has been sent by Tiny Tiny RSS installation at ${TTRSS_HOST}. +Sent by Tiny Tiny RSS mailer daemon at ${TTRSS_HOST}. <!-- $EndBlock email --> diff --git a/templates/mail_change_template.txt b/templates/mail_change_template.txt new file mode 100644 index 000000000..1f7b2a60e --- /dev/null +++ b/templates/mail_change_template.txt @@ -0,0 +1,10 @@ +<!-- $BeginBlock message --> +Hello, ${LOGIN}. + +Your mail address for this Tiny Tiny RSS instance has been changed to ${NEWMAIL}. + +If you haven't requested this change, consider contacting your instance owner or resetting your password. + +-- +Sent by tt-rss mailer daemon at ${TTRSS_HOST}. +<!-- $EndBlock message --> diff --git a/templates/otp_disabled_template.txt b/templates/otp_disabled_template.txt new file mode 100644 index 000000000..93334dc24 --- /dev/null +++ b/templates/otp_disabled_template.txt @@ -0,0 +1,10 @@ +<!-- $BeginBlock message --> +Hello, ${LOGIN}. + +One time passwords (2FA) has been disabled for your account on this Tiny Tiny RSS instance. + +If you haven't requested this change, consider contacting your instance owner or resetting your password. + +-- +Sent by tt-rss mailer daemon at ${TTRSS_HOST}. +<!-- $EndBlock message --> diff --git a/templates/password_change_template.txt b/templates/password_change_template.txt new file mode 100644 index 000000000..a5fa9c761 --- /dev/null +++ b/templates/password_change_template.txt @@ -0,0 +1,10 @@ +<!-- $BeginBlock message --> +Hello, ${LOGIN}. + +Your password for this Tiny Tiny RSS instance has been changed. + +If you haven't requested this change, consider contacting your instance owner. + +-- +Sent by tt-rss mailer daemon at ${TTRSS_HOST}. +<!-- $EndBlock message --> diff --git a/templates/resetpass_link_template.txt b/templates/resetpass_link_template.txt index d238c8616..c5c5d5f3a 100644 --- a/templates/resetpass_link_template.txt +++ b/templates/resetpass_link_template.txt @@ -10,5 +10,6 @@ Please note that the above link will only be valid for the next 15 minutes. If you don't want to reset your password, ignore this message. -Sincerely, Tiny Tiny RSS Mail Daemon. +-- +Sent by tt-rss mailer daemon at ${TTRSS_HOST}. <!-- $EndBlock message --> diff --git a/templates/resetpass_template.txt b/templates/resetpass_template.txt deleted file mode 100644 index c262f9a77..000000000 --- a/templates/resetpass_template.txt +++ /dev/null @@ -1,9 +0,0 @@ -<!-- $BeginBlock message --> -Hello, ${LOGIN}. - -Your password for this Tiny Tiny RSS installation has been reset. - -Your new password is ${NEWPASS}, please remember it for later reference. - -Sincerely, Tiny Tiny RSS Mail Daemon. -<!-- $EndBlock message --> |