diff options
Diffstat (limited to 'classes')
36 files changed, 2450 insertions, 1633 deletions
diff --git a/classes/api.php b/classes/api.php index a0ee773c1..a1ed7968c 100755 --- a/classes/api.php +++ b/classes/api.php @@ -1,7 +1,7 @@ <?php class API extends Handler { - const API_LEVEL = 15; + const API_LEVEL = 16; const STATUS_OK = 0; const STATUS_ERR = 1; @@ -49,7 +49,7 @@ class API extends Handler { } function getVersion() { - $rv = array("version" => get_version()); + $rv = array("version" => Config::get_version()); $this->_wrap(self::STATUS_OK, $rv); } @@ -98,8 +98,8 @@ class API extends Handler { } function getUnread() { - $feed_id = clean($_REQUEST["feed_id"]); - $is_cat = clean($_REQUEST["is_cat"]); + $feed_id = clean($_REQUEST["feed_id"] ?? ""); + $is_cat = clean($_REQUEST["is_cat"] ?? ""); if ($feed_id) { $this->_wrap(self::STATUS_OK, array("unread" => getFeedUnread($feed_id, $is_cat))); @@ -188,15 +188,15 @@ class API extends Handler { if (is_numeric($feed_id)) $feed_id = (int) $feed_id; - $limit = (int)clean($_REQUEST["limit"]); + $limit = (int)clean($_REQUEST["limit"] ?? 0 ); if (!$limit || $limit >= 200) $limit = 200; - $offset = (int)clean($_REQUEST["skip"]); + $offset = (int)clean($_REQUEST["skip"] ?? 0); $filter = clean($_REQUEST["filter"] ?? ""); $is_cat = self::_param_to_bool(clean($_REQUEST["is_cat"] ?? false)); $show_excerpt = self::_param_to_bool(clean($_REQUEST["show_excerpt"] ?? false)); - $show_content = self::_param_to_bool(clean($_REQUEST["show_content"])); + $show_content = self::_param_to_bool(clean($_REQUEST["show_content"] ?? false)); /* all_articles, unread, adaptive, marked, updated */ $view_mode = clean($_REQUEST["view_mode"] ?? null); $include_attachments = self::_param_to_bool(clean($_REQUEST["include_attachments"] ?? false)); @@ -258,6 +258,10 @@ class API extends Handler { break; case 3: $field = "note"; + break; + case 4: + $field = "score"; + break; }; switch ($mode) { @@ -273,6 +277,7 @@ class API extends Handler { } if ($field == "note") $set_to = $this->pdo->quote($data); + if ($field == "score") $set_to = (int) $data; if ($field && $set_to && count($article_ids) > 0) { @@ -363,6 +368,7 @@ class API extends Handler { } $this->_wrap(self::STATUS_OK, $articles); + // @phpstan-ignore-next-line } else { $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE)); } @@ -786,7 +792,8 @@ class API extends Handler { list ($flavor_image, $flavor_stream, $flavor_kind) = Article::_get_image($enclosures, $line["content"], // unsanitized - $line["site_url"]); + $line["site_url"], + $headline_row); $headline_row["flavor_image"] = $flavor_image; $headline_row["flavor_stream"] = $flavor_stream; diff --git a/classes/article.php b/classes/article.php index d8ae97257..04855ac9d 100755 --- a/classes/article.php +++ b/classes/article.php @@ -5,26 +5,23 @@ class Article extends Handler_Protected { const ARTICLE_KIND_YOUTUBE = 3; function redirect() { - $id = (int) clean($_REQUEST['id'] ?? 0); - - $sth = $this->pdo->prepare("SELECT link FROM ttrss_entries, ttrss_user_entries - WHERE id = ? AND id = ref_id AND owner_uid = ? - LIMIT 1"); - $sth->execute([$id, $_SESSION['uid']]); + $article = ORM::for_table('ttrss_entries') + ->table_alias('e') + ->join('ttrss_user_entries', [ 'ref_id', '=', 'e.id'], 'ue') + ->where('ue.owner_uid', $_SESSION['uid']) + ->find_one((int)$_REQUEST['id']); - if ($row = $sth->fetch()) { - $article_url = UrlHelper::validate(str_replace("\n", "", $row['link'])); + if ($article) { + $article_url = UrlHelper::validate($article->link); if ($article_url) { header("Location: $article_url"); - } else { - header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); - print "URL of article $id is blank."; + return; } - - } else { - print_error(__("Article not found.")); } + + header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); + print "Article not found or has an empty URL."; } static function _create_published_article($title, $url, $content, $labels_str, @@ -182,19 +179,6 @@ class Article extends Handler_Protected { print json_encode(["id" => $ids, "score" => $score]); } - function getScore() { - $id = clean($_REQUEST['id']); - - $sth = $this->pdo->prepare("SELECT score FROM ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?"); - $sth->execute([$id, $_SESSION['uid']]); - $row = $sth->fetch(); - - $score = $row['score']; - - print json_encode(["id" => $id, "score" => (int)$score]); - } - - function setArticleTags() { $id = clean($_REQUEST["id"]); @@ -433,42 +417,39 @@ class Article extends Handler_Protected { } function getmetadatabyid() { - $id = clean($_REQUEST['id']); - - $sth = $this->pdo->prepare("SELECT link, title FROM ttrss_entries, ttrss_user_entries - WHERE ref_id = ? AND ref_id = id AND owner_uid = ?"); - $sth->execute([$id, $_SESSION['uid']]); + $article = ORM::for_table('ttrss_entries') + ->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue') + ->where('ue.owner_uid', $_SESSION['uid']) + ->find_one((int)$_REQUEST['id']); - if ($row = $sth->fetch()) { - $link = $row['link']; - $title = $row['title']; - - echo json_encode(["link" => $link, "title" => $title]); + if ($article) { + echo json_encode(["link" => $article->link, "title" => $article->title]); + } else { + echo json_encode([]); } } static function _get_enclosures($id) { + $encs = ORM::for_table('ttrss_enclosures') + ->where('post_id', $id) + ->find_many(); - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT * FROM ttrss_enclosures - WHERE post_id = ? AND content_url != ''"); - $sth->execute([$id]); - - $rv = array(); + $rv = []; $cache = new DiskCache("images"); - while ($line = $sth->fetch(PDO::FETCH_ASSOC)) { + foreach ($encs as $enc) { + $cache_key = sha1($enc->content_url); - if ($cache->exists(sha1($line["content_url"]))) { - $line["content_url"] = $cache->get_url(sha1($line["content_url"])); + if ($cache->exists($cache_key)) { + $enc->content_url = $cache->get_url($cache_key); } - array_push($rv, $line); + array_push($rv, $enc->as_array()); } return $rv; + } static function _purge_orphans() { @@ -562,17 +543,20 @@ class Article extends Handler_Protected { return $rv; } - static function _get_image($enclosures, $content, $site_url) { + static function _get_image(array $enclosures, string $content, string $site_url, array $headline) { $article_image = ""; $article_stream = ""; $article_kind = 0; PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_IMAGE, - function ($result) use (&$article_image, &$article_stream, &$content) { + function ($result, $plugin) use (&$article_image, &$article_stream, &$content) { list ($article_image, $article_stream, $content) = $result; + + // run until first hard match + return !empty($article_image); }, - $enclosures, $content, $site_url); + $enclosures, $content, $site_url, $headline); if (!$article_image && !$article_stream) { $tmpdoc = new DOMDocument(); @@ -645,17 +629,16 @@ class Article extends Handler_Protected { if (count($article_ids) == 0) return []; - $id_qmarks = arr_qmarks($article_ids); - - $sth = Db::pdo()->prepare("SELECT DISTINCT label_cache FROM ttrss_entries e, ttrss_user_entries ue - WHERE ue.ref_id = e.id AND id IN ($id_qmarks)"); - - $sth->execute($article_ids); + $entries = ORM::for_table('ttrss_entries') + ->table_alias('e') + ->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue') + ->where_in('id', $article_ids) + ->find_many(); $rv = []; - while ($row = $sth->fetch()) { - $labels = json_decode($row["label_cache"]); + foreach ($entries as $entry) { + $labels = json_decode($entry->label_cache); if (isset($labels) && is_array($labels)) { foreach ($labels as $label) { @@ -672,19 +655,18 @@ class Article extends Handler_Protected { if (count($article_ids) == 0) return []; - $id_qmarks = arr_qmarks($article_ids); - - $sth = Db::pdo()->prepare("SELECT DISTINCT feed_id FROM ttrss_entries e, ttrss_user_entries ue - WHERE ue.ref_id = e.id AND id IN ($id_qmarks)"); - - $sth->execute($article_ids); + $entries = ORM::for_table('ttrss_entries') + ->table_alias('e') + ->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue') + ->where_in('id', $article_ids) + ->find_many(); $rv = []; - while ($row = $sth->fetch()) { - array_push($rv, $row["feed_id"]); + foreach ($entries as $entry) { + array_push($rv, $entry->feed_id); } - return $rv; + return array_unique($rv); } } diff --git a/classes/auth/base.php b/classes/auth/base.php index f18cc2d2d..82ea06e1b 100644 --- a/classes/auth/base.php +++ b/classes/auth/base.php @@ -23,13 +23,14 @@ abstract class Auth_Base extends Plugin implements IAuthModule { if (!$password) $password = make_password(); - $salt = substr(bin2hex(get_random_bytes(125)), 0, 250); - $pwd_hash = encrypt_password($password, $salt, true); - - $sth = $this->pdo->prepare("INSERT INTO ttrss_users - (login,access_level,last_login,created,pwd_hash,salt) - VALUES (LOWER(?), 0, null, NOW(), ?,?)"); - $sth->execute([$login, $pwd_hash, $salt]); + $user = ORM::for_table('ttrss_users')->create(); + + $user->salt = UserHelper::get_salt(); + $user->login = mb_strtolower($login); + $user->pwd_hash = UserHelper::hash_password($password, $user->salt); + $user->access_level = 0; + $user->created = Db::NOW(); + $user->save(); return UserHelper::find_user_by_login($login); diff --git a/classes/config.php b/classes/config.php index ee1d3cb4a..567a019c6 100644 --- a/classes/config.php +++ b/classes/config.php @@ -6,6 +6,8 @@ class Config { const T_STRING = 2; const T_INT = 3; + const SCHEMA_VERSION = 144; + // override defaults, defined below in _DEFAULTS[], via environment: DB_TYPE becomes TTRSS_DB_TYPE, etc const DB_TYPE = "DB_TYPE"; @@ -53,6 +55,8 @@ class Config { const HTTP_PROXY = "HTTP_PROXY"; const FORBID_PASSWORD_CHANGES = "FORBID_PASSWORD_CHANGES"; const SESSION_NAME = "SESSION_NAME"; + const CHECK_FOR_PLUGIN_UPDATES = "CHECK_FOR_PLUGIN_UPDATES"; + const ENABLE_PLUGIN_INSTALLER = "ENABLE_PLUGIN_INSTALLER"; private const _DEFAULTS = [ Config::DB_TYPE => [ "pgsql", Config::T_STRING ], @@ -80,10 +84,10 @@ class Config { Config::T_STRING ], Config::CHECK_FOR_UPDATES => [ "true", Config::T_BOOL ], Config::PLUGINS => [ "auth_internal", Config::T_STRING ], - Config::LOG_DESTINATION => [ "sql", Config::T_STRING ], + Config::LOG_DESTINATION => [ Logger::LOG_DEST_SQL, Config::T_STRING ], Config::LOCAL_OVERRIDE_STYLESHEET => [ "local-overrides.css", Config::T_STRING ], - Config::DAEMON_MAX_CHILD_RUNTIME => [ 1800, Config::T_STRING ], + Config::DAEMON_MAX_CHILD_RUNTIME => [ 1800, Config::T_INT ], Config::DAEMON_MAX_JOBS => [ 2, Config::T_INT ], Config::FEED_FETCH_TIMEOUT => [ 45, Config::T_INT ], Config::FEED_FETCH_NO_CACHE_TIMEOUT => [ 15, Config::T_INT ], @@ -102,12 +106,18 @@ class Config { Config::HTTP_PROXY => [ "", Config::T_STRING ], Config::FORBID_PASSWORD_CHANGES => [ "", Config::T_BOOL ], Config::SESSION_NAME => [ "ttrss_sid", Config::T_STRING ], + Config::CHECK_FOR_PLUGIN_UPDATES => [ "true", Config::T_BOOL ], + Config::ENABLE_PLUGIN_INSTALLER => [ "true", Config::T_BOOL ], ]; private static $instance; private $params = []; private $schema_version = null; + private $version = []; + + /** @var Db_Migrations $migrations */ + private $migrations; public static function get_instance() : Config { if (self::$instance == null) @@ -129,23 +139,109 @@ class Config { list ($defval, $deftype) = $this::_DEFAULTS[$const]; - $this->params[$cvalue] = [ self::cast_to(!empty($override) ? $override : $defval, $deftype), $deftype ]; + $this->params[$cvalue] = [ self::cast_to($override !== false ? $override : $defval, $deftype), $deftype ]; + } + } + } + + /* package maintainers who don't use git: if version_static.txt exists in tt-rss root + directory, its contents are displayed instead of git commit-based version, this could be generated + based on source git tree commit used when creating the package */ + + static function get_version(bool $as_string = true) { + return self::get_instance()->_get_version($as_string); + } + + private function _get_version(bool $as_string = true) { + $root_dir = dirname(__DIR__); + + if (empty($this->version)) { + $this->version["status"] = -1; + + if (PHP_OS === "Darwin") { + $ttrss_version["version"] = "UNKNOWN (Unsupported, Darwin)"; + } else if (file_exists("$root_dir/version_static.txt")) { + $this->version["version"] = trim(file_get_contents("$root_dir/version_static.txt")) . " (Unsupported)"; + } else if (is_dir("$root_dir/.git")) { + $this->version = self::get_version_from_git($root_dir); + + if ($this->version["status"] != 0) { + user_error("Unable to determine version: " . $this->version["version"], E_USER_WARNING); + + $this->version["version"] = "UNKNOWN (Unsupported, Git error)"; + } + } else { + $this->version["version"] = "UNKNOWN (Unsupported)"; } } + + return $as_string ? $this->version["version"] : $this->version; } - static function get_schema_version(bool $nocache = false) { - return self::get_instance()->_schema_version($nocache); + static function get_version_from_git(string $dir) { + $descriptorspec = [ + 1 => ["pipe", "w"], // STDOUT + 2 => ["pipe", "w"], // STDERR + ]; + + $rv = [ + "status" => -1, + "version" => "", + "commit" => "", + "timestamp" => 0, + ]; + + $proc = proc_open("git --no-pager log --pretty=\"version-%ct-%h\" -n1 HEAD", + $descriptorspec, $pipes, $dir); + + if (is_resource($proc)) { + $stdout = trim(stream_get_contents($pipes[1])); + $stderr = trim(stream_get_contents($pipes[2])); + $status = proc_close($proc); + + $rv["status"] = $status; + + list($check, $timestamp, $commit) = explode("-", $stdout); + + if ($check == "version") { + + $rv["version"] = strftime("%y.%m", (int)$timestamp) . "-$commit"; + $rv["commit"] = $commit; + $rv["timestamp"] = $timestamp; + + // proc_close() may return -1 even if command completed successfully + // so if it looks like we got valid data, we ignore it + + if ($rv["status"] == -1) + $rv["status"] = 0; + + } else { + $rv["version"] = T_sprintf("Git error [RC=%d]: %s", $status, $stderr); + } + } + + return $rv; } - function _schema_version(bool $nocache = false) { - if (empty($this->schema_version) || $nocache) { - $row = Db::pdo()->query("SELECT schema_version FROM ttrss_version")->fetch(); + static function get_migrations() : Db_Migrations { + return self::get_instance()->_get_migrations(); + } - $this->schema_version = (int) $row["schema_version"]; + private function _get_migrations() : Db_Migrations { + if (empty($this->migrations)) { + $this->migrations = new Db_Migrations(); + $this->migrations->initialize(dirname(__DIR__) . "/sql", "ttrss_version", true, self::SCHEMA_VERSION); } - return $this->schema_version; + return $this->migrations; + } + + static function is_migration_needed() : bool { + return self::get_migrations()->is_migration_needed(); + } + + static function get_schema_version() : int { + return self::get_migrations()->get_version(); } static function cast_to(string $value, int $type_hint) { @@ -168,7 +264,7 @@ class Config { private function _add(string $param, string $default, int $type_hint) { $override = getenv($this::_ENVVAR_PREFIX . $param); - $this->params[$param] = [ self::cast_to(!empty($override) ? $override : $default, $type_hint), $type_hint ]; + $this->params[$param] = [ self::cast_to($override !== false ? $override : $default, $type_hint), $type_hint ]; } static function add(string $param, string $default, int $type_hint = Config::T_STRING) { @@ -183,4 +279,228 @@ class Config { return $instance->_get($param); } + /** this returns Config::SELF_URL_PATH sans trailing slash */ + static function get_self_url() : string { + $self_url_path = self::get(Config::SELF_URL_PATH); + + if (substr($self_url_path, -1) === "/") { + return substr($self_url_path, 0, -1); + } else { + return $self_url_path; + } + } + + static function is_server_https() : bool { + return (!empty($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] != 'off')) || + (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https'); + } + + /** generates reference self_url_path (no trailing slash) */ + static function make_self_url() : string { + $proto = self::is_server_https() ? 'https' : 'http'; + $self_url_path = $proto . '://' . $_SERVER["HTTP_HOST"] . $_SERVER["REQUEST_URI"]; + + $self_url_path = preg_replace("/\w+\.php(\?.*$)?$/", "", $self_url_path); + + if (substr($self_url_path, -1) === "/") { + return substr($self_url_path, 0, -1); + } else { + return $self_url_path; + } + } + + /* sanity check stuff */ + + private static function check_mysql_tables() { + $pdo = Db::pdo(); + + $sth = $pdo->prepare("SELECT engine, table_name FROM information_schema.tables WHERE + table_schema = ? AND table_name LIKE 'ttrss_%' AND engine != 'InnoDB'"); + $sth->execute([self::get(Config::DB_NAME)]); + + $bad_tables = []; + + while ($line = $sth->fetch()) { + array_push($bad_tables, $line); + } + + return $bad_tables; + } + + static function sanity_check() { + + /* + we don't actually need the DB object right now but some checks below might use ORM which won't be initialized + because it is set up in the Db constructor, which is why it's a good idea to invoke it as early as possible + + it is a bit of a hack, maybe ORM should be initialized somewhere else (functions.php?) + */ + + $pdo = Db::pdo(); + + $errors = []; + + if (strpos(self::get(Config::PLUGINS), "auth_") === false) { + array_push($errors, "Please enable at least one authentication module via PLUGINS"); + } + + if (function_exists('posix_getuid') && posix_getuid() == 0) { + array_push($errors, "Please don't run this script as root."); + } + + if (version_compare(PHP_VERSION, '7.1.0', '<')) { + array_push($errors, "PHP version 7.1.0 or newer required. You're using " . PHP_VERSION . "."); + } + + if (!class_exists("UConverter")) { + array_push($errors, "PHP UConverter class is missing, it's provided by the Internationalization (intl) module."); + } + + if (!is_writable(self::get(Config::CACHE_DIR) . "/images")) { + array_push($errors, "Image cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/images)"); + } + + if (!is_writable(self::get(Config::CACHE_DIR) . "/upload")) { + array_push($errors, "Upload cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/upload)"); + } + + if (!is_writable(self::get(Config::CACHE_DIR) . "/export")) { + array_push($errors, "Data export cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/export)"); + } + + if (self::get(Config::SINGLE_USER_MODE) && class_exists("PDO")) { + if (UserHelper::get_login_by_id(1) != "admin") { + array_push($errors, "SINGLE_USER_MODE is enabled but default admin account (ID: 1) is not found."); + } + } + + if (php_sapi_name() != "cli") { + + if (self::get_schema_version() < 0) { + array_push($errors, "Base database schema is missing. Either load it manually or perform a migration (<code>update.php --update-schema</code>)"); + } + + $ref_self_url_path = self::make_self_url(); + + if ($ref_self_url_path) { + $ref_self_url_path = preg_replace("/\w+\.php$/", "", $ref_self_url_path); + } + + if (self::get_self_url() == "http://example.org/tt-rss") { + $hint = $ref_self_url_path ? "(possible value: <b>$ref_self_url_path</b>)" : ""; + array_push($errors, + "Please set SELF_URL_PATH to the correct value for your server: $hint"); + } + + if (self::get_self_url() != $ref_self_url_path) { + array_push($errors, + "Please set SELF_URL_PATH to the correct value detected for your server: <b>$ref_self_url_path</b> (you're using: <b>" . self::get_self_url() . "</b>)"); + } + } + + if (!is_writable(self::get(Config::ICONS_DIR))) { + array_push($errors, "ICONS_DIR defined in config.php is not writable (chmod -R 777 ".self::get(Config::ICONS_DIR).").\n"); + } + + if (!is_writable(self::get(Config::LOCK_DIRECTORY))) { + array_push($errors, "LOCK_DIRECTORY is not writable (chmod -R 777 ".self::get(Config::LOCK_DIRECTORY).").\n"); + } + + if (!function_exists("curl_init") && !ini_get("allow_url_fopen")) { + array_push($errors, "PHP configuration option allow_url_fopen is disabled, and CURL functions are not present. Either enable allow_url_fopen or install PHP extension for CURL."); + } + + if (!function_exists("json_encode")) { + array_push($errors, "PHP support for JSON is required, but was not found."); + } + + if (!class_exists("PDO")) { + array_push($errors, "PHP support for PDO is required but was not found."); + } + + if (!function_exists("mb_strlen")) { + array_push($errors, "PHP support for mbstring functions is required but was not found."); + } + + if (!function_exists("hash")) { + array_push($errors, "PHP support for hash() function is required but was not found."); + } + + if (ini_get("safe_mode")) { + array_push($errors, "PHP safe mode setting is obsolete and not supported by tt-rss."); + } + + if (!function_exists("mime_content_type")) { + array_push($errors, "PHP function mime_content_type() is missing, try enabling fileinfo module."); + } + + if (!class_exists("DOMDocument")) { + array_push($errors, "PHP support for DOMDocument is required, but was not found."); + } + + if (self::get(Config::DB_TYPE) == "mysql") { + $bad_tables = self::check_mysql_tables(); + + if (count($bad_tables) > 0) { + $bad_tables_fmt = []; + + foreach ($bad_tables as $bt) { + array_push($bad_tables_fmt, sprintf("%s (%s)", $bt['table_name'], $bt['engine'])); + } + + $msg = "<p>The following tables use an unsupported MySQL engine: <b>" . + implode(", ", $bad_tables_fmt) . "</b>.</p>"; + + $msg .= "<p>The only supported engine on MySQL is InnoDB. MyISAM lacks functionality to run + tt-rss. + Please backup your data (via OPML) and re-import the schema before continuing.</p> + <p><b>WARNING: importing the schema would mean LOSS OF ALL YOUR DATA.</b></p>"; + + + array_push($errors, $msg); + } + } + + if (count($errors) > 0 && php_sapi_name() != "cli") { ?> + <!DOCTYPE html> + <html> + <head> + <title>Startup failed</title> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <link rel="stylesheet" type="text/css" href="themes/light.css"> + </head> + <body class="sanity_failed flat ttrss_utility"> + <div class="content"> + <h1>Startup failed</h1> + + <p>Please fix errors indicated by the following messages:</p> + + <?php foreach ($errors as $error) { echo self::format_error($error); } ?> + + <p>You might want to check tt-rss <a target="_blank" href="https://tt-rss.org/wiki.php">wiki</a> or the + <a target="_blank" href="https://community.tt-rss.org/">forums</a> for more information. Please search the forums before creating new topic + for your question.</p> + </div> + </body> + </html> + + <?php + die; + } else if (count($errors) > 0) { + echo "Please fix errors indicated by the following messages:\n\n"; + + foreach ($errors as $error) { + echo " * " . strip_tags($error)."\n"; + } + + echo "\nYou might want to check tt-rss wiki or the forums for more information.\n"; + echo "Please search the forums before creating new topic for your question.\n"; + + exit(1); + } + } + + private static function format_error($msg) { + return "<div class=\"alert alert-danger\">$msg</div>"; + } } diff --git a/classes/counters.php b/classes/counters.php index b4602825c..8a8b8bc71 100644 --- a/classes/counters.php +++ b/classes/counters.php @@ -21,21 +21,20 @@ class Counters { ); } - static private function get_cat_children($cat_id, $owner_uid) { - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT id FROM ttrss_feed_categories WHERE parent_cat = ? - AND owner_uid = ?"); - $sth->execute([$cat_id, $owner_uid]); - + static private function get_cat_children(int $cat_id, int $owner_uid) { $unread = 0; $marked = 0; - while ($line = $sth->fetch()) { - list ($tmp_unread, $tmp_marked) = self::get_cat_children($line["id"], $owner_uid); + $cats = ORM::for_table('ttrss_feed_categories') + ->where('owner_uid', $owner_uid) + ->where('parent_cat', $cat_id) + ->find_many(); + + foreach ($cats as $cat) { + list ($tmp_unread, $tmp_marked) = self::get_cat_children($cat->id, $owner_uid); - $unread += $tmp_unread + Feeds::_get_cat_unread($line["id"], $owner_uid); - $marked += $tmp_marked + Feeds::_get_cat_marked($line["id"], $owner_uid); + $unread += $tmp_unread + Feeds::_get_cat_unread($cat->id, $owner_uid); + $marked += $tmp_marked + Feeds::_get_cat_marked($cat->id, $owner_uid); } return [$unread, $marked]; @@ -178,7 +177,7 @@ class Counters { $has_img = false; } - // hide default un-updated timestamp i.e. 1980-01-01 (?) -fox + // hide default un-updated timestamp i.e. 1970-01-01 (?) -fox if ((int)date('Y') - (int)date('Y', strtotime($line['last_updated'])) > 2) $last_updated = ''; @@ -200,35 +199,22 @@ class Counters { return $ret; } - private static function get_global($global_unread = -1) { - $ret = []; - - if ($global_unread == -1) { - $global_unread = Feeds::_get_global_unread(); - } - - $cv = [ - "id" => "global-unread", - "counter" => (int) $global_unread + private static function get_global() { + $ret = [ + [ + "id" => "global-unread", + "counter" => (int) Feeds::_get_global_unread() + ] ]; - array_push($ret, $cv); - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT COUNT(id) AS fn FROM - ttrss_feeds WHERE owner_uid = ?"); - $sth->execute([$_SESSION['uid']]); - $row = $sth->fetch(); + $subcribed_feeds = ORM::for_table('ttrss_feeds') + ->where('owner_uid', $_SESSION['uid']) + ->count(); - $subscribed_feeds = $row["fn"]; - - $cv = [ + array_push($ret, [ "id" => "subscribed-feeds", - "counter" => (int) $subscribed_feeds - ]; - - array_push($ret, $cv); + "counter" => $subcribed_feeds + ]); return $ret; } diff --git a/classes/db.php b/classes/db.php index a760d4402..008275bca 100755 --- a/classes/db.php +++ b/classes/db.php @@ -1,27 +1,42 @@ <?php class Db { - /* @var Db $instance */ + /** @var Db $instance */ private static $instance; private $link; - /* @var PDO $pdo */ + /** @var PDO $pdo */ private $pdo; + function __construct() { + ORM::configure(self::get_dsn()); + ORM::configure('username', Config::get(Config::DB_USER)); + ORM::configure('password', Config::get(Config::DB_PASS)); + ORM::configure('return_result_sets', true); + } + + static function NOW() { + return date("Y-m-d H:i:s", time()); + } + private function __clone() { // } - // this really shouldn't be used unless a separate PDO connection is needed - // normal usage is Db::pdo()->prepare(...) etc - public function pdo_connect() { - + public static function get_dsn() { $db_port = Config::get(Config::DB_PORT) ? ';port=' . Config::get(Config::DB_PORT) : ''; $db_host = Config::get(Config::DB_HOST) ? ';host=' . Config::get(Config::DB_HOST) : ''; + return Config::get(Config::DB_TYPE) . ':dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port; + } + + // this really shouldn't be used unless a separate PDO connection is needed + // normal usage is Db::pdo()->prepare(...) etc + public function pdo_connect() : PDO { + try { - $pdo = new PDO(Config::get(Config::DB_TYPE) . ':dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port, + $pdo = new PDO(self::get_dsn(), Config::get(Config::DB_USER), Config::get(Config::DB_PASS)); } catch (Exception $e) { @@ -49,7 +64,7 @@ class Db return $pdo; } - public static function instance() { + public static function instance() : Db { if (self::$instance == null) self::$instance = new self(); @@ -60,7 +75,7 @@ class Db if (self::$instance == null) self::$instance = new self(); - if (!self::$instance->pdo) { + if (empty(self::$instance->pdo)) { self::$instance->pdo = self::$instance->pdo_connect(); } diff --git a/classes/db/migrations.php b/classes/db/migrations.php new file mode 100644 index 000000000..3008af535 --- /dev/null +++ b/classes/db/migrations.php @@ -0,0 +1,198 @@ +<?php +class Db_Migrations { + + private $base_filename = "schema.sql"; + private $base_path; + private $migrations_path; + private $migrations_table; + private $base_is_latest; + private $pdo; + + private $cached_version; + private $cached_max_version; + private $max_version_override; + + function __construct() { + $this->pdo = Db::pdo(); + } + + function initialize_for_plugin(Plugin $plugin, bool $base_is_latest = true, string $schema_suffix = "sql") { + $plugin_dir = PluginHost::getInstance()->get_plugin_dir($plugin); + $this->initialize($plugin_dir . "/${schema_suffix}", + strtolower("ttrss_migrations_plugin_" . get_class($plugin)), + $base_is_latest); + } + + function initialize(string $root_path, string $migrations_table, bool $base_is_latest = true, int $max_version_override = 0) { + $this->base_path = "$root_path/" . Config::get(Config::DB_TYPE); + $this->migrations_path = $this->base_path . "/migrations"; + $this->migrations_table = $migrations_table; + $this->base_is_latest = $base_is_latest; + $this->max_version_override = $max_version_override; + } + + private function set_version(int $version) { + Debug::log("Updating table {$this->migrations_table} with version ${version}...", Debug::LOG_EXTENDED); + + $sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}"); + + if ($res = $sth->fetch()) { + $sth = $this->pdo->prepare("UPDATE {$this->migrations_table} SET schema_version = ?"); + } else { + $sth = $this->pdo->prepare("INSERT INTO {$this->migrations_table} (schema_version) VALUES (?)"); + } + + $sth->execute([$version]); + + $this->cached_version = $version; + } + + function get_version() : int { + if (isset($this->cached_version)) + return $this->cached_version; + + try { + $sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}"); + + if ($res = $sth->fetch()) { + return (int) $res['schema_version']; + } else { + return -1; + } + } catch (PDOException $e) { + $this->create_migrations_table(); + + return -1; + } + } + + private function create_migrations_table() { + $this->pdo->query("CREATE TABLE IF NOT EXISTS {$this->migrations_table} (schema_version integer not null)"); + } + + private function migrate_to(int $version) { + try { + if ($version <= $this->get_version()) { + Debug::log("Refusing to apply version $version: current version is higher", Debug::LOG_VERBOSE); + return false; + } + + if ($version == 0) + Debug::log("Loading base database schema...", Debug::LOG_VERBOSE); + else + Debug::log("Starting migration to $version...", Debug::LOG_VERBOSE); + + $lines = $this->get_lines($version); + + if (count($lines) > 0) { + // mysql doesn't support transactions for DDL statements + if (Config::get(Config::DB_TYPE) != "mysql") + $this->pdo->beginTransaction(); + + foreach ($lines as $line) { + Debug::log($line, Debug::LOG_EXTENDED); + try { + $this->pdo->query($line); + } catch (PDOException $e) { + Debug::log("Failed on line: $line", Debug::LOG_VERBOSE); + throw $e; + } + } + + if ($version == 0 && $this->base_is_latest) + $this->set_version($this->get_max_version()); + else + $this->set_version($version); + + if (Config::get(Config::DB_TYPE) != "mysql") + $this->pdo->commit(); + + Debug::log("Migration finished, current version: " . $this->get_version(), Debug::LOG_VERBOSE); + + Logger::log(E_USER_NOTICE, "Applied migration to version $version for {$this->migrations_table}"); + } else { + Debug::log("Migration failed: schema file is empty or missing.", Debug::LOG_VERBOSE); + } + + } catch (PDOException $e) { + Debug::log("Migration failed: " . $e->getMessage(), Debug::LOG_VERBOSE); + try { + $this->pdo->rollback(); + } catch (PDOException $ie) { + // + } + throw $e; + } + } + + function get_max_version() : int { + if ($this->max_version_override > 0) + return $this->max_version_override; + + if (isset($this->cached_max_version)) + return $this->cached_max_version; + + $migrations = glob("{$this->migrations_path}/*.sql"); + + if (count($migrations) > 0) { + natsort($migrations); + + $this->cached_max_version = (int) basename(array_pop($migrations), ".sql"); + + } else { + $this->cached_max_version = 0; + } + + return $this->cached_max_version; + } + + function is_migration_needed() : bool { + return $this->get_version() != $this->get_max_version(); + } + + function migrate() : bool { + + if ($this->get_version() == -1) { + try { + $this->migrate_to(0); + } catch (PDOException $e) { + user_error("Failed to load base schema for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING); + return false; + } + } + + for ($i = $this->get_version() + 1; $i <= $this->get_max_version(); $i++) { + try { + $this->migrate_to($i); + } catch (PDOException $e) { + user_error("Failed to apply migration ${i} for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING); + return false; + //throw $e; + } + } + + return !$this->is_migration_needed(); + } + + private function get_lines(int $version) : array { + if ($version > 0) + $filename = "{$this->migrations_path}/${version}.sql"; + else + $filename = "{$this->base_path}/{$this->base_filename}"; + + if (file_exists($filename)) { + $lines = array_filter(preg_split("/[\r\n]/", file_get_contents($filename)), + function ($line) { + return strlen(trim($line)) > 0 && strpos($line, "--") !== 0; + }); + + return array_filter(explode(";", implode("", $lines)), function ($line) { + return strlen(trim($line)) > 0 && !in_array(strtolower($line), ["begin", "commit"]); + }); + + } else { + user_error("Requested schema file ${filename} not found.", E_USER_ERROR); + return []; + } + } +} diff --git a/classes/dbupdater.php b/classes/dbupdater.php deleted file mode 100644 index d1df31b40..000000000 --- a/classes/dbupdater.php +++ /dev/null @@ -1,82 +0,0 @@ -<?php -class DbUpdater { - - private $pdo; - private $db_type; - private $need_version; - - function __construct($pdo, $db_type, $need_version) { - $this->pdo = $pdo; - $this->db_type = $db_type; - $this->need_version = (int) $need_version; - } - - function get_schema_version() { - return Config::get_schema_version(true); - } - - function is_update_required() { - return $this->get_schema_version() < $this->need_version; - } - - function get_schema_lines($version) { - $filename = "schema/versions/".$this->db_type."/$version.sql"; - - if (file_exists($filename)) { - return explode(";", (string)preg_replace("/[\r\n]/", "", (string)file_get_contents($filename))); - } else { - user_error("DB Updater: schema file for version $version is not found."); - return false; - } - } - - function update_to($version, $html_output = true) { - if ($this->get_schema_version() == $version - 1) { - - $lines = $this->get_schema_lines($version); - - if (is_array($lines)) { - - $this->pdo->beginTransaction(); - - foreach ($lines as $line) { - if (strpos($line, "--") !== 0 && $line) { - - if ($html_output) - print "<pre>$line</pre>"; - else - Debug::log("> $line"); - - try { - $this->pdo->query($line); // PDO returns errors as exceptions now - } catch (PDOException $e) { - if ($html_output) { - print "<div class='text-error'>Error: " . $e->getMessage() . "</div>"; - } else { - Debug::log("Error: " . $e->getMessage()); - } - - $this->pdo->rollBack(); - return false; - } - } - } - - $db_version = $this->get_schema_version(); - - if ($db_version == $version) { - $this->pdo->commit(); - return true; - } else { - $this->pdo->rollBack(); - return false; - } - } else { - return false; - } - } else { - return false; - } - } - -} diff --git a/classes/debug.php b/classes/debug.php index 3061c6893..2ae81e41a 100644 --- a/classes/debug.php +++ b/classes/debug.php @@ -1,14 +1,26 @@ <?php class Debug { - public static $LOG_DISABLED = -1; - public static $LOG_NORMAL = 0; - public static $LOG_VERBOSE = 1; - public static $LOG_EXTENDED = 2; + const LOG_DISABLED = -1; + const LOG_NORMAL = 0; + const LOG_VERBOSE = 1; + const LOG_EXTENDED = 2; + + /** @deprecated */ + public static $LOG_DISABLED = self::LOG_DISABLED; + + /** @deprecated */ + public static $LOG_NORMAL = self::LOG_NORMAL; + + /** @deprecated */ + public static $LOG_VERBOSE = self::LOG_VERBOSE; + + /** @deprecated */ + public static $LOG_EXTENDED = self::LOG_EXTENDED; private static $enabled = false; private static $quiet = false; private static $logfile = false; - private static $loglevel = 0; + private static $loglevel = self::LOG_NORMAL; public static function set_logfile($logfile) { self::$logfile = $logfile; @@ -34,7 +46,7 @@ class Debug { return self::$loglevel; } - public static function log($message, $level = 0) { + public static function log($message, int $level = 0) { if (!self::$enabled || self::$loglevel < $level) return false; diff --git a/classes/digest.php b/classes/digest.php index 26ca5221f..94e5cd1fc 100644 --- a/classes/digest.php +++ b/classes/digest.php @@ -78,7 +78,7 @@ class Digest Debug::log("All done."); } - static function prepare_headlines_digest($user_id, $days = 1, $limit = 1000) { + static function prepare_headlines_digest(int $user_id, int $days = 1, int $limit = 1000) { $tpl = new Templator(); $tpl_t = new Templator(); @@ -87,20 +87,22 @@ class Digest $tpl_t->readTemplateFromFile("digest_template.txt"); $user_tz_string = get_pref(Prefs::USER_TIMEZONE, $user_id); + + if ($user_tz_string == 'Automatic') + $user_tz_string = 'GMT'; + $local_ts = TimeHelper::convert_timestamp(time(), 'UTC', $user_tz_string); $tpl->setVariable('CUR_DATE', date('Y/m/d', $local_ts)); $tpl->setVariable('CUR_TIME', date('G:i', $local_ts)); - $tpl->setVariable('TTRSS_HOST', Config::get(Config::get(Config::SELF_URL_PATH))); + $tpl->setVariable('TTRSS_HOST', Config::get(Config::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', Config::get(Config::get(Config::SELF_URL_PATH))); + $tpl_t->setVariable('TTRSS_HOST', Config::get(Config::SELF_URL_PATH)); $affected_ids = array(); - $days = (int) $days; - if (Config::get(Config::DB_TYPE) == "pgsql") { $interval_qpart = "ttrss_entries.date_updated > NOW() - INTERVAL '$days days'"; } else /* if (Config::get(Config::DB_TYPE) == "mysql") */ { @@ -117,7 +119,7 @@ class Digest link, score, content, - " . SUBSTRING_FOR_DATE . "(last_updated,1,19) AS last_updated + ".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated FROM ttrss_user_entries,ttrss_entries,ttrss_feeds LEFT JOIN @@ -130,10 +132,8 @@ class Digest AND unread = true AND score >= 0 ORDER BY ttrss_feed_categories.title, ttrss_feeds.title, score DESC, date_updated DESC - LIMIT :limit"); - $sth->bindParam(':user_id', intval($user_id, 10), PDO::PARAM_INT); - $sth->bindParam(':limit', intval($limit, 10), PDO::PARAM_INT); - $sth->execute(); + LIMIT " . (int)$limit); + $sth->execute([':user_id' => $user_id]); $headlines_count = 0; $headlines = array(); diff --git a/classes/diskcache.php b/classes/diskcache.php index 9c594acc5..d7ea26d3b 100644 --- a/classes/diskcache.php +++ b/classes/diskcache.php @@ -273,7 +273,7 @@ class DiskCache { } public function get_url($filename) { - return get_self_url_prefix() . "/public.php?op=cached&file=" . basename($this->dir) . "/" . basename($filename); + return Config::get_self_url() . "/public.php?op=cached&file=" . basename($this->dir) . "/" . basename($filename); } // check for locally cached (media) URLs and rewrite to local versions diff --git a/classes/errors.php b/classes/errors.php index be175418e..3599c2639 100644 --- a/classes/errors.php +++ b/classes/errors.php @@ -5,8 +5,9 @@ class Errors { const E_UNKNOWN_METHOD = "E_UNKNOWN_METHOD"; const E_UNKNOWN_PLUGIN = "E_UNKNOWN_PLUGIN"; const E_SCHEMA_MISMATCH = "E_SCHEMA_MISMATCH"; + const E_URL_SCHEME_MISMATCH = "E_URL_SCHEME_MISMATCH"; - static function to_json(string $code) { - return json_encode(["error" => ["code" => $code]]); + static function to_json(string $code, array $params = []) { + return json_encode(["error" => ["code" => $code, "params" => $params]]); } } diff --git a/classes/feeditem.php b/classes/feeditem.php index e30df3086..3a5e5dc09 100644 --- a/classes/feeditem.php +++ b/classes/feeditem.php @@ -9,7 +9,7 @@ abstract class FeedItem { abstract function get_comments_url(); abstract function get_comments_count(); abstract function get_categories(); - abstract function _get_enclosures(); + abstract function get_enclosures(); abstract function get_author(); abstract function get_language(); } diff --git a/classes/feeditem/atom.php b/classes/feeditem/atom.php index 3e092a048..a03080981 100755 --- a/classes/feeditem/atom.php +++ b/classes/feeditem/atom.php @@ -119,7 +119,7 @@ class FeedItem_Atom extends FeedItem_Common { return $this->normalize_categories($cats); } - function _get_enclosures() { + function get_enclosures() { $links = $this->elem->getElementsByTagName("link"); $encs = array(); @@ -138,7 +138,7 @@ class FeedItem_Atom extends FeedItem_Common { } } - $encs = array_merge($encs, parent::_get_enclosures()); + $encs = array_merge($encs, parent::get_enclosures()); return $encs; } diff --git a/classes/feeditem/common.php b/classes/feeditem/common.php index 8f2b9188b..18afeaa94 100755 --- a/classes/feeditem/common.php +++ b/classes/feeditem/common.php @@ -78,7 +78,7 @@ abstract class FeedItem_Common extends FeedItem { } // this is common for both Atom and RSS types and deals with various media: elements - function _get_enclosures() { + function get_enclosures() { $encs = []; $enclosures = $this->xpath->query("media:content", $this->elem); diff --git a/classes/feeditem/rss.php b/classes/feeditem/rss.php index f103ad787..1f7953c51 100755 --- a/classes/feeditem/rss.php +++ b/classes/feeditem/rss.php @@ -112,7 +112,7 @@ class FeedItem_RSS extends FeedItem_Common { return $this->normalize_categories($cats); } - function _get_enclosures() { + function get_enclosures() { $enclosures = $this->elem->getElementsByTagName("enclosure"); $encs = array(); @@ -129,7 +129,7 @@ class FeedItem_RSS extends FeedItem_Common { array_push($encs, $enc); } - $encs = array_merge($encs, parent::_get_enclosures()); + $encs = array_merge($encs, parent::get_enclosures()); return $encs; } diff --git a/classes/feeds.php b/classes/feeds.php index b1b19500f..68d535481 100755 --- a/classes/feeds.php +++ b/classes/feeds.php @@ -200,17 +200,21 @@ class Feeds extends Handler_Protected { $feed_id = $line["feed_id"]; - $label_cache = $line["label_cache"]; - $labels = false; - - if ($label_cache) { - $label_cache = json_decode($label_cache, true); + if ($line["num_labels"] > 0) { + $label_cache = $line["label_cache"]; + $labels = false; if ($label_cache) { - if ($label_cache["no-labels"] ?? false == 1) - $labels = []; - else - $labels = $label_cache; + $label_cache = json_decode($label_cache, true); + + if ($label_cache) { + if ($label_cache["no-labels"] ?? 0 == 1) + $labels = []; + else + $labels = $label_cache; + } + } else { + $labels = Article::_get_labels($id); } $line["labels"] = $labels; @@ -218,10 +222,6 @@ class Feeds extends Handler_Protected { $line["labels"] = []; } - /*if (!is_array($labels)) $labels = Article::_get_labels($id); - - $line["labels"] = Article::_get_labels($id);*/ - if (count($topmost_article_ids) < 3) { array_push($topmost_article_ids, $id); } @@ -251,21 +251,6 @@ class Feeds extends Handler_Protected { $this->_mark_timestamp(" sanitize"); - PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_CDM, - function ($result, $plugin) use (&$line) { - $line = $result; - $this->_mark_timestamp(" hook_render_cdm: " . get_class($plugin)); - }, - $line); - - $this->_mark_timestamp(" hook_render_cdm"); - - $line['content'] = DiskCache::rewrite_urls($line['content']); - - $this->_mark_timestamp(" disk_cache_rewrite"); - - $this->_mark_timestamp(" note"); - if (!get_pref(Prefs::CDM_EXPANDED)) { $line["cdm_excerpt"] = "<span class='collapse'> <i class='material-icons' onclick='return Article.cdmUnsetActive(event)' @@ -330,6 +315,20 @@ class Feeds extends Handler_Protected { } $this->_mark_timestamp(" color"); + $this->_mark_timestamp(" pre-hook_render_cdm"); + + PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_CDM, + function ($result, $plugin) use (&$line) { + $line = $result; + $this->_mark_timestamp(" hook: " . get_class($plugin)); + }, + $line); + + $this->_mark_timestamp(" hook_render_cdm"); + + $line['content'] = DiskCache::rewrite_urls($line['content']); + + $this->_mark_timestamp(" disk_cache_rewrite"); /* we don't need those */ @@ -474,8 +473,9 @@ class Feeds extends Handler_Protected { /* bump login timestamp if needed */ if (time() - $_SESSION["last_login_update"] > 3600) { - $sth = $this->pdo->prepare("UPDATE ttrss_users SET last_login = NOW() WHERE id = ?"); - $sth->execute([$_SESSION['uid']]); + $user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]); + $user->last_login = Db::NOW(); + $user->save(); $_SESSION["last_login_update"] = time(); } @@ -963,7 +963,7 @@ class Feeds extends Handler_Protected { function add() { $feed = clean($_REQUEST['feed']); - $cat = clean($_REQUEST['cat']); + $cat = clean($_REQUEST['cat'] ?? ''); $need_auth = isset($_REQUEST['need_auth']); $login = $need_auth ? clean($_REQUEST['login']) : ''; $pass = $need_auth ? clean($_REQUEST['pass']) : ''; @@ -985,21 +985,18 @@ class Feeds extends Handler_Protected { * to get all possible feeds. * 5 - Couldn't download the URL content. * 6 - Content is an invalid XML. + * 7 - Error while creating feed database entry. */ static function _subscribe($url, $cat_id = 0, - $auth_login = '', $auth_pass = '') { - - global $fetch_last_error; - global $fetch_last_error_content; - global $fetch_last_content_type; + $auth_login = '', $auth_pass = '') : array { $pdo = Db::pdo(); $url = UrlHelper::validate($url); - if (!$url) return array("code" => 2); + if (!$url) return ["code" => 2]; - $contents = @UrlHelper::fetch($url, false, $auth_login, $auth_pass); + $contents = UrlHelper::fetch($url, false, $auth_login, $auth_pass); PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_SUBSCRIBE_FEED, function ($result) use (&$contents) { @@ -1008,14 +1005,14 @@ class Feeds extends Handler_Protected { $contents, $url, $auth_login, $auth_pass); if (empty($contents)) { - if (preg_match("/cloudflare\.com/", $fetch_last_error_content)) { - $fetch_last_error .= " (feed behind Cloudflare)"; + if (preg_match("/cloudflare\.com/", UrlHelper::$fetch_last_error_content)) { + UrlHelper::$fetch_last_error .= " (feed behind Cloudflare)"; } - return array("code" => 5, "message" => $fetch_last_error); + return array("code" => 5, "message" => UrlHelper::$fetch_last_error); } - if (mb_strpos($fetch_last_content_type, "html") !== false && self::_is_html($contents)) { + if (mb_strpos(UrlHelper::$fetch_last_content_type, "html") !== false && self::_is_html($contents)) { $feedUrls = self::_get_feeds_from_html($url, $contents); if (count($feedUrls) == 0) { @@ -1027,35 +1024,33 @@ class Feeds extends Handler_Protected { $url = key($feedUrls); } - if (!$cat_id) $cat_id = null; + $feed = ORM::for_table('ttrss_feeds') + ->where('feed_url', $url) + ->where('owner_uid', $_SESSION['uid']) + ->find_one(); - $sth = $pdo->prepare("SELECT id FROM ttrss_feeds - WHERE feed_url = ? AND owner_uid = ?"); - $sth->execute([$url, $_SESSION['uid']]); - - if ($row = $sth->fetch()) { - return array("code" => 0, "feed_id" => (int) $row["id"]); + if ($feed) { + return ["code" => 0, "feed_id" => $feed->id]; } else { - $sth = $pdo->prepare( - "INSERT INTO ttrss_feeds - (owner_uid,feed_url,title,cat_id, auth_login,auth_pass,update_method,auth_pass_encrypted) - VALUES (?, ?, ?, ?, ?, ?, 0, false)"); - - $sth->execute([$_SESSION['uid'], $url, "[Unknown]", $cat_id, (string)$auth_login, (string)$auth_pass]); - - $sth = $pdo->prepare("SELECT id FROM ttrss_feeds WHERE feed_url = ? - AND owner_uid = ?"); - $sth->execute([$url, $_SESSION['uid']]); - $row = $sth->fetch(); - - $feed_id = $row["id"]; - - if ($feed_id) { - RSSUtils::set_basic_feed_info($feed_id); + $feed = ORM::for_table('ttrss_feeds')->create(); + + $feed->set([ + 'owner_uid' => $_SESSION['uid'], + 'feed_url' => $url, + 'title' => "[Unknown]", + 'cat_id' => $cat_id ? $cat_id : null, + 'auth_login' => (string)$auth_login, + 'auth_pass' => (string)$auth_pass, + 'update_method' => 0, + 'auth_pass_encrypted' => false, + ]); + + if ($feed->save()) { + RSSUtils::update_basic_info($feed->id); + return ["code" => 1, "feed_id" => (int) $feed->id]; } - return array("code" => 1, "feed_id" => (int) $feed_id); - + return ["code" => 7]; } } @@ -1097,19 +1092,20 @@ class Feeds extends Handler_Protected { return false; } - static function _find_by_url($feed_url, $owner_uid) { - $sth = Db::pdo()->prepare("SELECT id FROM ttrss_feeds WHERE - feed_url = ? AND owner_uid = ?"); - $sth->execute([$feed_url, $owner_uid]); + static function _find_by_url(string $feed_url, int $owner_uid) { + $feed = ORM::for_table('ttrss_feeds') + ->where('owner_uid', $owner_uid) + ->where('feed_url', $feed_url) + ->find_one(); - if ($row = $sth->fetch()) { - return $row["id"]; + if ($feed) { + return $feed->id; + } else { + return false; } - - return false; } - static function _get_title($id, $cat = false) { + static function _get_title($id, bool $cat = false) { $pdo = Db::pdo(); if ($cat) { @@ -1156,7 +1152,7 @@ class Feeds extends Handler_Protected { } // only real cats - static function _get_cat_marked($cat, $owner_uid = false) { + static function _get_cat_marked(int $cat, int $owner_uid = 0) { if (!$owner_uid) $owner_uid = $_SESSION["uid"]; @@ -1179,7 +1175,7 @@ class Feeds extends Handler_Protected { } } - static function _get_cat_unread($cat, $owner_uid = false) { + static function _get_cat_unread(int $cat, int $owner_uid = 0) { if (!$owner_uid) $owner_uid = $_SESSION["uid"]; @@ -1213,7 +1209,7 @@ class Feeds extends Handler_Protected { } // only accepts real cats (>= 0) - static function _get_cat_children_unread($cat, $owner_uid = false) { + static function _get_cat_children_unread(int $cat, int $owner_uid = 0) { if (!$owner_uid) $owner_uid = $_SESSION["uid"]; $pdo = Db::pdo(); @@ -1232,7 +1228,7 @@ class Feeds extends Handler_Protected { return $unread; } - static function _get_global_unread($user_id = false) { + static function _get_global_unread(int $user_id = 0) { if (!$user_id) $user_id = $_SESSION["uid"]; @@ -1248,29 +1244,27 @@ class Feeds extends Handler_Protected { return $row["count"]; } - static function _get_cat_title($cat_id) { - - if ($cat_id == -1) { - return __("Special"); - } else if ($cat_id == -2) { - return __("Labels"); - } else { - - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT title FROM ttrss_feed_categories WHERE - id = ?"); - $sth->execute([$cat_id]); - - if ($row = $sth->fetch()) { - return $row["title"]; - } else { + static function _get_cat_title(int $cat_id) { + switch ($cat_id) { + case 0: return __("Uncategorized"); - } + case -1: + return __("Special"); + case -2: + return __("Labels"); + default: + $cat = ORM::for_table('ttrss_feed_categories') + ->find_one($cat_id); + + if ($cat) { + return $cat->title; + } else { + return "UNKNOWN"; + } } } - private static function _get_label_unread($label_id, $owner_uid = false) { + private static function _get_label_unread($label_id, int $owner_uid = 0) { if (!$owner_uid) $owner_uid = $_SESSION["uid"]; $pdo = Db::pdo(); @@ -1574,6 +1568,12 @@ class Feeds extends Handler_Protected { $first_id = 0; + if (Config::get(Config::DB_TYPE) == "pgsql") { + $yyiw_qpart = "to_char(date_entered, 'IYYY-IW') AS yyiw"; + } else { + $yyiw_qpart = "date_format(date_entered, '%Y-%u') AS yyiw"; + } + if (is_numeric($feed)) { // proper override_order applied above if ($vfeed_query_part && !$ignore_vfeed_group && get_pref(Prefs::VFEED_GROUP_BY_FEED, $owner_uid)) { @@ -1611,18 +1611,16 @@ class Feeds extends Handler_Protected { if (Config::get(Config::DB_TYPE) == "pgsql") { $sanity_interval_qpart = "date_entered >= NOW() - INTERVAL '1 hour' AND"; - $yyiw_qpart = "to_char(date_entered, 'IYYY-IW') AS yyiw"; $distinct_columns = str_replace("desc", "", strtolower($order_by)); $distinct_qpart = "DISTINCT ON (id, $distinct_columns)"; } else { $sanity_interval_qpart = "date_entered >= DATE_SUB(NOW(), INTERVAL 1 hour) AND"; - $yyiw_qpart = "date_format(date_entered, '%Y-%u') AS yyiw"; $distinct_qpart = "DISTINCT"; //fallback } // except for Labels category - if (get_pref(Prefs::HEADLINES_NO_DISTINCT) && !($feed == -2 && $cat_view)) { + if (get_pref(Prefs::HEADLINES_NO_DISTINCT, $owner_uid) && !($feed == -2 && $cat_view)) { $distinct_qpart = ""; } @@ -1691,6 +1689,7 @@ class Feeds extends Handler_Protected { $vfeed_query_part $content_query_part author,score, + (SELECT count(label_id) FROM ttrss_user_labels2 WHERE article_id = ttrss_entries.id) AS num_labels, (SELECT count(id) FROM ttrss_enclosures WHERE post_id = ttrss_entries.id) AS num_enclosures FROM $from_qpart @@ -1715,41 +1714,46 @@ class Feeds extends Handler_Protected { } else { // browsing by tag - if (Config::get(Config::DB_TYPE) == "pgsql") { - $distinct_columns = str_replace("desc", "", strtolower($order_by)); - $distinct_qpart = "DISTINCT ON (id, $distinct_columns)"; + if (get_pref(Prefs::HEADLINES_NO_DISTINCT, $owner_uid)) { + $distinct_qpart = ""; } else { - $distinct_qpart = "DISTINCT"; //fallback + if (Config::get(Config::DB_TYPE) == "pgsql") { + $distinct_columns = str_replace("desc", "", strtolower($order_by)); + $distinct_qpart = "DISTINCT ON (id, $distinct_columns)"; + } else { + $distinct_qpart = "DISTINCT"; //fallback + } } $query = "SELECT $distinct_qpart + ttrss_entries.id AS id, date_entered, + $yyiw_qpart, guid, - note, - ttrss_entries.id as id, - title, + ttrss_entries.title, updated, - unread, - feed_id, - marked, - published, + label_cache, + tag_cache, + always_display_enclosures, + site_url, + note, num_comments, comments, int_id, - tag_cache, - label_cache, - link, - lang, uuid, - last_read, - (SELECT hide_images FROM ttrss_feeds WHERE id = feed_id) AS hide_images, + lang, + hide_images, + unread,feed_id,marked,published,link,last_read, last_marked, last_published, $since_id_part $vfeed_query_part $content_query_part - author, score - FROM ttrss_entries, ttrss_user_entries, ttrss_tags + author, score, + (SELECT count(label_id) FROM ttrss_user_labels2 WHERE article_id = ttrss_entries.id) AS num_labels, + (SELECT count(id) FROM ttrss_enclosures WHERE post_id = ttrss_entries.id) AS num_enclosures + FROM ttrss_entries, ttrss_user_entries, ttrss_tags, ttrss_feeds WHERE + ttrss_feeds.id = ttrss_user_entries.feed_id AND ref_id = ttrss_entries.id AND ttrss_user_entries.owner_uid = ".$pdo->quote($owner_uid)." AND post_int_id = int_id AND @@ -1773,7 +1777,7 @@ class Feeds extends Handler_Protected { } - static function _get_parent_cats($cat, $owner_uid) { + static function _get_parent_cats(int $cat, int $owner_uid) { $rv = array(); $pdo = Db::pdo(); @@ -1790,7 +1794,7 @@ class Feeds extends Handler_Protected { return $rv; } - static function _get_child_cats($cat, $owner_uid) { + static function _get_child_cats(int $cat, int $owner_uid) { $rv = array(); $pdo = Db::pdo(); @@ -1835,19 +1839,15 @@ class Feeds extends Handler_Protected { return $rv; } - static function _cat_of_feed($feed) { - $pdo = Db::pdo(); - - $sth = $pdo->prepare("SELECT cat_id FROM ttrss_feeds - WHERE id = ?"); - $sth->execute([$feed]); + // returns Uncategorized as 0 + static function _cat_of(int $feed) : int { + $feed = ORM::for_table('ttrss_feeds')->find_one($feed); - if ($row = $sth->fetch()) { - return $row["cat_id"]; + if ($feed) { + return (int)$feed->cat_id; } else { - return false; + return -1; } - } private function _color_of($name) { @@ -1898,80 +1898,79 @@ class Feeds extends Handler_Protected { return preg_match("/<html|DOCTYPE html/i", substr($content, 0, 8192)) !== 0; } - static function _add_cat($feed_cat, $parent_cat_id = false, $order_id = 0) { + static function _remove_cat(int $id, int $owner_uid) { + $cat = ORM::for_table('ttrss_feed_categories') + ->where('owner_uid', $owner_uid) + ->find_one($id); - if (!$feed_cat) return false; - - $feed_cat = mb_substr($feed_cat, 0, 250); - if (!$parent_cat_id) $parent_cat_id = null; - - $pdo = Db::pdo(); - $tr_in_progress = false; - - try { - $pdo->beginTransaction(); - } catch (Exception $e) { - $tr_in_progress = true; - } + if ($cat) + $cat->delete(); + } - $sth = $pdo->prepare("SELECT id FROM ttrss_feed_categories - WHERE (parent_cat = :parent OR (:parent IS NULL AND parent_cat IS NULL)) - AND title = :title AND owner_uid = :uid"); - $sth->execute([':parent' => $parent_cat_id, ':title' => $feed_cat, ':uid' => $_SESSION['uid']]); + static function _add_cat(string $title, int $owner_uid, int $parent_cat = null, int $order_id = 0) { - if (!$sth->fetch()) { + $cat = ORM::for_table('ttrss_feed_categories') + ->where('owner_uid', $owner_uid) + ->where('parent_cat', $parent_cat) + ->where('title', $title) + ->find_one(); - $sth = $pdo->prepare("INSERT INTO ttrss_feed_categories (owner_uid,title,parent_cat,order_id) - VALUES (?, ?, ?, ?)"); - $sth->execute([$_SESSION['uid'], $feed_cat, $parent_cat_id, (int)$order_id]); + if (!$cat) { + $cat = ORM::for_table('ttrss_feed_categories')->create(); - if (!$tr_in_progress) $pdo->commit(); + $cat->set([ + 'owner_uid' => $owner_uid, + 'parent_cat' => $parent_cat, + 'order_id' => $order_id, + 'title' => $title, + ]); - return true; + return $cat->save(); } - $pdo->commit(); - return false; } - static function _get_access_key($feed_id, $is_cat, $owner_uid = false) { - - if (!$owner_uid) $owner_uid = $_SESSION["uid"]; + static function _clear_access_keys(int $owner_uid) { + $key = ORM::for_table('ttrss_access_keys') + ->where('owner_uid', $owner_uid) + ->delete_many(); + } - $is_cat = bool_to_sql_bool($is_cat); + static function _update_access_key(string $feed_id, bool $is_cat, int $owner_uid) { + $key = ORM::for_table('ttrss_access_keys') + ->where('owner_uid', $owner_uid) + ->where('feed_id', $feed_id) + ->where('is_cat', $is_cat) + ->delete_many(); - $pdo = Db::pdo(); + return self::_get_access_key($feed_id, $is_cat, $owner_uid); + } - $sth = $pdo->prepare("SELECT access_key FROM ttrss_access_keys - WHERE feed_id = ? AND is_cat = ? - AND owner_uid = ?"); - $sth->execute([$feed_id, $is_cat, $owner_uid]); + static function _get_access_key(string $feed_id, bool $is_cat, int $owner_uid) { + $key = ORM::for_table('ttrss_access_keys') + ->where('owner_uid', $owner_uid) + ->where('feed_id', $feed_id) + ->where('is_cat', $is_cat) + ->find_one(); - if ($row = $sth->fetch()) { - return $row["access_key"]; + if ($key) { + return $key->access_key; } else { - $key = uniqid_short(); - - $sth = $pdo->prepare("INSERT INTO ttrss_access_keys - (access_key, feed_id, is_cat, owner_uid) - VALUES (?, ?, ?, ?)"); + $key = ORM::for_table('ttrss_access_keys')->create(); - $sth->execute([$key, $feed_id, $is_cat, $owner_uid]); + $key->owner_uid = $owner_uid; + $key->feed_id = $feed_id; + $key->is_cat = $is_cat; + $key->access_key = uniqid_short(); - return $key; + if ($key->save()) { + return $key->access_key; + } } } - /** - * Purge a feed old posts. - * - * @param mixed $feed_id The id of the purged feed. - * @param mixed $purge_interval Olderness of purged posts. - * @access public - * @return mixed - */ - static function _purge($feed_id, $purge_interval) { + static function _purge(int $feed_id, int $purge_interval) { if (!$purge_interval) $purge_interval = self::_get_purge_interval($feed_id); @@ -2041,22 +2040,14 @@ class Feeds extends Handler_Protected { return $rows_deleted; } - private static function _get_purge_interval($feed_id) { - - $pdo = Db::pdo(); + private static function _get_purge_interval(int $feed_id) { + $feed = ORM::for_table('ttrss_feeds')->find_one($feed_id); - $sth = $pdo->prepare("SELECT purge_interval, owner_uid FROM ttrss_feeds - WHERE id = ?"); - $sth->execute([$feed_id]); - - if ($row = $sth->fetch()) { - $purge_interval = $row["purge_interval"]; - $owner_uid = $row["owner_uid"]; - - if ($purge_interval == 0) - $purge_interval = get_pref(Prefs::PURGE_OLD_DAYS, $owner_uid); - - return $purge_interval; + if ($feed) { + if ($feed->purge_interval != 0) + return $feed->purge_interval; + else + return get_pref(Prefs::PURGE_OLD_DAYS, $feed->owner_uid); } else { return -1; } diff --git a/classes/handler/public.php b/classes/handler/public.php index e4572382e..2de073cc2 100755 --- a/classes/handler/public.php +++ b/classes/handler/public.php @@ -1,9 +1,10 @@ <?php class Handler_Public extends Handler { - private function generate_syndicated_feed($owner_uid, $feed, $is_cat, - $limit, $offset, $search, - $view_mode = false, $format = 'atom', $order = false, $orig_guid = false, $start_ts = false) { + // $feed may be a tag + private function generate_syndicated_feed(int $owner_uid, string $feed, bool $is_cat, + int $limit, int $offset, string $search, string $view_mode = "", + string $format = 'atom', string $order = "", string $orig_guid = "", string $start_ts = "") { $note_style = "background-color : #fff7d5; border-width : 1px; ". @@ -48,10 +49,10 @@ class Handler_Public extends Handler { //$tmppluginhost->load_data(); $handler = $tmppluginhost->get_feed_handler( - PluginHost::feed_to_pfeed_id($feed)); + PluginHost::feed_to_pfeed_id((int)$feed)); if ($handler) { - $qfh_ret = $handler->get_headlines(PluginHost::feed_to_pfeed_id($feed), $params); + $qfh_ret = $handler->get_headlines(PluginHost::feed_to_pfeed_id((int)$feed), $params); } } else { @@ -63,7 +64,7 @@ class Handler_Public extends Handler { $feed_site_url = $qfh_ret[2]; /* $last_error = $qfh_ret[3]; */ - $feed_self_url = get_self_url_prefix() . + $feed_self_url = Config::get_self_url() . "/public.php?op=rss&id=$feed&key=" . Feeds::_get_access_key($feed, false, $owner_uid); @@ -75,7 +76,7 @@ class Handler_Public extends Handler { $tpl->readTemplateFromFile("generated_feed.txt"); $tpl->setVariable('FEED_TITLE', $feed_title, true); - $tpl->setVariable('VERSION', get_version(), true); + $tpl->setVariable('VERSION', Config::get_version(), true); $tpl->setVariable('FEED_URL', htmlspecialchars($feed_self_url), true); $tpl->setVariable('SELF_URL', htmlspecialchars(get_self_url_prefix()), true); @@ -151,7 +152,7 @@ class Handler_Public extends Handler { $tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', "", true); } - list ($og_image, $og_stream) = Article::_get_image($enclosures, $line['content'], $feed_site_url); + list ($og_image, $og_stream) = Article::_get_image($enclosures, $line['content'], $feed_site_url, $line); $tpl->setVariable('ARTICLE_OG_IMAGE', $og_image, true); @@ -176,10 +177,8 @@ class Handler_Public extends Handler { $feed['title'] = $feed_title; $feed['feed_url'] = $feed_self_url; - - $feed['self_url'] = get_self_url_prefix(); - - $feed['articles'] = array(); + $feed['self_url'] = Config::get_self_url(); + $feed['articles'] = []; while ($line = $result->fetch()) { @@ -304,7 +303,7 @@ class Handler_Public extends Handler { $search = clean($_REQUEST["q"] ?? ""); $view_mode = clean($_REQUEST["view-mode"] ?? ""); $order = clean($_REQUEST["order"] ?? ""); - $start_ts = (int)clean($_REQUEST["ts"] ?? 0); + $start_ts = clean($_REQUEST["ts"] ?? ""); $format = clean($_REQUEST['format'] ?? "atom"); $orig_guid = clean($_REQUEST["orig_guid"] ?? false); @@ -402,7 +401,7 @@ class Handler_Public extends Handler { if ($_REQUEST['return'] && mb_strpos($return, Config::get(Config::SELF_URL_PATH)) === 0) { header("Location: " . clean($_REQUEST['return'])); } else { - header("Location: " . get_self_url_prefix()); + header("Location: " . Config::get_self_url()); } } } @@ -624,33 +623,57 @@ class Handler_Public extends Handler { <!DOCTYPE html> <html> <head> - <title>Database Updater</title> + <title>Tiny Tiny RSS: Database Updater</title> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> - <?= stylesheet_tag("themes/light.css") ?> - <link rel="shortcut icon" type="image/png" href="images/favicon.png"> <link rel="icon" type="image/png" sizes="72x72" href="images/favicon-72px.png"> + <link rel="shortcut icon" type="image/png" href="images/favicon.png"> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> <?php - echo stylesheet_tag("themes/light.css"); - echo javascript_tag("lib/dojo/dojo.js"); - echo javascript_tag("lib/dojo/tt-rss-layer.js"); - ?> + foreach (["lib/dojo/dojo.js", + "lib/dojo/tt-rss-layer.js", + "js/common.js", + "js/utility.js"] as $jsfile) { + + echo javascript_tag($jsfile); + + } ?> + + <?php if (theme_exists(Config::get(Config::LOCAL_OVERRIDE_STYLESHEET))) { + echo stylesheet_tag(get_theme_path(Config::get(Config::LOCAL_OVERRIDE_STYLESHEET))); + } ?> + <style type="text/css"> - span.ok { color : #009000; font-weight : bold; } - span.err { color : #ff0000; font-weight : bold; } + @media (prefers-color-scheme: dark) { + body { + background : #303030; + } + } + + body.css_loading * { + display : none; + } </style> + + <script type="text/javascript"> + require({cache:{}}); + </script> </head> - <body class="flat ttrss_utility"> + <body class="flat ttrss_utility css_loading"> <script type="text/javascript"> - require(['dojo/parser', "dojo/ready", 'dijit/form/Button','dijit/form/CheckBox', 'dijit/form/Form', - 'dijit/form/Select','dijit/form/TextBox','dijit/form/ValidationTextBox'],function(parser, ready){ - ready(function() { - parser.parse(); - }); - }); - - function confirmOP() { - return confirm("Update the database?"); + const UtilityApp = { + init: function() { + require(['dojo/parser', "dojo/ready", 'dijit/form/Button','dijit/form/CheckBox', 'dijit/form/Form', + 'dijit/form/Select','dijit/form/TextBox','dijit/form/ValidationTextBox'],function(parser, ready){ + ready(function() { + parser.parse(); + }); + }); + } + } + + function confirmDbUpdate() { + return confirm(__("Proceed with update?")); } </script> @@ -661,72 +684,66 @@ class Handler_Public extends Handler { <?php @$op = clean($_REQUEST["subop"] ?? ""); - $updater = new DbUpdater(Db::pdo(), Config::get(Config::DB_TYPE), SCHEMA_VERSION); - - if ($op == "performupdate") { - if ($updater->is_update_required()) { - print "<h2>" . T_sprintf("Performing updates to version %d", SCHEMA_VERSION) . "</h2>"; + $migrations = Config::get_migrations(); - for ($i = $updater->get_schema_version() + 1; $i <= SCHEMA_VERSION; $i++) { - print "<ul>"; - - print "<li class='text-info'>" . T_sprintf("Updating to version %d", $i) . "</li>"; + if ($op == "performupdate") { + if ($migrations->is_migration_needed()) { + ?> - print "<li>"; - $result = $updater->update_to($i, true); - print "</li>"; + <h2><?= T_sprintf("Performing updates to version %d", Config::SCHEMA_VERSION) ?></h2> - if (!$result) { - print "</ul>"; + <code><pre class="small pre-wrap"><?php + Debug::set_enabled(true); + Debug::set_loglevel(Debug::LOG_VERBOSE); + $result = $migrations->migrate(); + Debug::set_loglevel(Debug::LOG_NORMAL); + Debug::set_enabled(false); + ?></pre></code> - print_error("One of the updates failed. Either retry the process or perform updates manually."); + <?php if (!$result) { ?> + <?= format_error("One of migrations failed. Either retry the process or perform updates manually.") ?> - print "<form method='POST'> - <input type='hidden' name='subop' value='performupdate'> - <button type='submit' dojoType='dijit.form.Button' class='alt-danger' onclick='return confirmOP()'>".__("Try again")."</button> - <a href='index.php'>".__("Return to Tiny Tiny RSS")."</a> - </form>"; + <form method="post"> + <?= \Controls\hidden_tag('subop', 'performupdate') ?> + <?= \Controls\submit_tag(__("Update"), ["onclick" => "return confirmDbUpdate()"]) ?> + </form> + <?php } else { ?> + <?= format_notice("Update successful.") ?> - return; - } else { - print "<li class='text-success'>" . __("Completed.") . "</li>"; - print "</ul>"; - } - } + <a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a> + <?php } - print_notice("Your Tiny Tiny RSS database is now updated to the latest version."); + } else { ?> - print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>"; + <?= format_notice("Database is already up to date.") ?> - } else { - print_notice("Tiny Tiny RSS database is up to date."); + <a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a> - print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>"; + <?php } } else { - if ($updater->is_update_required()) { + if ($migrations->is_migration_needed()) { - print "<h2>".T_sprintf("Tiny Tiny RSS database needs update to the latest version (%d to %d).", - $updater->get_schema_version(), SCHEMA_VERSION)."</h2>"; + ?> + <h2><?= T_sprintf("Database schema needs update to the latest version (%d to %d).", + Config::get_schema_version(), Config::SCHEMA_VERSION) ?></h2> - if (Config::get(Config::DB_TYPE) == "mysql") { - print_error("<strong>READ THIS:</strong> Due to MySQL limitations, your database is not completely protected while updating. ". - "Errors may put it in an inconsistent state requiring manual rollback. <strong>BACKUP YOUR DATABASE BEFORE CONTINUING.</strong>"); - } else { - print_warning("Please backup your database before proceeding."); - } + <?= format_warning("Please backup your database before proceeding.") ?> - print "<form method='POST'> - <input type='hidden' name='subop' value='performupdate'> - <button type='submit' dojoType='dijit.form.Button' class='alt-danger' onclick='return confirmOP()'>".__("Perform updates")."</button> - </form>"; + <form method="post"> + <?= \Controls\hidden_tag('subop', 'performupdate') ?> + <?= \Controls\submit_tag(__("Update"), ["onclick" => "return confirmDbUpdate()"]) ?> + </form> - } else { + <?php + } else { ?> + + <?= format_notice("Database is already up to date.") ?> - print_notice("Tiny Tiny RSS database is up to date."); + <a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a> - print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>"; + <?php } } ?> @@ -779,7 +796,7 @@ class Handler_Public extends Handler { $timestamp = date("Y-m-d", strtotime($timestamp)); - return "tag:" . parse_url(get_self_url_prefix(), PHP_URL_HOST) . ",$timestamp:/$id"; + return "tag:" . parse_url(Config::get_self_url(), PHP_URL_HOST) . ",$timestamp:/$id"; } // this should be used very carefully because this endpoint is exposed to unauthenticated users @@ -817,9 +834,12 @@ class Handler_Public extends Handler { } } - static function _render_login_form() { + static function _render_login_form(string $return_to = "") { header('Cache-Control: public'); + if ($return_to) + $_REQUEST['return'] = $return_to; + require_once "login_form.php"; exit; } diff --git a/classes/logger.php b/classes/logger.php index 864b66743..f8abb5f84 100755 --- a/classes/logger.php +++ b/classes/logger.php @@ -3,6 +3,10 @@ class Logger { private static $instance; private $adapter; + const LOG_DEST_SQL = "sql"; + const LOG_DEST_STDOUT = "stdout"; + const LOG_DEST_SYSLOG = "syslog"; + const ERROR_NAMES = [ 1 => 'E_ERROR', 2 => 'E_WARNING', @@ -51,13 +55,13 @@ class Logger { function __construct() { switch (Config::get(Config::LOG_DESTINATION)) { - case "sql": + case self::LOG_DEST_SQL: $this->adapter = new Logger_SQL(); break; - case "syslog": + case self::LOG_DEST_SYSLOG: $this->adapter = new Logger_Syslog(); break; - case "stdout": + case self::LOG_DEST_STDOUT: $this->adapter = new Logger_Stdout(); break; default: diff --git a/classes/logger/sql.php b/classes/logger/sql.php index d21934aa6..784ebef31 100755 --- a/classes/logger/sql.php +++ b/classes/logger/sql.php @@ -3,12 +3,18 @@ class Logger_SQL implements Logger_Adapter { private $pdo; - function log_error(int $errno, string $errstr, string $file, int $line, $context) { + function __construct() { + $conn = get_class($this); - // separate PDO connection object is used for logging - if (!$this->pdo) $this->pdo = Db::instance()->pdo_connect(); + ORM::configure(Db::get_dsn(), null, $conn); + ORM::configure('username', Config::get(Config::DB_USER), $conn); + ORM::configure('password', Config::get(Config::DB_PASS), $conn); + ORM::configure('return_result_sets', true, $conn); + } - if ($this->pdo && get_schema_version() > 117) { + function log_error(int $errno, string $errstr, string $file, int $line, $context) { + + if (Config::get_schema_version() > 117) { // limit context length, DOMDocument dumps entire XML in here sometimes, which may be huge $context = mb_substr($context, 0, 8192); @@ -36,12 +42,19 @@ class Logger_SQL implements Logger_Adapter { // this would cause a PDOException on insert below $owner_uid = !empty($_SESSION["uid"]) ? $_SESSION["uid"] : null; - $sth = $this->pdo->prepare("INSERT INTO ttrss_error_log - (errno, errstr, filename, lineno, context, owner_uid, created_at) VALUES - (?, ?, ?, ?, ?, ?, NOW())"); - $sth->execute([$errno, $errstr, $file, (int)$line, $context, $owner_uid]); + $entry = ORM::for_table('ttrss_error_log', get_class($this))->create(); + + $entry->set([ + 'errno' => $errno, + 'errstr' => $errstr, + 'filename' => $file, + 'lineno' => (int)$line, + 'context' => $context, + 'owner_uid' => $owner_uid, + 'created_at' => Db::NOW(), + ]); - return $sth->rowCount(); + return $entry->save(); } return false; diff --git a/classes/mailer.php b/classes/mailer.php index a4270ba88..564338f69 100644 --- a/classes/mailer.php +++ b/classes/mailer.php @@ -1,8 +1,6 @@ <?php class Mailer { - // TODO: support HTML mail (i.e. MIME messages) - - private $last_error = "Unable to send mail: check local configuration."; + private $last_error = ""; function mail($params) { @@ -10,11 +8,10 @@ class Mailer { $to_address = $params["to_address"]; $subject = $params["subject"]; $message = $params["message"]; - $message_html = $params["message_html"]; - $from_name = $params["from_name"] ? $params["from_name"] : Config::get(Config::SMTP_FROM_NAME); - $from_address = $params["from_address"] ? $params["from_address"] : Config::get(Config::SMTP_FROM_ADDRESS); - - $additional_headers = $params["headers"] ? $params["headers"] : []; + $message_html = $params["message_html"] ?? ""; + $from_name = $params["from_name"] ?? Config::get(Config::SMTP_FROM_NAME); + $from_address = $params["from_address"] ?? Config::get(Config::SMTP_FROM_ADDRESS); + $additional_headers = $params["headers"] ?? []; $from_combined = $from_name ? "$from_name <$from_address>" : $from_address; $to_combined = $to_name ? "$to_name <$to_address>" : $to_address; @@ -40,11 +37,18 @@ class Mailer { $headers = [ "From: $from_combined", "Content-Type: text/plain; charset=UTF-8" ]; - return mail($to_combined, $subject, $message, implode("\r\n", array_merge($headers, $additional_headers))); + $rc = mail($to_combined, $subject, $message, implode("\r\n", array_merge($headers, $additional_headers))); + + if (!$rc) { + $this->set_error(error_get_last()['message']); + } + + return $rc; } function set_error($message) { $this->last_error = $message; + user_error("Error sending mail: $message", E_USER_WARNING); } function error() { diff --git a/classes/opml.php b/classes/opml.php index 6c7cab606..f8e9f6728 100644 --- a/classes/opml.php +++ b/classes/opml.php @@ -31,7 +31,7 @@ class OPML extends Handler_Protected { <body class='claro ttrss_utility'> <h1>".__('OPML Utility')."</h1><div class='content'>"; - Feeds::_add_cat("Imported feeds"); + Feeds::_add_cat("Imported feeds", $owner_uid); $this->opml_notice(__("Importing OPML...")); @@ -151,7 +151,7 @@ class OPML extends Handler_Protected { # export tt-rss settings if ($include_settings) { - $out .= "<outline text=\"tt-rss-prefs\" schema-version=\"".SCHEMA_VERSION."\">"; + $out .= "<outline text=\"tt-rss-prefs\" schema-version=\"".Config::SCHEMA_VERSION."\">"; $sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2 WHERE profile IS NULL AND owner_uid = ? ORDER BY pref_name"); @@ -166,7 +166,7 @@ class OPML extends Handler_Protected { $out .= "</outline>"; - $out .= "<outline text=\"tt-rss-labels\" schema-version=\"".SCHEMA_VERSION."\">"; + $out .= "<outline text=\"tt-rss-labels\" schema-version=\"".Config::SCHEMA_VERSION."\">"; $sth = $this->pdo->prepare("SELECT * FROM ttrss_labels2 WHERE owner_uid = ?"); @@ -183,7 +183,7 @@ class OPML extends Handler_Protected { $out .= "</outline>"; - $out .= "<outline text=\"tt-rss-filters\" schema-version=\"".SCHEMA_VERSION."\">"; + $out .= "<outline text=\"tt-rss-filters\" schema-version=\"".Config::SCHEMA_VERSION."\">"; $sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2 WHERE owner_uid = ? ORDER BY id"); @@ -521,9 +521,8 @@ class OPML extends Handler_Protected { if ($cat_id === false) { $order_id = (int) $root_node->attributes->getNamedItem('ttrssSortOrder')->nodeValue; - if (!$order_id) $order_id = 0; - Feeds::_add_cat($cat_title, $parent_id, $order_id); + Feeds::_add_cat($cat_title, $_SESSION['uid'], $parent_id ? $parent_id : null, (int)$order_id); $cat_id = $this->get_feed_category($cat_title, $parent_id); } @@ -635,7 +634,7 @@ class OPML extends Handler_Protected { } static function get_publish_url(){ - return get_self_url_prefix() . + return Config::get_self_url() . "/public.php?op=publishOpml&key=" . Feeds::_get_access_key('OPML:Publish', false, $_SESSION["uid"]); } diff --git a/classes/plugin.php b/classes/plugin.php index 6c572467a..ecafa7888 100644 --- a/classes/plugin.php +++ b/classes/plugin.php @@ -2,11 +2,10 @@ abstract class Plugin { const API_VERSION_COMPAT = 1; - /** @var PDO */ + /** @var PDO $pdo */ protected $pdo; - /* @var PluginHost $host */ - abstract function init($host); + abstract function init(PluginHost $host); abstract function about(); // return array(1.0, "plugin", "No description", "No author", false); @@ -26,6 +25,10 @@ abstract class Plugin { return false; } + function csrf_ignore($method) { + return false; + } + function get_js() { return ""; } @@ -55,7 +58,4 @@ abstract class Plugin { return vsprintf($this->__($msgid), $args); } - function csrf_ignore($method) { - return false; - } } diff --git a/classes/pluginhost.php b/classes/pluginhost.php index b6f645a9c..17d1e0c5f 100755 --- a/classes/pluginhost.php +++ b/classes/pluginhost.php @@ -61,7 +61,7 @@ class PluginHost { const HOOK_FEED_BASIC_INFO = "hook_feed_basic_info"; // hook_feed_basic_info($basic_info, $fetch_url, $owner_uid, $feed_id, $auth_login, $auth_pass) (byref) const HOOK_SEND_LOCAL_FILE = "hook_send_local_file"; // hook_send_local_file($filename) const HOOK_UNSUBSCRIBE_FEED = "hook_unsubscribe_feed"; // hook_unsubscribe_feed($feed_id, $owner_uid) - const HOOK_SEND_MAIL = "hook_send_mail"; // hook_send_mail($mailer, $params) + const HOOK_SEND_MAIL = "hook_send_mail"; // hook_send_mail(Mailer $mailer, $params) const HOOK_FILTER_TRIGGERED = "hook_filter_triggered"; // hook_filter_triggered($feed_id, $owner_uid, $article, $matched_filters, $matched_rules, $article_filters) const HOOK_GET_FULL_TEXT = "hook_get_full_text"; // hook_get_full_text($url) const HOOK_ARTICLE_IMAGE = "hook_article_image"; // hook_article_image($enclosures, $content, $site_url) @@ -274,16 +274,14 @@ class PluginHost { $class = trim($class); $class_file = strtolower(basename(clean($class))); - if (!is_dir(__DIR__ . "/../plugins/$class_file") && - !is_dir(__DIR__ . "/../plugins.local/$class_file")) continue; - // try system plugin directory first - $file = __DIR__ . "/../plugins/$class_file/init.php"; - $vendor_dir = __DIR__ . "/../plugins/$class_file/vendor"; + $file = dirname(__DIR__) . "/plugins/$class_file/init.php"; if (!file_exists($file)) { - $file = __DIR__ . "/../plugins.local/$class_file/init.php"; - $vendor_dir = __DIR__ . "/../plugins.local/$class_file/vendor"; + $file = dirname(__DIR__) . "/plugins.local/$class_file/init.php"; + + if (!file_exists($file)) + continue; } if (!isset($this->plugins[$class])) { @@ -296,27 +294,7 @@ class PluginHost { if (class_exists($class) && is_subclass_of($class, "Plugin")) { - // register plugin autoloader if necessary, for namespaced classes ONLY - // layout corresponds to tt-rss main /vendor/author/Package/Class.php - - if (file_exists($vendor_dir)) { - spl_autoload_register(function($class) use ($vendor_dir) { - - if (strpos($class, '\\') !== false) { - list ($namespace, $class_name) = explode('\\', $class, 2); - - if ($namespace && $class_name) { - $class_file = "$vendor_dir/$namespace/" . str_replace('\\', '/', $class_name) . ".php"; - - if (file_exists($class_file)) - require_once $class_file; - } - } - }); - } - $plugin = new $class($this); - $plugin_api = $plugin->api_version(); if ($plugin_api < self::API_VERSION) { @@ -491,7 +469,7 @@ class PluginHost { } } - function set(Plugin $sender, string $name, $value, bool $sync = true) { + function set(Plugin $sender, string $name, $value) { $idx = get_class($sender); if (!isset($this->storage[$idx])) @@ -499,7 +477,19 @@ class PluginHost { $this->storage[$idx][$name] = $value; - if ($sync) $this->save_data(get_class($sender)); + $this->save_data(get_class($sender)); + } + + function set_array(Plugin $sender, array $params) { + $idx = get_class($sender); + + if (!isset($this->storage[$idx])) + $this->storage[$idx] = array(); + + foreach ($params as $name => $value) + $this->storage[$idx][$name] = $value; + + $this->save_data(get_class($sender)); } function get(Plugin $sender, string $name, $default_value = false) { @@ -514,6 +504,14 @@ class PluginHost { } } + function get_array(Plugin $sender, string $name, array $default_value = []) { + $tmp = $this->get($sender, $name); + + if (!is_array($tmp)) $tmp = $default_value; + + return $tmp; + } + function get_all($sender) { $idx = get_class($sender); @@ -601,7 +599,7 @@ class PluginHost { // handled by classes/pluginhandler.php, requires valid session function get_method_url(Plugin $sender, string $method, $params = []) { - return get_self_url_prefix() . "/backend.php?" . + return Config::get_self_url() . "/backend.php?" . http_build_query( array_merge( [ @@ -614,7 +612,7 @@ class PluginHost { // shortcut syntax (disabled for now) /* function get_method_url(Plugin $sender, string $method, $params) { - return get_self_url_prefix() . "/backend.php?" . + return Config::get_self_url() . "/backend.php?" . http_build_query( array_merge( [ @@ -626,7 +624,7 @@ class PluginHost { // WARNING: endpoint in public.php, exposed to unauthenticated users function get_public_method_url(Plugin $sender, string $method, $params = []) { if ($sender->is_public_method($method)) { - return get_self_url_prefix() . "/public.php?" . + return Config::get_self_url() . "/public.php?" . http_build_query( array_merge( [ @@ -637,4 +635,15 @@ class PluginHost { user_error("get_public_method_url: requested method '$method' of '" . get_class($sender) . "' is private."); } } + + function get_plugin_dir(Plugin $plugin) { + $ref = new ReflectionClass(get_class($plugin)); + return dirname($ref->getFileName()); + } + + // TODO: use get_plugin_dir() + function is_local(Plugin $plugin) { + $ref = new ReflectionClass(get_class($plugin)); + return basename(dirname(dirname($ref->getFileName()))) == "plugins.local"; + } } diff --git a/classes/pref/feeds.php b/classes/pref/feeds.php index 3b4afab26..788104d38 100755 --- a/classes/pref/feeds.php +++ b/classes/pref/feeds.php @@ -1,5 +1,10 @@ <?php class Pref_Feeds extends Handler_Protected { + const E_ICON_FILE_TOO_LARGE = 'E_ICON_FILE_TOO_LARGE'; + const E_ICON_RENAME_FAILED = 'E_ICON_RENAME_FAILED'; + const E_ICON_UPLOAD_FAILED = 'E_ICON_UPLOAD_FAILED'; + const E_ICON_UPLOAD_SUCCESS = 'E_ICON_UPLOAD_SUCCESS'; + function csrf_ignore($method) { $csrf_ignored = array("index", "getfeedtree", "savefeedorder"); @@ -22,14 +27,16 @@ class Pref_Feeds extends Handler_Protected { return $rv; } - function renamecat() { + function renameCat() { + $cat = ORM::for_table("ttrss_feed_categories") + ->where("owner_uid", $_SESSION["uid"]) + ->find_one($_REQUEST['id']); + $title = clean($_REQUEST['title']); - $id = clean($_REQUEST['id']); - if ($title) { - $sth = $this->pdo->prepare("UPDATE ttrss_feed_categories SET - title = ? WHERE id = ? AND owner_uid = ?"); - $sth->execute([$title, $id, $_SESSION['uid']]); + if ($cat && $title) { + $cat->title = $title; + $cat->save(); } } @@ -433,78 +440,67 @@ class Pref_Feeds extends Handler_Protected { } } - function removeicon() { - $feed_id = clean($_REQUEST["feed_id"]); - - $sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds - WHERE id = ? AND owner_uid = ?"); - $sth->execute([$feed_id, $_SESSION['uid']]); + function removeIcon() { + $feed_id = (int) $_REQUEST["feed_id"]; + $icon_file = Config::get(Config::ICONS_DIR) . "/$feed_id.ico"; - if ($row = $sth->fetch()) { - @unlink(Config::get(Config::ICONS_DIR) . "/$feed_id.ico"); + $feed = ORM::for_table('ttrss_feeds') + ->where('owner_uid', $_SESSION['uid']) + ->find_one($feed_id); - $sth = $this->pdo->prepare("UPDATE ttrss_feeds SET favicon_avg_color = NULL, favicon_last_checked = '1970-01-01' - where id = ?"); - $sth->execute([$feed_id]); + if ($feed && file_exists($icon_file)) { + if (unlink($icon_file)) { + $feed->set([ + 'favicon_avg_color' => null, + 'favicon_last_checked' => '1970-01-01', + 'favicon_is_custom' => false, + ]); + $feed->save(); + } } } - function uploadicon() { - header("Content-type: text/html"); + function uploadIcon() { + $feed_id = (int) $_REQUEST['feed_id']; + $tmp_file = tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'icon'); - if (is_uploaded_file($_FILES['icon_file']['tmp_name'])) { - $tmp_file = tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'icon'); + // default value + $rc = self::E_ICON_UPLOAD_FAILED; - if (!$tmp_file) - return; + $feed = ORM::for_table('ttrss_feeds') + ->where('owner_uid', $_SESSION['uid']) + ->find_one($feed_id); - $result = move_uploaded_file($_FILES['icon_file']['tmp_name'], $tmp_file); + if ($feed && $tmp_file && move_uploaded_file($_FILES['icon_file']['tmp_name'], $tmp_file)) { + if (filesize($tmp_file) < Config::get(Config::MAX_FAVICON_FILE_SIZE)) { - if (!$result) { - return; - } - } else { - return; - } + $new_filename = Config::get(Config::ICONS_DIR) . "/$feed_id.ico"; - $icon_file = $tmp_file; - $feed_id = clean($_REQUEST["feed_id"]); - $rc = 2; // failed + if (file_exists($new_filename)) unlink($new_filename); + if (rename($tmp_file, $new_filename)) { + chmod($new_filename, 0644); - if ($icon_file && is_file($icon_file) && $feed_id) { - if (filesize($icon_file) < 65535) { + $feed->set([ + 'favicon_avg_color' => null, + 'favicon_is_custom' => true, + ]); - $sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds - WHERE id = ? AND owner_uid = ?"); - $sth->execute([$feed_id, $_SESSION['uid']]); - - if ($row = $sth->fetch()) { - $new_filename = Config::get(Config::ICONS_DIR) . "/$feed_id.ico"; - - if (file_exists($new_filename)) unlink($new_filename); - - if (rename($icon_file, $new_filename)) { - chmod($new_filename, 644); - - $sth = $this->pdo->prepare("UPDATE ttrss_feeds SET - favicon_avg_color = '' - WHERE id = ?"); - $sth->execute([$feed_id]); + if ($feed->save()) { + $rc = self::E_ICON_UPLOAD_SUCCESS; + } - $rc = Feeds::_get_icon($feed_id); + } else { + $rc = self::E_ICON_RENAME_FAILED; } - } } else { - $rc = 1; + $rc = self::E_ICON_FILE_TOO_LARGE; } } - if ($icon_file && is_file($icon_file)) { - unlink($icon_file); - } + if (file_exists($tmp_file)) + unlink($tmp_file); - print $rc; - return; + print json_encode(['rc' => $rc, 'icon_url' => Feeds::_get_icon($feed_id)]); } function editfeed() { @@ -513,11 +509,11 @@ class Pref_Feeds extends Handler_Protected { $feed_id = (int)clean($_REQUEST["id"]); - $sth = $this->pdo->prepare("SELECT * FROM ttrss_feeds WHERE id = ? AND - owner_uid = ?"); - $sth->execute([$feed_id, $_SESSION['uid']]); + $row = ORM::for_table('ttrss_feeds') + ->where("owner_uid", $_SESSION["uid"]) + ->find_one($feed_id)->as_array(); - if ($row = $sth->fetch(PDO::FETCH_ASSOC)) { + if ($row) { ob_start(); PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_EDIT_FEED, $feed_id); @@ -694,7 +690,7 @@ class Pref_Feeds extends Handler_Protected { $purge_intl = (int) clean($_POST["purge_interval"] ?? 0); $feed_id = (int) clean($_POST["id"] ?? 0); /* editSave */ $feed_ids = explode(",", clean($_POST["ids"] ?? "")); /* batchEditSave */ - $cat_id = (int) clean($_POST["cat_id"]); + $cat_id = (int) clean($_POST["cat_id"] ?? 0); $auth_login = clean($_POST["auth_login"]); $auth_pass = clean($_POST["auth_pass"]); $private = checkbox_to_sql_bool(clean($_POST["private"] ?? "")); @@ -710,7 +706,7 @@ class Pref_Feeds extends Handler_Protected { $mark_unread_on_update = checkbox_to_sql_bool( clean($_POST["mark_unread_on_update"] ?? "")); - $feed_language = clean($_POST["feed_language"]); + $feed_language = clean($_POST["feed_language"] ?? ""); if (!$batch) { @@ -720,48 +716,32 @@ class Pref_Feeds extends Handler_Protected { $reset_basic_info = $orig_feed_url != $feed_url; */ - $sth = $this->pdo->prepare("UPDATE ttrss_feeds SET - cat_id = :cat_id, - title = :title, - feed_url = :feed_url, - site_url = :site_url, - update_interval = :upd_intl, - purge_interval = :purge_intl, - auth_login = :auth_login, - auth_pass = :auth_pass, - auth_pass_encrypted = false, - private = :private, - cache_images = :cache_images, - hide_images = :hide_images, - include_in_digest = :include_in_digest, - always_display_enclosures = :always_display_enclosures, - mark_unread_on_update = :mark_unread_on_update, - feed_language = :feed_language - WHERE id = :id AND owner_uid = :uid"); - - $sth->execute([":title" => $feed_title, - ":cat_id" => $cat_id ? $cat_id : null, - ":feed_url" => $feed_url, - ":site_url" => $site_url, - ":upd_intl" => $upd_intl, - ":purge_intl" => $purge_intl, - ":auth_login" => $auth_login, - ":auth_pass" => $auth_pass, - ":private" => (int)$private, - ":cache_images" => (int)$cache_images, - ":hide_images" => (int)$hide_images, - ":include_in_digest" => (int)$include_in_digest, - ":always_display_enclosures" => (int)$always_display_enclosures, - ":mark_unread_on_update" => (int)$mark_unread_on_update, - ":feed_language" => $feed_language, - ":id" => $feed_id, - ":uid" => $_SESSION['uid']]); - -/* if ($reset_basic_info) { - RSSUtils::set_basic_feed_info($feed_id); - } */ - - PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_SAVE_FEED, $feed_id); + $feed = ORM::for_table('ttrss_feeds') + ->where('owner_uid', $_SESSION['uid']) + ->find_one($feed_id); + + if ($feed) { + + $feed->title = $feed_title; + $feed->cat_id = $cat_id ? $cat_id : null; + $feed->feed_url = $feed_url; + $feed->site_url = $site_url; + $feed->update_interval = $upd_intl; + $feed->purge_interval = $purge_intl; + $feed->auth_login = $auth_login; + $feed->auth_pass = $auth_pass; + $feed->private = (int)$private; + $feed->cache_images = (int)$cache_images; + $feed->hide_images = (int)$hide_images; + $feed->feed_language = $feed_language; + $feed->include_in_digest = (int)$include_in_digest; + $feed->always_display_enclosures = (int)$always_display_enclosures; + $feed->mark_unread_on_update = (int)$mark_unread_on_update; + + $feed->save(); + + PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_SAVE_FEED, $feed_id); + } } else { $feed_data = array(); @@ -874,14 +854,14 @@ class Pref_Feeds extends Handler_Protected { function removeCat() { $ids = explode(",", clean($_REQUEST["ids"])); foreach ($ids as $id) { - $this->remove_feed_category($id, $_SESSION["uid"]); + Feeds::_remove_cat((int)$id, $_SESSION["uid"]); } } function addCat() { $feed_cat = clean($_REQUEST["cat"]); - Feeds::_add_cat($feed_cat); + Feeds::_add_cat($feed_cat, $_SESSION['uid']); } function importOpml() { @@ -1003,10 +983,6 @@ class Pref_Feeds extends Handler_Protected { private function index_opml() { ?> - <h3><?= __("Using OPML you can export and import your feeds, filters, labels and Tiny Tiny RSS settings.") ?></h3> - - <?php print_notice("Only main settings profile can be migrated using OPML.") ?> - <form id='opml_import_form' method='post' enctype='multipart/form-data'> <label class='dijitButton'><?= __("Choose file...") ?> <input style='display : none' id='opml_file' name='opml_file' type='file'> @@ -1015,20 +991,24 @@ class Pref_Feeds extends Handler_Protected { <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"> + <?= \Controls\icon("file_upload") ?> <?= __('Import OPML') ?> </button> </form> <hr/> + <?php print_notice("Only main settings profile can be migrated using OPML.") ?> + <form dojoType='dijit.form.Form' id='opmlExportForm' style='display : inline-block'> <button dojoType='dijit.form.Button' onclick='Helpers.OPML.export()'> + <?= \Controls\icon("file_download") ?> <?= __('Export OPML') ?> </button> <label class='checkbox'> <?= \Controls\checkbox_tag("include_settings", true, "1") ?> - <?= __("Include settings") ?> + <?= __("Include tt-rss settings") ?> </label> </form> @@ -1036,12 +1016,10 @@ class Pref_Feeds extends Handler_Protected { <h2><?= __("Published OPML") ?></h2> - <p> - <?= __('Your OPML can be published publicly and can be subscribed by anyone who knows the URL below.') ?> - <?= __("Published OPML does not include your Tiny Tiny RSS settings, feeds that require authentication or feeds hidden from Popular feeds.") ?> - </p> + <?= format_notice("Your OPML can be published and then subscribed by anyone who knows the URL below. This won't include your settings nor authenticated feeds.") ?> <button dojoType='dijit.form.Button' class='alt-primary' onclick="return Helpers.OPML.publish()"> + <?= \Controls\icon("share") ?> <?= __('Display published OPML URL') ?> </button> @@ -1052,14 +1030,16 @@ class Pref_Feeds extends Handler_Protected { private function index_shared() { ?> - <h3><?= __('Published articles can be subscribed by anyone who knows the following URL:') ?></h3> + <?= format_notice('Published articles can be subscribed by anyone who knows the following URL:') ?></h3> <button dojoType='dijit.form.Button' class='alt-primary' onclick="CommonDialogs.generatedFeed(-2, false)"> + <?= \Controls\icon('share') ?> <?= __('Display URL') ?> </button> <button class='alt-danger' dojoType='dijit.form.Button' onclick='return Helpers.Feeds.clearFeedAccessKeys()'> + <?= \Controls\icon('delete') ?> <?= __('Clear all generated URLs') ?> </button> @@ -1188,12 +1168,6 @@ class Pref_Feeds extends Handler_Protected { print json_encode($rv); } - private function remove_feed_category($id, $owner_uid) { - $sth = $this->pdo->prepare("DELETE FROM ttrss_feed_categories - WHERE id = ? AND owner_uid = ?"); - $sth->execute([$id, $owner_uid]); - } - static function remove_feed($id, $owner_uid) { if (PluginHost::getInstance()->run_hooks_until(PluginHost::HOOK_UNSUBSCRIBE_FEED, true, $id, $owner_uid)) @@ -1273,12 +1247,16 @@ class Pref_Feeds extends Handler_Protected { } } + function clearKeys() { + return Feeds::_clear_access_keys($_SESSION['uid']); + } + function getOPMLKey() { print json_encode(["link" => OPML::get_publish_url()]); } function regenOPMLKey() { - $this->update_feed_access_key('OPML:Publish', + Feeds::_update_access_key('OPML:Publish', false, $_SESSION["uid"]); print json_encode(["link" => OPML::get_publish_url()]); @@ -1288,17 +1266,17 @@ class Pref_Feeds extends Handler_Protected { $feed_id = clean($_REQUEST['id']); $is_cat = clean($_REQUEST['is_cat']); - $new_key = $this->update_feed_access_key($feed_id, $is_cat, $_SESSION["uid"]); + $new_key = Feeds::_update_access_key($feed_id, $is_cat, $_SESSION["uid"]); print json_encode(["link" => $new_key]); } - function getsharedurl() { + function getSharedURL() { $feed_id = clean($_REQUEST['id']); $is_cat = clean($_REQUEST['is_cat']) == "true"; $search = clean($_REQUEST['search']); - $link = get_self_url_prefix() . "/public.php?" . http_build_query([ + $link = Config::get_self_url() . "/public.php?" . http_build_query([ 'op' => 'rss', 'id' => $feed_id, 'is_cat' => (int)$is_cat, @@ -1312,23 +1290,6 @@ class Pref_Feeds extends Handler_Protected { ]); } - private function update_feed_access_key($feed_id, $is_cat, $owner_uid) { - - // clear old value and generate new one - $sth = $this->pdo->prepare("DELETE FROM ttrss_access_keys - WHERE feed_id = ? AND is_cat = ? AND owner_uid = ?"); - $sth->execute([$feed_id, bool_to_sql_bool($is_cat), $owner_uid]); - - return Feeds::_get_access_key($feed_id, $is_cat, $owner_uid); - } - - // Silent - function clearKeys() { - $sth = $this->pdo->prepare("DELETE FROM ttrss_access_keys WHERE - owner_uid = ?"); - $sth->execute([$_SESSION['uid']]); - } - private function calculate_children_count($cat) { $c = 0; diff --git a/classes/pref/filters.php b/classes/pref/filters.php index a6ea9f982..29d309dbb 100755 --- a/classes/pref/filters.php +++ b/classes/pref/filters.php @@ -51,8 +51,8 @@ class Pref_Filters extends Handler_Protected { $filter = array(); $filter["enabled"] = true; - $filter["match_any_rule"] = checkbox_to_sql_bool(clean($_REQUEST["match_any_rule"])); - $filter["inverse"] = checkbox_to_sql_bool(clean($_REQUEST["inverse"])); + $filter["match_any_rule"] = checkbox_to_sql_bool(clean($_REQUEST["match_any_rule"] ?? false)); + $filter["inverse"] = checkbox_to_sql_bool(clean($_REQUEST["inverse"] ?? false)); $filter["rules"] = array(); $filter["actions"] = array("dummy-action"); diff --git a/classes/pref/labels.php b/classes/pref/labels.php index 5bc094d55..2cdb919ce 100644 --- a/classes/pref/labels.php +++ b/classes/pref/labels.php @@ -8,14 +8,12 @@ class Pref_Labels extends Handler_Protected { } function edit() { - $label_id = clean($_REQUEST['id']); + $label = ORM::for_table('ttrss_labels2') + ->where('owner_uid', $_SESSION['uid']) + ->find_one($_REQUEST['id']); - $sth = $this->pdo->prepare("SELECT id, caption, fg_color, bg_color FROM ttrss_labels2 WHERE - id = ? AND owner_uid = ?"); - $sth->execute([$label_id, $_SESSION['uid']]); - - if ($line = $sth->fetch(PDO::FETCH_ASSOC)) { - print json_encode($line); + if ($label) { + print json_encode($label->as_array()); } } diff --git a/classes/pref/prefs.php b/classes/pref/prefs.php index ba63d76b3..16c41df9d 100644 --- a/classes/pref/prefs.php +++ b/classes/pref/prefs.php @@ -1,4 +1,5 @@ <?php +use chillerlan\QRCode; class Pref_Prefs extends Handler_Protected { @@ -7,6 +8,15 @@ class Pref_Prefs extends Handler_Protected { private $pref_help_bottom = []; private $pref_blacklist = []; + const PI_RES_ALREADY_INSTALLED = "PI_RES_ALREADY_INSTALLED"; + const PI_RES_SUCCESS = "PI_RES_SUCCESS"; + const PI_ERR_NO_CLASS = "PI_ERR_NO_CLASS"; + const PI_ERR_NO_INIT_PHP = "PI_ERR_NO_INIT_PHP"; + const PI_ERR_EXEC_FAILED = "PI_ERR_EXEC_FAILED"; + const PI_ERR_NO_TEMPDIR = "PI_ERR_NO_TEMPDIR"; + const PI_ERR_PLUGIN_NOT_FOUND = "PI_ERR_PLUGIN_NOT_FOUND"; + const PI_ERR_NO_WORKDIR = "PI_ERR_NO_WORKDIR"; + function csrf_ignore($method) { $csrf_ignored = array("index", "updateself", "otpqrcode"); @@ -64,6 +74,7 @@ class Pref_Prefs extends Handler_Protected { 'BLOCK_SEPARATOR', Prefs::SSL_CERT_SERIAL, 'BLOCK_SEPARATOR', + Prefs::DISABLE_CONDITIONAL_COUNTERS, Prefs::HEADLINES_NO_DISTINCT, ], __('Debugging') => [ @@ -105,6 +116,7 @@ class Pref_Prefs extends Handler_Protected { Prefs::USER_CSS_THEME => array(__("Theme")), Prefs::HEADLINES_NO_DISTINCT => array(__("Don't enforce DISTINCT headlines"), __("May produce duplicate entries")), Prefs::DEBUG_HEADLINE_IDS => array(__("Show article and feed IDs"), __("In the headlines buffer")), + Prefs::DISABLE_CONDITIONAL_COUNTERS => array(__("Disable conditional counter updates"), __("May increase server load")), ]; // hidden in the main prefs UI (use to hide things that have description set above) @@ -209,48 +221,45 @@ class Pref_Prefs extends Handler_Protected { } } - function changeemail() { + function changePersonalData() { + + $user = ORM::for_table('ttrss_users')->find_one($_SESSION['uid']); + $new_email = clean($_POST['email']); - $email = clean($_POST["email"]); - $full_name = clean($_POST["full_name"]); - $active_uid = $_SESSION["uid"]; + if ($user) { + $user->full_name = clean($_POST['full_name']); - $sth = $this->pdo->prepare("SELECT email, login, full_name FROM ttrss_users WHERE id = ?"); - $sth->execute([$active_uid]); + if ($user->email != $new_email) + Logger::log(E_USER_NOTICE, "Email address of user ".$user->login." has been changed to ${new_email}."); - if ($row = $sth->fetch()) { - $old_email = $row["email"]; + if ($user->email && $user->email != $new_email) { - if ($old_email != $email) { $mailer = new Mailer(); $tpl = new Templator(); $tpl->readTemplateFromFile("mail_change_template.txt"); - $tpl->setVariable('LOGIN', $row["login"]); - $tpl->setVariable('NEWMAIL', $email); + $tpl->setVariable('LOGIN', $user->login); + $tpl->setVariable('NEWMAIL', $new_email); $tpl->setVariable('TTRSS_HOST', Config::get(Config::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", + $mailer->mail(["to_name" => $user->login, + "to_address" => $user->email, + "subject" => "[tt-rss] Email address change notification", "message" => $message]); + $user->email = $new_email; } - } - $sth = $this->pdo->prepare("UPDATE ttrss_users SET email = ?, - full_name = ? WHERE id = ?"); - $sth->execute([$email, $full_name, $active_uid]); + $user->save(); + } print __("Your personal data has been saved."); - - return; } function resetconfig() { @@ -261,21 +270,13 @@ class Pref_Prefs extends Handler_Protected { private function index_auth_personal() { - $sth = $this->pdo->prepare("SELECT email,full_name,otp_enabled, - access_level FROM ttrss_users - WHERE id = ?"); - $sth->execute([$_SESSION["uid"]]); - $row = $sth->fetch(); - - $email = htmlspecialchars($row["email"]); - $full_name = htmlspecialchars($row["full_name"]); - $otp_enabled = sql_bool_to_bool($row["otp_enabled"]); + $user = ORM::for_table('ttrss_users')->find_one($_SESSION['uid']); ?> <form dojoType='dijit.form.Form'> <?= \Controls\hidden_tag("op", "pref-prefs") ?> - <?= \Controls\hidden_tag("method", "changeemail") ?> + <?= \Controls\hidden_tag("method", "changePersonalData") ?> <script type="dojo/method" event="onSubmit" args="evt"> evt.preventDefault(); @@ -289,18 +290,19 @@ class Pref_Prefs extends Handler_Protected { <fieldset> <label><?= __('Full name:') ?></label> - <input dojoType='dijit.form.ValidationTextBox' name='full_name' required='1' value="<?= $full_name ?>"> + <input dojoType='dijit.form.ValidationTextBox' name='full_name' required='1' value="<?= htmlspecialchars($user->full_name) ?>"> </fieldset> <fieldset> <label><?= __('E-mail:') ?></label> - <input dojoType='dijit.form.ValidationTextBox' name='email' required='1' value="<?= $email ?>"> + <input dojoType='dijit.form.ValidationTextBox' name='email' required='1' value="<?= htmlspecialchars($user->email) ?>"> </fieldset> <hr/> <button dojoType='dijit.form.Button' type='submit' class='alt-primary'> - <?= __("Save data") ?> + <?= \Controls\icon("save") ?> + <?= __("Save") ?> </button> </form> <?php @@ -313,7 +315,7 @@ class Pref_Prefs extends Handler_Protected { $authenticator = false; } - $otp_enabled = $this->is_otp_enabled(); + $otp_enabled = UserHelper::is_otp_enabled($_SESSION["uid"]); if ($authenticator && method_exists($authenticator, "change_password")) { ?> @@ -350,10 +352,6 @@ class Pref_Prefs extends Handler_Protected { } </script> - <?php if ($otp_enabled) { - print_notice(__("Changing your current password will disable OTP.")); - } ?> - <fieldset> <label><?= __("Old password:") ?></label> <input dojoType='dijit.form.ValidationTextBox' type='password' required='1' name='old_password'> @@ -372,6 +370,7 @@ class Pref_Prefs extends Handler_Protected { <hr/> <button dojoType='dijit.form.Button' type='submit' class='alt-primary'> + <?= \Controls\icon("security") ?> <?= __("Change password") ?> </button> </form> @@ -385,7 +384,7 @@ class Pref_Prefs extends Handler_Protected { } private function index_auth_app_passwords() { - print_notice("You can create separate passwords for API clients. Using one is required if you enable OTP."); + print_notice("Separate passwords used for API clients. Required if you enable OTP."); ?> <div id='app_passwords_holder'> @@ -395,31 +394,21 @@ class Pref_Prefs extends Handler_Protected { <hr> <button style='float : left' class='alt-primary' dojoType='dijit.form.Button' onclick="Helpers.AppPasswords.generate()"> - <?= __('Generate new password') ?> + <?= \Controls\icon("add") ?> + <?= __('Generate password') ?> </button> <button style='float : left' class='alt-danger' dojoType='dijit.form.Button' onclick="Helpers.AppPasswords.removeSelected()"> - <?= __('Remove selected passwords') ?> + <?= \Controls\icon("delete") ?> + <?= __('Remove selected') ?> </button> <?php } - private function is_otp_enabled() { - $sth = $this->pdo->prepare("SELECT otp_enabled FROM ttrss_users - WHERE id = ?"); - $sth->execute([$_SESSION["uid"]]); - - if ($row = $sth->fetch()) { - return sql_bool_to_bool($row["otp_enabled"]); - } - - return false; - } - private function index_auth_2fa() { - $otp_enabled = $this->is_otp_enabled(); + $otp_enabled = UserHelper::is_otp_enabled($_SESSION["uid"]); if ($_SESSION["auth_module"] == "auth_internal") { if ($otp_enabled) { @@ -455,6 +444,7 @@ class Pref_Prefs extends Handler_Protected { <hr/> <button dojoType='dijit.form.Button' type='submit' class='alt-danger'> + <?= \Controls\icon("lock_open") ?> <?= __("Disable OTP") ?> </button> @@ -464,19 +454,11 @@ class Pref_Prefs extends Handler_Protected { } else { - print_warning("You will need a compatible Authenticator to use this. Changing your password would automatically disable OTP."); - print_notice("You will need to generate app passwords for the API clients if you enable OTP."); + print "<img src=".($this->_get_otp_qrcode_img()).">"; - if (function_exists("imagecreatefromstring")) { - print "<h3>" . __("Scan the following code by the Authenticator application or copy the key manually") . "</h3>"; - $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>"; - } + print_notice("You will need to generate app passwords for API clients if you enable OTP."); - $otp_secret = $this->otpsecret(); + $otp_secret = UserHelper::get_otp_secret($_SESSION["uid"]); ?> <form dojoType='dijit.form.Form'> @@ -486,7 +468,7 @@ class Pref_Prefs extends Handler_Protected { <fieldset> <label><?= __("OTP Key:") ?></label> - <input dojoType='dijit.form.ValidationTextBox' disabled='disabled' value="<?= $otp_secret ?>" size='32'> + <input dojoType='dijit.form.ValidationTextBox' disabled='disabled' value="<?= $otp_secret ?>" style='width : 215px'> </fieldset> <!-- TODO: return JSON from the backend call --> @@ -519,6 +501,7 @@ class Pref_Prefs extends Handler_Protected { <hr/> <button dojoType='dijit.form.Button' type='submit' class='alt-primary'> + <?= \Controls\icon("lock") ?> <?= __("Enable OTP") ?> </button> @@ -635,30 +618,27 @@ class Pref_Prefs extends Handler_Protected { } else if ($pref_name == "USER_CSS_THEME") { - $themes = array_merge(glob("themes/*.php"), glob("themes/*.css"), glob("themes.local/*.css")); - $themes = array_map("basename", $themes); - $themes = array_filter($themes, "theme_exists"); - asort($themes); - - if (!theme_exists($value)) $value = ""; + $theme_files = array_map("basename", + array_merge(glob("themes/*.php"), + glob("themes/*.css"), + glob("themes.local/*.css"))); - print "<select name='$pref_name' id='$pref_name' dojoType='fox.form.Select'>"; + asort($theme_files); - $issel = $value == "" ? "selected='selected'" : ""; - print "<option $issel value=''>".__("default")."</option>"; + $themes = [ "" => __("default") ]; - foreach ($themes as $theme) { - $issel = $value == $theme ? "selected='selected'" : ""; - print "<option $issel value='$theme'>$theme</option>"; + foreach ($theme_files as $file) { + $themes[$file] = basename($file, ".css"); } + ?> - print "</select>"; + <?= \Controls\select_hash($pref_name, $value, $themes) ?> + <?= \Controls\button_tag(\Controls\icon("palette") . " " . __("Customize"), "", + ["onclick" => "Helpers.Prefs.customizeCSS()"]) ?> + <?= \Controls\button_tag(\Controls\icon("open_in_new") . " " . __("More themes..."), "", + ["class" => "alt-info", "onclick" => "window.open(\"https://tt-rss.org/wiki/Themes\")"]) ?> - print " <button dojoType=\"dijit.form.Button\" class='alt-info' - onclick=\"Helpers.Prefs.customizeCSS()\">" . __('Customize') . "</button>"; - - print " <button dojoType='dijit.form.Button' onclick='window.open(\"https://tt-rss.org/wiki/Themes\")'> - <i class='material-icons'>open_in_new</i> ".__("More themes...")."</button>"; + <?php } else if ($pref_name == "DEFAULT_UPDATE_INTERVAL") { @@ -685,6 +665,11 @@ class Pref_Prefs extends Handler_Protected { print \Controls\checkbox_tag($pref_name, $is_checked, "true", ["disabled" => $is_disabled], "CB_$pref_name"); + if ($pref_name == Prefs::DIGEST_ENABLE) { + print \Controls\button_tag(\Controls\icon("info") . " " . __('Preview'), '', + ['onclick' => 'Helpers.Digest.preview()', 'style' => 'margin-left : 10px']); + } + } else if (in_array($pref_name, ['FRESH_ARTICLE_MAX_AGE', 'PURGE_OLD_DAYS', 'LONG_DATE_FORMAT', 'SHORT_DATE_FORMAT'])) { @@ -704,14 +689,14 @@ class Pref_Prefs extends Handler_Protected { print \Controls\input_tag($pref_name, $value, "text", ["readonly" => true], "SSL_CERT_SERIAL"); - $cert_serial = htmlspecialchars(get_ssl_certificate_id()); + $cert_serial = htmlspecialchars(self::_get_ssl_certificate_id()); $has_serial = ($cert_serial) ? true : false; - print \Controls\button_tag(__('Register'), "", [ + print \Controls\button_tag(\Controls\icon("security") . " " . __('Register'), "", [ "disabled" => !$has_serial, "onclick" => "dijit.byId('SSL_CERT_SERIAL').attr('value', '$cert_serial')"]); - print \Controls\button_tag(__('Clear'), "", [ + print \Controls\button_tag(\Controls\icon("clear") . " " . __('Clear'), "", [ "class" => "alt-danger", "onclick" => "dijit.byId('SSL_CERT_SERIAL').attr('value', '')"]); @@ -719,11 +704,10 @@ class Pref_Prefs extends Handler_Protected { "class" => "alt-info", "onclick" => "window.open('https://tt-rss.org/wiki/SSL%20Certificate%20Authentication')"]); - } else if ($pref_name == 'DIGEST_PREFERRED_TIME') { + } else if ($pref_name == Prefs::DIGEST_PREFERRED_TIME) { print "<input dojoType=\"dijit.form.ValidationTextBox\" id=\"$pref_name\" regexp=\"[012]?\d:\d\d\" placeHolder=\"12:00\" name=\"$pref_name\" value=\"$value\">"; - $item['help_text'] .= ". " . T_sprintf("Current server time: %s", date("H:i")); } else { $regexp = ($type_hint == Config::T_INT) ? 'regexp="^\d*$"' : ''; @@ -772,19 +756,21 @@ class Pref_Prefs extends Handler_Protected { <div dojoType="dijit.layout.ContentPane" region="bottom"> <div dojoType="fox.form.ComboButton" type="submit" class="alt-primary"> - <span><?= __('Save configuration') ?></span> + <span> <?= __('Save configuration') ?></span> <div dojoType="dijit.DropDownMenu"> <div dojoType="dijit.MenuItem" onclick="dijit.byId('changeSettingsForm').onSubmit(null, true)"> - <?= __("Save and exit preferences") ?> + <?= __("Save and exit") ?> </div> </div> </div> <button dojoType="dijit.form.Button" onclick="return Helpers.Profiles.edit()"> + <?= \Controls\icon("settings") ?> <?= __('Manage profiles') ?> </button> <button dojoType="dijit.form.Button" class="alt-danger" onclick="return Helpers.Prefs.confirmReset()"> + <?= \Controls\icon("clear") ?> <?= __('Reset to defaults') ?> </button> @@ -795,119 +781,73 @@ class Pref_Prefs extends Handler_Protected { <?php } - private function index_plugins_system() { - print_notice("System plugins are enabled in <strong>config.php</strong> for all users."); - - $system_enabled = array_map("trim", explode(",", (string)Config::get(Config::PLUGINS))); - - $tmppluginhost = new PluginHost(); - $tmppluginhost->load_all($tmppluginhost::KIND_ALL, $_SESSION["uid"], true); - - foreach ($tmppluginhost->get_plugins() as $name => $plugin) { - $about = $plugin->about(); - - if ($about[3] ?? false) { - $is_checked = in_array($name, $system_enabled) ? "checked" : ""; - ?> - <fieldset class='prefs plugin'> - <label><?= $name ?>:</label> - <label class='checkbox description text-muted' id="PLABEL-<?= htmlspecialchars($name) ?>"> - <input disabled='1' dojoType='dijit.form.CheckBox' <?= $is_checked ?> type='checkbox'><?= htmlspecialchars($about[1]) ?> - </label> - - <?php if ($about[4] ?? false) { ?> - <button dojoType='dijit.form.Button' class='alt-info' - onclick='window.open("<?= htmlspecialchars($about[4]) ?>")'> - <i class='material-icons'>open_in_new</i> <?= __("More info...") ?></button> - <?php } ?> - - <div dojoType='dijit.Tooltip' connectId='PLABEL-<?= htmlspecialchars($name) ?>' position='after'> - <?= htmlspecialchars(T_sprintf("v%.2f, by %s", $about[0], $about[2])) ?> - </div> - </fieldset> - <?php - } - } - } - - private function index_plugins_user() { + function getPluginsList() { $system_enabled = array_map("trim", explode(",", (string)Config::get(Config::PLUGINS))); $user_enabled = array_map("trim", explode(",", get_pref(Prefs::_ENABLED_PLUGINS))); $tmppluginhost = new PluginHost(); $tmppluginhost->load_all($tmppluginhost::KIND_ALL, $_SESSION["uid"], true); + $rv = []; + foreach ($tmppluginhost->get_plugins() as $name => $plugin) { $about = $plugin->about(); + $is_local = $tmppluginhost->is_local($plugin); + $version = htmlspecialchars($this->_get_plugin_version($plugin)); + + array_push($rv, [ + "name" => $name, + "is_local" => $is_local, + "system_enabled" => in_array($name, $system_enabled), + "user_enabled" => in_array($name, $user_enabled), + "has_data" => count($tmppluginhost->get_all($plugin)) > 0, + "is_system" => (bool)($about[3] ?? false), + "version" => $version, + "author" => $about[2] ?? "", + "description" => $about[1] ?? "", + "more_info" => $about[4] ?? "", + ]); + } - if (empty($about[3]) || $about[3] == false) { - - $is_checked = ""; - $is_disabled = ""; - - if (in_array($name, $system_enabled)) { - $is_checked = "checked='1'"; - $is_disabled = "disabled='1'"; - } else if (in_array($name, $user_enabled)) { - $is_checked = "checked='1'"; - } - - ?> - - <fieldset class='prefs plugin'> - <label><?= $name ?>:</label> - <label class='checkbox description text-muted' id="PLABEL-<?= htmlspecialchars($name) ?>"> - <input name='plugins[]' value="<?= htmlspecialchars($name) ?>" - dojoType='dijit.form.CheckBox' <?= $is_checked ?> <?= $is_disabled ?> type='checkbox'> - <?= htmlspecialchars($about[1]) ?> - </input> - </label> - - <?php if (count($tmppluginhost->get_all($plugin)) > 0) { - if (in_array($name, $system_enabled) || in_array($name, $user_enabled)) { ?> - <button dojoType='dijit.form.Button' - onclick='Helpers.Prefs.clearPluginData("<?= htmlspecialchars($name) ?>")'> - <i class='material-icons'>clear</i> <?= __("Clear data") ?></button> - <?php } - } ?> - - <?php if ($about[4] ?? false) { ?> - <button dojoType='dijit.form.Button' class='alt-info' - onclick='window.open("<?= htmlspecialchars($about[4]) ?>")'> - <i class='material-icons'>open_in_new</i> <?= __("More info...") ?></button> - <?php } ?> - - <div dojoType='dijit.Tooltip' connectId="PLABEL-<?= htmlspecialchars($name) ?>" position='after'> - <?= htmlspecialchars(T_sprintf("v%.2f, by %s", $about[0], $about[2])) ?> - </div> + usort($rv, function($a, $b) { return strcmp($a["name"], $b["name"]); }); - </fieldset> - <?php - } - } + print json_encode(['plugins' => $rv, 'is_admin' => $_SESSION['access_level'] >= 10]); } function index_plugins() { ?> <form dojoType="dijit.form.Form" id="changePluginsForm"> - <script type="dojo/method" event="onSubmit" args="evt"> - evt.preventDefault(); - if (this.validate()) { - xhr.post("backend.php", this.getValues(), () => { - Notify.close(); - if (confirm(__('Selected plugins have been enabled. Reload?'))) { - window.location.reload(); - } - }) - } - </script> <?= \Controls\hidden_tag("op", "pref-prefs") ?> <?= \Controls\hidden_tag("method", "setplugins") ?> <div dojoType="dijit.layout.BorderContainer" gutters="false"> + <div region="top" dojoType='fox.Toolbar'> + <div class='pull-right'> + <input name="search" type="search" onkeyup='Helpers.Plugins.search()' dojoType="dijit.form.TextBox"> + <button dojoType='dijit.form.Button' onclick='Helpers.Plugins.search()'> + <?= __('Search') ?> + </button> + </div> + + <div dojoType='fox.form.DropDownButton'> + <span><?= __('Select') ?></span> + <div dojoType='dijit.Menu' style='display: none'> + <div onclick="Lists.select('prefs-plugin-list', true)" + dojoType='dijit.MenuItem'><?= __('All') ?></div> + <div onclick="Lists.select('prefs-plugin-list', false)" + dojoType='dijit.MenuItem'><?= __('None') ?></div> + </div> + </div> + </div> + <div dojoType="dijit.layout.ContentPane" region="center" style="overflow-y : auto"> - <?php + + <script type="dojo/method" event="onShow"> + Helpers.Plugins.reload(); + </script> + + <!-- <?php if (!empty($_SESSION["safe_mode"])) { print_error("You have logged in using safe mode, no user plugins will be actually enabled until you login again."); } @@ -929,24 +869,41 @@ class Pref_Prefs extends Handler_Protected { ) . " (<a href='https://tt-rss.org/wiki/FeedHandlerPlugins' target='_blank'>".__("More info...")."</a>)" ); } - ?> + ?> --> - <h2><?= __("System plugins") ?></h2> - - <?php $this->index_plugins_system() ?> - - <h2><?= __("User plugins") ?></h2> - - <?php $this->index_plugins_user() ?> + <ul id="prefs-plugin-list" class="prefs-plugin-list list-unstyled"> + <li class='text-center'><?= __("Loading, please wait...") ?></li> + </ul> </div> <div dojoType="dijit.layout.ContentPane" region="bottom"> - <button dojoType='dijit.form.Button' style='float : left' class='alt-info' onclick='window.open("https://tt-rss.org/wiki/Plugins")'> - <i class='material-icons'>help</i> <?= __("More info...") ?> - </button> - <button dojoType='dijit.form.Button' class='alt-primary' type='submit'> - <?= __("Enable selected plugins") ?> + + <button dojoType='dijit.form.Button' class="alt-info pull-right" onclick='window.open("https://tt-rss.org/wiki/Plugins")'> + <i class='material-icons'>help</i> + <?= __("More info") ?> </button> + + <?= \Controls\button_tag(\Controls\icon("check") . " " .__("Enable selected"), "", ["class" => "alt-primary", + "onclick" => "Helpers.Plugins.enableSelected()"]) ?> + + <?= \Controls\button_tag(\Controls\icon("refresh"), "", ["title" => __("Reload"), "onclick" => "Helpers.Plugins.reload()"]) ?> + + <?php if ($_SESSION["access_level"] >= 10) { ?> + <?php if (Config::get(Config::CHECK_FOR_UPDATES) && Config::get(Config::CHECK_FOR_PLUGIN_UPDATES)) { ?> + + <button class='alt-warning' dojoType='dijit.form.Button' onclick="Helpers.Plugins.update()"> + <?= \Controls\icon("update") ?> + <?= __("Check for updates") ?> + </button> + <?php } ?> + + <?php if (Config::get(Config::ENABLE_PLUGIN_INSTALLER)) { ?> + <button dojoType='dijit.form.Button' onclick="Helpers.Plugins.install()"> + <?= \Controls\icon("add") ?> + <?= __("Install plugin") ?> + </button> + <?php } ?> + <?php } ?> </div> </div> </form> @@ -970,120 +927,44 @@ class Pref_Prefs extends Handler_Protected { <div dojoType='dijit.layout.AccordionPane' selected='true' title="<i class='material-icons'>settings</i> <?= __('Preferences') ?>"> <?php $this->index_prefs() ?> </div> - <div dojoType='dijit.layout.AccordionPane' title="<i class='material-icons'>extension</i> <?= __('Plugins') ?>"> - <script type='dojo/method' event='onSelected' args='evt'> - if (this.domNode.querySelector('.loading')) - window.setTimeout(() => { - xhr.post("backend.php", {op: 'pref-prefs', method: 'index_plugins'}, (reply) => { - this.attr('content', reply); - }); - }, 200); - </script> - <span class='loading'><?= __("Loading, please wait...") ?></span> + <div dojoType='dijit.layout.AccordionPane' style='padding : 0' title="<i class='material-icons'>extension</i> <?= __('Plugins') ?>"> + <?php $this->index_plugins() ?> </div> <?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefPrefs") ?> </div> <?php } - function toggleAdvanced() { - $_SESSION["prefs_show_advanced"] = !$_SESSION["prefs_show_advanced"]; - } - - function otpsecret() { - $sth = $this->pdo->prepare("SELECT salt, otp_enabled - FROM ttrss_users - WHERE id = ?"); - $sth->execute([$_SESSION['uid']]); + function _get_otp_qrcode_img() { + $secret = UserHelper::get_otp_secret($_SESSION["uid"]); + $login = UserHelper::get_login_by_id($_SESSION["uid"]); - if ($row = $sth->fetch()) { - $otp_enabled = sql_bool_to_bool($row["otp_enabled"]); + if ($secret && $login) { + $qrcode = new \chillerlan\QRCode\QRCode(); - if (!$otp_enabled) { - $base32 = new \OTPHP\Base32(); - $secret = $base32->encode(mb_substr(sha1($row["salt"]), 0, 12), false); + $otpurl = "otpauth://totp/".urlencode($login)."?secret=$secret&issuer=".urlencode("Tiny Tiny RSS"); - return $secret; - } + return $qrcode->render($otpurl); } return false; } - function otpqrcode() { - $csrf_token_hash = clean($_REQUEST["csrf_token_hash"]); - - if (sha1($_SESSION["csrf_token"]) === $csrf_token_hash) { - 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")); - } - } - } else { - header("Content-Type: text/json"); - print Errors::to_json(Errors::E_UNAUTHORIZED); - } - } - function otpenable() { - $password = clean($_REQUEST["password"]); - $otp = clean($_REQUEST["otp"]); + $otp_check = clean($_REQUEST["otp"]); $authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]); if ($authenticator->check_password($_SESSION["uid"], $password)) { - - $secret = $this->otpsecret(); - - if ($secret) { - - $base32 = new \OTPHP\Base32(); - - $topt = new \OTPHP\TOTP($secret); - - $otp_check = $topt->now(); - - if ($otp == $otp_check) { - $sth = $this->pdo->prepare("UPDATE ttrss_users - SET otp_enabled = true WHERE id = ?"); - - $sth->execute([$_SESSION['uid']]); - - print "OK"; - } else { - print "ERROR:".__("Incorrect one time password"); - } + if (UserHelper::enable_otp($_SESSION["uid"], $otp_check)) { + print "OK"; + } else { + print "ERROR:".__("Incorrect one time password"); } - } else { print "ERROR:".__("Incorrect password"); } - - } - - static function isdefaultpassword() { - $authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]); - - if ($authenticator && - method_exists($authenticator, "check_password") && - $authenticator->check_password($_SESSION["uid"], "password")) { - - return true; - } - - return false; } function otpdisable() { @@ -1116,9 +997,7 @@ class Pref_Prefs extends Handler_Protected { "message" => $message]); } - $sth = $this->pdo->prepare("UPDATE ttrss_users SET otp_enabled = false WHERE - id = ?"); - $sth->execute([$_SESSION['uid']]); + UserHelper::disable_otp($_SESSION["uid"]); print "OK"; } else { @@ -1128,12 +1007,306 @@ class Pref_Prefs extends Handler_Protected { } function setplugins() { - if (is_array(clean($_REQUEST["plugins"]))) - $plugins = join(",", clean($_REQUEST["plugins"])); - else - $plugins = ""; + $plugins = array_filter($_REQUEST["plugins"], 'clean') ?? []; + + set_pref(Prefs::_ENABLED_PLUGINS, implode(",", $plugins)); + } + + function _get_plugin_version(Plugin $plugin) { + $about = $plugin->about(); + + if (!empty($about[0])) { + return T_sprintf("v%.2f, by %s", $about[0], $about[2]); + } else { + $ref = new ReflectionClass(get_class($plugin)); + + $plugin_dir = dirname($ref->getFileName()); + + if (basename($plugin_dir) == "plugins") { + return ""; + } + + if (is_dir("$plugin_dir/.git")) { + $ver = Config::get_version_from_git($plugin_dir); + + return $ver["status"] == 0 ? T_sprintf("v%s, by %s", $ver["version"], $about[2]) : $ver["version"]; + } + } + } + + static function _get_updated_plugins() { + $root_dir = dirname(dirname(__DIR__)); # we're in classes/pref/ + $plugin_dirs = array_filter(glob("$root_dir/plugins.local/*"), "is_dir"); + + $rv = []; + + foreach ($plugin_dirs as $dir) { + if (is_dir("$dir/.git")) { + $plugin_name = basename($dir); + + array_push($rv, ["plugin" => $plugin_name, "rv" => self::_plugin_needs_update($root_dir, $plugin_name)]); + } + } + + $rv = array_values(array_filter($rv, function ($item) { + return $item["rv"]["need_update"]; + })); + + return $rv; + } + + private static function _plugin_needs_update($root_dir, $plugin_name) { + $plugin_dir = "$root_dir/plugins.local/" . basename($plugin_name); + $rv = null; + + if (is_dir($plugin_dir) && is_dir("$plugin_dir/.git")) { + $pipes = []; + + $descriptorspec = [ + //0 => ["pipe", "r"], // STDIN + 1 => ["pipe", "w"], // STDOUT + 2 => ["pipe", "w"], // STDERR + ]; + + $proc = proc_open("git fetch -q origin -a && git log HEAD..origin/master --oneline", $descriptorspec, $pipes, $plugin_dir); + + if (is_resource($proc)) { + $rv = [ + "stdout" => stream_get_contents($pipes[1]), + "stderr" => stream_get_contents($pipes[2]), + "git_status" => proc_close($proc), + ]; + $rv["need_update"] = !empty($rv["stdout"]); + } + } + + return $rv; + } + + + private function _update_plugin($root_dir, $plugin_name) { + $plugin_dir = "$root_dir/plugins.local/" . basename($plugin_name); + $rv = []; + + if (is_dir($plugin_dir) && is_dir("$plugin_dir/.git")) { + $pipes = []; + + $descriptorspec = [ + //0 => ["pipe", "r"], // STDIN + 1 => ["pipe", "w"], // STDOUT + 2 => ["pipe", "w"], // STDERR + ]; + + $proc = proc_open("git fetch origin -a && git log HEAD..origin/master --oneline && git pull --ff-only origin master", $descriptorspec, $pipes, $plugin_dir); + + if (is_resource($proc)) { + $rv["stdout"] = stream_get_contents($pipes[1]); + $rv["stderr"] = stream_get_contents($pipes[2]); + $rv["git_status"] = proc_close($proc); + } + } + + return $rv; + } + + // https://gist.github.com/mindplay-dk/a4aad91f5a4f1283a5e2#gistcomment-2036828 + private function _recursive_rmdir(string $dir, bool $keep_root = false) { + // Handle bad arguments. + if (empty($dir) || !file_exists($dir)) { + return true; // No such file/dir$dir exists. + } elseif (is_file($dir) || is_link($dir)) { + return unlink($dir); // Delete file/link. + } + + // Delete all children. + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $fileinfo) { + $action = $fileinfo->isDir() ? 'rmdir' : 'unlink'; + if (!$action($fileinfo->getRealPath())) { + return false; // Abort due to the failure. + } + } + + return $keep_root ? true : rmdir($dir); + } + + // https://stackoverflow.com/questions/7153000/get-class-name-from-file + private function _get_class_name_from_file($file) { + $tokens = token_get_all(file_get_contents($file)); + + for ($i = 0; $i < count($tokens); $i++) { + if (isset($tokens[$i][0]) && $tokens[$i][0] == T_CLASS) { + for ($j = $i+1; $j < count($tokens); $j++) { + if (isset($tokens[$j][1]) && $tokens[$j][1] != " ") { + return $tokens[$j][1]; + } + } + } + } + } + + function uninstallPlugin() { + if ($_SESSION["access_level"] >= 10) { + $plugin_name = basename(clean($_REQUEST['plugin'])); + $status = 0; + + $plugin_dir = dirname(dirname(__DIR__)) . "/plugins.local/$plugin_name"; + + if (is_dir($plugin_dir)) { + $status = $this->_recursive_rmdir($plugin_dir); + } + + print json_encode(['status' => $status]); + } + } + + function installPlugin() { + if ($_SESSION["access_level"] >= 10 && Config::get(Config::ENABLE_PLUGIN_INSTALLER)) { + $plugin_name = basename(clean($_REQUEST['plugin'])); + $all_plugins = $this->_get_available_plugins(); + $plugin_dir = dirname(dirname(__DIR__)) . "/plugins.local"; + + $work_dir = "$plugin_dir/plugin-installer"; + + $rv = [ ]; + + if (is_dir($work_dir) || mkdir($work_dir)) { + foreach ($all_plugins as $plugin) { + if ($plugin['name'] == $plugin_name) { + + $tmp_dir = tempnam($work_dir, $plugin_name); + + if (file_exists($tmp_dir)) { + unlink($tmp_dir); + + $pipes = []; + + $descriptorspec = [ + 1 => ["pipe", "w"], // STDOUT + 2 => ["pipe", "w"], // STDERR + ]; + + $proc = proc_open("git clone " . escapeshellarg($plugin['clone_url']) . " " . $tmp_dir, + $descriptorspec, $pipes, sys_get_temp_dir()); + + $status = 0; + + if (is_resource($proc)) { + $rv["stdout"] = stream_get_contents($pipes[1]); + $rv["stderr"] = stream_get_contents($pipes[2]); + $status = proc_close($proc); + $rv["git_status"] = $status; + + // yeah I know about mysterious RC = -1 + if (file_exists("$tmp_dir/init.php")) { + $class_name = strtolower(basename($this->_get_class_name_from_file("$tmp_dir/init.php"))); + + if ($class_name) { + $dst_dir = "$plugin_dir/$class_name"; + + if (is_dir($dst_dir)) { + $rv['result'] = self::PI_RES_ALREADY_INSTALLED; + } else { + if (rename($tmp_dir, "$plugin_dir/$class_name")) { + $rv['result'] = self::PI_RES_SUCCESS; + } + } + } else { + $rv['result'] = self::PI_ERR_NO_CLASS; + } + } else { + $rv['result'] = self::PI_ERR_NO_INIT_PHP; + } + + } else { + $rv['result'] = self::PI_ERR_EXEC_FAILED; + } + } else { + $rv['result'] = self::PI_ERR_NO_TEMPDIR; + } + + // cleanup after failure + if ($tmp_dir && is_dir($tmp_dir)) { + $this->_recursive_rmdir($tmp_dir); + } + + break; + } + } + + if (empty($rv['result'])) + $rv['result'] = self::PI_ERR_PLUGIN_NOT_FOUND; + + } else { + $rv["result"] = self::PI_ERR_NO_WORKDIR; + } + + print json_encode($rv); + } + } + + private function _get_available_plugins() { + if ($_SESSION["access_level"] >= 10 && Config::get(Config::ENABLE_PLUGIN_INSTALLER)) { + return json_decode(UrlHelper::fetch(['url' => 'https://tt-rss.org/plugins.json']), true); + } + } + function getAvailablePlugins() { + if ($_SESSION["access_level"] >= 10) { + print json_encode($this->_get_available_plugins()); + } + } + + function checkForPluginUpdates() { + if ($_SESSION["access_level"] >= 10 && Config::get(Config::CHECK_FOR_UPDATES) && Config::get(Config::CHECK_FOR_PLUGIN_UPDATES)) { + $plugin_name = $_REQUEST["name"] ?? ""; + + $root_dir = dirname(dirname(__DIR__)); # we're in classes/pref/ - set_pref(Prefs::_ENABLED_PLUGINS, $plugins); + if (!empty($plugin_name)) { + $rv = [["plugin" => $plugin_name, "rv" => self::_plugin_needs_update($root_dir, $plugin_name)]]; + } else { + $rv = self::_get_updated_plugins(); + } + + print json_encode($rv); + } + } + + function updateLocalPlugins() { + if ($_SESSION["access_level"] >= 10) { + $plugins = explode(",", $_REQUEST["plugins"] ?? ""); + + # we're in classes/pref/ + $root_dir = dirname(dirname(__DIR__)); + + $rv = []; + + if (count($plugins) > 0) { + foreach ($plugins as $plugin_name) { + array_push($rv, ["plugin" => $plugin_name, "rv" => $this->_update_plugin($root_dir, $plugin_name)]); + } + // @phpstan-ignore-next-line + } else { + $plugin_dirs = array_filter(glob("$root_dir/plugins.local/*"), "is_dir"); + + foreach ($plugin_dirs as $dir) { + if (is_dir("$dir/.git")) { + $plugin_name = basename($dir); + + $test = self::_plugin_needs_update($root_dir, $plugin_name); + + if (!empty($test["o"])) + array_push($rv, ["plugin" => $plugin_name, "rv" => $this->_update_plugin($root_dir, $plugin_name)]); + } + } + } + + print json_encode($rv); + } } function clearplugindata() { @@ -1150,66 +1323,61 @@ class Pref_Prefs extends Handler_Protected { } function activateprofile() { - $_SESSION["profile"] = (int) clean($_REQUEST["id"]); + $id = (int) $_REQUEST['id'] ?? 0; + + $profile = ORM::for_table('ttrss_settings_profiles') + ->where('owner_uid', $_SESSION['uid']) + ->find_one($id); - // default value - if (!$_SESSION["profile"]) $_SESSION["profile"] = null; + if ($profile) { + $_SESSION["profile"] = $id; + } else { + $_SESSION["profile"] = null; + } } function remprofiles() { - $ids = explode(",", clean($_REQUEST["ids"])); + $ids = $_REQUEST["ids"] ?? []; - foreach ($ids as $id) { - if ($_SESSION["profile"] != $id) { - $sth = $this->pdo->prepare("DELETE FROM ttrss_settings_profiles WHERE id = ? AND - owner_uid = ?"); - $sth->execute([$id, $_SESSION['uid']]); - } - } + ORM::for_table('ttrss_settings_profiles') + ->where('owner_uid', $_SESSION['uid']) + ->where_in('id', $ids) + ->where_not_equal('id', $_SESSION['profile'] ?? 0) + ->delete_many(); } function addprofile() { $title = clean($_REQUEST["title"]); if ($title) { - $this->pdo->beginTransaction(); - - $sth = $this->pdo->prepare("SELECT id FROM ttrss_settings_profiles - WHERE title = ? AND owner_uid = ?"); - $sth->execute([$title, $_SESSION['uid']]); - - if (!$sth->fetch()) { + $profile = ORM::for_table('ttrss_settings_profiles') + ->where('owner_uid', $_SESSION['uid']) + ->where('title', $title) + ->find_one(); - $sth = $this->pdo->prepare("INSERT INTO ttrss_settings_profiles (title, owner_uid) - VALUES (?, ?)"); + if (!$profile) { + $profile = ORM::for_table('ttrss_settings_profiles')->create(); - $sth->execute([$title, $_SESSION['uid']]); - - $sth = $this->pdo->prepare("SELECT id FROM ttrss_settings_profiles WHERE - title = ? AND owner_uid = ?"); - $sth->execute([$title, $_SESSION['uid']]); + $profile->title = $title; + $profile->owner_uid = $_SESSION['uid']; + $profile->save(); } - - $this->pdo->commit(); } } function saveprofile() { - $id = clean($_REQUEST["id"]); - $title = clean($_REQUEST["title"]); + $id = (int)$_REQUEST["id"]; + $title = clean($_REQUEST["value"]); - if ($id == 0) { - print __("Default profile"); - return; - } - - if ($title) { - $sth = $this->pdo->prepare("UPDATE ttrss_settings_profiles - SET title = ? WHERE id = ? AND - owner_uid = ?"); + if ($title && $id) { + $profile = ORM::for_table('ttrss_settings_profiles') + ->where('owner_uid', $_SESSION['uid']) + ->find_one($id); - $sth->execute([$title, $id, $_SESSION['uid']]); - print $title; + if ($profile) { + $profile->title = $title; + $profile->save(); + } } } @@ -1217,18 +1385,19 @@ class Pref_Prefs extends Handler_Protected { function getProfiles() { $rv = []; - $sth = $this->pdo->prepare("SELECT title,id FROM ttrss_settings_profiles - WHERE owner_uid = ? ORDER BY title"); - $sth->execute([$_SESSION['uid']]); + $profiles = ORM::for_table('ttrss_settings_profiles') + ->where('owner_uid', $_SESSION['uid']) + ->order_by_expr('title') + ->find_many(); array_push($rv, ["title" => __("Default profile"), "id" => 0, "active" => empty($_SESSION["profile"]) ]); - while ($row = $sth->fetch(PDO::FETCH_ASSOC)) { - $row["active"] = isset($_SESSION["profile"]) && $_SESSION["profile"] == $row["id"]; - array_push($rv, $row); + foreach ($profiles as $profile) { + $profile['active'] = ($_SESSION["profile"] ?? 0) == $profile->id; + array_push($rv, $profile->as_array()); }; print json_encode($rv); @@ -1271,23 +1440,25 @@ class Pref_Prefs extends Handler_Protected { <th align='right'><?= __("Last used") ?></th> </tr> <?php - $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()) { ?> - <tr data-row-id='<?= $row['id'] ?>'> + $passwords = ORM::for_table('ttrss_app_passwords') + ->where('owner_uid', $_SESSION['uid']) + ->order_by_asc('title') + ->find_many(); + + foreach ($passwords as $pass) { ?> + <tr data-row-id='<?= $pass['id'] ?>'> <td align='center'> <input onclick='Tables.onRowChecked(this)' dojoType='dijit.form.CheckBox' type='checkbox'> </td> <td> - <?= htmlspecialchars($row["title"]) ?> + <?= htmlspecialchars($pass["title"]) ?> </td> <td align='right' class='text-muted'> - <?= TimeHelper::make_local_datetime($row['created'], false) ?> + <?= TimeHelper::make_local_datetime($pass['created'], false) ?> </td> <td align='right' class='text-muted'> - <?= TimeHelper::make_local_datetime($row['last_used'], false) ?> + <?= TimeHelper::make_local_datetime($pass['last_used'], false) ?> </td> </tr> <?php } ?> @@ -1296,18 +1467,11 @@ class Pref_Prefs extends Handler_Protected { <?php } - private function _encrypt_app_password($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']])); + function deleteAppPasswords() { + $passwords = ORM::for_table('ttrss_app_passwords') + ->where('owner_uid', $_SESSION['uid']) + ->where_in('id', $_REQUEST['ids'] ?? []) + ->delete_many(); $this->appPasswordList(); } @@ -1315,17 +1479,41 @@ class Pref_Prefs extends Handler_Protected { function generateAppPassword() { $title = clean($_REQUEST['title']); $new_password = make_password(16); - $new_password_hash = $this->_encrypt_app_password($new_password); + $new_salt = UserHelper::get_salt(); + $new_password_hash = UserHelper::hash_password($new_password, $new_salt, UserHelper::HASH_ALGOS[0]); print_warning(T_sprintf("Generated password <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(), ?)"); + $password = ORM::for_table('ttrss_app_passwords')->create(); - $sth->execute([$title, $new_password_hash, Auth_Base::AUTH_SERVICE_API, $_SESSION['uid']]); + $password->title = $title; + $password->owner_uid = $_SESSION['uid']; + $password->pwd_hash = "$new_password_hash:$new_salt"; + $password->service = Auth_Base::AUTH_SERVICE_API; + $password->created = Db::NOW(); + + $password->save(); $this->appPasswordList(); } + + function previewDigest() { + print json_encode(Digest::prepare_headlines_digest($_SESSION["uid"], 1, 16)); + } + + static function _get_ssl_certificate_id() { + if ($_SERVER["REDIRECT_SSL_CLIENT_M_SERIAL"] ?? false) { + return sha1($_SERVER["REDIRECT_SSL_CLIENT_M_SERIAL"] . + $_SERVER["REDIRECT_SSL_CLIENT_V_START"] . + $_SERVER["REDIRECT_SSL_CLIENT_V_END"] . + $_SERVER["REDIRECT_SSL_CLIENT_S_DN"]); + } + if ($_SERVER["SSL_CLIENT_M_SERIAL"] ?? false) { + return sha1($_SERVER["SSL_CLIENT_M_SERIAL"] . + $_SERVER["SSL_CLIENT_V_START"] . + $_SERVER["SSL_CLIENT_V_END"] . + $_SERVER["SSL_CLIENT_S_DN"]); + } + return ""; + } } diff --git a/classes/pref/system.php b/classes/pref/system.php index 85635e753..c79b5095d 100644 --- a/classes/pref/system.php +++ b/classes/pref/system.php @@ -14,6 +14,20 @@ class Pref_System extends Handler_Administrative { $this->pdo->query("DELETE FROM ttrss_error_log"); } + function sendTestEmail() { + $mail_address = clean($_REQUEST["mail_address"]); + + $mailer = new Mailer(); + + $rc = $mailer->mail(["to_name" => "", + "to_address" => $mail_address, + "subject" => __("Test message from tt-rss"), + "message" => ("This message confirms that tt-rss can send outgoing mail.") + ]); + + print json_encode(['rc' => $rc, 'error' => $mailer->error()]); + } + function getphpinfo() { ob_start(); phpinfo(); @@ -103,12 +117,12 @@ class Pref_System extends Handler_Administrative { <table width='100%' class='event-log'> - <tr class='title'> - <td width='5%'><?= __("Error") ?></td> - <td><?= __("Filename") ?></td> - <td><?= __("Message") ?></td> - <td width='5%'><?= __("User") ?></td> - <td width='5%'><?= __("Date") ?></td> + <tr> + <th width='5%'><?= __("Error") ?></th> + <th><?= __("Filename") ?></th> + <th><?= __("Message") ?></th> + <th width='5%'><?= __("User") ?></th> + <th width='5%'><?= __("Date") ?></th> </tr> <?php @@ -151,16 +165,48 @@ class Pref_System extends Handler_Administrative { $page = (int) ($_REQUEST["page"] ?? 0); ?> <div dojoType='dijit.layout.AccordionContainer' region='center'> - <div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">report</i> <?= __('Event Log') ?>'> - <?php - if (Config::get(Config::LOG_DESTINATION) == "sql") { + <?php if (Config::get(Config::LOG_DESTINATION) == Logger::LOG_DEST_SQL) { ?> + <div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">report</i> <?= __('Event log') ?>'> + <?php $this->_log_viewer($page, $severity); - } else { - print_notice("Please set Config::get(Config::LOG_DESTINATION) to 'sql' in config.php to enable database logging."); - } - ?> + ?> + </div> + <?php } ?> + <div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">mail</i> <?= __('Mail configuration') ?>'> + <div dojoType="dijit.layout.ContentPane"> + + <form dojoType="dijit.form.Form"> + <script type="dojo/method" event="onSubmit" args="evt"> + evt.preventDefault(); + if (this.validate()) { + xhr.json("backend.php", this.getValues(), (reply) => { + const msg = App.byId("mail-test-result"); + + if (reply.rc) { + msg.innerHTML = __("Mail sent."); + msg.className = 'alert alert-success'; + } else { + msg.innerHTML = reply.error; + msg.className = 'alert alert-danger'; + } + + msg.show(); + }) + } + </script> + + <?= \Controls\hidden_tag("op", "pref-system") ?> + <?= \Controls\hidden_tag("method", "sendTestEmail") ?> + + <fieldset> + <label><?= __("To:") ?></label> + <?= \Controls\input_tag("mail_address", "", "text", ['required' => 1]) ?> + <?= \Controls\submit_tag(__("Send test email")) ?> + <span style="display: none; margin-left : 10px" class="alert alert-error" id="mail-test-result">...</span> + </fieldset> + </form> + </div> </div> - <div dojoType='dijit.layout.AccordionPane' title='<i class="material-icons">info</i> <?= __('PHP Information') ?>'> <script type='dojo/method' event='onSelected' args='evt'> if (this.domNode.querySelector('.loading')) diff --git a/classes/pref/users.php b/classes/pref/users.php index 13f808cb3..2e3dc4b67 100644 --- a/classes/pref/users.php +++ b/classes/pref/users.php @@ -1,24 +1,22 @@ <?php class Pref_Users extends Handler_Administrative { function csrf_ignore($method) { - $csrf_ignored = array("index"); - - return array_search($method, $csrf_ignored) !== false; + return $method == "index"; } function edit() { - global $access_level_names; - - $id = (int)clean($_REQUEST["id"]); + $user = ORM::for_table('ttrss_users') + ->select_expr("id,login,access_level,email,full_name,otp_enabled") + ->find_one((int)$_REQUEST["id"]) + ->as_array(); - $sth = $this->pdo->prepare("SELECT id, login, access_level, email FROM ttrss_users WHERE id = ?"); - $sth->execute([$id]); + global $access_level_names; - if ($row = $sth->fetch(PDO::FETCH_ASSOC)) { + if ($user) { print json_encode([ - "user" => $row, - "access_level_names" => $access_level_names - ]); + "user" => $user, + "access_level_names" => $access_level_names + ]); } } @@ -106,31 +104,32 @@ class Pref_Users extends Handler_Administrative { } function editSave() { - $login = clean($_REQUEST["login"]); - $uid = clean($_REQUEST["id"]); - $access_level = (int) clean($_REQUEST["access_level"]); - $email = clean($_REQUEST["email"]); + $id = (int)$_REQUEST['id']; $password = clean($_REQUEST["password"]); + $user = ORM::for_table('ttrss_users')->find_one($id); - // no blank usernames - if (!$login) return; + if ($user) { + $login = clean($_REQUEST["login"]); - // forbid renaming admin - if ($uid == 1) $login = "admin"; + if ($id == 1) $login = "admin"; + if (!$login) return; - if ($password) { - $salt = substr(bin2hex(get_random_bytes(125)), 0, 250); - $pwd_hash = encrypt_password($password, $salt, true); - $pass_query_part = "pwd_hash = ".$this->pdo->quote($pwd_hash).", - salt = ".$this->pdo->quote($salt).","; - } else { - $pass_query_part = ""; - } + $user->login = mb_strtolower($login); + $user->access_level = (int) clean($_REQUEST["access_level"]); + $user->email = clean($_REQUEST["email"]); + $user->otp_enabled = checkbox_to_sql_bool($_REQUEST["otp_enabled"]); - $sth = $this->pdo->prepare("UPDATE ttrss_users SET $pass_query_part login = LOWER(?), - access_level = ?, email = ?, otp_enabled = false WHERE id = ?"); - $sth->execute([$login, $access_level, $email, $uid]); + // force new OTP secret when next enabled + if (Config::get_schema_version() >= 143 && !$user->otp_enabled) { + $user->otp_secret = null; + } + + $user->save(); + } + if ($password) { + UserHelper::reset_password($id, false, $password); + } } function remove() { @@ -152,24 +151,25 @@ class Pref_Users extends Handler_Administrative { function add() { $login = clean($_REQUEST["login"]); - $tmp_user_pwd = make_password(); - $salt = substr(bin2hex(get_random_bytes(125)), 0, 250); - $pwd_hash = encrypt_password($tmp_user_pwd, $salt, true); if (!$login) return; // no blank usernames if (!UserHelper::find_user_by_login($login)) { - $sth = $this->pdo->prepare("INSERT INTO ttrss_users - (login,pwd_hash,access_level,last_login,created, salt) - VALUES (LOWER(?), ?, 0, null, NOW(), ?)"); - $sth->execute([$login, $pwd_hash, $salt]); + $new_password = make_password(); - if ($new_uid = UserHelper::find_user_by_login($login)) { + $user = ORM::for_table('ttrss_users')->create(); - print T_sprintf("Added user %s with password %s", - $login, $tmp_user_pwd); + $user->salt = UserHelper::get_salt(); + $user->login = mb_strtolower($login); + $user->pwd_hash = UserHelper::hash_password($new_password, $user->salt); + $user->access_level = 0; + $user->created = Db::NOW(); + $user->save(); + if ($new_uid = UserHelper::find_user_by_login($login)) { + print T_sprintf("Added user %s with password %s", + $login, $new_password); } else { print T_sprintf("Could not create user %s", $login); } @@ -200,11 +200,10 @@ class Pref_Users extends Handler_Administrative { $sort = "login"; } - $sort = $this->_validate_field($sort, - ["login", "access_level", "created", "num_feeds", "created", "last_login"], "login"); + if (!in_array($sort, ["login", "access_level", "created", "num_feeds", "created", "last_login"])) + $sort = "login"; if ($sort != "login") $sort = "$sort DESC"; - ?> <div dojoType='dijit.layout.BorderContainer' gutters='false'> @@ -249,42 +248,41 @@ class Pref_Users extends Handler_Administrative { <table width='100%' class='users-list' id='users-list'> - <tr class='title'> - <td align='center' width='5%'> </td> - <td width='20%'><a href='#' onclick="Users.reload('login')"><?= ('Login') ?></a></td> - <td width='20%'><a href='#' onclick="Users.reload('access_level')"><?= ('Access Level') ?></a></td> - <td width='10%'><a href='#' onclick="Users.reload('num_feeds')"><?= ('Subscribed feeds') ?></a></td> - <td width='20%'><a href='#' onclick="Users.reload('created')"><?= ('Registered') ?></a></td> - <td width='20%'><a href='#' onclick="Users.reload('last_login')"><?= ('Last login') ?></a></td> + <tr> + <th></th> + <th><a href='#' onclick="Users.reload('login')"><?= ('Login') ?></a></th> + <th><a href='#' onclick="Users.reload('access_level')"><?= ('Access Level') ?></a></th> + <th><a href='#' onclick="Users.reload('num_feeds')"><?= ('Subscribed feeds') ?></a></th> + <th><a href='#' onclick="Users.reload('created')"><?= ('Registered') ?></a></th> + <th><a href='#' onclick="Users.reload('last_login')"><?= ('Last login') ?></a></th> </tr> <?php - $sth = $this->pdo->prepare("SELECT - tu.id, - login,access_level,email, - ".SUBSTRING_FOR_DATE."(last_login,1,16) as last_login, - ".SUBSTRING_FOR_DATE."(created,1,16) as created, - (SELECT COUNT(id) FROM ttrss_feeds WHERE owner_uid = tu.id) AS num_feeds - FROM - ttrss_users tu - WHERE - (:search = '' OR login LIKE :search) AND tu.id > 0 - ORDER BY $sort"); - $sth->execute([":search" => $user_search ? "%$user_search%" : ""]); - - while ($row = $sth->fetch()) { ?> - - <tr data-row-id='<?= $row["id"] ?>' onclick='Users.edit(<?= $row["id"] ?>)' title="<?= __('Click to edit') ?>"> - <td align='center'> + $users = ORM::for_table('ttrss_users') + ->table_alias('u') + ->left_outer_join("ttrss_feeds", ["owner_uid", "=", "u.id"], 'f') + ->select_expr('u.*,COUNT(f.id) AS num_feeds') + ->where_like("login", $user_search ? "%$user_search%" : "%") + ->order_by_expr($sort) + ->group_by_expr('u.id') + ->find_many(); + + foreach ($users as $user) { ?> + + <tr data-row-id='<?= $user["id"] ?>' onclick='Users.edit(<?= $user["id"] ?>)' title="<?= __('Click to edit') ?>"> + <td class='checkbox'> <input onclick='Tables.onRowChecked(this); event.stopPropagation();' dojoType='dijit.form.CheckBox' type='checkbox'> </td> - <td><i class='material-icons'>person</i> <?= htmlspecialchars($row["login"]) ?></td> - <td><?= $access_level_names[$row["access_level"]] ?></td> - <td><?= $row["num_feeds"] ?></td> - <td><?= TimeHelper::make_local_datetime($row["created"], false) ?></td> - <td><?= TimeHelper::make_local_datetime($row["last_login"], false) ?></td> + <td width='30%'> + <i class='material-icons'>person</i> + <strong><?= htmlspecialchars($user["login"]) ?></strong> + </td> + <td><?= $access_level_names[$user["access_level"]] ?></td> + <td><?= $user["num_feeds"] ?></td> + <td class='text-muted'><?= TimeHelper::make_local_datetime($user["created"], false) ?></td> + <td class='text-muted'><?= TimeHelper::make_local_datetime($user["last_login"], false) ?></td> </tr> <?php } ?> </table> @@ -294,11 +292,4 @@ class Pref_Users extends Handler_Administrative { <?php } - private function _validate_field($string, $allowed, $default = "") { - if (in_array($string, $allowed)) - return $string; - else - return $default; - } - } diff --git a/classes/prefs.php b/classes/prefs.php index 8eba40ad5..24f0f7a80 100644 --- a/classes/prefs.php +++ b/classes/prefs.php @@ -58,6 +58,8 @@ class Prefs { const _PREFS_MIGRATED = "_PREFS_MIGRATED"; const HEADLINES_NO_DISTINCT = "HEADLINES_NO_DISTINCT"; const DEBUG_HEADLINE_IDS = "DEBUG_HEADLINE_IDS"; + const DISABLE_CONDITIONAL_COUNTERS = "DISABLE_CONDITIONAL_COUNTERS"; + const WIDESCREEN_MODE = "WIDESCREEN_MODE"; private const _DEFAULTS = [ Prefs::PURGE_OLD_DAYS => [ 60, Config::T_INT ], @@ -76,7 +78,7 @@ class Prefs { Prefs::DIGEST_ENABLE => [ false, Config::T_BOOL ], Prefs::CONFIRM_FEED_CATCHUP => [ true, Config::T_BOOL ], Prefs::CDM_AUTO_CATCHUP => [ false, Config::T_BOOL ], - Prefs::_DEFAULT_VIEW_MODE => [ "adaptive", Config::T_BOOL ], + Prefs::_DEFAULT_VIEW_MODE => [ "adaptive", Config::T_STRING ], Prefs::_DEFAULT_VIEW_LIMIT => [ 30, Config::T_INT ], //Prefs::_PREFS_ACTIVE_TAB => [ "", Config::T_STRING ], //Prefs::STRIP_UNSAFE_TAGS => [ true, Config::T_BOOL ], @@ -116,6 +118,8 @@ class Prefs { Prefs::_PREFS_MIGRATED => [ false, Config::T_BOOL ], Prefs::HEADLINES_NO_DISTINCT => [ false, Config::T_BOOL ], Prefs::DEBUG_HEADLINE_IDS => [ false, Config::T_BOOL ], + Prefs::DISABLE_CONDITIONAL_COUNTERS => [ false, Config::T_BOOL ], + Prefs::WIDESCREEN_MODE => [ false, Config::T_BOOL ], ]; const _PROFILE_BLACKLIST = [ diff --git a/classes/rpc.php b/classes/rpc.php index aaaf4f8d5..35125ae04 100755 --- a/classes/rpc.php +++ b/classes/rpc.php @@ -121,7 +121,8 @@ class RPC extends Handler_Protected { else $label_ids = array_map("intval", clean($_REQUEST["label_ids"] ?? [])); - $counters = is_array($feed_ids) ? Counters::get_conditional($feed_ids, $label_ids) : Counters::get_all(); + $counters = is_array($feed_ids) && !get_pref(Prefs::DISABLE_CONDITIONAL_COUNTERS) ? + Counters::get_conditional($feed_ids, $label_ids) : Counters::get_all(); $reply = [ 'counters' => $counters, @@ -151,7 +152,9 @@ class RPC extends Handler_Protected { if (count($ids) > 0) $this->markArticlesById($ids, $cmode); - print json_encode(["message" => "UPDATE_COUNTERS", "feeds" => Article::_feeds_of($ids)]); + print json_encode(["message" => "UPDATE_COUNTERS", + "labels" => Article::_labels_of($ids), + "feeds" => Article::_feeds_of($ids)]); } function publishSelected() { @@ -161,17 +164,30 @@ class RPC extends Handler_Protected { if (count($ids) > 0) $this->publishArticlesById($ids, $cmode); - print json_encode(["message" => "UPDATE_COUNTERS", "feeds" => Article::_feeds_of($ids)]); + print json_encode(["message" => "UPDATE_COUNTERS", + "labels" => Article::_labels_of($ids), + "feeds" => Article::_feeds_of($ids)]); } function sanityCheck() { $_SESSION["hasSandbox"] = clean($_REQUEST["hasSandbox"]) === "true"; $_SESSION["clientTzOffset"] = clean($_REQUEST["clientTzOffset"]); + $client_location = $_REQUEST["clientLocation"]; + $error = Errors::E_SUCCESS; + $error_params = []; + + $client_scheme = parse_url($client_location, PHP_URL_SCHEME); + $server_scheme = parse_url(Config::get_self_url(), PHP_URL_SCHEME); - if (get_schema_version() != SCHEMA_VERSION) { + if (Config::is_migration_needed()) { $error = Errors::E_SCHEMA_MISMATCH; + } else if ($client_scheme != $server_scheme) { + $error = Errors::E_URL_SCHEME_MISMATCH; + $error_params["client_scheme"] = $client_scheme; + $error_params["server_scheme"] = $server_scheme; + $error_params["self_url_path"] = Config::get_self_url(); } if ($error == Errors::E_SUCCESS) { @@ -183,7 +199,7 @@ class RPC extends Handler_Protected { print json_encode($reply); } else { - print Errors::to_json($error); + print Errors::to_json($error, $error_params); } } @@ -219,14 +235,12 @@ class RPC extends Handler_Protected { //print json_encode(array("message" => "UPDATE_COUNTERS")); } - function setpanelmode() { + function setWidescreen() { $wide = (int) clean($_REQUEST["wide"]); - // FIXME should this use SESSION_COOKIE_LIFETIME and be renewed periodically? - setcookie("ttrss_widescreen", (string)$wide, - time() + 86400*365); + set_pref(Prefs::WIDESCREEN_MODE, $wide); - print json_encode(array("wide" => $wide)); + print json_encode(["wide" => $wide]); } static function updaterandomfeed_real() { @@ -237,27 +251,27 @@ class RPC extends Handler_Protected { if (Config::get(Config::DB_TYPE) == "pgsql") { $update_limit_qpart = "AND (( update_interval = 0 - AND p.value != '-1' + AND (p.value IS NULL OR p.value != '-1') AND last_updated < NOW() - CAST((COALESCE(p.value, '$default_interval') || ' minutes') AS INTERVAL) ) OR ( update_interval > 0 AND last_updated < NOW() - CAST((update_interval || ' minutes') AS INTERVAL) ) OR ( update_interval >= 0 - AND p.value != '-1' + AND (p.value IS NULL OR p.value != '-1') AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) ))"; } else { $update_limit_qpart = "AND (( update_interval = 0 - AND p.value != '-1' + AND (p.value IS NULL OR p.value != '-1') AND last_updated < DATE_SUB(NOW(), INTERVAL CONVERT(COALESCE(p.value, '$default_interval'), SIGNED INTEGER) MINUTE) ) OR ( update_interval > 0 AND last_updated < DATE_SUB(NOW(), INTERVAL update_interval MINUTE) ) OR ( update_interval >= 0 - AND p.value != '-1' + AND (p.value IS NULL OR p.value != '-1') AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) ))"; } @@ -368,10 +382,10 @@ class RPC extends Handler_Protected { } function log() { - $msg = clean($_REQUEST['msg']); - $file = basename(clean($_REQUEST['file'])); - $line = (int) clean($_REQUEST['line']); - $context = clean($_REQUEST['context']); + $msg = clean($_REQUEST['msg'] ?? ""); + $file = basename(clean($_REQUEST['file'] ?? "")); + $line = (int) clean($_REQUEST['line'] ?? 0); + $context = clean($_REQUEST['context'] ?? ""); if ($msg) { Logger::log_error(E_USER_WARNING, @@ -382,12 +396,12 @@ class RPC extends Handler_Protected { } function checkforupdates() { - $rv = []; + $rv = ["changeset" => [], "plugins" => []]; - $git_timestamp = false; - $git_commit = false; + $version = Config::get_version(false); - get_version($git_commit, $git_timestamp); + $git_timestamp = $version["timestamp"] ?? false; + $git_commit = $version["commit"] ?? false; if (Config::get(Config::CHECK_FOR_UPDATES) && $_SESSION["access_level"] >= 10 && $git_timestamp) { $content = @UrlHelper::fetch(["url" => "https://tt-rss.org/version.json"]); @@ -399,10 +413,12 @@ class RPC extends Handler_Protected { if ($git_timestamp < (int)$content["changeset"]["timestamp"] && $git_commit != $content["changeset"]["id"]) { - $rv = $content["changeset"]; + $rv["changeset"] = $content["changeset"]; } } } + + $rv["plugins"] = Pref_Prefs::_get_updated_plugins(); } print json_encode($rv); @@ -427,8 +443,8 @@ class RPC extends Handler_Protected { $params["default_view_mode"] = get_pref(Prefs::_DEFAULT_VIEW_MODE); $params["default_view_limit"] = (int) get_pref(Prefs::_DEFAULT_VIEW_LIMIT); $params["default_view_order_by"] = get_pref(Prefs::_DEFAULT_VIEW_ORDER_BY); - $params["bw_limit"] = (int) $_SESSION["bw_limit"]; - $params["is_default_pw"] = Pref_Prefs::isdefaultpassword(); + $params["bw_limit"] = (int) ($_SESSION["bw_limit"] ?? false); + $params["is_default_pw"] = UserHelper::is_default_password(); $params["label_base_index"] = LABEL_BASE_INDEX; $theme = get_pref(Prefs::USER_CSS_THEME); @@ -449,11 +465,11 @@ class RPC extends Handler_Protected { $max_feed_id = $row["mid"]; $num_feeds = $row["nf"]; - $params["self_url_prefix"] = get_self_url_prefix(); + $params["self_url_prefix"] = Config::get_self_url(); $params["max_feed_id"] = (int) $max_feed_id; $params["num_feeds"] = (int) $num_feeds; $params["hotkeys"] = $this->get_hotkeys_map(); - $params["widescreen"] = (int) ($_COOKIE["ttrss_widescreen"] ?? 0); + $params["widescreen"] = (int) get_pref(Prefs::WIDESCREEN_MODE); $params['simple_update'] = Config::get(Config::SIMPLE_UPDATE_MODE); $params["icon_indicator_white"] = $this->image_to_base64("images/indicator_white.gif"); $params["labels"] = Labels::get_all($_SESSION["uid"]); diff --git a/classes/rssutils.php b/classes/rssutils.php index 11a94162c..e6bf08ab1 100755 --- a/classes/rssutils.php +++ b/classes/rssutils.php @@ -21,7 +21,7 @@ class RSSUtils { } // Strips utf8mb4 characters (i.e. emoji) for mysql - static function strip_utf8mb4($str) { + static function strip_utf8mb4(string $str) { return preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $str); } @@ -52,10 +52,10 @@ class RSSUtils { } } - static function update_daemon_common($limit = null, $options = []) { + static function update_daemon_common(int $limit = 0, array $options = []) { if (!$limit) $limit = Config::get(Config::DAEMON_FEED_LIMIT); - if (get_schema_version() != SCHEMA_VERSION) { + if (Config::get_schema_version() != Config::SCHEMA_VERSION) { die("Schema version is wrong, please upgrade the database.\n"); } @@ -78,27 +78,27 @@ class RSSUtils { if (Config::get(Config::DB_TYPE) == "pgsql") { $update_limit_qpart = "AND (( update_interval = 0 - AND p.value != '-1' + AND (p.value IS NULL OR p.value != '-1') AND last_updated < NOW() - CAST((COALESCE(p.value, '$default_interval') || ' minutes') AS INTERVAL) ) OR ( update_interval > 0 AND last_updated < NOW() - CAST((update_interval || ' minutes') AS INTERVAL) ) OR ( update_interval >= 0 - AND p.value != '-1' + AND (p.value IS NULL OR p.value != '-1') AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) ))"; } else { $update_limit_qpart = "AND (( update_interval = 0 - AND p.value != '-1' + AND (p.value IS NULL OR p.value != '-1') AND last_updated < DATE_SUB(NOW(), INTERVAL CONVERT(COALESCE(p.value, '$default_interval'), SIGNED INTEGER) MINUTE) ) OR ( update_interval > 0 AND last_updated < DATE_SUB(NOW(), INTERVAL update_interval MINUTE) ) OR ( update_interval >= 0 - AND p.value != '-1' + AND (p.value IS NULL OR p.value != '-1') AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL) ))"; } @@ -229,11 +229,9 @@ class RSSUtils { } else { try { if (!self::update_rss_feed($tline["id"], true)) { - global $fetch_last_error; - Logger::log(E_USER_NOTICE, sprintf("Update request for feed %d (%s, owner UID: %d) failed: %s.", - $tline["id"], clean($tline["title"]), $tline["owner_uid"], clean($fetch_last_error))); + $tline["id"], clean($tline["title"]), $tline["owner_uid"], clean(UrlHelper::$fetch_last_error))); } Debug::log(sprintf("<= %.4f (sec) (not using a separate process)", microtime(true) - $fstarted)); @@ -271,8 +269,8 @@ class RSSUtils { return $nf; } - // this is used when subscribing - static function set_basic_feed_info($feed) { + /** this is used when subscribing; TODO: update to ORM */ + static function update_basic_info(int $feed) { $pdo = Db::pdo(); @@ -346,215 +344,197 @@ class RSSUtils { } } - static function update_rss_feed($feed, $no_cache = false) { + static function update_rss_feed(int $feed, bool $no_cache = false) : bool { - Debug::log("start", Debug::$LOG_VERBOSE); + Debug::log("start", Debug::LOG_VERBOSE); $pdo = Db::pdo(); - $sth = $pdo->prepare("SELECT title, site_url FROM ttrss_feeds WHERE id = ?"); - $sth->execute([$feed]); - - if (!$row = $sth->fetch()) { - Debug::log("feed $feed not found, skipping."); - user_error("Attempt to update unknown/invalid feed $feed", E_USER_WARNING); - return false; - } - - $title = $row["title"]; - $site_url = $row["site_url"]; - - // feed was batch-subscribed or something, we need to get basic info - // this is not optimal currently as it fetches stuff separately TODO: optimize - if ($title == "[Unknown]" || !$title || !$site_url) { - Debug::log("setting basic feed info for $feed [$title, $site_url]..."); - self::set_basic_feed_info($feed); + if (Config::get(Config::DB_TYPE) == "pgsql") { + $favicon_interval_qpart = "favicon_last_checked < NOW() - INTERVAL '12 hour'"; + } else { + $favicon_interval_qpart = "favicon_last_checked < DATE_SUB(NOW(), INTERVAL 12 HOUR)"; } - $sth = $pdo->prepare("SELECT id,update_interval,auth_login, - feed_url,auth_pass,cache_images, - mark_unread_on_update, owner_uid, - auth_pass_encrypted, feed_language, - last_modified, - ".SUBSTRING_FOR_DATE."(last_unconditional, 1, 19) AS last_unconditional - FROM ttrss_feeds WHERE id = ?"); - $sth->execute([$feed]); + $feed_obj = ORM::for_table('ttrss_feeds') + ->select_expr("ttrss_feeds.*, + ".SUBSTRING_FOR_DATE."(last_unconditional, 1, 19) AS last_unconditional, + (favicon_is_custom IS NOT TRUE AND + (favicon_last_checked IS NULL OR $favicon_interval_qpart)) AS favicon_needs_check") + ->find_one($feed); - if ($row = $sth->fetch()) { + if ($feed_obj) { + $feed_obj->last_update_started = Db::NOW(); + $feed_obj->save(); - $owner_uid = $row["owner_uid"]; - $mark_unread_on_update = $row["mark_unread_on_update"]; + $feed_language = mb_strtolower($feed_obj->feed_language); - $sth = $pdo->prepare("UPDATE ttrss_feeds SET last_update_started = NOW() - WHERE id = ?"); - $sth->execute([$feed]); - - $auth_login = $row["auth_login"]; - $auth_pass = $row["auth_pass"]; - $stored_last_modified = $row["last_modified"]; - $last_unconditional = $row["last_unconditional"]; - $cache_images = $row["cache_images"]; - $fetch_url = $row["feed_url"]; - - $feed_language = mb_strtolower($row["feed_language"]); - - if (!$feed_language) - $feed_language = mb_strtolower(get_pref(Prefs::DEFAULT_SEARCH_LANGUAGE, $owner_uid)); - - if (!$feed_language) - $feed_language = 'simple'; + if (!$feed_language) $feed_language = mb_strtolower(get_pref(Prefs::DEFAULT_SEARCH_LANGUAGE, $feed_obj->owner_uid)); + if (!$feed_language) $feed_language = 'simple'; } else { + Debug::log("error: feeds table record not found for feed: $feed"); return false; } + // feed was batch-subscribed or something, we need to get basic info + // this is not optimal currently as it fetches stuff separately TODO: optimize + if ($feed_obj->title == "[Unknown]" || empty($feed_obj->title) || empty($feed_obj->site_url)) { + Debug::log("setting basic feed info for $feed..."); + self::update_basic_info($feed); + } + $date_feed_processed = date('Y-m-d H:i'); - $cache_filename = Config::get(Config::CACHE_DIR) . "/feeds/" . sha1($fetch_url) . ".xml"; + $cache_filename = Config::get(Config::CACHE_DIR) . "/feeds/" . sha1($feed_obj->feed_url) . ".xml"; $pluginhost = new PluginHost(); - $user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $owner_uid); + $user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $feed_obj->owner_uid); $pluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL); - $pluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $owner_uid); - //$pluginhost->load_data(); + $pluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $feed_obj->owner_uid); $rss_hash = false; $force_refetch = isset($_REQUEST["force_refetch"]); $feed_data = ""; - Debug::log("running HOOK_FETCH_FEED handlers...", Debug::$LOG_VERBOSE); + Debug::log("running HOOK_FETCH_FEED handlers...", Debug::LOG_VERBOSE); $start_ts = microtime(true); $last_article_timestamp = 0; + + $hff_owner_uid = $feed_obj->owner_uid; + $hff_feed_url = $feed_obj->feed_url; + $pluginhost->chain_hooks_callback(PluginHost::HOOK_FETCH_FEED, function ($result, $plugin) use (&$feed_data, $start_ts) { $feed_data = $result; - Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), Debug::$LOG_VERBOSE); + Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), Debug::LOG_VERBOSE); }, - $feed_data, $fetch_url, $owner_uid, $feed, $last_article_timestamp, $auth_login, $auth_pass); + $feed_data, $hff_feed_url, $hff_owner_uid, $feed, $last_article_timestamp, $auth_login, $auth_pass); if ($feed_data) { - Debug::log("feed data has been modified by a plugin.", Debug::$LOG_VERBOSE); + 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); + Debug::log("feed data has not been modified by a plugin.", Debug::LOG_VERBOSE); } // try cache if (!$feed_data && - file_exists($cache_filename) && is_readable($cache_filename) && - !$auth_login && !$auth_pass && + !$feed_obj->auth_login && !$feed_obj->auth_pass && filemtime($cache_filename) > time() - 30) { - Debug::log("using local cache [$cache_filename].", Debug::$LOG_VERBOSE); + Debug::log("using local cache: {$cache_filename}.", Debug::LOG_VERBOSE); - @$feed_data = file_get_contents($cache_filename); + $feed_data = file_get_contents($cache_filename); if ($feed_data) { $rss_hash = sha1($feed_data); } } else { - Debug::log("local cache will not be used for this feed", Debug::$LOG_VERBOSE); + Debug::log("local cache will not be used for this feed", Debug::LOG_VERBOSE); } - global $fetch_last_modified; - // fetch feed from source if (!$feed_data) { - Debug::log("last unconditional update request: $last_unconditional", Debug::$LOG_VERBOSE); + Debug::log("last unconditional update request: {$feed_obj->last_unconditional}", Debug::LOG_VERBOSE); if (ini_get("open_basedir") && function_exists("curl_init")) { - Debug::log("not using CURL due to open_basedir restrictions", Debug::$LOG_VERBOSE); + Debug::log("not using CURL due to open_basedir restrictions", Debug::LOG_VERBOSE); } - if (time() - strtotime($last_unconditional) > Config::get(Config::MAX_CONDITIONAL_INTERVAL)) { - Debug::log("maximum allowed interval for conditional requests exceeded, forcing refetch", Debug::$LOG_VERBOSE); + if (time() - strtotime($feed_obj->last_unconditional) > Config::get(Config::MAX_CONDITIONAL_INTERVAL)) { + Debug::log("maximum allowed interval for conditional requests exceeded, forcing refetch", Debug::LOG_VERBOSE); $force_refetch = true; } else { - Debug::log("stored last modified for conditional request: $stored_last_modified", Debug::$LOG_VERBOSE); + Debug::log("stored last modified for conditional request: {$feed_obj->last_modified}", Debug::LOG_VERBOSE); } - Debug::log("fetching [$fetch_url] (force_refetch: $force_refetch)...", Debug::$LOG_VERBOSE); + Debug::log("fetching {$feed_obj->feed_url} (force_refetch: $force_refetch)...", Debug::LOG_VERBOSE); $feed_data = UrlHelper::fetch([ - "url" => $fetch_url, - "login" => $auth_login, - "pass" => $auth_pass, + "url" => $feed_obj->feed_url, + "login" => $feed_obj->auth_login, + "pass" => $feed_obj->auth_pass, "timeout" => $no_cache ? Config::get(Config::FEED_FETCH_NO_CACHE_TIMEOUT) : Config::get(Config::FEED_FETCH_TIMEOUT), - "last_modified" => $force_refetch ? "" : $stored_last_modified + "last_modified" => $force_refetch ? "" : $feed_obj->last_modified ]); $feed_data = trim($feed_data); - global $fetch_effective_url; - global $fetch_effective_ip_addr; + Debug::log("fetch done.", Debug::LOG_VERBOSE); + Debug::log(sprintf("effective URL (after redirects): %s (IP: %s) ", UrlHelper::$fetch_effective_url, UrlHelper::$fetch_effective_ip_addr), Debug::LOG_VERBOSE); + Debug::log("server last modified: " . UrlHelper::$fetch_last_modified, Debug::LOG_VERBOSE); - 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) { - $sth = $pdo->prepare("UPDATE ttrss_feeds SET last_modified = ? WHERE id = ?"); - $sth->execute([substr($fetch_last_modified, 0, 245), $feed]); + if ($feed_data && UrlHelper::$fetch_last_modified != $feed_obj->last_modified) { + $feed_obj->last_modified = substr(UrlHelper::$fetch_last_modified, 0, 245); + $feed_obj->save(); } // cache vanilla feed data for re-use - if ($feed_data && !$auth_pass && !$auth_login && is_writable(Config::get(Config::CACHE_DIR) . "/feeds")) { + if ($feed_data && !$feed_obj->auth_pass && !$feed_obj->auth_login && is_writable(Config::get(Config::CACHE_DIR) . "/feeds")) { $new_rss_hash = sha1($feed_data); if ($new_rss_hash != $rss_hash) { - Debug::log("saving $cache_filename", Debug::$LOG_VERBOSE); - @file_put_contents($cache_filename, $feed_data); + Debug::log("saving to local cache: $cache_filename", Debug::LOG_VERBOSE); + file_put_contents($cache_filename, $feed_data); } } } if (!$feed_data) { - global $fetch_last_error; - global $fetch_last_error_code; - - Debug::log("unable to fetch: $fetch_last_error [$fetch_last_error_code]", Debug::$LOG_VERBOSE); + Debug::log(sprintf("unable to fetch: %s [%s]", UrlHelper::$fetch_last_error, UrlHelper::$fetch_last_error_code), Debug::LOG_VERBOSE); // If-Modified-Since - if ($fetch_last_error_code == 304) { - Debug::log("source claims data not modified, nothing to do.", Debug::$LOG_VERBOSE); + if (UrlHelper::$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 = ?, - last_successful_update = NOW(), - last_updated = NOW() WHERE id = ?"); + $feed_obj->set([ + 'last_error' => '', + 'last_successful_update' => Db::NOW(), + 'last_updated' => Db::NOW(), + ]); + + $feed_obj->save(); } else { - $error_message = $fetch_last_error; + $error_message = UrlHelper::$fetch_last_error; - $sth = $pdo->prepare("UPDATE ttrss_feeds SET last_error = ?, - last_updated = NOW() WHERE id = ?"); - } + $feed_obj->set([ + 'last_error' => $error_message, + 'last_updated' => Db::NOW(), + ]); - $sth->execute([$error_message, $feed]); + $feed_obj->save(); + } return $error_message == ""; } - Debug::log("running HOOK_FEED_FETCHED handlers...", Debug::$LOG_VERBOSE); + Debug::log("running HOOK_FEED_FETCHED handlers...", Debug::LOG_VERBOSE); $feed_data_checksum = md5($feed_data); + // because chain_hooks_callback() accepts variables by value + $pff_owner_uid = $feed_obj->owner_uid; + $pff_feed_url = $feed_obj->feed_url; + $start_ts = microtime(true); $pluginhost->chain_hooks_callback(PluginHost::HOOK_FEED_FETCHED, function ($result, $plugin) use (&$feed_data, $start_ts) { $feed_data = $result; - Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), Debug::$LOG_VERBOSE); + Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), Debug::LOG_VERBOSE); }, - $feed_data, $fetch_url, $owner_uid, $feed); + $feed_data, $pff_feed_url, $pff_owner_uid, $feed); if (md5($feed_data) != $feed_data_checksum) { - Debug::log("feed data has been modified by a plugin.", Debug::$LOG_VERBOSE); + 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); + Debug::log("feed data has not been modified by a plugin.", Debug::LOG_VERBOSE); } $rss = new FeedParser($feed_data); @@ -562,46 +542,29 @@ class RSSUtils { if (!$rss->error()) { - Debug::log("running HOOK_FEED_PARSED handlers...", Debug::$LOG_VERBOSE); + Debug::log("running HOOK_FEED_PARSED handlers...", Debug::LOG_VERBOSE); // We use local pluginhost here because we need to load different per-user feed plugins $start_ts = microtime(true); $pluginhost->chain_hooks_callback(PluginHost::HOOK_FEED_PARSED, function($result, $plugin) use ($start_ts) { - Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), Debug::$LOG_VERBOSE); + Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), Debug::LOG_VERBOSE); }, $rss, $feed); - Debug::log("language: $feed_language", Debug::$LOG_VERBOSE); - Debug::log("processing feed data...", Debug::$LOG_VERBOSE); - - if (Config::get(Config::DB_TYPE) == "pgsql") { - $favicon_interval_qpart = "favicon_last_checked < NOW() - INTERVAL '12 hour'"; - } else { - $favicon_interval_qpart = "favicon_last_checked < DATE_SUB(NOW(), INTERVAL 12 HOUR)"; - } - - $sth = $pdo->prepare("SELECT owner_uid,favicon_avg_color, - (favicon_last_checked IS NULL OR $favicon_interval_qpart) AS - favicon_needs_check - FROM ttrss_feeds WHERE id = ?"); - $sth->execute([$feed]); + Debug::log("language: $feed_language", Debug::LOG_VERBOSE); + Debug::log("processing feed data...", Debug::LOG_VERBOSE); - if ($row = $sth->fetch()) { - $favicon_needs_check = $row["favicon_needs_check"]; - $favicon_avg_color = $row["favicon_avg_color"]; - $owner_uid = $row["owner_uid"]; - } else { - return false; - } + $site_url = mb_substr(rewrite_relative_url($feed_obj->feed_url, clean($rss->get_link())), 0, 245); - $site_url = mb_substr(rewrite_relative_url($fetch_url, clean($rss->get_link())), 0, 245); + Debug::log("site_url: $site_url", Debug::LOG_VERBOSE); + Debug::log("feed_title: {$rss->get_title()}", Debug::LOG_VERBOSE); - Debug::log("site_url: $site_url", Debug::$LOG_VERBOSE); - Debug::log("feed_title: " . clean($rss->get_title()), Debug::$LOG_VERBOSE); + Debug::log("favicon: needs check: {$feed_obj->favicon_needs_check} is custom: {$feed_obj->favicon_is_custom} avg color: {$feed_obj->favicon_avg_color}", + Debug::LOG_VERBOSE); - if ($favicon_needs_check || $force_refetch) { + if ($feed_obj->favicon_needs_check || $force_refetch) { /* terrible hack: if we crash on floicon shit here, we won't check * the icon avgcolor again (unless the icon got updated) */ @@ -609,70 +572,74 @@ class RSSUtils { $favicon_file = Config::get(Config::ICONS_DIR) . "/$feed.ico"; $favicon_modified = file_exists($favicon_file) ? filemtime($favicon_file) : -1; - Debug::log("checking favicon for feed $feed...", Debug::$LOG_VERBOSE); - - self::check_feed_favicon($site_url, $feed); - $favicon_modified_new = file_exists($favicon_file) ? filemtime($favicon_file) : -1; + if (!$feed_obj->favicon_is_custom) { + Debug::log("favicon: trying to update favicon...", Debug::LOG_VERBOSE); + self::update_favicon($site_url, $feed); - if ($favicon_modified_new > $favicon_modified) - $favicon_avg_color = ''; + if ((file_exists($favicon_file) ? filemtime($favicon_file) : -1) > $favicon_modified) + $feed_obj->favicon_avg_color = null; + } - $favicon_colorstring = ""; - if (file_exists($favicon_file) && function_exists("imagecreatefromstring") && $favicon_avg_color == '') { + if (is_readable($favicon_file) && function_exists("imagecreatefromstring") && empty($feed_obj->favicon_avg_color)) { require_once "colors.php"; - $sth = $pdo->prepare("UPDATE ttrss_feeds SET favicon_avg_color = 'fail' WHERE - id = ?"); - $sth->execute([$feed]); + Debug::log("favicon: trying to calculate average color...", Debug::LOG_VERBOSE); - $favicon_color = \Colors\calculate_avg_color($favicon_file); + $feed_obj->favicon_avg_color = 'fail'; + $feed_obj->save(); - $favicon_colorstring = ",favicon_avg_color = " . $pdo->quote($favicon_color); + $feed_obj->favicon_avg_color = \Colors\calculate_avg_color($favicon_file); + $feed_obj->save(); - } else if ($favicon_avg_color == 'fail') { - Debug::log("floicon failed on this file, not trying to recalculate avg color", Debug::$LOG_VERBOSE); - } + Debug::log("favicon: avg color: {$feed_obj->favicon_avg_color}", Debug::LOG_VERBOSE); - $sth = $pdo->prepare("UPDATE ttrss_feeds SET favicon_last_checked = NOW() - $favicon_colorstring WHERE id = ?"); - $sth->execute([$feed]); + } else if ($feed_obj->favicon_avg_color == 'fail') { + Debug::log("floicon failed $favicon_file, not trying to recalculate avg color", Debug::LOG_VERBOSE); + } } - Debug::log("loading filters & labels...", Debug::$LOG_VERBOSE); + Debug::log("loading filters & labels...", Debug::LOG_VERBOSE); - $filters = self::load_filters($feed, $owner_uid); + $filters = self::load_filters($feed, $feed_obj->owner_uid); - if (Debug::get_loglevel() >= Debug::$LOG_EXTENDED) { + if (Debug::get_loglevel() >= Debug::LOG_EXTENDED) { print_r($filters); } - Debug::log("" . count($filters) . " filters loaded.", Debug::$LOG_VERBOSE); + Debug::log("" . count($filters) . " filters loaded.", Debug::LOG_VERBOSE); $items = $rss->get_items(); if (!is_array($items)) { - Debug::log("no articles found.", Debug::$LOG_VERBOSE); + Debug::log("no articles found.", Debug::LOG_VERBOSE); - $sth = $pdo->prepare("UPDATE ttrss_feeds - SET last_updated = NOW(), last_unconditional = NOW(), last_error = '' WHERE id = ?"); - $sth->execute([$feed]); + $feed_obj->set([ + 'last_updated' => Db::NOW(), + 'last_unconditional' => Db::NOW(), + 'last_error' => '', + ]); + + $feed_obj->save(); return true; // no articles } - Debug::log("processing articles...", Debug::$LOG_VERBOSE); + Debug::log("processing articles...", Debug::LOG_VERBOSE); $tstart = time(); foreach ($items as $item) { $pdo->beginTransaction(); + Debug::log("=================================================================================================================================", + Debug::LOG_VERBOSE); + if (Debug::get_loglevel() >= 3) { print_r($item); } if (ini_get("max_execution_time") > 0 && time() - $tstart >= ini_get("max_execution_time") * 0.7) { - Debug::log("looks like there's too many articles to process at once, breaking out", Debug::$LOG_VERBOSE); + Debug::log("looks like there's too many articles to process at once, breaking out.", Debug::LOG_VERBOSE); $pdo->commit(); break; } @@ -686,15 +653,16 @@ class RSSUtils { 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_compat = 'SHA1:' . sha1("{$feed_obj->owner_uid},$entry_guid"); + $entry_guid_hashed = json_encode(["ver" => 2, "uid" => $feed_obj->owner_uid, "hash" => 'SHA1:' . sha1($entry_guid)]); + $entry_guid = "$feed_obj->owner_uid,$entry_guid"; - Debug::log("guid $entry_guid (hash: $entry_guid_hashed compat: $entry_guid_hashed_compat)", Debug::$LOG_VERBOSE); + Debug::log("guid $entry_guid (hash: $entry_guid_hashed compat: $entry_guid_hashed_compat)", Debug::LOG_VERBOSE); $entry_timestamp = (int)$item->get_date(); - Debug::log("orig date: " . $item->get_date(), Debug::$LOG_VERBOSE); + Debug::log(sprintf("orig date: %s (%s)", $item->get_date(), date("Y-m-d H:i:s", $item->get_date())), + Debug::LOG_VERBOSE); $entry_title = strip_tags($item->get_title()); @@ -702,9 +670,9 @@ class RSSUtils { $entry_language = mb_substr(trim($item->get_language()), 0, 2); - Debug::log("title $entry_title", Debug::$LOG_VERBOSE); - Debug::log("link $entry_link", Debug::$LOG_VERBOSE); - Debug::log("language $entry_language", Debug::$LOG_VERBOSE); + Debug::log("title $entry_title", Debug::LOG_VERBOSE); + Debug::log("link $entry_link", Debug::LOG_VERBOSE); + Debug::log("language $entry_language", Debug::LOG_VERBOSE); if (!$entry_title) $entry_title = date("Y-m-d H:i:s", $entry_timestamp);; @@ -723,13 +691,13 @@ class RSSUtils { $entry_author = strip_tags($item->get_author()); $entry_guid = mb_substr($entry_guid, 0, 245); - Debug::log("author $entry_author", Debug::$LOG_VERBOSE); - Debug::log("looking for tags...", Debug::$LOG_VERBOSE); + Debug::log("author $entry_author", Debug::LOG_VERBOSE); + Debug::log("looking for tags...", Debug::LOG_VERBOSE); $entry_tags = $item->get_categories(); - Debug::log("tags found: " . join(", ", $entry_tags), Debug::$LOG_VERBOSE); + Debug::log("tags found: " . join(", ", $entry_tags), Debug::LOG_VERBOSE); - Debug::log("done collecting data.", Debug::$LOG_VERBOSE); + Debug::log("done collecting data.", Debug::LOG_VERBOSE); $sth = $pdo->prepare("SELECT id, content_hash, lang FROM ttrss_entries WHERE guid IN (?, ?, ?)"); @@ -738,9 +706,9 @@ class RSSUtils { if ($row = $sth->fetch()) { $base_entry_id = $row["id"]; $entry_stored_hash = $row["content_hash"]; - $article_labels = Article::_get_labels($base_entry_id, $owner_uid); + $article_labels = Article::_get_labels($base_entry_id, $feed_obj->owner_uid); - $existing_tags = Article::_get_tags($base_entry_id, $owner_uid); + $existing_tags = Article::_get_tags($base_entry_id, $feed_obj->owner_uid); $entry_tags = array_unique(array_merge($entry_tags, $existing_tags)); } else { $base_entry_id = false; @@ -748,13 +716,13 @@ class RSSUtils { $article_labels = array(); } - Debug::log("looking for enclosures...", Debug::$LOG_VERBOSE); + Debug::log("looking for enclosures...", Debug::LOG_VERBOSE); // enclosures $enclosures = array(); - $encs = $item->_get_enclosures(); + $encs = $item->get_enclosures(); if (is_array($encs)) { foreach ($encs as $e) { @@ -782,7 +750,7 @@ class RSSUtils { } } - $article = array("owner_uid" => $owner_uid, // read only + $article = array("owner_uid" => $feed_obj->owner_uid, // read only "guid" => $entry_guid, // read only "guid_hashed" => $entry_guid_hashed, // read only "title" => $entry_title, @@ -798,33 +766,35 @@ class RSSUtils { "num_comments" => $num_comments, "enclosures" => $enclosures, "feed" => array("id" => $feed, - "fetch_url" => $fetch_url, + "fetch_url" => $feed_obj->feed_url, "site_url" => $site_url, - "cache_images" => $cache_images) + "cache_images" => $feed_obj->cache_images) ); $entry_plugin_data = ""; $entry_current_hash = self::calculate_article_hash($article, $pluginhost); - Debug::log("article hash: $entry_current_hash [stored=$entry_stored_hash]", Debug::$LOG_VERBOSE); + Debug::log("article hash: $entry_current_hash [stored=$entry_stored_hash]", Debug::LOG_VERBOSE); if ($entry_current_hash == $entry_stored_hash && !isset($_REQUEST["force_rehash"])) { - Debug::log("stored article seems up to date [IID: $base_entry_id], updating timestamp only", Debug::$LOG_VERBOSE); + Debug::log("stored article seems up to date [IID: $base_entry_id], updating timestamp only.", Debug::LOG_VERBOSE); // we keep encountering the entry in feeds, so we need to // update date_updated column so that we don't get horrible // dupes when the entry gets purged and reinserted again e.g. // in the case of SLOW SLOW OMG SLOW updating feeds - $sth = $pdo->prepare("UPDATE ttrss_entries SET date_updated = NOW() - WHERE id = ?"); - $sth->execute([$base_entry_id]); + $entry_obj = ORM::for_table('ttrss_entries') + ->find_one($base_entry_id) + ->set('date_updated', Db::NOW()) + ->save(); $pdo->commit(); + continue; } - Debug::log("hash differs, applying plugin filters:", Debug::$LOG_VERBOSE); + Debug::log("hash differs, running HOOK_ARTICLE_FILTER handlers...", Debug::LOG_VERBOSE); $start_ts = microtime(true); @@ -835,7 +805,7 @@ class RSSUtils { $entry_plugin_data .= mb_strtolower(get_class($plugin)) . ","; Debug::log(sprintf("=== %.4f (sec) %s", microtime(true) - $start_ts, get_class($plugin)), - Debug::$LOG_VERBOSE); + Debug::LOG_VERBOSE); }, $article); @@ -845,7 +815,7 @@ class RSSUtils { print "\n"; } - Debug::log("plugin data: $entry_plugin_data", Debug::$LOG_VERBOSE); + Debug::log("plugin data: {$entry_plugin_data}", Debug::LOG_VERBOSE); // Workaround: 4-byte unicode requires utf8mb4 in MySQL. See https://tt-rss.org/forum/viewtopic.php?f=1&t=3377&p=20077#p20077 if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET) != "UTF8MB4") { @@ -868,33 +838,35 @@ class RSSUtils { // $article_filters should be renamed to something like $filter_actions; actual filter objects are in $matched_filters $pluginhost->run_hooks(PluginHost::HOOK_FILTER_TRIGGERED, - $feed, $owner_uid, $article, $matched_filters, $matched_rules, $article_filters); + $feed, $feed_obj->owner_uid, $article, $matched_filters, $matched_rules, $article_filters); $matched_filter_ids = array_map(function($f) { return $f['id']; }, $matched_filters); if (count($matched_filter_ids) > 0) { - $filter_ids_qmarks = arr_qmarks($matched_filter_ids); + $filter_objs = ORM::for_table('ttrss_filters2') + ->where('owner_uid', $feed_obj->owner_uid) + ->where_in('id', $matched_filter_ids); - $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])); + foreach ($filter_objs as $filter_obj) { + $filter_obj->set('last_triggered', Db::NOW()); + $filter_obj->save(); + } } - if (Debug::get_loglevel() >= Debug::$LOG_EXTENDED) { - Debug::log("matched filters: ", Debug::$LOG_VERBOSE); + if (Debug::get_loglevel() >= Debug::LOG_EXTENDED) { + Debug::log("matched filters: ", Debug::LOG_VERBOSE); if (count($matched_filters) != 0) { print_r($matched_filters); } - Debug::log("matched filter rules: ", Debug::$LOG_VERBOSE); + Debug::log("matched filter rules: ", Debug::LOG_VERBOSE); if (count($matched_rules) != 0) { print_r($matched_rules); } - Debug::log("filter actions: ", Debug::$LOG_VERBOSE); + Debug::log("filter actions: ", Debug::LOG_VERBOSE); if (count($article_filters) != 0) { print_r($article_filters); @@ -905,7 +877,7 @@ class RSSUtils { $plugin_filter_actions = $pluginhost->get_filter_actions(); if (count($plugin_filter_names) > 0) { - Debug::log("applying plugin filter actions...", Debug::$LOG_VERBOSE); + Debug::log("applying plugin filter actions...", Debug::LOG_VERBOSE); foreach ($plugin_filter_names as $pfn) { list($pfclass,$pfaction) = explode(":", $pfn["param"]); @@ -913,18 +885,18 @@ class RSSUtils { if (isset($plugin_filter_actions[$pfclass])) { $plugin = $pluginhost->get_plugin($pfclass); - Debug::log("... $pfclass: $pfaction", Debug::$LOG_VERBOSE); + Debug::log("... $pfclass: $pfaction", Debug::LOG_VERBOSE); if ($plugin) { $start = microtime(true); $article = $plugin->hook_article_filter_action($article, $pfaction); - Debug::log(sprintf("=== %.4f (sec)", microtime(true) - $start), Debug::$LOG_VERBOSE); + Debug::log(sprintf("=== %.4f (sec)", microtime(true) - $start), Debug::LOG_VERBOSE); } else { - Debug::log("??? $pfclass: plugin object not found.", Debug::$LOG_VERBOSE); + Debug::log("??? $pfclass: plugin object not found.", Debug::LOG_VERBOSE); } } else { - Debug::log("??? $pfclass: filter plugin not registered.", Debug::$LOG_VERBOSE); + Debug::log("??? $pfclass: filter plugin not registered.", Debug::LOG_VERBOSE); } } } @@ -948,20 +920,20 @@ class RSSUtils { $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); + Debug::log("date: $entry_timestamp ($entry_timestamp_fmt)", Debug::LOG_VERBOSE); + Debug::log("num_comments: $num_comments", Debug::LOG_VERBOSE); - if (Debug::get_loglevel() >= Debug::$LOG_EXTENDED) { - Debug::log("article labels:", Debug::$LOG_VERBOSE); + if (Debug::get_loglevel() >= Debug::LOG_EXTENDED) { + Debug::log("article labels:", Debug::LOG_VERBOSE); if (count($article_labels) != 0) { print_r($article_labels); } } - Debug::log("force catchup: $entry_force_catchup", Debug::$LOG_VERBOSE); + Debug::log("force catchup: $entry_force_catchup", Debug::LOG_VERBOSE); - if ($cache_images) + if ($feed_obj->cache_images) self::cache_media($entry_content, $site_url); $csth = $pdo->prepare("SELECT id FROM ttrss_entries @@ -970,7 +942,7 @@ class RSSUtils { if (!$row = $csth->fetch()) { - Debug::log("base guid [$entry_guid or $entry_guid_hashed] not found, creating...", Debug::$LOG_VERBOSE); + Debug::log("base guid [$entry_guid or $entry_guid_hashed] not found, creating...", Debug::LOG_VERBOSE); // base post entry does not exist, create it @@ -1018,36 +990,36 @@ class RSSUtils { if ($row = $csth->fetch()) { - Debug::log("base guid found, checking for user record", Debug::$LOG_VERBOSE); + Debug::log("base guid found, checking for user record", Debug::LOG_VERBOSE); $ref_id = $row['id']; $entry_ref_id = $ref_id; if (self::find_article_filter($article_filters, "filter")) { - Debug::log("article is filtered out, nothing to do.", Debug::$LOG_VERBOSE); + Debug::log("article is filtered out, nothing to do.", Debug::LOG_VERBOSE); $pdo->commit(); continue; } $score = self::calculate_article_score($article_filters) + $entry_score_modifier; - Debug::log("initial score: $score [including plugin modifier: $entry_score_modifier]", Debug::$LOG_VERBOSE); + Debug::log("initial score: $score [including plugin modifier: $entry_score_modifier]", Debug::LOG_VERBOSE); // check for user post link to main table $sth = $pdo->prepare("SELECT ref_id, int_id FROM ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?"); - $sth->execute([$ref_id, $owner_uid]); + $sth->execute([$ref_id, $feed_obj->owner_uid]); // okay it doesn't exist - create user entry if ($row = $sth->fetch()) { $entry_ref_id = $row["ref_id"]; $entry_int_id = $row["int_id"]; - Debug::log("user record FOUND: RID: $entry_ref_id, IID: $entry_int_id", Debug::$LOG_VERBOSE); + Debug::log("user record FOUND: RID: $entry_ref_id, IID: $entry_int_id", Debug::LOG_VERBOSE); } else { - Debug::log("user record not found, creating...", Debug::$LOG_VERBOSE); + Debug::log("user record not found, creating...", Debug::LOG_VERBOSE); if ($score >= -500 && !self::find_article_filter($article_filters, 'catchup') && !$entry_force_catchup) { $unread = 1; @@ -1079,20 +1051,20 @@ class RSSUtils { last_marked, last_published) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', '', '', ".$last_marked.", ".$last_published.")"); - $sth->execute([$ref_id, $owner_uid, $feed, $unread, $last_read_qpart, $marked, + $sth->execute([$ref_id, $feed_obj->owner_uid, $feed, $unread, $last_read_qpart, $marked, $published, $score]); $sth = $pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE ref_id = ? AND owner_uid = ? AND feed_id = ? LIMIT 1"); - $sth->execute([$ref_id, $owner_uid, $feed]); + $sth->execute([$ref_id, $feed_obj->owner_uid, $feed]); if ($row = $sth->fetch()) $entry_int_id = $row['int_id']; } - Debug::log("resulting RID: $entry_ref_id, IID: $entry_int_id", Debug::$LOG_VERBOSE); + Debug::log("resulting RID: $entry_ref_id, IID: $entry_int_id", Debug::LOG_VERBOSE); if (Config::get(Config::DB_TYPE) == "pgsql") $tsvector_qpart = "tsvector_combined = to_tsvector(:ts_lang, :ts_content),"; @@ -1134,36 +1106,36 @@ class RSSUtils { SET score = ? WHERE ref_id = ?"); $sth->execute([$score, $ref_id]); - if ($mark_unread_on_update && + if ($feed_obj->mark_unread_on_update && !$entry_force_catchup && !self::find_article_filter($article_filters, 'catchup')) { - Debug::log("article updated, marking unread as requested.", Debug::$LOG_VERBOSE); + Debug::log("article updated, marking unread as requested.", Debug::LOG_VERBOSE); $sth = $pdo->prepare("UPDATE ttrss_user_entries SET last_read = null, unread = true WHERE ref_id = ?"); $sth->execute([$ref_id]); } else { - Debug::log("article updated, but we're forbidden to mark it unread.", Debug::$LOG_VERBOSE); + Debug::log("article updated, but we're forbidden to mark it unread.", Debug::LOG_VERBOSE); } } - Debug::log("assigning labels [other]...", Debug::$LOG_VERBOSE); + Debug::log("assigning labels [other]...", Debug::LOG_VERBOSE); foreach ($article_labels as $label) { - Labels::add_article($entry_ref_id, $label[1], $owner_uid); + Labels::add_article($entry_ref_id, $label[1], $feed_obj->owner_uid); } - Debug::log("assigning labels [filters]...", Debug::$LOG_VERBOSE); + Debug::log("assigning labels [filters]...", Debug::LOG_VERBOSE); self::assign_article_to_label_filters($entry_ref_id, $article_filters, - $owner_uid, $article_labels); + $feed_obj->owner_uid, $article_labels); - if ($cache_images) + if ($feed_obj->cache_images) self::cache_enclosures($enclosures, $site_url); - if (Debug::get_loglevel() >= Debug::$LOG_EXTENDED) { - Debug::log("article enclosures:", Debug::$LOG_VERBOSE); + if (Debug::get_loglevel() >= Debug::LOG_EXTENDED) { + Debug::log("article enclosures:", Debug::LOG_VERBOSE); print_r($enclosures); } @@ -1206,13 +1178,13 @@ class RSSUtils { $boring_tags = array_map('trim', explode(",", mb_strtolower( - get_pref(Prefs::BLACKLISTED_TAGS, $owner_uid)))); + get_pref(Prefs::BLACKLISTED_TAGS, $feed_obj->owner_uid)))); $entry_tags = FeedItem_Common::normalize_categories( array_unique( array_diff($entry_tags, $boring_tags))); - Debug::log("filtered tags: " . implode(", ", $entry_tags), Debug::$LOG_VERBOSE); + Debug::log("filtered tags: " . implode(", ", $entry_tags), Debug::LOG_VERBOSE); // Save article tags in the database @@ -1227,10 +1199,10 @@ class RSSUtils { VALUES (?, ?, ?)"); foreach ($entry_tags as $tag) { - $tsth->execute([$tag, $entry_int_id, $owner_uid]); + $tsth->execute([$tag, $entry_int_id, $feed_obj->owner_uid]); if (!$tsth->fetch()) { - $usth->execute([$owner_uid, $tag, $entry_int_id]); + $usth->execute([$feed_obj->owner_uid, $tag, $entry_int_id]); } } @@ -1243,52 +1215,58 @@ class RSSUtils { $tsth->execute([ join(",", $entry_tags), $entry_ref_id, - $owner_uid + $feed_obj->owner_uid ]); } - Debug::log("article processed", Debug::$LOG_VERBOSE); + Debug::log("article processed.", Debug::LOG_VERBOSE); $pdo->commit(); } - Debug::log("purging feed...", Debug::$LOG_VERBOSE); + Debug::log("=================================================================================================================================", + Debug::LOG_VERBOSE); + + Debug::log("purging feed...", Debug::LOG_VERBOSE); Feeds::_purge($feed, 0); - $sth = $pdo->prepare("UPDATE ttrss_feeds SET - last_updated = NOW(), - last_unconditional = NOW(), - last_successful_update = NOW(), - last_error = '' WHERE id = ?"); - $sth->execute([$feed]); + $feed_obj->set([ + 'last_updated' => Db::NOW(), + 'last_unconditional' => Db::NOW(), + 'last_successful_update' => Db::NOW(), + 'last_error' => '', + ]); + + $feed_obj->save(); } else { $error_msg = mb_substr($rss->error(), 0, 245); - Debug::log("fetch error: $error_msg", Debug::$LOG_VERBOSE); + Debug::log("fetch error: $error_msg", Debug::LOG_VERBOSE); if (count($rss->errors()) > 1) { foreach ($rss->errors() as $error) { - Debug::log("+ $error", Debug::$LOG_VERBOSE); + Debug::log("+ $error", Debug::LOG_VERBOSE); } } - $sth = $pdo->prepare("UPDATE ttrss_feeds SET - last_error = ?, - last_updated = NOW(), - last_unconditional = NOW() WHERE id = ?"); - $sth->execute([$error_msg, $feed]); + $feed_obj->set([ + 'last_updated' => Db::NOW(), + 'last_unconditional' => Db::NOW(), + 'last_error' => $error_msg, + ]); + + $feed_obj->save(); unset($rss); - Debug::log("update failed.", Debug::$LOG_VERBOSE); + Debug::log("update failed.", Debug::LOG_VERBOSE); return false; } - Debug::log("update done.", Debug::$LOG_VERBOSE); - + Debug::log("update done.", Debug::LOG_VERBOSE); return true; } @@ -1304,13 +1282,9 @@ class RSSUtils { $local_filename = sha1($src); - Debug::log("cache_enclosures: downloading: $src to $local_filename", Debug::$LOG_VERBOSE); + Debug::log("cache_enclosures: downloading: $src to $local_filename", Debug::LOG_VERBOSE); if (!$cache->exists($local_filename)) { - - global $fetch_last_error_code; - global $fetch_last_error; - $file_content = UrlHelper::fetch(array("url" => $src, "http_referrer" => $src, "max_size" => Config::get(Config::MAX_CACHE_FILE_SIZE))); @@ -1318,7 +1292,7 @@ class RSSUtils { if ($file_content) { $cache->put($local_filename, $file_content); } else { - Debug::log("cache_enclosures: failed with $fetch_last_error_code: $fetch_last_error"); + Debug::log("cache_enclosures: failed with ".UrlHelper::$fetch_last_error_code.": ".UrlHelper::$fetch_last_error); } } else if (is_writable($local_filename)) { $cache->touch($local_filename); @@ -1333,13 +1307,10 @@ class RSSUtils { $url = rewrite_relative_url($site_url, $url); $local_filename = sha1($url); - Debug::log("cache_media: checking $url", Debug::$LOG_VERBOSE); + 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; + Debug::log("cache_media: downloading: $url to $local_filename", Debug::LOG_VERBOSE); $file_content = UrlHelper::fetch(array("url" => $url, "http_referrer" => $url, @@ -1348,7 +1319,7 @@ class RSSUtils { if ($file_content) { $cache->put($local_filename, $file_content); } else { - Debug::log("cache_media: failed with $fetch_last_error_code: $fetch_last_error"); + Debug::log("cache_media: failed with ".UrlHelper::$fetch_last_error_code.": ".UrlHelper::$fetch_last_error); } } else if ($cache->is_writable($local_filename)) { $cache->touch($local_filename); @@ -1407,7 +1378,7 @@ class RSSUtils { } static function expire_lock_files() { - Debug::log("Removing old lock files...", Debug::$LOG_VERBOSE); + Debug::log("Removing old lock files...", Debug::LOG_VERBOSE); $num_deleted = 0; @@ -1657,12 +1628,12 @@ class RSSUtils { PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); } - static function check_feed_favicon($site_url, $feed) { + static function update_favicon(string $site_url, int $feed) { $icon_file = Config::get(Config::ICONS_DIR) . "/$feed.ico"; $favicon_url = self::get_favicon_url($site_url); if (!$favicon_url) { - Debug::log("couldn't find favicon URL in $site_url", Debug::$LOG_VERBOSE); + Debug::log("favicon: couldn't find favicon URL in $site_url", Debug::LOG_VERBOSE); return false; } @@ -1673,7 +1644,7 @@ class RSSUtils { //'type' => 'image', ]); if (!$contents) { - Debug::log("fetching favicon $favicon_url failed", Debug::$LOG_VERBOSE); + Debug::log("favicon: fetching $favicon_url failed", Debug::LOG_VERBOSE); return false; } @@ -1681,35 +1652,35 @@ class RSSUtils { // Patterns gleaned from the file(1) source code. if (preg_match('/^\x00\x00\x01\x00/', $contents)) { // 0 string \000\000\001\000 MS Windows icon resource - //error_log("check_feed_favicon: favicon_url=$favicon_url isa MS Windows icon resource"); + //error_log("update_favicon: favicon_url=$favicon_url isa MS Windows icon resource"); } elseif (preg_match('/^GIF8/', $contents)) { // 0 string GIF8 GIF image data - //error_log("check_feed_favicon: favicon_url=$favicon_url isa GIF image"); + //error_log("update_favicon: favicon_url=$favicon_url isa GIF image"); } elseif (preg_match('/^\x89PNG\x0d\x0a\x1a\x0a/', $contents)) { // 0 string \x89PNG\x0d\x0a\x1a\x0a PNG image data - //error_log("check_feed_favicon: favicon_url=$favicon_url isa PNG image"); + //error_log("update_favicon: favicon_url=$favicon_url isa PNG image"); } elseif (preg_match('/^\xff\xd8/', $contents)) { // 0 beshort 0xffd8 JPEG image data - //error_log("check_feed_favicon: favicon_url=$favicon_url isa JPG image"); + //error_log("update_favicon: favicon_url=$favicon_url isa JPG image"); } elseif (preg_match('/^BM/', $contents)) { // 0 string BM PC bitmap (OS2, Windows BMP files) - //error_log("check_feed_favicon, favicon_url=$favicon_url isa BMP image"); + //error_log("update_favicon, favicon_url=$favicon_url isa BMP image"); } else { - //error_log("check_feed_favicon: favicon_url=$favicon_url isa UNKNOWN type"); - Debug::log("favicon $favicon_url type is unknown (not updating)", Debug::$LOG_VERBOSE); + //error_log("update_favicon: favicon_url=$favicon_url isa UNKNOWN type"); + Debug::log("favicon $favicon_url type is unknown (not updating)", Debug::LOG_VERBOSE); return false; } - Debug::log("setting contents of $icon_file", Debug::$LOG_VERBOSE); + Debug::log("favicon: saving to $icon_file", Debug::LOG_VERBOSE); $fp = @fopen($icon_file, "w"); if (!$fp) { - Debug::log("failed to open $icon_file for writing", Debug::$LOG_VERBOSE); + Debug::log("favicon: failed to open $icon_file for writing", Debug::LOG_VERBOSE); return false; } @@ -1726,13 +1697,13 @@ class RSSUtils { "\x1f" . "\x8b" . "\x08", 0) === 0; } - static function load_filters($feed_id, $owner_uid) { + static function load_filters(int $feed_id, int $owner_uid) { $filters = array(); $feed_id = (int) $feed_id; - $cat_id = (int)Feeds::_cat_of_feed($feed_id); + $cat_id = Feeds::_cat_of($feed_id); - if ($cat_id == 0) + if (!$cat_id) $null_cat_qpart = "cat_id IS NULL OR"; else $null_cat_qpart = ""; @@ -1845,7 +1816,7 @@ class RSSUtils { * @access public * @return mixed The favicon URL, or false if none was found. */ - static function get_favicon_url($url) { + static function get_favicon_url(string $url) { $favicon_url = false; diff --git a/classes/sanitizer.php b/classes/sanitizer.php index 52feb5e28..07766dc16 100644 --- a/classes/sanitizer.php +++ b/classes/sanitizer.php @@ -49,6 +49,10 @@ class Sanitizer { return false; } + private static function is_prefix_https() { + return parse_url(Config::get(Config::SELF_URL_PATH), PHP_URL_SCHEME) == 'https'; + } + public static function sanitize($str, $force_remove_images = false, $owner = false, $site_url = false, $highlight_words = false, $article_id = false) { if (!$owner && isset($_SESSION["uid"])) @@ -60,7 +64,9 @@ class Sanitizer { $doc->loadHTML('<?xml encoding="UTF-8">' . $res); $xpath = new DOMXPath($doc); - $rewrite_base_url = $site_url ? $site_url : get_self_url_prefix(); + // is it a good idea to possibly rewrite urls to our own prefix? + // $rewrite_base_url = $site_url ? $site_url : Config::get_self_url(); + $rewrite_base_url = $site_url ? $site_url : "http://domain.invalid/"; $entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src])'); @@ -125,7 +131,7 @@ class Sanitizer { if (!self::iframe_whitelisted($entry)) { $entry->setAttribute('sandbox', 'allow-scripts'); } else { - if (is_prefix_https()) { + if (self::is_prefix_https()) { $entry->setAttribute("src", str_replace("http://", "https://", $entry->getAttribute("src"))); diff --git a/classes/urlhelper.php b/classes/urlhelper.php index bf2e22a76..55d5d1e6a 100644 --- a/classes/urlhelper.php +++ b/classes/urlhelper.php @@ -1,5 +1,14 @@ <?php class UrlHelper { + static $fetch_last_error; + static $fetch_last_error_code; + static $fetch_last_error_content; + static $fetch_last_content_type; + static $fetch_last_modified; + static $fetch_effective_url; + static $fetch_effective_ip_addr; + static $fetch_curl_used; + static function build_url($parts) { $tmp = $parts['scheme'] . "://" . $parts['host']; @@ -158,23 +167,14 @@ class UrlHelper { 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; - - $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 = ""; + self::$fetch_last_error = false; + self::$fetch_last_error_code = -1; + self::$fetch_last_error_content = ""; + self::$fetch_last_content_type = ""; + self::$fetch_curl_used = false; + self::$fetch_last_modified = ""; + self::$fetch_effective_url = ""; + self::$fetch_effective_ip_addr = ""; if (!is_array($options)) { @@ -219,7 +219,7 @@ class UrlHelper { $url = self::validate($url, true); if (!$url) { - $fetch_last_error = "Requested URL failed extended validation."; + self::$fetch_last_error = "Requested URL failed extended validation."; return false; } @@ -227,13 +227,13 @@ class UrlHelper { $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)"; + self::$fetch_last_error = "URL hostname failed to resolve or resolved to a loopback address ($ip_addr)"; return false; } if (function_exists('curl_init') && !ini_get("open_basedir")) { - $fetch_curl_used = true; + self::$fetch_curl_used = true; $ch = curl_init($url); @@ -306,13 +306,13 @@ class UrlHelper { list ($key, $value) = explode(": ", $header); if (strtolower($key) == "last-modified") { - $fetch_last_modified = $value; + self::$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; + self::$fetch_last_error_code = (int) substr($header, 9, 3); + self::$fetch_last_error = $header; } } @@ -322,39 +322,39 @@ class UrlHelper { } $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $fetch_last_content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + self::$fetch_last_content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); - $fetch_effective_url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); + self::$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."; + if (!self::validate(self::$fetch_effective_url, true)) { + self::$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)); + self::$fetch_effective_ip_addr = gethostbyname(parse_url(self::$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)"; + if (!self::$fetch_effective_ip_addr || strpos(self::$fetch_effective_ip_addr, "127.") === 0) { + self::$fetch_last_error = "URL hostname received after redirection failed to resolve or resolved to a loopback address (".self::$fetch_effective_ip_addr.")"; return false; } - $fetch_last_error_code = $http_code; + self::$fetch_last_error_code = $http_code; - if ($http_code != 200 || $type && strpos($fetch_last_content_type, "$type") === false) { + if ($http_code != 200 || $type && strpos(self::$fetch_last_content_type, "$type") === false) { if (curl_errno($ch) != 0) { - $fetch_last_error .= "; " . curl_errno($ch) . " " . curl_error($ch); + self::$fetch_last_error .= "; " . curl_errno($ch) . " " . curl_error($ch); } - $fetch_last_error_content = $contents; + self::$fetch_last_error_content = $contents; curl_close($ch); return false; } if (!$contents) { - $fetch_last_error = curl_errno($ch) . " " . curl_error($ch); + self::$fetch_last_error = curl_errno($ch) . " " . curl_error($ch); curl_close($ch); return false; } @@ -372,7 +372,7 @@ class UrlHelper { return $contents; } else { - $fetch_curl_used = false; + self::$fetch_curl_used = false; if ($login && $pass){ $url_parts = array(); @@ -417,18 +417,18 @@ class UrlHelper { $old_error = error_get_last(); - $fetch_effective_url = self::resolve_redirects($url, $timeout ? $timeout : Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT)); + self::$fetch_effective_url = self::resolve_redirects($url, $timeout ? $timeout : Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT)); - if (!self::validate($fetch_effective_url, true)) { - $fetch_last_error = "URL received after redirection failed extended validation."; + if (!self::validate(self::$fetch_effective_url, true)) { + self::$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)); + self::$fetch_effective_ip_addr = gethostbyname(parse_url(self::$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)"; + if (!self::$fetch_effective_ip_addr || strpos(self::$fetch_effective_ip_addr, "127.") === 0) { + self::$fetch_last_error = "URL hostname received after redirection failed to resolve or resolved to a loopback address (".self::$fetch_effective_ip_addr.")"; return false; } @@ -442,30 +442,30 @@ class UrlHelper { $key = strtolower($key); if ($key == 'content-type') { - $fetch_last_content_type = $value; + self::$fetch_last_content_type = $value; // don't abort here b/c there might be more than one // e.g. if we were being redirected -- last one is the right one } else if ($key == 'last-modified') { - $fetch_last_modified = $value; + self::$fetch_last_modified = $value; } else if ($key == 'location') { - $fetch_effective_url = $value; + self::$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; + self::$fetch_last_error_code = (int) substr($header, 9, 3); + self::$fetch_last_error = $header; } } - if ($fetch_last_error_code != 200) { + if (self::$fetch_last_error_code != 200) { $error = error_get_last(); - if ($error['message'] != $old_error['message']) { - $fetch_last_error .= "; " . $error["message"]; + if (($error['message'] ?? '') != ($old_error['message'] ?? '')) { + self::$fetch_last_error .= "; " . $error["message"]; } - $fetch_last_error_content = $data; + self::$fetch_last_error_content = $data; return false; } diff --git a/classes/userhelper.php b/classes/userhelper.php index ca673cf58..ce26e6c71 100644 --- a/classes/userhelper.php +++ b/classes/userhelper.php @@ -1,6 +1,22 @@ <?php +use OTPHP\TOTP; + class UserHelper { + const HASH_ALGO_SSHA512 = 'SSHA-512'; + const HASH_ALGO_SSHA256 = 'SSHA-256'; + const HASH_ALGO_MODE2 = 'MODE2'; + const HASH_ALGO_SHA1X = 'SHA1X'; + const HASH_ALGO_SHA1 = 'SHA1'; + + const HASH_ALGOS = [ + self::HASH_ALGO_SSHA512, + self::HASH_ALGO_SSHA256, + self::HASH_ALGO_MODE2, + self::HASH_ALGO_SHA1X, + self::HASH_ALGO_SHA1 + ]; + static function authenticate(string $login = null, string $password = null, bool $check_only = false, string $service = null) { if (!Config::get(Config::SINGLE_USER_MODE)) { $user_id = false; @@ -23,27 +39,24 @@ class UserHelper { session_regenerate_id(true); - $_SESSION["uid"] = $user_id; - $_SESSION["auth_module"] = $auth_module; + $user = ORM::for_table('ttrss_users')->find_one($user_id); - $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)); + if ($user) { + $_SESSION["uid"] = $user_id; + $_SESSION["auth_module"] = $auth_module; + $_SESSION["name"] = $user->login; + $_SESSION["access_level"] = $user->access_level; + $_SESSION["csrf_token"] = bin2hex(get_random_bytes(16)); + $_SESSION["ip_address"] = UserHelper::get_user_ip(); + $_SESSION["pwd_hash"] = $user->pwd_hash; - $usth = $pdo->prepare("UPDATE ttrss_users SET last_login = NOW() WHERE id = ?"); - $usth->execute([$user_id]); + $user->last_login = Db::NOW(); + $user->save(); - $_SESSION["ip_address"] = UserHelper::get_user_ip(); - $_SESSION["user_agent"] = sha1($_SERVER['HTTP_USER_AGENT']); - $_SESSION["pwd_hash"] = $row["pwd_hash"]; + return true; + } - return true; + return false; } if ($login && $password && !$user_id && !$check_only) @@ -75,7 +88,7 @@ class UserHelper { if (!$pluginhost) $pluginhost = PluginHost::getInstance(); - if ($owner_uid && SCHEMA_VERSION >= 100 && empty($_SESSION["safe_mode"])) { + if ($owner_uid && Config::get_schema_version() >= 100 && empty($_SESSION["safe_mode"])) { $plugins = get_pref(Prefs::_ENABLED_PLUGINS, $owner_uid); $pluginhost->load((string)$plugins, PluginHost::KIND_USER, $owner_uid); @@ -117,8 +130,9 @@ class UserHelper { } else { /* bump login timestamp */ - $sth = $pdo->prepare("UPDATE ttrss_users SET last_login = NOW() WHERE id = ?"); - $sth->execute([$_SESSION['uid']]); + $user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]); + $user->last_login = Db::NOW(); + $user->save(); $_SESSION["last_login_update"] = time(); } @@ -146,20 +160,29 @@ class UserHelper { if (isset($_SERVER[$hdr])) return $_SERVER[$hdr]; } - } - static function find_user_by_login(string $login) { - $pdo = Db::pdo(); + return null; + } - $sth = $pdo->prepare("SELECT id FROM ttrss_users WHERE - LOWER(login) = LOWER(?)"); - $sth->execute([$login]); + static function get_login_by_id(int $id) { + $user = ORM::for_table('ttrss_users') + ->find_one($id); - if ($row = $sth->fetch()) { - return $row["id"]; - } + if ($user) + return $user->login; + else + return null; + } - return false; + static function find_user_by_login(string $login) { + $user = ORM::for_table('ttrss_users') + ->where('login', $login) + ->find_one(); + + if ($user) + return $user->id; + else + return null; } static function logout() { @@ -173,34 +196,167 @@ class UserHelper { session_commit(); } - static function reset_password($uid, $format_output = false) { + static function get_salt() { + return substr(bin2hex(get_random_bytes(125)), 0, 250); + } - $pdo = Db::pdo(); + static function reset_password($uid, $format_output = false, $new_password = "") { - $sth = $pdo->prepare("SELECT login FROM ttrss_users WHERE id = ?"); - $sth->execute([$uid]); + $user = ORM::for_table('ttrss_users')->find_one($uid); + $message = ""; - if ($row = $sth->fetch()) { + if ($user) { - $login = $row["login"]; + $login = $user->login; - $new_salt = substr(bin2hex(get_random_bytes(125)), 0, 250); - $tmp_user_pwd = make_password(); + $new_salt = self::get_salt(); + $tmp_user_pwd = $new_password ? $new_password : make_password(); - $pwd_hash = encrypt_password($tmp_user_pwd, $new_salt, true); + $pwd_hash = self::hash_password($tmp_user_pwd, $new_salt, self::HASH_ALGOS[0]); - $sth = $pdo->prepare("UPDATE ttrss_users - SET pwd_hash = ?, salt = ?, otp_enabled = false - WHERE id = ?"); - $sth->execute([$pwd_hash, $new_salt, $uid]); + $user->pwd_hash = $pwd_hash; + $user->salt = $new_salt; + $user->save(); $message = T_sprintf("Changed password of user %s to %s", "<strong>$login</strong>", "<strong>$tmp_user_pwd</strong>"); + } else { + $message = __("User not found"); + } - if ($format_output) - print_notice($message); - else - print $message; + if ($format_output) + print_notice($message); + else + print $message; + } + static function check_otp(int $owner_uid, int $otp_check) : bool { + $otp = TOTP::create(self::get_otp_secret($owner_uid, true)); + + return $otp->now() == $otp_check; + } + + static function disable_otp(int $owner_uid) : bool { + $user = ORM::for_table('ttrss_users')->find_one($owner_uid); + + if ($user) { + $user->otp_enabled = false; + + // force new OTP secret when next enabled + if (Config::get_schema_version() >= 143) { + $user->otp_secret = null; + } + + $user->save(); + + return true; + } else { + return false; } } + + static function enable_otp(int $owner_uid, int $otp_check) : bool { + $secret = self::get_otp_secret($owner_uid); + + if ($secret) { + $otp = TOTP::create($secret); + $user = ORM::for_table('ttrss_users')->find_one($owner_uid); + + if ($otp->now() == $otp_check && $user) { + + $user->otp_enabled = true; + $user->save(); + + return true; + } + } + return false; + } + + + static function is_otp_enabled(int $owner_uid) : bool { + $user = ORM::for_table('ttrss_users')->find_one($owner_uid); + + if ($user) { + return $user->otp_enabled; + } else { + return false; + } + } + + static function get_otp_secret(int $owner_uid, bool $show_if_enabled = false) { + $user = ORM::for_table('ttrss_users')->find_one($owner_uid); + + if ($user) { + + $salt_based_secret = mb_substr(sha1($user->salt), 0, 12); + + if (Config::get_schema_version() >= 143) { + $secret = $user->otp_secret; + + if (empty($secret)) { + + /* migrate secret if OTP is already enabled, otherwise make a new one */ + if ($user->otp_enabled) { + $user->otp_secret = $salt_based_secret; + } else { + $user->otp_secret = bin2hex(get_random_bytes(6)); + } + + $user->save(); + + $secret = $user->otp_secret; + } + } else { + $secret = $salt_based_secret; + } + + if (!$user->otp_enabled || $show_if_enabled) { + return \ParagonIE\ConstantTime\Base32::encodeUpperUnpadded($secret); + } + } + + return null; + } + + static function is_default_password() { + $authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]); + + if ($authenticator && + method_exists($authenticator, "check_password") && + $authenticator->check_password($_SESSION["uid"], "password")) { + + return true; + } + return false; + } + + static function hash_password(string $pass, string $salt, string $algo = "") { + + if (!$algo) $algo = self::HASH_ALGOS[0]; + + $pass_hash = ""; + + switch ($algo) { + case self::HASH_ALGO_SHA1: + $pass_hash = sha1($pass); + break; + case self::HASH_ALGO_SHA1X: + $pass_hash = sha1("$salt:$pass"); + break; + case self::HASH_ALGO_MODE2: + case self::HASH_ALGO_SSHA256: + $pass_hash = hash('sha256', $salt . $pass); + break; + case self::HASH_ALGO_SSHA512: + $pass_hash = hash('sha512', $salt . $pass); + break; + default: + user_error("hash_password: unknown hash algo: $algo", E_USER_ERROR); + } + + if ($pass_hash) + return "$algo:$pass_hash"; + else + return false; + } } |