diff options
Diffstat (limited to 'classes')
33 files changed, 1027 insertions, 629 deletions
diff --git a/classes/api.php b/classes/api.php index b17114693..262d5c5bc 100755 --- a/classes/api.php +++ b/classes/api.php @@ -286,7 +286,7 @@ class API extends Handler { $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET $field = $set_to $additional_fields WHERE ref_id IN ($article_qmarks) AND owner_uid = ?"); - $sth->execute(array_merge($article_ids, [$_SESSION['uid']])); + $sth->execute([...$article_ids, $_SESSION['uid']]); $num_updated = $sth->rowCount(); @@ -333,7 +333,7 @@ class API extends Handler { 'published' => self::_param_to_bool($entry->published), 'comments' => $entry->comments, 'author' => $entry->author, - 'updated' => (int) strtotime($entry->updated), + 'updated' => (int) strtotime($entry->updated ?? ''), 'feed_id' => $entry->feed_id, 'attachments' => Article::_get_enclosures($entry->id), 'score' => (int) $entry->score, @@ -544,6 +544,29 @@ class API extends Handler { /* Virtual feeds */ + $vfeeds = PluginHost::getInstance()->get_feeds(-1); + + if (is_array($vfeeds)) { + foreach ($vfeeds as $feed) { + if (!implements_interface($feed['sender'], 'IVirtualFeed')) + continue; + + /** @var IVirtualFeed $feed['sender'] */ + $unread = $feed['sender']->get_unread($feed['id']); + + if ($unread || !$unread_only) { + $row = [ + 'id' => PluginHost::pfeed_to_feed_id($feed['id']), + 'title' => $feed['title'], + 'unread' => $unread, + 'cat_id' => -1, + ]; + + array_push($feeds, $row); + } + } + } + if ($cat_id == -4 || $cat_id == -1) { foreach ([-1, -2, -3, -4, -6, 0] as $i) { $unread = Feeds::_get_counters($i, false, true); @@ -618,7 +641,7 @@ class API extends Handler { 'unread' => (int) $unread, 'has_icon' => $has_icon, 'cat_id' => (int) $feed->cat_id, - 'last_updated' => (int) strtotime($feed->last_updated), + 'last_updated' => (int) strtotime($feed->last_updated ?? ''), 'order_id' => (int) $feed->order_id, ]; @@ -648,7 +671,7 @@ class API extends Handler { ->find_one($feed_id); if ($feed) { - $last_updated = strtotime($feed->last_updated); + $last_updated = strtotime($feed->last_updated ?? ''); $cache_images = self::_param_to_bool($feed->cache_images); if (!$cache_images && time() - $last_updated > 120) { @@ -675,7 +698,50 @@ class API extends Handler { "skip_first_id_check" => $skip_first_id_check ); - $qfh_ret = Feeds::_get_headlines($params); + $qfh_ret = []; + + if (!$is_cat && is_numeric($feed_id) && $feed_id < PLUGIN_FEED_BASE_INDEX && $feed_id > LABEL_BASE_INDEX) { + $pfeed_id = PluginHost::feed_to_pfeed_id($feed_id); + + /** @var IVirtualFeed|false $handler */ + $handler = PluginHost::getInstance()->get_feed_handler($pfeed_id); + + if ($handler) { + $params = array( + "feed" => $feed_id, + "limit" => $limit, + "view_mode" => $view_mode, + "cat_view" => $is_cat, + "search" => $search, + "override_order" => $order, + "offset" => $offset, + "since_id" => 0, + "include_children" => $include_nested, + "check_first_id" => $check_first_id, + "skip_first_id_check" => $skip_first_id_check + ); + + $qfh_ret = $handler->get_headlines($pfeed_id, $params); + } + + } else { + + $params = array( + "feed" => $feed_id, + "limit" => $limit, + "view_mode" => $view_mode, + "cat_view" => $is_cat, + "search" => $search, + "override_order" => $order, + "offset" => $offset, + "since_id" => $since_id, + "include_children" => $include_nested, + "check_first_id" => $check_first_id, + "skip_first_id_check" => $skip_first_id_check + ); + + $qfh_ret = Feeds::_get_headlines($params); + } $result = $qfh_ret[0]; $feed_title = $qfh_ret[1]; @@ -725,7 +791,7 @@ class API extends Handler { "unread" => self::_param_to_bool($line["unread"]), "marked" => self::_param_to_bool($line["marked"]), "published" => self::_param_to_bool($line["published"]), - "updated" => (int)strtotime($line["updated"]), + "updated" => (int)strtotime($line["updated"] ?? ''), "is_updated" => $is_updated, "title" => $line["title"], "link" => $line["link"], @@ -835,7 +901,7 @@ class API extends Handler { } function getFeedTree(): bool { - $include_empty = self::_param_to_bool(clean($_REQUEST['include_empty'])); + $include_empty = self::_param_to_bool($_REQUEST['include_empty'] ?? false); $pf = new Pref_Feeds($_REQUEST); diff --git a/classes/article.php b/classes/article.php index e113ed219..609ddeebe 100755 --- a/classes/article.php +++ b/classes/article.php @@ -177,7 +177,7 @@ class Article extends Handler_Protected { $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET score = ? WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); - $sth->execute(array_merge([$score], $ids, [$_SESSION['uid']])); + $sth->execute([$score, ...$ids, $_SESSION['uid']]); print json_encode(["id" => $ids, "score" => $score]); } @@ -293,6 +293,7 @@ class Article extends Handler_Protected { } /** + * @param int $id article id * @return array{'formatted': string, 'entries': array<int, array<string, mixed>>} */ static function _format_enclosures(int $id, bool $always_display_enclosures, string $article_content, bool $hide_images = false): array { @@ -446,7 +447,7 @@ class Article extends Handler_Protected { $rv = []; - $cache = new DiskCache("images"); + $cache = DiskCache::instance("images"); foreach ($encs as $enc) { $cache_key = sha1($enc->content_url); @@ -507,7 +508,7 @@ class Article extends Handler_Protected { WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); } - $sth->execute(array_merge($ids, [$owner_uid])); + $sth->execute([...$ids, $owner_uid]); } /** @@ -639,7 +640,7 @@ class Article extends Handler_Protected { $article_stream = UrlHelper::rewrite_relative($site_url, $article_stream); } - $cache = new DiskCache("images"); + $cache = DiskCache::instance("images"); if ($article_image && $cache->exists(sha1($article_image))) $article_image = $cache->get_url(sha1($article_image)); diff --git a/classes/cache/adapter.php b/classes/cache/adapter.php new file mode 100644 index 000000000..fecfc7667 --- /dev/null +++ b/classes/cache/adapter.php @@ -0,0 +1,36 @@ +<?php +interface Cache_Adapter { + public function set_dir(string $dir) : void; + public function get_dir(): string; + public function make_dir(): bool; + public function is_writable(?string $filename = null): bool; + public function exists(string $filename): bool; + /** + * @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise + */ + public function get_size(string $filename); + /** + * @return int|false -1 if the file doesn't exist, false if an error occurred, timestamp otherwise + */ + public function get_mtime(string $filename); + /** + * @param mixed $data + * + * @return int|false Bytes written or false if an error occurred. + */ + public function put(string $filename, $data); + public function get(string $filename): ?string; + public function get_full_path(string $filename): string; + public function remove(string $filename) : bool; + /** + * @return false|null|string false if detection failed, null if the file doesn't exist, string mime content type otherwise + */ + public function get_mime_type(string $filename); + /** + * @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent + */ + public function send(string $filename); + + /** Catchall function to expire all subfolders/prefixes in the cache, invoked on the backend */ + public function expire_all(): void; +} diff --git a/classes/cache/local.php b/classes/cache/local.php new file mode 100644 index 000000000..8ac634a3e --- /dev/null +++ b/classes/cache/local.php @@ -0,0 +1,148 @@ +<?php +class Cache_Local implements Cache_Adapter { + private string $dir; + + public function remove(string $filename): bool { + return unlink($this->get_full_path($filename)); + } + + public function get_mtime(string $filename) { + return filemtime($this->get_full_path($filename)); + } + + public function set_dir(string $dir) : void { + $this->dir = Config::get(Config::CACHE_DIR) . "/" . basename(clean($dir)); + + $this->make_dir(); + } + + public function get_dir(): string { + return $this->dir; + } + + public function make_dir(): bool { + if (!is_dir($this->dir)) { + return mkdir($this->dir); + } + return false; + } + + public function is_writable(?string $filename = null): bool { + if ($filename) { + if (file_exists($this->get_full_path($filename))) + return is_writable($this->get_full_path($filename)); + else + return is_writable($this->dir); + } else { + return is_writable($this->dir); + } + } + + public function exists(string $filename): bool { + return file_exists($this->get_full_path($filename)); + } + + /** + * @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise + */ + public function get_size(string $filename) { + if ($this->exists($filename)) + return filesize($this->get_full_path($filename)); + else + return -1; + } + + public function get_full_path(string $filename): string { + return $this->dir . "/" . basename(clean($filename)); + } + + public function get(string $filename): ?string { + if ($this->exists($filename)) + return file_get_contents($this->get_full_path($filename)); + else + return null; + } + + /** + * @param mixed $data + * + * @return int|false Bytes written or false if an error occurred. + */ + public function put(string $filename, $data) { + return file_put_contents($this->get_full_path($filename), $data); + } + + /** + * @return false|null|string false if detection failed, null if the file doesn't exist, string mime content type otherwise + */ + public function get_mime_type(string $filename) { + if ($this->exists($filename)) + return mime_content_type($this->get_full_path($filename)); + else + return null; + } + + /** + * @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent + */ + public function send(string $filename) { + return $this->send_local_file($this->get_full_path($filename)); + } + + public function expire_all(): void { + $dirs = array_filter(glob(Config::get(Config::CACHE_DIR) . "/*"), "is_dir"); + + foreach ($dirs as $cache_dir) { + $num_deleted = 0; + + if (is_writable($cache_dir) && !file_exists("$cache_dir/.no-auto-expiry")) { + $files = glob("$cache_dir/*"); + + if ($files) { + foreach ($files as $file) { + if (time() - filemtime($file) > 86400 * Config::get(Config::CACHE_MAX_DAYS)) { + unlink($file); + + ++$num_deleted; + } + } + } + + Debug::log("Expired $cache_dir: removed $num_deleted files."); + } + } + } + + /** + * this is essentially a wrapper for readfile() which allows plugins to hook + * output with httpd-specific "fast" implementation i.e. X-Sendfile or whatever else + * + * hook function should return true if request was handled (or at least attempted to) + * + * note that this can be called without user context so the plugin to handle this + * should be loaded systemwide in config.php + * + * @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent + */ + private function send_local_file(string $filename) { + if (file_exists($filename)) { + + if (is_writable($filename) && !$this->exists('.no-auto-expiry')) { + touch($filename); + } + + $tmppluginhost = new PluginHost(); + + $tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_SYSTEM); + //$tmppluginhost->load_data(); + + if ($tmppluginhost->run_hooks_until(PluginHost::HOOK_SEND_LOCAL_FILE, true, $filename)) + return true; + + return readfile($filename); + } else { + return false; + } + } + +} diff --git a/classes/config.php b/classes/config.php index cc089b7ba..ab31bc860 100644 --- a/classes/config.php +++ b/classes/config.php @@ -6,7 +6,7 @@ class Config { const T_STRING = 2; const T_INT = 3; - const SCHEMA_VERSION = 146; + const SCHEMA_VERSION = 147; /** override default values, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX: * @@ -189,6 +189,9 @@ class Config { /** http user agent (changing this is not recommended) */ const HTTP_USER_AGENT = "HTTP_USER_AGENT"; + /** delay updates for this feed if received HTTP 429 (Too Many Requests) for this amount of seconds (base value, actual delay is base...base*2) */ + const HTTP_429_THROTTLE_INTERVAL = "HTTP_429_THROTTLE_INTERVAL"; + /** default values for all global configuration options */ private const _DEFAULTS = [ Config::DB_TYPE => [ "pgsql", Config::T_STRING ], @@ -245,6 +248,7 @@ class Config { Config::AUTH_MIN_INTERVAL => [ 5, Config::T_INT ], Config::HTTP_USER_AGENT => [ 'Tiny Tiny RSS/%s (https://tt-rss.org/)', Config::T_STRING ], + Config::HTTP_429_THROTTLE_INTERVAL => [ 3600, Config::T_INT ], ]; /** @var Config|null */ @@ -444,13 +448,7 @@ class Config { /** 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; - } + return preg_replace("#/*$#", "", self::get(Config::SELF_URL_PATH)); } static function is_server_https() : bool { @@ -461,9 +459,11 @@ class Config { /** 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 = $proto . '://' . $_SERVER["HTTP_HOST"] . parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH); $self_url_path = preg_replace("/\w+\.php(\?.*$)?$/", "", $self_url_path); + #$self_url_path = preg_replace("/(\?.*$)?$/", "", $self_url_path); if (substr($self_url_path, -1) === "/") { return substr($self_url_path, 0, -1); @@ -518,8 +518,8 @@ class Config { 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 (version_compare(PHP_VERSION, '7.4.0', '<')) { + array_push($errors, "PHP version 7.4.0 or newer required. You're using " . PHP_VERSION . "."); } if (!class_exists("UConverter")) { diff --git a/classes/counters.php b/classes/counters.php index 8756b5acf..48b7264dd 100644 --- a/classes/counters.php +++ b/classes/counters.php @@ -5,13 +5,13 @@ class Counters { * @return array<int, array<string, int|string>> */ static function get_all(): array { - return array_merge( - self::get_global(), - self::get_virt(), - self::get_labels(), - self::get_feeds(), - self::get_cats() - ); + return [ + ...self::get_global(), + ...self::get_virt(), + ...self::get_labels(), + ...self::get_feeds(), + ...self::get_cats(), + ]; } /** @@ -20,13 +20,13 @@ class Counters { * @return array<int, array<string, int|string>> */ static function get_conditional(array $feed_ids = null, array $label_ids = null): array { - return array_merge( - self::get_global(), - self::get_virt(), - self::get_labels($label_ids), - self::get_feeds($feed_ids), - self::get_cats(is_array($feed_ids) ? Feeds::_cats_of($feed_ids, $_SESSION["uid"], true) : null) - ); + return [ + ...self::get_global(), + ...self::get_virt(), + ...self::get_labels($label_ids), + ...self::get_feeds($feed_ids), + ...self::get_cats(is_array($feed_ids) ? Feeds::_cats_of($feed_ids, $_SESSION["uid"], true) : null) + ]; } /** @@ -93,11 +93,7 @@ class Counters { ue.feed_id = f.id AND ue.owner_uid = ?"); - $sth->execute(array_merge( - [$_SESSION['uid']], - $cat_ids, - [$_SESSION['uid']] - )); + $sth->execute([$_SESSION['uid'], ...$cat_ids, $_SESSION['uid']]); } else { $sth = $pdo->prepare("SELECT fc.id, @@ -170,7 +166,7 @@ class Counters { WHERE f.id = ue.feed_id AND ue.owner_uid = ? AND f.id IN ($feed_ids_qmarks) GROUP BY f.id"); - $sth->execute(array_merge([$_SESSION['uid']], $feed_ids)); + $sth->execute([$_SESSION['uid'], ...$feed_ids]); } else { $sth = $pdo->prepare("SELECT f.id, f.title, @@ -191,13 +187,13 @@ class Counters { $last_updated = TimeHelper::make_local_datetime($line['last_updated'], false); if (Feeds::_has_icon($id)) { - $has_img = filemtime(Feeds::_get_icon_file($id)); + $ts = filemtime(Feeds::_get_icon_file($id)); } else { - $has_img = false; + $ts = 0; } // hide default un-updated timestamp i.e. 1970-01-01 (?) -fox - if ((int)date('Y') - (int)date('Y', strtotime($line['last_updated'])) > 2) + if ((int)date('Y') - (int)date('Y', strtotime($line['last_updated'] ?? '')) > 2) $last_updated = ''; $cv = [ @@ -205,7 +201,7 @@ class Counters { "updated" => $last_updated, "counter" => (int) $line["count"], "markedcounter" => (int) $line["count_marked"], - "has_img" => (int) $has_img + "ts" => (int) $ts ]; $cv["error"] = $line["last_error"]; @@ -319,7 +315,7 @@ class Counters { LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = ? WHERE ttrss_labels2.owner_uid = ? AND ttrss_labels2.id IN ($label_ids_qmarks) GROUP BY ttrss_labels2.id, ttrss_labels2.caption"); - $sth->execute(array_merge([$_SESSION["uid"], $_SESSION["uid"]], $label_ids)); + $sth->execute([$_SESSION["uid"], $_SESSION["uid"], ...$label_ids]); } else { $sth = $pdo->prepare("SELECT id, caption, diff --git a/classes/db.php b/classes/db.php index 2cc89f5ba..4331b662e 100755 --- a/classes/db.php +++ b/classes/db.php @@ -17,8 +17,12 @@ class Db } } - static function NOW(): string { - return date("Y-m-d H:i:s", time()); + /** + * @param int $delta adjust generated timestamp by this value in seconds (either positive or negative) + * @return string + */ + static function NOW(int $delta = 0): string { + return date("Y-m-d H:i:s", time() + $delta); } private function __clone() { diff --git a/classes/db/migrations.php b/classes/db/migrations.php index aecd9186c..d63736987 100644 --- a/classes/db/migrations.php +++ b/classes/db/migrations.php @@ -1,33 +1,15 @@ <?php class Db_Migrations { - // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ - /** @var string */ - private $base_filename = "schema.sql"; - - /** @var string */ - private $base_path; - - /** @var string */ - private $migrations_path; - - /** @var string */ - private $migrations_table; - - /** @var bool */ - private $base_is_latest; - - /** @var PDO */ - private $pdo; - - /** @var int */ - private $cached_version = 0; - - /** @var int */ - private $cached_max_version = 0; - - /** @var int */ - private $max_version_override; + private string $base_filename = "schema.sql"; + private string $base_path; + private string $migrations_path; + private string $migrations_table; + private bool $base_is_latest; + private PDO $pdo; + private int $cached_version = 0; + private int $cached_max_version = 0; + private int $max_version_override; function __construct() { $this->pdo = Db::pdo(); @@ -35,7 +17,7 @@ class Db_Migrations { function initialize_for_plugin(Plugin $plugin, bool $base_is_latest = true, string $schema_suffix = "sql"): void { $plugin_dir = PluginHost::getInstance()->get_plugin_dir($plugin); - $this->initialize($plugin_dir . "/${schema_suffix}", + $this->initialize("{$plugin_dir}/{$schema_suffix}", strtolower("ttrss_migrations_plugin_" . get_class($plugin)), $base_is_latest); } @@ -49,7 +31,7 @@ class Db_Migrations { } private function set_version(int $version): void { - Debug::log("Updating table {$this->migrations_table} with version ${version}...", Debug::LOG_EXTENDED); + Debug::log("Updating table {$this->migrations_table} with version {$version}...", Debug::LOG_EXTENDED); $sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}"); @@ -188,7 +170,7 @@ class Db_Migrations { try { $this->migrate_to($i); } catch (PDOException $e) { - user_error("Failed to apply migration ${i} for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING); + user_error("Failed to apply migration {$i} for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING); return false; //throw $e; } @@ -202,22 +184,19 @@ class Db_Migrations { */ private function get_lines(int $version) : array { if ($version > 0) - $filename = "{$this->migrations_path}/${version}.sql"; + $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; - }); + $lines = array_filter(preg_split("/[\r\n]/", file_get_contents($filename)), + fn($line) => strlen(trim($line)) > 0 && strpos($line, "--") !== 0); - return array_filter(explode(";", implode("", $lines)), function ($line) { - return strlen(trim($line)) > 0 && !in_array(strtolower($line), ["begin", "commit"]); - }); + return array_filter(explode(";", implode("", $lines)), + fn($line) => strlen(trim($line)) > 0 && !in_array(strtolower($line), ["begin", "commit"])); } else { - user_error("Requested schema file ${filename} not found.", E_USER_ERROR); + user_error("Requested schema file {$filename} not found.", E_USER_ERROR); return []; } } diff --git a/classes/debug.php b/classes/debug.php index fbdf260e0..40fa27377 100644 --- a/classes/debug.php +++ b/classes/debug.php @@ -12,44 +12,31 @@ class Debug { Debug::LOG_EXTENDED, ]; - // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ /** * @deprecated - * @var int */ - public static $LOG_DISABLED = self::LOG_DISABLED; + public static int $LOG_DISABLED = self::LOG_DISABLED; /** * @deprecated - * @var int */ - public static $LOG_NORMAL = self::LOG_NORMAL; + public static int $LOG_NORMAL = self::LOG_NORMAL; /** * @deprecated - * @var int */ - public static $LOG_VERBOSE = self::LOG_VERBOSE; + public static int $LOG_VERBOSE = self::LOG_VERBOSE; /** * @deprecated - * @var int */ - public static $LOG_EXTENDED = self::LOG_EXTENDED; + public static int $LOG_EXTENDED = self::LOG_EXTENDED; - /** @var bool */ - private static $enabled = false; + private static bool $enabled = false; + private static bool $quiet = false; + private static ?string $logfile = null; - /** @var bool */ - private static $quiet = false; - - /** @var string|null */ - private static $logfile = null; - - /** - * @var int Debug::LOG_* - */ - private static $loglevel = self::LOG_NORMAL; + private static int $loglevel = self::LOG_NORMAL; public static function set_logfile(string $logfile): void { self::$logfile = $logfile; @@ -68,7 +55,7 @@ class Debug { } /** - * @param int $level Debug::LOG_* + * @param Debug::LOG_* $level */ public static function set_loglevel(int $level): void { self::$loglevel = $level; @@ -82,7 +69,21 @@ class Debug { } /** - * @param int $level Debug::LOG_* + * @param int $level integer loglevel value + * @return Debug::LOG_* if valid, warn and return LOG_DISABLED otherwise + */ + public static function map_loglevel(int $level) : int { + if (in_array($level, self::ALL_LOG_LEVELS)) { + /** @phpstan-ignore-next-line */ + return $level; + } else { + user_error("Passed invalid debug log level: $level", E_USER_WARNING); + return self::LOG_DISABLED; + } + } + + /** + * @param Debug::LOG_* $level log level */ public static function log(string $message, int $level = Debug::LOG_NORMAL): bool { diff --git a/classes/digest.php b/classes/digest.php index 15203166b..b19c37c5f 100644 --- a/classes/digest.php +++ b/classes/digest.php @@ -22,7 +22,7 @@ class Digest while ($line = $res->fetch()) { if (get_pref(Prefs::DIGEST_ENABLE, $line['id'])) { - $preferred_ts = strtotime(get_pref(Prefs::DIGEST_PREFERRED_TIME, $line['id'])); + $preferred_ts = strtotime(get_pref(Prefs::DIGEST_PREFERRED_TIME, $line['id']) ?? ''); // try to send digests within 2 hours of preferred time if ($preferred_ts && time() >= $preferred_ts && @@ -98,11 +98,11 @@ class Digest $tpl->setVariable('CUR_DATE', date('Y/m/d', $local_ts)); $tpl->setVariable('CUR_TIME', date('G:i', $local_ts)); - $tpl->setVariable('TTRSS_HOST', Config::get(Config::SELF_URL_PATH)); + $tpl->setVariable('TTRSS_HOST', Config::get_self_url()); $tpl_t->setVariable('CUR_DATE', date('Y/m/d', $local_ts)); $tpl_t->setVariable('CUR_TIME', date('G:i', $local_ts)); - $tpl_t->setVariable('TTRSS_HOST', Config::get(Config::SELF_URL_PATH)); + $tpl_t->setVariable('TTRSS_HOST', Config::get_self_url()); $affected_ids = array(); @@ -163,9 +163,7 @@ class Digest $article_labels_formatted = ""; if (is_array($article_labels) && count($article_labels) > 0) { - $article_labels_formatted = implode(", ", array_map(function($a) { - return $a[1]; - }, $article_labels)); + $article_labels_formatted = implode(", ", array_map(fn($a) => $a[1], $article_labels)); } $tpl->setVariable('FEED_TITLE', $line["feed_title"]); diff --git a/classes/diskcache.php b/classes/diskcache.php index 34bba25f1..2a3f8c8d7 100644 --- a/classes/diskcache.php +++ b/classes/diskcache.php @@ -1,15 +1,17 @@ <?php -class DiskCache { - // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ - /** @var string */ - private $dir; +class DiskCache implements Cache_Adapter { + /** @var Cache_Adapter $adapter */ + private $adapter; + + /** @var array<string, DiskCache> $instances */ + private static $instances = []; /** * https://stackoverflow.com/a/53662733 * * @var array<string, string> */ - private $mimeMap = [ + private array $mimeMap = [ 'video/3gpp2' => '3g2', 'video/3gp' => '3gp', 'video/3gpp' => '3gp', @@ -196,48 +198,60 @@ class DiskCache { 'text/x-scriptzsh' => 'zsh' ]; + public static function instance(string $dir) : DiskCache { + if ((self::$instances[$dir] ?? null) == null) + self::$instances[$dir] = new self($dir); + + return self::$instances[$dir]; + } + public function __construct(string $dir) { - $this->dir = Config::get(Config::CACHE_DIR) . "/" . basename(clean($dir)); + foreach (PluginHost::getInstance()->get_plugins() as $n => $p) { + if (implements_interface($p, "Cache_Adapter")) { + + /** @var Cache_Adapter $p */ + $this->adapter = clone $p; // we need separate object instances for separate directories + $this->adapter->set_dir($dir); + return; + } + } + + $this->adapter = new Cache_Local(); + $this->adapter->set_dir($dir); } - public function get_dir(): string { - return $this->dir; + public function remove(string $filename): bool { + return $this->adapter->remove($filename); + } + + public function set_dir(string $dir) : void { + $this->adapter->set_dir($dir); + } + + /** + * @return int|false -1 if the file doesn't exist, false if an error occurred, timestamp otherwise + */ + public function get_mtime(string $filename) { + return $this->adapter->get_mtime(basename($filename)); } public function make_dir(): bool { - if (!is_dir($this->dir)) { - return mkdir($this->dir); - } - return false; + return $this->adapter->make_dir(); } public function is_writable(?string $filename = null): bool { - if ($filename) { - if (file_exists($this->get_full_path($filename))) - return is_writable($this->get_full_path($filename)); - else - return is_writable($this->dir); - } else { - return is_writable($this->dir); - } + return $this->adapter->is_writable(basename($filename)); } public function exists(string $filename): bool { - return file_exists($this->get_full_path($filename)); + return $this->adapter->exists(basename($filename)); } /** * @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise */ public function get_size(string $filename) { - if ($this->exists($filename)) - return filesize($this->get_full_path($filename)); - else - return -1; - } - - public function get_full_path(string $filename): string { - return $this->dir . "/" . basename(clean($filename)); + return $this->adapter->get_size(basename($filename)); } /** @@ -246,11 +260,27 @@ class DiskCache { * @return int|false Bytes written or false if an error occurred. */ public function put(string $filename, $data) { - return file_put_contents($this->get_full_path($filename), $data); + return $this->adapter->put(basename($filename), $data); } + /** @deprecated we can't assume cached files are local, and other storages + * might not support this operation (object metadata may be immutable) */ public function touch(string $filename): bool { - return touch($this->get_full_path($filename)); + user_error("DiskCache: called unsupported method touch() for $filename", E_USER_DEPRECATED); + + return false; + } + + public function get(string $filename): ?string { + return $this->adapter->get(basename($filename)); + } + + public function expire_all(): void { + $this->adapter->expire_all(); + } + + public function get_dir(): string { + return $this->adapter->get_dir(); } /** Downloads $url to cache as $local_filename if its missing (unless $force-ed) @@ -273,48 +303,80 @@ class DiskCache { return false; } - public function get(string $filename): ?string { - if ($this->exists($filename)) - return file_get_contents($this->get_full_path($filename)); - else - return null; - } + public function send(string $filename) { + $filename = basename($filename); - /** - * @return false|null|string false if detection failed, null if the file doesn't exist, string mime content type otherwise - */ - public function get_mime_type(string $filename) { - if ($this->exists($filename)) - return mime_content_type($this->get_full_path($filename)); - else - return null; - } + if (!$this->exists($filename)) { + header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); + echo "File not found."; + return false; + } + + $file_mtime = $this->get_mtime($filename); + $gmt_modified = gmdate("D, d M Y H:i:s", (int)$file_mtime) . " GMT"; + + if (($_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '') == $gmt_modified || ($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') == $file_mtime) { + header('HTTP/1.1 304 Not Modified'); + return false; + } - public function get_fake_extension(string $filename): string { $mimetype = $this->get_mime_type($filename); - if ($mimetype) - return isset($this->mimeMap[$mimetype]) ? $this->mimeMap[$mimetype] : ""; - else - return ""; - } + if ($mimetype == "application/octet-stream") + $mimetype = "video/mp4"; + + # block SVG because of possible embedded javascript (.....) + $mimetype_blacklist = [ "image/svg+xml" ]; + + /* only serve video and images */ + if (!preg_match("/(image|audio|video)\//", (string)$mimetype) || in_array($mimetype, $mimetype_blacklist)) { + http_response_code(400); + header("Content-type: text/plain"); + + print "Stored file has disallowed content type ($mimetype)"; + return false; + } - /** - * @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent - */ - public function send(string $filename) { $fake_extension = $this->get_fake_extension($filename); if ($fake_extension) $fake_extension = ".$fake_extension"; - header("Content-Disposition: inline; filename=\"${filename}${fake_extension}\""); + header("Content-Disposition: inline; filename=\"{$filename}{$fake_extension}\""); + header("Content-type: $mimetype"); - return $this->send_local_file($this->get_full_path($filename)); + $stamp_expires = gmdate("D, d M Y H:i:s", + (int)$this->get_mtime($filename) + 86400 * Config::get(Config::CACHE_MAX_DAYS)) . " GMT"; + + header("Expires: $stamp_expires", true); + header("Last-Modified: $gmt_modified", true); + header("Cache-Control: no-cache"); + header("ETag: $file_mtime"); + + header_remove("Pragma"); + + return $this->adapter->send($filename); + } + + public function get_full_path(string $filename): string { + return $this->adapter->get_full_path(basename($filename)); + } + + public function get_mime_type(string $filename) { + return $this->adapter->get_mime_type(basename($filename)); + } + + public function get_fake_extension(string $filename): string { + $mimetype = $this->adapter->get_mime_type(basename($filename)); + + if ($mimetype) + return isset($this->mimeMap[$mimetype]) ? $this->mimeMap[$mimetype] : ""; + else + return ""; } public function get_url(string $filename): string { - return Config::get_self_url() . "/public.php?op=cached&file=" . basename($this->dir) . "/" . basename($filename); + return Config::get_self_url() . "/public.php?op=cached&file=" . basename($this->adapter->get_dir()) . "/" . basename($filename); } // check for locally cached (media) URLs and rewrite to local versions @@ -328,7 +390,7 @@ class DiskCache { $doc = new DOMDocument(); if (@$doc->loadHTML('<?xml encoding="UTF-8">' . $res)) { $xpath = new DOMXPath($doc); - $cache = new DiskCache("images"); + $cache = DiskCache::instance("images"); $entries = $xpath->query('(//img[@src]|//source[@src|@srcset]|//video[@poster|@src])'); @@ -377,84 +439,4 @@ class DiskCache { } return $res; } - - static function expire(): void { - $dirs = array_filter(glob(Config::get(Config::CACHE_DIR) . "/*"), "is_dir"); - - foreach ($dirs as $cache_dir) { - $num_deleted = 0; - - if (is_writable($cache_dir) && !file_exists("$cache_dir/.no-auto-expiry")) { - $files = glob("$cache_dir/*"); - - if ($files) { - foreach ($files as $file) { - if (time() - filemtime($file) > 86400*Config::get(Config::CACHE_MAX_DAYS)) { - unlink($file); - - ++$num_deleted; - } - } - } - - Debug::log("Expired $cache_dir: removed $num_deleted files."); - } - } - } - - /* */ - /** - * this is essentially a wrapper for readfile() which allows plugins to hook - * output with httpd-specific "fast" implementation i.e. X-Sendfile or whatever else - * - * hook function should return true if request was handled (or at least attempted to) - * - * note that this can be called without user context so the plugin to handle this - * should be loaded systemwide in config.php - * - * @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent - */ - function send_local_file(string $filename) { - if (file_exists($filename)) { - - if (is_writable($filename)) touch($filename); - - $mimetype = mime_content_type($filename); - - // this is hardly ideal but 1) only media is cached in images/ and 2) seemingly only mp4 - // video files are detected as octet-stream by mime_content_type() - - if ($mimetype == "application/octet-stream") - $mimetype = "video/mp4"; - - # block SVG because of possible embedded javascript (.....) - $mimetype_blacklist = [ "image/svg+xml" ]; - - /* only serve video and images */ - if (!preg_match("/(image|audio|video)\//", (string)$mimetype) || in_array($mimetype, $mimetype_blacklist)) { - http_response_code(400); - header("Content-type: text/plain"); - - print "Stored file has disallowed content type ($mimetype)"; - return false; - } - - $tmppluginhost = new PluginHost(); - - $tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_SYSTEM); - //$tmppluginhost->load_data(); - - if ($tmppluginhost->run_hooks_until(PluginHost::HOOK_SEND_LOCAL_FILE, true, $filename)) - return true; - - header("Content-type: $mimetype"); - - $stamp = gmdate("D, d M Y H:i:s", (int)filemtime($filename)) . " GMT"; - header("Last-Modified: $stamp", true); - - return readfile($filename); - } else { - return false; - } - } } diff --git a/classes/feeditem/atom.php b/classes/feeditem/atom.php index cac6d8c54..f6c96f959 100755 --- a/classes/feeditem/atom.php +++ b/classes/feeditem/atom.php @@ -19,19 +19,19 @@ class FeedItem_Atom extends FeedItem_Common { $updated = $this->elem->getElementsByTagName("updated")->item(0); if ($updated) { - return strtotime($updated->nodeValue); + return strtotime($updated->nodeValue ?? ''); } $published = $this->elem->getElementsByTagName("published")->item(0); if ($published) { - return strtotime($published->nodeValue); + return strtotime($published->nodeValue ?? ''); } $date = $this->xpath->query("dc:date", $this->elem)->item(0); if ($date) { - return strtotime($date->nodeValue); + return strtotime($date->nodeValue ?? ''); } // consistent with strtotime failing to parse @@ -43,7 +43,8 @@ class FeedItem_Atom extends FeedItem_Common { $links = $this->elem->getElementsByTagName("link"); foreach ($links as $link) { - if ($link && $link->hasAttribute("href") && + /** @phpstan-ignore-next-line */ + if ($link->hasAttribute("href") && (!$link->hasAttribute("rel") || $link->getAttribute("rel") == "alternate" || $link->getAttribute("rel") == "standout")) { @@ -180,7 +181,8 @@ class FeedItem_Atom extends FeedItem_Common { $encs = []; foreach ($links as $link) { - if ($link && $link->hasAttribute("href") && $link->hasAttribute("rel")) { + /** @phpstan-ignore-next-line */ + if ($link->hasAttribute("href") && $link->hasAttribute("rel")) { $base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $link); if ($link->getAttribute("rel") == "enclosure") { @@ -199,7 +201,7 @@ class FeedItem_Atom extends FeedItem_Common { } } - $encs = array_merge($encs, parent::get_enclosures()); + array_push($encs, ...parent::get_enclosures()); return $encs; } diff --git a/classes/feeditem/common.php b/classes/feeditem/common.php index 6a9be8aca..fde481179 100755 --- a/classes/feeditem/common.php +++ b/classes/feeditem/common.php @@ -189,7 +189,7 @@ abstract class FeedItem_Common extends FeedItem { $tmp = []; foreach ($cats as $rawcat) { - $tmp = array_merge($tmp, explode(",", $rawcat)); + array_push($tmp, ...explode(",", $rawcat)); } $tmp = array_map(function($srccat) { diff --git a/classes/feeditem/rss.php b/classes/feeditem/rss.php index 7017d04e9..e07fd1d06 100755 --- a/classes/feeditem/rss.php +++ b/classes/feeditem/rss.php @@ -17,13 +17,13 @@ class FeedItem_RSS extends FeedItem_Common { $pubDate = $this->elem->getElementsByTagName("pubDate")->item(0); if ($pubDate) { - return strtotime($pubDate->nodeValue); + return strtotime($pubDate->nodeValue ?? ''); } $date = $this->xpath->query("dc:date", $this->elem)->item(0); if ($date) { - return strtotime($date->nodeValue); + return strtotime($date->nodeValue ?? ''); } // consistent with strtotime failing to parse @@ -153,7 +153,7 @@ class FeedItem_RSS extends FeedItem_Common { array_push($encs, $enc); } - $encs = array_merge($encs, parent::get_enclosures()); + array_push($encs, ...parent::get_enclosures()); return $encs; } diff --git a/classes/feedparser.php b/classes/feedparser.php index fc2489e2d..4b9c63f56 100644 --- a/classes/feedparser.php +++ b/classes/feedparser.php @@ -43,10 +43,8 @@ class FeedParser { foreach (libxml_get_errors() as $error) { if ($error->level == LIBXML_ERR_FATAL) { // currently only the first error is reported - if (!isset($this->error)) { - $this->error = $this->format_error($error); - } - $this->libxml_errors[] = $this->format_error($error); + $this->error ??= Errors::format_libxml_error($error); + $this->libxml_errors[] = Errors::format_libxml_error($error); } } } @@ -87,9 +85,7 @@ class FeedParser { $this->type = $this::FEED_ATOM; break; default: - if (!isset($this->error)) { - $this->error = "Unknown/unsupported feed type"; - } + $this->error ??= "Unknown/unsupported feed type"; return; } } @@ -186,9 +182,7 @@ class FeedParser { if ($this->link) $this->link = trim($this->link); } else { - if (!isset($this->error)) { - $this->error = "Unknown/unsupported feed type"; - } + $this->error ??= "Unknown/unsupported feed type"; return; } } diff --git a/classes/feeds.php b/classes/feeds.php index a06486883..de2f750cd 100755 --- a/classes/feeds.php +++ b/classes/feeds.php @@ -122,7 +122,7 @@ class Feeds extends Handler_Protected { $feed_title = $qfh_ret[1]; $feed_site_url = $qfh_ret[2]; $last_error = $qfh_ret[3]; - $last_updated = strpos($qfh_ret[4], '1970-') === false ? + $last_updated = strpos($qfh_ret[4] ?? "", '1970-') === false ? TimeHelper::make_local_datetime($qfh_ret[4], false) : __("Never"); $highlight_words = $qfh_ret[5]; $reply['first_id'] = $qfh_ret[6]; @@ -248,11 +248,12 @@ class Feeds extends Handler_Protected { function ($result, $plugin) use (&$line, &$button_doc) { if ($result && $button_doc->loadXML($result)) { - /** @var DOMElement|null */ + /** @var DOMElement|null $child */ $child = $button_doc->firstChild; if ($child) { do { + /** @var DOMElement|null $child */ $child->setAttribute('data-plugin-name', get_class($plugin)); } while ($child = $child->nextSibling); @@ -271,11 +272,12 @@ class Feeds extends Handler_Protected { function ($result, $plugin) use (&$line, &$button_doc) { if ($result && $button_doc->loadXML($result)) { - /** @var DOMElement|null */ + /** @var DOMElement|null $child */ $child = $button_doc->firstChild; if ($child) { do { + /** @var DOMElement|null $child */ $child->setAttribute('data-plugin-name', get_class($plugin)); } while ($child = $child->nextSibling); @@ -429,12 +431,11 @@ class Feeds extends Handler_Protected { $reply['content'] .= sprintf(__("Feeds last updated at %s"), $last_updated); - $sth = $this->pdo->prepare("SELECT COUNT(id) AS num_errors - FROM ttrss_feeds WHERE last_error != '' AND owner_uid = ?"); - $sth->execute([$_SESSION['uid']]); - $row = $sth->fetch(); - - $num_errors = $row["num_errors"]; + $num_errors = ORM::for_table('ttrss_feeds') + ->where_not_equal('last_error', '') + ->where('owner_uid', $_SESSION['uid']) + ->where_gte('update_interval', 0) + ->count('id'); if ($num_errors > 0) { $reply['content'] .= "<br/>"; @@ -583,12 +584,11 @@ class Feeds extends Handler_Protected { $reply['headlines']['content'] .= sprintf(__("Feeds last updated at %s"), $last_updated); - $sth = $this->pdo->prepare("SELECT COUNT(id) AS num_errors - FROM ttrss_feeds WHERE last_error != '' AND owner_uid = ?"); - $sth->execute([$_SESSION['uid']]); - $row = $sth->fetch(); - - $num_errors = $row["num_errors"]; + $num_errors = ORM::for_table('ttrss_feeds') + ->where_not_equal('last_error', '') + ->where('owner_uid', $_SESSION['uid']) + ->where_gte('update_interval', 0) + ->count('id'); if ($num_errors > 0) { $reply['headlines']['content'] .= "<br/>"; @@ -665,7 +665,7 @@ class Feeds extends Handler_Protected { } Debug::set_enabled(true); - Debug::set_loglevel($xdebug); + Debug::set_loglevel((int)Debug::map_loglevel($xdebug)); $feed_id = (int)$_REQUEST["feed_id"]; $do_update = ($_REQUEST["action"] ?? "") == "do_update"; @@ -963,6 +963,15 @@ class Feeds extends Handler_Protected { if ($is_cat) { return self::_get_cat_unread($n_feed, $owner_uid); + } else if(is_numeric($feed) && $feed < PLUGIN_FEED_BASE_INDEX && $feed > LABEL_BASE_INDEX) { // virtual Feed + $feed_id = PluginHost::feed_to_pfeed_id($feed); + $handler = PluginHost::getInstance()->get_feed_handler($feed_id); + if (implements_interface($handler, 'IVirtualFeed')) { + /** @var IVirtualFeed $handler */ + return $handler->get_unread($feed_id); + } else { + return 0; + } } else if ($n_feed == -6) { return 0; // tags @@ -1152,11 +1161,28 @@ class Feeds extends Handler_Protected { } static function _get_icon_file(int $feed_id): string { - return Config::get(Config::ICONS_DIR) . "/$feed_id.ico"; + $favicon_cache = DiskCache::instance('feed-icons'); + + return $favicon_cache->get_full_path((string)$feed_id); + } + + static function _get_icon_url(int $feed_id, string $fallback_url = "") : string { + if (self::_has_icon($feed_id)) { + $icon_url = Config::get_self_url() . "/public.php?" . http_build_query([ + 'op' => 'feed_icon', + 'id' => $feed_id, + ]); + + return $icon_url; + } + + return $fallback_url; } - static function _has_icon(int $id): bool { - return is_file(Config::get(Config::ICONS_DIR) . "/$id.ico") && filesize(Config::get(Config::ICONS_DIR) . "/$id.ico") > 0; + static function _has_icon(int $feed_id): bool { + $favicon_cache = DiskCache::instance('feed-icons'); + + return $favicon_cache->exists((string)$feed_id); } /** @@ -1180,16 +1206,9 @@ class Feeds extends Handler_Protected { if ($id < LABEL_BASE_INDEX) { return "label"; } else { - $icon = self::_get_icon_file($id); - - if ($icon && file_exists($icon)) { - return Config::get(Config::ICONS_URL) . "/" . basename($icon) . "?" . filemtime($icon); - } + return self::_get_icon_url($id); } - break; } - - return false; } /** @@ -1738,11 +1757,11 @@ class Feeds extends Handler_Protected { } if (!$allow_archived) { - $from_qpart = "${ext_tables_part}ttrss_entries LEFT JOIN ttrss_user_entries ON (ref_id = ttrss_entries.id), ttrss_feeds"; + $from_qpart = "{$ext_tables_part}ttrss_entries LEFT JOIN ttrss_user_entries ON (ref_id = ttrss_entries.id), ttrss_feeds"; $feed_check_qpart = "ttrss_user_entries.feed_id = ttrss_feeds.id AND"; } else { - $from_qpart = "${ext_tables_part}ttrss_entries LEFT JOIN ttrss_user_entries ON (ref_id = ttrss_entries.id) + $from_qpart = "{$ext_tables_part}ttrss_entries LEFT JOIN ttrss_user_entries ON (ref_id = ttrss_entries.id) LEFT JOIN ttrss_feeds ON (feed_id = ttrss_feeds.id)"; $feed_check_qpart = ""; } @@ -1936,8 +1955,8 @@ class Feeds extends Handler_Protected { $sth->execute([$cat, $owner_uid]); while ($line = $sth->fetch()) { - array_push($rv, (int)$line["parent_cat"]); - $rv = array_merge($rv, self::_get_parent_cats($line["parent_cat"], $owner_uid)); + $cat = (int) $line["parent_cat"]; + array_push($rv, $cat, ...self::_get_parent_cats($cat, $owner_uid)); } return $rv; @@ -1956,8 +1975,7 @@ class Feeds extends Handler_Protected { $sth->execute([$cat, $owner_uid]); while ($line = $sth->fetch()) { - array_push($rv, $line["id"]); - $rv = array_merge($rv, self::_get_child_cats($line["id"], $owner_uid)); + array_push($rv, $line["id"], ...self::_get_child_cats($line["id"], $owner_uid)); } return $rv; @@ -1978,16 +1996,18 @@ class Feeds extends Handler_Protected { $sth = $pdo->prepare("SELECT DISTINCT cat_id, fc.parent_cat FROM ttrss_feeds f LEFT JOIN ttrss_feed_categories fc ON (fc.id = f.cat_id) WHERE f.owner_uid = ? AND f.id IN ($feeds_qmarks)"); - $sth->execute(array_merge([$owner_uid], $feeds)); + $sth->execute([$owner_uid, ...$feeds]); $rv = []; if ($row = $sth->fetch()) { + $cat_id = (int) $row["cat_id"]; + $rv[] = $cat_id; array_push($rv, (int)$row["cat_id"]); - if ($with_parents && $row["parent_cat"]) - $rv = array_merge($rv, - self::_get_parent_cats($row["cat_id"], $owner_uid)); + if ($with_parents && $row["parent_cat"]) { + array_push($rv, ...self::_get_parent_cats($cat_id, $owner_uid)); + } } $rv = array_unique($rv); @@ -2226,7 +2246,7 @@ class Feeds extends Handler_Protected { * @return array{0: string, 1: array<int, string>} [$search_query_part, $search_words] */ private static function _search_to_sql(string $search, string $search_language, int $owner_uid): array { - $keywords = str_getcsv(preg_replace('/(-?\w+)\:"(\w+)/', '"${1}:${2}', trim($search)), ' '); + $keywords = str_getcsv(preg_replace('/(-?\w+)\:"(\w+)/', '"{$1}:{$2}', trim($search)), ' '); $query_keywords = array(); $search_words = array(); $search_query_leftover = array(); @@ -2357,8 +2377,11 @@ class Feeds extends Handler_Protected { $k = mb_strtolower($k); array_push($search_query_leftover, $not ? "!$k" : $k); } else { - array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER(".$pdo->quote("%$k%").") - OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); + $k = mb_strtolower($k); + array_push($search_query_leftover, $not ? "-$k" : $k); + + //array_push($query_keywords, "(UPPER(ttrss_entries.title) $not LIKE UPPER(".$pdo->quote("%$k%").") + // OR UPPER(ttrss_entries.content) $not LIKE UPPER(".$pdo->quote("%$k%")."))"); } if (!$not) array_push($search_words, $k); @@ -2380,12 +2403,16 @@ class Feeds extends Handler_Protected { array_push($query_keywords, "(tsvector_combined @@ to_tsquery($search_language, $tsquery))"); - } + } else { + $ft_query = $pdo->quote(implode(" ", $search_query_leftover)); + array_push($query_keywords, + "MATCH (ttrss_entries.title, ttrss_entries.content) AGAINST ($ft_query IN BOOLEAN MODE)"); + } } if (count($query_keywords) > 0) - $search_query_part = implode("AND", $query_keywords); + $search_query_part = implode("AND ", $query_keywords); else $search_query_part = "false"; diff --git a/classes/handler.php b/classes/handler.php index 806c9cfbe..5b54570d8 100644 --- a/classes/handler.php +++ b/classes/handler.php @@ -1,11 +1,9 @@ <?php class Handler implements IHandler { - // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ - /** @var PDO */ - protected $pdo; + protected PDO $pdo; /** @var array<int|string, mixed> */ - protected $args; + protected array $args; /** * @param array<int|string, mixed> $args diff --git a/classes/handler/public.php b/classes/handler/public.php index 3fef4c2b9..190c806be 100755 --- a/classes/handler/public.php +++ b/classes/handler/public.php @@ -76,7 +76,7 @@ class Handler_Public extends Handler { "/public.php?op=rss&id=$feed&key=" . Feeds::_get_access_key($feed, false, $owner_uid); - if (!$feed_site_url) $feed_site_url = get_self_url_prefix(); + if (!$feed_site_url) $feed_site_url = Config::get_self_url(); if ($format == 'atom') { $tpl = new Templator(); @@ -87,7 +87,7 @@ class Handler_Public extends Handler { $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); + $tpl->setVariable('SELF_URL', htmlspecialchars(Config::get_self_url()), true); while ($line = $result->fetch()) { $line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...')); @@ -128,13 +128,13 @@ class Handler_Public extends Handler { $tpl->setVariable('ARTICLE_CONTENT', $content, true); $tpl->setVariable('ARTICLE_UPDATED_ATOM', - date('c', strtotime($line["updated"])), true); + date('c', strtotime($line["updated"] ?? '')), true); $tpl->setVariable('ARTICLE_UPDATED_RFC822', - date(DATE_RFC822, strtotime($line["updated"])), true); + date(DATE_RFC822, strtotime($line["updated"] ?? '')), true); $tpl->setVariable('ARTICLE_AUTHOR', htmlspecialchars($line['author']), true); - $tpl->setVariable('ARTICLE_SOURCE_LINK', htmlspecialchars($line['site_url'] ? $line["site_url"] : get_self_url_prefix()), true); + $tpl->setVariable('ARTICLE_SOURCE_LINK', htmlspecialchars($line['site_url'] ? $line["site_url"] : Config::get_self_url()), true); $tpl->setVariable('ARTICLE_SOURCE_TITLE', htmlspecialchars($line['feed_title'] ?? $feed_title), true); foreach ($line["tags"] as $tag) { @@ -214,11 +214,16 @@ class Handler_Public extends Handler { $article['title'] = $line['title']; $article['excerpt'] = $line["content_preview"]; $article['content'] = Sanitizer::sanitize($line["content"], false, $owner_uid, $feed_site_url, null, $line["id"]); - $article['updated'] = date('c', strtotime($line["updated"])); + $article['updated'] = date('c', strtotime($line["updated"] ?? '')); if (!empty($line['note'])) $article['note'] = $line['note']; if (!empty($line['author'])) $article['author'] = $line['author']; + $article['source'] = [ + 'link' => $line['site_url'] ? $line["site_url"] : Config::get_self_url(), + 'title' => $line['feed_title'] ?? $feed_title + ]; + if (count($line["tags"]) > 0) { $article['tags'] = array(); @@ -312,7 +317,7 @@ class Handler_Public extends Handler { $login, $user_id); if (!$redirect_url) - $redirect_url = get_self_url_prefix() . "/index.php"; + $redirect_url = Config::get_self_url() . "/index.php"; header("Location: " . $redirect_url); } else { @@ -389,11 +394,7 @@ class Handler_Public extends Handler { if (UserHelper::authenticate($login, $password)) { $_POST["password"] = ""; - if (get_schema_version() >= 120) { - $_SESSION["language"] = get_pref(Prefs::USER_LANGUAGE, $_SESSION["uid"]); - } - - $_SESSION["ref_schema_version"] = get_schema_version(); + $_SESSION["ref_schema_version"] = Config::get_schema_version(); $_SESSION["bw_limit"] = !!clean($_POST["bw_limit"] ?? false); $_SESSION["safe_mode"] = $safe_mode; @@ -412,13 +413,12 @@ class Handler_Public extends Handler { if (session_status() != PHP_SESSION_ACTIVE) session_start(); - if (!isset($_SESSION["login_error_msg"])) - $_SESSION["login_error_msg"] = __("Incorrect username or password"); + $_SESSION["login_error_msg"] ??= __("Incorrect username or password"); } $return = clean($_REQUEST['return']); - if ($_REQUEST['return'] && mb_strpos($return, Config::get(Config::SELF_URL_PATH)) === 0) { + if ($_REQUEST['return'] && mb_strpos($return, Config::get_self_url()) === 0) { header("Location: " . clean($_REQUEST['return'])); } else { header("Location: " . Config::get_self_url()); @@ -563,7 +563,7 @@ class Handler_Public extends Handler { print_notice("Password reset instructions are being sent to your email address."); $resetpass_token = sha1(get_random_bytes(128)); - $resetpass_link = get_self_url_prefix() . "/public.php?op=forgotpass&hash=" . $resetpass_token . + $resetpass_link = Config::get_self_url() . "/public.php?op=forgotpass&hash=" . $resetpass_token . "&login=" . urlencode($login); $tpl = new Templator(); @@ -572,7 +572,7 @@ class Handler_Public extends Handler { $tpl->setVariable('LOGIN', $login); $tpl->setVariable('RESETPASS_LINK', $resetpass_link); - $tpl->setVariable('TTRSS_HOST', Config::get(Config::SELF_URL_PATH)); + $tpl->setVariable('TTRSS_HOST', Config::get_self_url()); $tpl->addBlock('message'); @@ -759,7 +759,7 @@ class Handler_Public extends Handler { // we do not allow files with extensions at the moment $filename = str_replace(".", "", $filename); - $cache = new DiskCache($cache_dir); + $cache = DiskCache::instance($cache_dir); if ($cache->exists($filename)) { $cache->send($filename); @@ -769,6 +769,18 @@ class Handler_Public extends Handler { } } + function feed_icon() : void { + $id = (int)$_REQUEST['id']; + $cache = DiskCache::instance('feed-icons'); + + if ($cache->exists((string)$id)) { + $cache->send((string)$id); + } else { + header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); + echo "File not found."; + } + } + private function _make_article_tag_uri(int $id, string $timestamp): string { $timestamp = date("Y-m-d", strtotime($timestamp)); @@ -785,7 +797,7 @@ class Handler_Public extends Handler { $plugin_name = basename(clean($_REQUEST["plugin"])); $method = clean($_REQUEST["pmethod"]); - $host->load($plugin_name, PluginHost::KIND_USER, 0); + $host->load($plugin_name, PluginHost::KIND_ALL, 0); //$host->load_data(); $plugin = $host->get_plugin($plugin_name); @@ -807,7 +819,7 @@ class Handler_Public extends Handler { } else { user_error("PluginHandler[PUBLIC]: Requested method '$method' of unknown plugin '$plugin_name'.", E_USER_WARNING); header("Content-Type: text/json"); - print Errors::to_json(Errors::E_UNKNOWN_PLUGIN); + print Errors::to_json(Errors::E_UNKNOWN_PLUGIN, ['plugin' => $plugin_name]); } } diff --git a/classes/mailer.php b/classes/mailer.php index a15c8546b..ac5c641eb 100644 --- a/classes/mailer.php +++ b/classes/mailer.php @@ -1,8 +1,6 @@ <?php class Mailer { - // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ - /** @var string */ - private $last_error = ""; + private string $last_error = ""; /** * @param array<string, mixed> $params @@ -45,16 +43,9 @@ class Mailer { ++$hooks_tried; } - $headers = [ "From: $from_combined" ]; + $headers = [ "From: $from_combined", "Content-Type: text/plain; charset=UTF-8" ]; - if ($message_html) { - $headers[] = "MIME-Version: 1.0"; - $headers[] = "Content-Type: text/html; charset=UTF-8"; - } else { - $headers[] = "Content-Type: text/plain; charset=UTF-8"; - } - - $rc = mail($to_combined, $subject, $message, implode("\r\n", array_merge($headers, $additional_headers))); + $rc = mail($to_combined, $subject, $message, implode("\r\n", [...$headers, ...$additional_headers])); if (!$rc) { $this->set_error(error_get_last()['message'] ?? T_sprintf("Unknown error while sending mail. Hooks tried: %d.", $hooks_tried)); diff --git a/classes/plugin.php b/classes/plugin.php index 39af6a9a1..d941a1616 100644 --- a/classes/plugin.php +++ b/classes/plugin.php @@ -50,6 +50,11 @@ abstract class Plugin { } /** @return string */ + function get_login_js() { + return ""; + } + + /** @return string */ function get_css() { return ""; } @@ -477,7 +482,7 @@ abstract class Plugin { /** Invoked for every enclosure entry as article is being rendered * @param array<string,string> $entry - * @param int $id + * @param int $id article id * @param array{'formatted': string, 'entries': array<int, array<string, mixed>>} $rv * @return array<string,string> ($entry) * @see PluginHost::HOOK_ENCLOSURE_ENTRY @@ -690,6 +695,15 @@ abstract class Plugin { * @return array<mixed> - [0] - if set, url to redirect to */ function hook_post_logout($login, $user_id) { + user_error("Dummy method invoked.", E_USER_ERROR); + return [""]; } + + /** Adds buttons to the right of default Login button + * @return void + */ + function hook_loginform_additional_buttons() { + user_error("Dummy method invoked.", E_USER_ERROR); + } } diff --git a/classes/pluginhandler.php b/classes/pluginhandler.php index 5c73920e5..a6f0a4965 100644 --- a/classes/pluginhandler.php +++ b/classes/pluginhandler.php @@ -14,15 +14,15 @@ class PluginHandler extends Handler_Protected { if (validate_csrf($csrf_token) || $plugin->csrf_ignore($method)) { $plugin->$method(); } else { - user_error("Rejected ${plugin_name}->${method}(): invalid CSRF token.", E_USER_WARNING); + user_error("Rejected {$plugin_name}->{$method}(): invalid CSRF token.", E_USER_WARNING); print Errors::to_json(Errors::E_UNAUTHORIZED); } } else { - user_error("Rejected ${plugin_name}->${method}(): unknown method.", E_USER_WARNING); + user_error("Rejected {$plugin_name}->{$method}(): unknown method.", E_USER_WARNING); print Errors::to_json(Errors::E_UNKNOWN_METHOD); } } else { - user_error("Rejected ${plugin_name}->${method}(): unknown plugin.", E_USER_WARNING); + user_error("Rejected {$plugin_name}->{$method}(): unknown plugin.", E_USER_WARNING); print Errors::to_json(Errors::E_UNKNOWN_PLUGIN); } } diff --git a/classes/pluginhost.php b/classes/pluginhost.php index 952d4df77..1159e8ed1 100755 --- a/classes/pluginhost.php +++ b/classes/pluginhost.php @@ -1,49 +1,42 @@ <?php class PluginHost { - // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ - /** @var PDO|null */ - private $pdo = null; + private ?PDO $pdo = null; /** * separate handle for plugin data so transaction while saving wouldn't clash with possible main * tt-rss code transactions; only initialized when first needed - * - * @var PDO|null */ - private $pdo_data = null; + private ?PDO $pdo_data = null; /** @var array<string, array<int, array<int, Plugin>>> hook types -> priority levels -> Plugins */ - private $hooks = []; + private array $hooks = []; /** @var array<string, Plugin> */ - private $plugins = []; + private array $plugins = []; /** @var array<string, array<string, Plugin>> handler type -> method type -> Plugin */ - private $handlers = []; + private array $handlers = []; /** @var array<string, array{'description': string, 'suffix': string, 'arghelp': string, 'class': Plugin}> command type -> details array */ - private $commands = []; + private array $commands = []; /** @var array<string, array<string, mixed>> plugin name -> (potential profile array) -> key -> value */ - private $storage = []; + private array $storage = []; /** @var array<int, array<int, array{'id': int, 'title': string, 'sender': Plugin, 'icon': string}>> */ - private $feeds = []; + private array $feeds = []; /** @var array<string, Plugin> API method name, Plugin sender */ - private $api_methods = []; + private array $api_methods = []; /** @var array<string, array<int, array{'action': string, 'description': string, 'sender': Plugin}>> */ - private $plugin_actions = []; + private array $plugin_actions = []; - /** @var int|null */ - private $owner_uid = null; + private ?int $owner_uid = null; - /** @var bool */ - private $data_loaded = false; + private bool $data_loaded = false; - /** @var PluginHost|null */ - private static $instance = null; + private static ?PluginHost $instance = null; const API_VERSION = 2; const PUBLIC_METHOD_DELIMITER = "--"; @@ -203,6 +196,9 @@ class PluginHost { /** @see Plugin::hook_post_logout() */ const HOOK_POST_LOGOUT = "hook_post_logout"; + /** @see Plugin::hook_loginform_additional_buttons() */ + const HOOK_LOGINFORM_ADDITIONAL_BUTTONS = "hook_loginform_additional_buttons"; + const KIND_ALL = 1; const KIND_SYSTEM = 2; const KIND_USER = 3; @@ -409,7 +405,7 @@ class PluginHost { $tmp = []; foreach (array_keys($this->hooks[$type]) as $prio) { - $tmp = array_merge($tmp, $this->hooks[$type][$prio]); + array_push($tmp, ...$this->hooks[$type][$prio]); } return $tmp; @@ -422,7 +418,7 @@ class PluginHost { */ function load_all(int $kind, int $owner_uid = null, bool $skip_init = false): void { - $plugins = array_merge(glob("plugins/*"), glob("plugins.local/*")); + $plugins = [...(glob("plugins/*") ?: []), ...(glob("plugins.local/*") ?: [])]; $plugins = array_filter($plugins, "is_dir"); $plugins = array_map("basename", $plugins); @@ -458,7 +454,7 @@ class PluginHost { // WIP hack // we can't catch incompatible method signatures via Throwable // this also enables global tt-rss safe mode in case there are more plugins like this - if (($_SESSION["plugin_blacklist"][$class] ?? 0)) { + if (!getenv('TTRSS_XDEBUG_ENABLED') && ($_SESSION["plugin_blacklist"][$class] ?? 0)) { // only report once per-plugin per-session if ($_SESSION["plugin_blacklist"][$class] < 2) { @@ -539,10 +535,7 @@ class PluginHost { $method = strtolower($method); if ($this->is_system($sender)) { - if (!isset($this->handlers[$handler])) { - $this->handlers[$handler] = []; - } - + $this->handlers[$handler] ??= []; $this->handlers[$handler][$method] = $sender; } } @@ -645,8 +638,7 @@ class PluginHost { owner_uid= ? AND name = ?"); $sth->execute([$this->owner_uid, $plugin]); - if (!isset($this->storage[$plugin])) - $this->storage[$plugin] = []; + $this->storage[$plugin] ??= []; $content = serialize($this->storage[$plugin]); @@ -677,14 +669,8 @@ class PluginHost { if ($profile_id) { $idx = get_class($sender); - if (!isset($this->storage[$idx])) { - $this->storage[$idx] = []; - } - - if (!isset($this->storage[$idx][$profile_id])) { - $this->storage[$idx][$profile_id] = []; - } - + $this->storage[$idx] ??= []; + $this->storage[$idx][$profile_id] ??= []; $this->storage[$idx][$profile_id][$name] = $value; $this->save_data(get_class($sender)); @@ -699,9 +685,7 @@ class PluginHost { function set(Plugin $sender, string $name, $value): void { $idx = get_class($sender); - if (!isset($this->storage[$idx])) - $this->storage[$idx] = []; - + $this->storage[$idx] ??= []; $this->storage[$idx][$name] = $value; $this->save_data(get_class($sender)); @@ -713,8 +697,7 @@ class PluginHost { function set_array(Plugin $sender, array $params): void { $idx = get_class($sender); - if (!isset($this->storage[$idx])) - $this->storage[$idx] = []; + $this->storage[$idx] ??= []; foreach ($params as $name => $value) $this->storage[$idx][$name] = $value; @@ -852,11 +835,13 @@ class PluginHost { function add_filter_action(Plugin $sender, string $action_name, string $action_desc): void { $sender_class = get_class($sender); - if (!isset($this->plugin_actions[$sender_class])) - $this->plugin_actions[$sender_class] = []; + $this->plugin_actions[$sender_class] ??= []; - array_push($this->plugin_actions[$sender_class], - array("action" => $action_name, "description" => $action_desc, "sender" => $sender)); + $this->plugin_actions[$sender_class][] = [ + "action" => $action_name, + "description" => $action_desc, + "sender" => $sender, + ]; } /** diff --git a/classes/pref/feeds.php b/classes/pref/feeds.php index 03b70580b..a91d4b1e3 100755 --- a/classes/pref/feeds.php +++ b/classes/pref/feeds.php @@ -172,7 +172,7 @@ class Pref_Feeds extends Handler_Protected { if ($enable_cats) { array_push($root['items'], $cat); } else { - $root['items'] = array_merge($root['items'], $cat['items']); + array_push($root['items'], ...$cat['items']); } $sth = $this->pdo->prepare("SELECT * FROM @@ -202,7 +202,7 @@ class Pref_Feeds extends Handler_Protected { if ($enable_cats) { array_push($root['items'], $cat); } else { - $root['items'] = array_merge($root['items'], $cat['items']); + array_push($root['items'], ...$cat['items']); } } } @@ -373,7 +373,7 @@ class Pref_Feeds extends Handler_Protected { $order_id = 1; - $cat = $data_map[$item_id]; + $cat = ($data_map[$item_id] ?? false); if ($cat && is_array($cat)) { foreach ($cat as $item) { @@ -436,7 +436,7 @@ class Pref_Feeds extends Handler_Protected { foreach ($data['items'] as $item) { # if ($item['id'] != 'root') { - if (is_array($item['items'])) { + if (is_array($item['items'] ?? false)) { if (isset($item['items']['_reference'])) { $data_map[$item['id']] = array($item['items']); } else { @@ -454,14 +454,15 @@ class Pref_Feeds extends Handler_Protected { function removeIcon(): void { $feed_id = (int) $_REQUEST["feed_id"]; - $icon_file = Config::get(Config::ICONS_DIR) . "/$feed_id.ico"; + + $cache = DiskCache::instance('feed-icons'); $feed = ORM::for_table('ttrss_feeds') ->where('owner_uid', $_SESSION['uid']) ->find_one($feed_id); - if ($feed && file_exists($icon_file)) { - if (unlink($icon_file)) { + if ($feed && $cache->exists((string)$feed_id)) { + if ($cache->remove((string)$feed_id)) { $feed->set([ 'favicon_avg_color' => null, 'favicon_last_checked' => '1970-01-01', @@ -486,24 +487,25 @@ class Pref_Feeds extends Handler_Protected { 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)) { - $new_filename = Config::get(Config::ICONS_DIR) . "/$feed_id.ico"; - - if (file_exists($new_filename)) unlink($new_filename); - if (rename($tmp_file, $new_filename)) { - chmod($new_filename, 0644); + $cache = DiskCache::instance('feed-icons'); - $feed->set([ - 'favicon_avg_color' => null, - 'favicon_is_custom' => true, - ]); + if ($cache->put((string)$feed_id, file_get_contents($tmp_file))) { - if ($feed->save()) { - $rc = self::E_ICON_UPLOAD_SUCCESS; - } + $feed->set([ + 'favicon_avg_color' => null, + 'favicon_is_custom' => true, + ]); - } else { - $rc = self::E_ICON_RENAME_FAILED; + if ($feed->save()) { + $rc = self::E_ICON_UPLOAD_SUCCESS; } + + } else { + $rc = self::E_ICON_RENAME_FAILED; + } + + @unlink($tmp_file); + } else { $rc = self::E_ICON_FILE_TOO_LARGE; } @@ -512,7 +514,8 @@ class Pref_Feeds extends Handler_Protected { if (file_exists($tmp_file)) unlink($tmp_file); - print json_encode(['rc' => $rc, 'icon_url' => Feeds::_get_icon($feed_id)]); + print json_encode(['rc' => $rc, 'icon_url' => + Feeds::_get_icon($feed_id) . "?ts=" . time() ]); } function editfeed(): void { @@ -848,7 +851,7 @@ class Pref_Feeds extends Handler_Protected { if ($qpart) { $sth = $this->pdo->prepare("UPDATE ttrss_feeds SET $qpart WHERE id IN ($feed_ids_qmarks) AND owner_uid = ?"); - $sth->execute(array_merge($feed_ids, [$_SESSION['uid']])); + $sth->execute([...$feed_ids, $_SESSION['uid']]); } } @@ -973,16 +976,6 @@ class Pref_Feeds extends Handler_Protected { persist="true" model="feedModel" openOnClick="false"> - <script type="dojo/method" event="onClick" args="item"> - var id = String(item.id); - var bare_id = id.substr(id.indexOf(':')+1); - - if (id.match('FEED:')) { - CommonDialogs.editFeed(bare_id); - } else if (id.match('CAT:')) { - dijit.byId('feedTree').editCategory(bare_id, item); - } - </script> </div> </div> </div> @@ -1161,6 +1154,7 @@ class Pref_Feeds extends Handler_Protected { ->select_many('id', 'title', 'feed_url', 'last_error', 'site_url') ->where_not_equal('last_error', '') ->where('owner_uid', $_SESSION['uid']) + ->where_gte('update_interval', 0) ->find_array()); } @@ -1196,9 +1190,10 @@ class Pref_Feeds extends Handler_Protected { $pdo->commit(); - if (file_exists(Config::get(Config::ICONS_DIR) . "/$id.ico")) { - unlink(Config::get(Config::ICONS_DIR) . "/$id.ico"); - } + $favicon_cache = DiskCache::instance('feed-icons'); + + if ($favicon_cache->exists((string)$id)) + $favicon_cache->remove((string)$id); } else { Labels::remove(Labels::feed_to_label_id($id), $owner_uid); diff --git a/classes/pref/filters.php b/classes/pref/filters.php index 79dd78993..19ec8d39e 100755 --- a/classes/pref/filters.php +++ b/classes/pref/filters.php @@ -115,6 +115,7 @@ class Pref_Filters extends Handler_Protected { $glue = $filter['match_any_rule'] ? " OR " : " AND "; $scope_qpart = join($glue, $scope_qparts); + /** @phpstan-ignore-next-line */ if (!$scope_qpart) $scope_qpart = "true"; $rv = array(); @@ -537,7 +538,7 @@ class Pref_Filters extends Handler_Protected { $sth = $this->pdo->prepare("DELETE FROM ttrss_filters2 WHERE id IN ($ids_qmarks) AND owner_uid = ?"); - $sth->execute(array_merge($ids, [$_SESSION['uid']])); + $sth->execute([...$ids, $_SESSION['uid']]); } private function _save_rules_and_actions(int $filter_id): void { @@ -703,14 +704,6 @@ class Pref_Filters extends Handler_Protected { </div> <div dojoType="fox.PrefFilterTree" id="filterTree" dndController="dijit.tree.dndSource" betweenThreshold="5" model="filterModel" openOnClick="true"> - <script type="dojo/method" event="onClick" args="item"> - var id = String(item.id); - var bare_id = id.substr(id.indexOf(':')+1); - - if (id.match('FILTER:')) { - Filters.edit(bare_id); - } - </script> </div> </div> <?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefFilters") ?> @@ -788,11 +781,11 @@ class Pref_Filters extends Handler_Protected { $sth = $this->pdo->prepare("UPDATE ttrss_filters2_rules SET filter_id = ? WHERE filter_id IN ($ids_qmarks)"); - $sth->execute(array_merge([$base_id], $ids)); + $sth->execute([$base_id, ...$ids]); $sth = $this->pdo->prepare("UPDATE ttrss_filters2_actions SET filter_id = ? WHERE filter_id IN ($ids_qmarks)"); - $sth->execute(array_merge([$base_id], $ids)); + $sth->execute([$base_id, ...$ids]); $sth = $this->pdo->prepare("DELETE FROM ttrss_filters2 WHERE id IN ($ids_qmarks)"); $sth->execute($ids); diff --git a/classes/pref/labels.php b/classes/pref/labels.php index a50a85a66..2e128691e 100644 --- a/classes/pref/labels.php +++ b/classes/pref/labels.php @@ -61,7 +61,7 @@ class Pref_Labels extends Handler_Protected { if ($kind == "fg" || $kind == "bg") { $sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET - ${kind}_color = ? WHERE id = ? + {$kind}_color = ? WHERE id = ? AND owner_uid = ?"); $sth->execute([$color, $id, $_SESSION['uid']]); diff --git a/classes/pref/prefs.php b/classes/pref/prefs.php index 8c044b49f..1e02a0508 100644 --- a/classes/pref/prefs.php +++ b/classes/pref/prefs.php @@ -2,18 +2,17 @@ use chillerlan\QRCode; class Pref_Prefs extends Handler_Protected { - // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ /** @var array<Prefs::*, array<int, string>> */ - private $pref_help = []; + private array $pref_help = []; /** @var array<string, array<int, string>> pref items are Prefs::*|Pref_Prefs::BLOCK_SEPARATOR (PHPStan was complaining) */ - private $pref_item_map = []; + private array $pref_item_map = []; /** @var array<string, string> */ - private $pref_help_bottom = []; + private array $pref_help_bottom = []; /** @var array<int, string> */ - private $pref_blacklist = []; + private array $pref_blacklist = []; private const BLOCK_SEPARATOR = 'BLOCK_SEPARATOR'; @@ -26,7 +25,6 @@ class Pref_Prefs extends Handler_Protected { const PI_ERR_PLUGIN_NOT_FOUND = "PI_ERR_PLUGIN_NOT_FOUND"; const PI_ERR_NO_WORKDIR = "PI_ERR_NO_WORKDIR"; - /** @param string $method */ function csrf_ignore(string $method) : bool { $csrf_ignored = array("index", "updateself", "otpqrcode"); @@ -187,7 +185,7 @@ class Pref_Prefs extends Handler_Protected { $boolean_prefs = explode(",", clean($_POST["boolean_prefs"])); foreach ($boolean_prefs as $pref) { - if (!isset($_POST[$pref])) $_POST[$pref] = 'false'; + $_POST[$pref] ??= 'false'; } $need_reload = false; @@ -242,7 +240,7 @@ class Pref_Prefs extends Handler_Protected { $user->full_name = clean($_POST['full_name']); if ($user->email != $new_email) { - Logger::log(E_USER_NOTICE, "Email address of user ".$user->login." has been changed to ${new_email}."); + Logger::log(E_USER_NOTICE, "Email address of user {$user->login} has been changed to {$new_email}."); if ($user->email) { $mailer = new Mailer(); @@ -253,7 +251,7 @@ class Pref_Prefs extends Handler_Protected { $tpl->setVariable('LOGIN', $user->login); $tpl->setVariable('NEWMAIL', $new_email); - $tpl->setVariable('TTRSS_HOST', Config::get(Config::SELF_URL_PATH)); + $tpl->setVariable('TTRSS_HOST', Config::get_self_url()); $tpl->addBlock('message'); @@ -633,10 +631,11 @@ class Pref_Prefs extends Handler_Protected { } else if ($pref_name == Prefs::USER_CSS_THEME) { - $theme_files = array_map("basename", - array_merge(glob("themes/*.php"), - glob("themes/*.css"), - glob("themes.local/*.css"))); + $theme_files = array_map("basename", [ + ...glob("themes/*.php") ?: [], + ...glob("themes/*.css") ?: [], + ...glob("themes.local/*.css") ?: [], + ]); asort($theme_files); @@ -869,18 +868,19 @@ class Pref_Prefs extends Handler_Protected { $feed_handler_whitelist = [ "Af_Comics" ]; - $feed_handlers = array_merge( - PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FEED_FETCHED), - PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FEED_PARSED), - PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FETCH_FEED)); + $feed_handlers = [ + ...PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FEED_FETCHED), + ...PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FEED_PARSED), + ...PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FETCH_FEED), + ]; - $feed_handlers = array_filter($feed_handlers, function($plugin) use ($feed_handler_whitelist) { - return in_array(get_class($plugin), $feed_handler_whitelist) === false; }); + $feed_handlers = array_filter($feed_handlers, + fn($plugin) => in_array(get_class($plugin), $feed_handler_whitelist) === false); if (count($feed_handlers) > 0) { print_error( T_sprintf("The following plugins use per-feed content hooks. This may cause excessive data usage and origin server load resulting in a ban of your instance: <b>%s</b>" , - implode(", ", array_map(function($plugin) { return get_class($plugin); }, $feed_handlers)) + implode(", ", array_map(fn($plugin) => get_class($plugin), $feed_handlers)) ) . " (<a href='https://tt-rss.org/wiki/FeedHandlerPlugins' target='_blank'>".__("More info...")."</a>)" ); } @@ -1002,7 +1002,7 @@ class Pref_Prefs extends Handler_Protected { $tpl->readTemplateFromFile("otp_disabled_template.txt"); $tpl->setVariable('LOGIN', $row["login"]); - $tpl->setVariable('TTRSS_HOST', Config::get(Config::SELF_URL_PATH)); + $tpl->setVariable('TTRSS_HOST', Config::get_self_url()); $tpl->addBlock('message'); @@ -1024,7 +1024,7 @@ class Pref_Prefs extends Handler_Protected { } function setplugins(): void { - $plugins = array_filter($_REQUEST["plugins"], 'clean') ?? []; + $plugins = array_filter($_REQUEST["plugins"] ?? [], 'clean'); set_pref(Prefs::_ENABLED_PLUGINS, implode(",", $plugins)); } @@ -1069,9 +1069,7 @@ class Pref_Prefs extends Handler_Protected { } } - $rv = array_values(array_filter($rv, function ($item) { - return $item["rv"]["need_update"]; - })); + $rv = array_values(array_filter($rv, fn($item) => $item["rv"]["need_update"])); return $rv; } diff --git a/classes/pref/users.php b/classes/pref/users.php index c48619614..78291592d 100644 --- a/classes/pref/users.php +++ b/classes/pref/users.php @@ -82,11 +82,10 @@ class Pref_Users extends Handler_Administrative { <?php while ($row = $sth->fetch()) { ?> <li> <?php - $icon_file = Config::get(Config::ICONS_URL) . "/" . $row["id"] . ".ico"; - $icon = file_exists($icon_file) ? $icon_file : "images/blank_icon.gif"; + $icon_url = Feeds::_get_icon_url($row['id'], 'images/blank_icon.gif'); ?> - <img class="icon" src="<?= $icon_file ?>"> + <img class="icon" src="<?= htmlspecialchars($icon_url) ?>"> <a target="_blank" href="<?= htmlspecialchars($row["site_url"]) ?>"> <?= htmlspecialchars($row["title"]) ?> diff --git a/classes/prefs.php b/classes/prefs.php index 7e6033f4d..378fea293 100644 --- a/classes/prefs.php +++ b/classes/prefs.php @@ -230,7 +230,7 @@ class Prefs { } } - if (get_schema_version() >= 141) { + if (Config::get_schema_version() >= 141) { // fill in any overrides from the database $sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2 WHERE owner_uid = :uid AND @@ -265,7 +265,7 @@ class Prefs { if ($this->_is_cached($pref_name, $owner_uid, $profile_id)) { $cached_value = $this->_get_cache($pref_name, $owner_uid, $profile_id); return Config::cast_to($cached_value, $type_hint); - } else if (get_schema_version() >= 141) { + } else if (Config::get_schema_version() >= 141) { $sth = $this->pdo->prepare("SELECT value FROM ttrss_user_prefs2 WHERE pref_name = :name AND owner_uid = :uid AND (profile = :profile OR (:profile IS NULL AND profile IS NULL))"); @@ -390,7 +390,7 @@ class Prefs { } function migrate(int $owner_uid, ?int $profile_id): void { - if (get_schema_version() < 141) + if (Config::get_schema_version() < 141) return; if (!$profile_id) $profile_id = null; diff --git a/classes/rpc.php b/classes/rpc.php index dbb54cec5..204b002d5 100755 --- a/classes/rpc.php +++ b/classes/rpc.php @@ -77,7 +77,7 @@ class RPC extends Handler_Protected { $sth = $this->pdo->prepare("DELETE FROM ttrss_user_entries WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); - $sth->execute(array_merge($ids, [$_SESSION['uid']])); + $sth->execute([...$ids, $_SESSION['uid']]); Article::_purge_orphans(); @@ -364,7 +364,7 @@ class RPC extends Handler_Protected { WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); } - $sth->execute(array_merge($ids, [$_SESSION['uid']])); + $sth->execute([...$ids, $_SESSION['uid']]); } /** @@ -388,7 +388,7 @@ class RPC extends Handler_Protected { WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?"); } - $sth->execute(array_merge($ids, [$_SESSION['uid']])); + $sth->execute([...$ids, $_SESSION['uid']]); } function log(): void { @@ -451,7 +451,7 @@ class RPC extends Handler_Protected { $params["safe_mode"] = !empty($_SESSION["safe_mode"]); $params["check_for_updates"] = Config::get(Config::CHECK_FOR_UPDATES); - $params["icons_url"] = Config::get(Config::ICONS_URL); + $params["icons_url"] = Config::get_self_url() . '/public.php'; $params["cookie_lifetime"] = Config::get(Config::SESSION_COOKIE_LIFETIME); $params["default_view_mode"] = get_pref(Prefs::_DEFAULT_VIEW_MODE); $params["default_view_limit"] = (int) get_pref(Prefs::_DEFAULT_VIEW_LIMIT); @@ -753,12 +753,11 @@ class RPC extends Handler_Protected { function hotkeyHelp(): void { $info = self::get_hotkeys_info(); $imap = self::get_hotkeys_map(); - $omap = array(); + $omap = []; foreach ($imap[1] as $sequence => $action) { - if (!isset($omap[$action])) $omap[$action] = array(); - - array_push($omap[$action], $sequence); + $omap[$action] ??= []; + $omap[$action][] = $sequence; } ?> diff --git a/classes/rssutils.php b/classes/rssutils.php index aec17d538..561171d09 100755 --- a/classes/rssutils.php +++ b/classes/rssutils.php @@ -37,20 +37,29 @@ class RSSUtils { $pdo = Db::pdo(); $sth = $pdo->prepare("SELECT id FROM ttrss_feeds WHERE id = ?"); - // check icon files once every Config::get(Config::CACHE_MAX_DAYS) days - $icon_files = array_filter(glob(Config::get(Config::ICONS_DIR) . "/*.ico"), - function($f) { return filemtime($f) < time() - 86400 * Config::get(Config::CACHE_MAX_DAYS); }); + $cache = DiskCache::instance('feed-icons'); - foreach ($icon_files as $icon) { - $feed_id = basename($icon, ".ico"); + if ($cache->is_writable()) { + $dh = opendir($cache->get_full_path("")); - $sth->execute([$feed_id]); + if ($dh) { + while (($icon = readdir($dh)) !== false) { + if (preg_match('/^[0-9]{1,}$/', $icon) && $cache->get_mtime($icon) < time() - 86400 * Config::get(Config::CACHE_MAX_DAYS)) { - if ($sth->fetch()) { - @touch($icon); - } else { - Debug::log("Removing orphaned feed icon: $icon"); - unlink($icon); + $sth->execute([(int)$icon]); + + if ($sth->fetch()) { + $cache->put($icon, $cache->get($icon)); + } else { + $icon_path = $cache->get_full_path($icon); + + Debug::log("Removing orphaned feed icon: $icon_path"); + unlink($icon_path); + } + } + } + + closedir($dh); } } } @@ -338,6 +347,9 @@ class RSSUtils { $pdo = Db::pdo(); + /** @var DiskCache $cache */ + $cache = DiskCache::instance('feeds'); + if (Config::get(Config::DB_TYPE) == "pgsql") { $favicon_interval_qpart = "favicon_last_checked < NOW() - INTERVAL '12 hour'"; } else { @@ -387,7 +399,7 @@ class RSSUtils { $date_feed_processed = date('Y-m-d H:i'); - $cache_filename = Config::get(Config::CACHE_DIR) . "/feeds/" . sha1($feed_obj->feed_url) . ".xml"; + $cache_filename = sha1($feed_obj->feed_url) . ".xml"; $pluginhost = new PluginHost(); $user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $feed_obj->owner_uid); @@ -423,13 +435,13 @@ class RSSUtils { // try cache if (!$feed_data && - is_readable($cache_filename) && + $cache->exists($cache_filename) && !$feed_obj->auth_login && !$feed_obj->auth_pass && - filemtime($cache_filename) > time() - 30) { + $cache->get_mtime($cache_filename) > time() - 30) { Debug::log("using local cache: {$cache_filename}.", Debug::LOG_VERBOSE); - $feed_data = file_get_contents($cache_filename); + $feed_data = $cache->get($cache_filename); if ($feed_data) { $rss_hash = sha1($feed_data); @@ -447,7 +459,7 @@ class RSSUtils { Debug::log("not using CURL due to open_basedir restrictions", Debug::LOG_VERBOSE); } - if (time() - strtotime($feed_obj->last_unconditional) > Config::get(Config::MAX_CONDITIONAL_INTERVAL)) { + 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; @@ -477,12 +489,12 @@ class RSSUtils { } // cache vanilla feed data for re-use - if ($feed_data && !$feed_obj->auth_pass && !$feed_obj->auth_login && is_writable(Config::get(Config::CACHE_DIR) . "/feeds")) { + if ($feed_data && !$feed_obj->auth_pass && !$feed_obj->auth_login && $cache->is_writable()) { $new_rss_hash = sha1($feed_data); if ($new_rss_hash != $rss_hash) { Debug::log("saving to local cache: $cache_filename", Debug::LOG_VERBOSE); - file_put_contents($cache_filename, $feed_data); + $cache->put($cache_filename, $feed_data); } } } @@ -492,7 +504,7 @@ class RSSUtils { // If-Modified-Since if (UrlHelper::$fetch_last_error_code == 304) { - Debug::log("source claims data not modified, nothing to do.", Debug::LOG_VERBOSE); + Debug::log("source claims data not modified (304), nothing to do.", Debug::LOG_VERBOSE); $error_message = ""; $feed_obj->set([ @@ -503,6 +515,24 @@ class RSSUtils { $feed_obj->save(); + } else if (UrlHelper::$fetch_last_error_code == 429) { + + // randomize interval using Config::HTTP_429_THROTTLE_INTERVAL as a base value (1-2x) + $http_429_throttle_interval = rand(Config::get(Config::HTTP_429_THROTTLE_INTERVAL), + Config::get(Config::HTTP_429_THROTTLE_INTERVAL)*2); + + $error_message = UrlHelper::$fetch_last_error; + + Debug::log("source claims we're requesting too often (429), throttling updates for $http_429_throttle_interval seconds.", + Debug::LOG_VERBOSE); + + $feed_obj->set([ + 'last_error' => $error_message . " (updates throttled for $http_429_throttle_interval seconds.)", + 'last_successful_update' => Db::NOW($http_429_throttle_interval), + 'last_updated' => Db::NOW($http_429_throttle_interval), + ]); + + $feed_obj->save(); } else { $error_message = UrlHelper::$fetch_last_error; @@ -572,21 +602,28 @@ class RSSUtils { 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) */ + // restrict update attempts to once per 12h + $feed_obj->favicon_last_checked = Db::NOW(); + $feed_obj->save(); + + $favicon_cache = DiskCache::instance('feed-icons'); - $favicon_file = Config::get(Config::ICONS_DIR) . "/$feed.ico"; - $favicon_modified = file_exists($favicon_file) ? filemtime($favicon_file) : -1; + $favicon_modified = $favicon_cache->exists($feed) ? $favicon_cache->get_mtime($feed) : -1; + // don't try to redownload custom favicons if (!$feed_obj->favicon_is_custom) { Debug::log("favicon: trying to update favicon...", Debug::LOG_VERBOSE); self::update_favicon($feed_obj->site_url, $feed); - if ((file_exists($favicon_file) ? filemtime($favicon_file) : -1) > $favicon_modified) + if (!$favicon_cache->exists($feed) || $favicon_cache->get_mtime($feed) > $favicon_modified) { $feed_obj->favicon_avg_color = null; + $feed_obj->save(); + } } - if (is_readable($favicon_file) && function_exists("imagecreatefromstring") && empty($feed_obj->favicon_avg_color)) { + /* terrible hack: if we crash on floicon shit here, we won't check + * the icon avgcolor again (unless icon got updated) */ + if (file_exists($favicon_cache->get_full_path($feed)) && function_exists("imagecreatefromstring") && empty($feed_obj->favicon_avg_color)) { require_once "colors.php"; Debug::log("favicon: trying to calculate average color...", Debug::LOG_VERBOSE); @@ -594,13 +631,13 @@ class RSSUtils { $feed_obj->favicon_avg_color = 'fail'; $feed_obj->save(); - $feed_obj->favicon_avg_color = \Colors\calculate_avg_color($favicon_file); + $feed_obj->favicon_avg_color = \Colors\calculate_avg_color($favicon_cache->get_full_path($feed)); $feed_obj->save(); Debug::log("favicon: avg color: {$feed_obj->favicon_avg_color}", Debug::LOG_VERBOSE); } 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("floicon failed on $feed, not trying to recalculate avg color", Debug::LOG_VERBOSE); } } @@ -644,7 +681,7 @@ class RSSUtils { print_r($item); } - if (ini_get("max_execution_time") > 0 && time() - $tstart >= ini_get("max_execution_time") * 0.7) { + if (ini_get("max_execution_time") > 0 && time() - $tstart >= ((float)ini_get("max_execution_time") * 0.7)) { Debug::log("looks like there's too many articles to process at once, breaking out.", Debug::LOG_VERBOSE); $pdo->commit(); break; @@ -715,7 +752,7 @@ class RSSUtils { $article_labels = Article::_get_labels($base_entry_id, $feed_obj->owner_uid); $existing_tags = Article::_get_tags($base_entry_id, $feed_obj->owner_uid); - $entry_tags = array_unique(array_merge($entry_tags, $existing_tags)); + $entry_tags = array_unique([...$entry_tags, ...$existing_tags]); } else { $base_entry_id = false; $entry_stored_hash = ""; @@ -847,7 +884,7 @@ class RSSUtils { $pluginhost->run_hooks(PluginHost::HOOK_FILTER_TRIGGERED, $feed, $feed_obj->owner_uid, $article, $matched_filters, $matched_rules, $article_filters); - $matched_filter_ids = array_map(function($f) { return $f['id']; }, $matched_filters); + $matched_filter_ids = array_map(fn(array $f) => $f['id'], $matched_filters); if (count($matched_filter_ids) > 0) { $filter_objs = ORM::for_table('ttrss_filters2') @@ -1172,8 +1209,7 @@ class RSSUtils { // check for manual tags (we have to do it here since they're loaded from filters) foreach ($article_filters as $f) { if ($f["type"] == "tag") { - $entry_tags = array_merge($entry_tags, - FeedItem_Common::normalize_categories(explode(",", $f["param"]))); + $entry_tags = [...$entry_tags, ...FeedItem_Common::normalize_categories(explode(",", $f["param"]))]; } } @@ -1284,7 +1320,7 @@ class RSSUtils { * @see FeedEnclosure */ static function cache_enclosures(array $enclosures, string $site_url): void { - $cache = new DiskCache("images"); + $cache = DiskCache::instance("images"); if ($cache->is_writable()) { foreach ($enclosures as $enc) { @@ -1306,8 +1342,6 @@ class RSSUtils { } else { 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,14 +1367,12 @@ class RSSUtils { } else { 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); } } /* TODO: move to DiskCache? */ static function cache_media(string $html, string $site_url): void { - $cache = new DiskCache("images"); + $cache = DiskCache::instance("images"); if ($html && $cache->is_writable()) { $doc = new DOMDocument(); @@ -1656,9 +1688,36 @@ class RSSUtils { $tmph->run_hooks(PluginHost::HOOK_HOUSE_KEEPING); } + /** migrates favicons from legacy storage in feed-icons/ to cache/feed-icons/using new naming (sans .ico suffix) */ + static function migrate_feed_icons() : void { + $old_dir = Config::get(Config::ICONS_DIR); + $new_dir = Config::get(Config::CACHE_DIR) . '/feed-icons'; + + $dh = opendir($old_dir); + + $cache = DiskCache::instance('feed-icons'); + + if ($dh) { + while (($old_filename = readdir($dh)) !== false) { + if (strpos($old_filename, ".ico") !== false) { + $new_filename = str_replace(".ico", "", $old_filename); + $old_full_path = "$old_dir/$old_filename"; + + if (is_file($old_full_path) && $cache->put($new_filename, file_get_contents($old_full_path))) { + unlink($old_full_path); + } + } + } + + closedir($dh); + } + } + static function housekeeping_common(): void { - DiskCache::expire(); + $cache = DiskCache::instance(""); + $cache->expire_all(); + self::migrate_feed_icons(); self::expire_lock_files(); self::expire_error_log(); self::expire_feed_archive(); @@ -1676,8 +1735,6 @@ class RSSUtils { * @return false|string */ static function update_favicon(string $site_url, int $feed) { - $icon_file = Config::get(Config::ICONS_DIR) . "/$feed.ico"; - $favicon_urls = self::get_favicon_urls($site_url); if (count($favicon_urls) == 0) { @@ -1732,21 +1789,18 @@ class RSSUtils { break; } - Debug::log("favicon: $favicon_url looks valid, saving to $icon_file", Debug::LOG_VERBOSE); - - $fp = @fopen($icon_file, "w"); - - if ($fp) { + $favicon_cache = DiskCache::instance('feed-icons'); - fwrite($fp, $contents); - fclose($fp); - chmod($icon_file, 0644); - clearstatcache(); + if ($favicon_cache->is_writable()) { + Debug::log("favicon: $favicon_url looks valid, saving to cache", Debug::LOG_VERBOSE); - return $icon_file; + // we deal with this manually + if (!$favicon_cache->exists(".no-auto-expiry")) + $favicon_cache->put(".no-auto-expiry", ""); + return $favicon_cache->put((string)$feed, $contents); } else { - Debug::log("favicon: failed to open $icon_file for writing", Debug::LOG_VERBOSE); + Debug::log("favicon: $favicon_url skipping, local cache is not writable", Debug::LOG_VERBOSE); } } @@ -1778,12 +1832,10 @@ class RSSUtils { owner_uid = ? AND enabled = true ORDER BY order_id, title"); $sth->execute([$owner_uid]); - $check_cats = array_merge( - Feeds::_get_parent_cats($cat_id, $owner_uid), - [$cat_id]); + $check_cats = [...Feeds::_get_parent_cats($cat_id, $owner_uid), $cat_id]; $check_cats_str = join(",", $check_cats); - $check_cats_fullids = array_map(function($a) { return "CAT:$a"; }, $check_cats); + $check_cats_fullids = array_map(fn(int $a) => "CAT:$a", $check_cats); while ($line = $sth->fetch()) { $filter_id = $line["id"]; diff --git a/classes/sanitizer.php b/classes/sanitizer.php index e2055930b..8b4584a28 100644 --- a/classes/sanitizer.php +++ b/classes/sanitizer.php @@ -54,7 +54,7 @@ class Sanitizer { } private static function is_prefix_https(): bool { - return parse_url(Config::get(Config::SELF_URL_PATH), PHP_URL_SCHEME) == 'https'; + return parse_url(Config::get_self_url(), PHP_URL_SCHEME) == 'https'; } /** diff --git a/classes/urlhelper.php b/classes/urlhelper.php index bb51f5d06..dc47f5ad8 100644 --- a/classes/urlhelper.php +++ b/classes/urlhelper.php @@ -10,31 +10,14 @@ class UrlHelper { "application/x-bittorrent" => [ "magnet" ], ]; - // TODO: class properties can be switched to PHP typing if/when the minimum PHP_VERSION is raised to 7.4.0+ - /** @var string */ - static $fetch_last_error; - - /** @var int */ - static $fetch_last_error_code; - - /** @var string */ - static $fetch_last_error_content; - - /** @var string */ - static $fetch_last_content_type; - - /** @var string */ - static $fetch_last_modified; - - - /** @var string */ - static $fetch_effective_url; - - /** @var string */ - static $fetch_effective_ip_addr; - - /** @var bool */ - static $fetch_curl_used; + static string $fetch_last_error; + static int $fetch_last_error_code; + static string $fetch_last_error_content; + static string $fetch_last_content_type; + static string $fetch_last_modified; + static string $fetch_effective_url; + static string $fetch_effective_ip_addr; + static bool $fetch_curl_used; /** * @param array<string, string|int> $parts @@ -207,32 +190,26 @@ class UrlHelper { if ($nest > 10) return false; - if (version_compare(PHP_VERSION, '7.1.0', '>=')) { - $context_options = array( - 'http' => array( - 'header' => array( - 'Connection: close' - ), - 'method' => 'HEAD', - 'timeout' => $timeout, - 'protocol_version'=> 1.1) - ); - - if (Config::get(Config::HTTP_PROXY)) { - $context_options['http']['request_fulluri'] = true; - $context_options['http']['proxy'] = Config::get(Config::HTTP_PROXY); - } + $context_options = array( + 'http' => array( + 'header' => array( + 'Connection: close' + ), + 'method' => 'HEAD', + 'timeout' => $timeout, + 'protocol_version'=> 1.1) + ); + + if (Config::get(Config::HTTP_PROXY)) { + $context_options['http']['request_fulluri'] = true; + $context_options['http']['proxy'] = Config::get(Config::HTTP_PROXY); + } - $context = stream_context_create($context_options); + $context = stream_context_create($context_options); - // PHP 8 changed the second param from int to bool, but we still support PHP >= 7.1.0 - // @phpstan-ignore-next-line - $headers = get_headers($url, 0, $context); - } else { - // PHP 8 changed the second param from int to bool, but we still support PHP >= 7.1.0 - // @phpstan-ignore-next-line - $headers = get_headers($url, 0); - } + // PHP 8 changed the second param from int to bool, but we still support PHP >= 7.4.0 + // @phpstan-ignore-next-line + $headers = get_headers($url, 0, $context); if (is_array($headers)) { $headers = array_reverse($headers); // last one is the correct one @@ -462,7 +439,11 @@ class UrlHelper { } if (!$contents) { - self::$fetch_last_error = curl_errno($ch) . " " . curl_error($ch); + if (curl_errno($ch) === 0) { + self::$fetch_last_error = 'Successful response, but no content was received.'; + } else { + self::$fetch_last_error = curl_errno($ch) . " " . curl_error($ch); + } curl_close($ch); return false; } @@ -543,6 +524,11 @@ class UrlHelper { $data = @file_get_contents($url, false, $context); + if ($data === false) { + self::$fetch_last_error = "'file_get_contents' failed."; + return false; + } + foreach ($http_response_header as $header) { if (strstr($header, ": ") !== false) { list ($key, $value) = explode(": ", $header); @@ -578,15 +564,20 @@ class UrlHelper { return false; } - $is_gzipped = RSSUtils::is_gzipped($data); + if ($data) { + $is_gzipped = RSSUtils::is_gzipped($data); - if ($is_gzipped && $data) { - $tmp = @gzdecode($data); + if ($is_gzipped) { + $tmp = @gzdecode($data); - if ($tmp) $data = $tmp; - } + if ($tmp) $data = $tmp; + } - return $data; + return $data; + } else { + self::$fetch_last_error = 'Successful response, but no content was received.'; + return false; + } } } diff --git a/classes/userhelper.php b/classes/userhelper.php index 91e40665d..4d9f30548 100644 --- a/classes/userhelper.php +++ b/classes/userhelper.php @@ -17,6 +17,15 @@ class UserHelper { self::HASH_ALGO_SHA1 ]; + const ACCESS_LEVELS = [ + self::ACCESS_LEVEL_DISABLED, + self::ACCESS_LEVEL_READONLY, + self::ACCESS_LEVEL_USER, + self::ACCESS_LEVEL_POWERUSER, + self::ACCESS_LEVEL_ADMIN, + self::ACCESS_LEVEL_KEEP_CURRENT + ]; + /** forbidden to login */ const ACCESS_LEVEL_DISABLED = -2; @@ -32,6 +41,23 @@ class UserHelper { /** has administrator permissions */ const ACCESS_LEVEL_ADMIN = 10; + /** used by self::user_modify() to keep current access level */ + const ACCESS_LEVEL_KEEP_CURRENT = -1024; + + /** + * @param int $level integer loglevel value + * @return UserHelper::ACCESS_LEVEL_* if valid, warn and return ACCESS_LEVEL_KEEP_CURRENT otherwise + */ + public static function map_access_level(int $level) : int { + if (in_array($level, self::ACCESS_LEVELS)) { + /** @phpstan-ignore-next-line */ + return $level; + } else { + user_error("Passed invalid user access level: $level", E_USER_WARNING); + return self::ACCESS_LEVEL_KEEP_CURRENT; + } + } + static function authenticate(string $login = null, string $password = null, bool $check_only = false, string $service = null): bool { if (!Config::get(Config::SINGLE_USER_MODE)) { $user_id = false; @@ -57,19 +83,15 @@ class UserHelper { $user = ORM::for_table('ttrss_users')->find_one($user_id); if ($user && $user->access_level != self::ACCESS_LEVEL_DISABLED) { - $_SESSION["uid"] = $user_id; + self::set_session_for_user($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; $user->last_login = Db::NOW(); $user->save(); - $_SESSION["last_login_update"] = time(); - return true; } @@ -82,8 +104,7 @@ class UserHelper { return false; } else { - - $_SESSION["uid"] = 1; + self::set_session_for_user(1); $_SESSION["name"] = "admin"; $_SESSION["access_level"] = self::ACCESS_LEVEL_ADMIN; @@ -92,12 +113,20 @@ class UserHelper { $_SESSION["auth_module"] = false; - if (empty($_SESSION["csrf_token"])) - $_SESSION["csrf_token"] = bin2hex(get_random_bytes(16)); + return true; + } + } - $_SESSION["ip_address"] = UserHelper::get_user_ip(); + static function set_session_for_user(int $owner_uid): void { + $_SESSION["uid"] = $owner_uid; + $_SESSION["last_login_update"] = time(); + $_SESSION["ip_address"] = UserHelper::get_user_ip(); - return true; + if (empty($_SESSION["csrf_token"])) + $_SESSION["csrf_token"] = bin2hex(get_random_bytes(16)); + + if (Config::get_schema_version() >= 120) { + $_SESSION["language"] = get_pref(Prefs::USER_LANGUAGE, $owner_uid); } } @@ -133,7 +162,7 @@ class UserHelper { if (empty($_SESSION["uid"])) { if (Config::get(Config::AUTH_AUTO_LOGIN) && self::authenticate(null, null)) { - $_SESSION["ref_schema_version"] = get_schema_version(); + $_SESSION["ref_schema_version"] = Config::get_schema_version(); } else { self::authenticate(null, null, true); } @@ -217,6 +246,7 @@ class UserHelper { return substr(bin2hex(get_random_bytes(125)), 0, 250); } + /** TODO: this should invoke UserHelper::user_modify() */ static function reset_password(int $uid, bool $format_output = false, string $new_password = ""): void { $user = ORM::for_table('ttrss_users')->find_one($uid); @@ -335,18 +365,14 @@ class UserHelper { return null; } - static function is_default_password(): bool { - - /** @var Auth_Internal|false $authenticator -- this is only here to make check_password() visible to static analyzer */ - $authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]); - - if ($authenticator && - method_exists($authenticator, "check_password") && - $authenticator->check_password($_SESSION["uid"], "password")) { - - return true; - } - return false; + /** + * @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only + * @return bool + * @throws PDOException + * @throws Exception + */ + static function is_default_password(?int $owner_uid = null): bool { + return self::user_has_password($owner_uid, 'password'); } /** @@ -380,4 +406,115 @@ class UserHelper { else return false; } + + /** + * @param string $login Login for new user (case-insensitive) + * @param string $password Password for new user (may not be blank) + * @param UserHelper::ACCESS_LEVEL_* $access_level Access level for new user + * @return bool true if user has been created + */ + static function user_add(string $login, string $password, int $access_level) : bool { + $login = clean($login); + + if ($login && + $password && + !self::find_user_by_login($login) && + self::map_access_level((int)$access_level) != self::ACCESS_LEVEL_KEEP_CURRENT) { + + $user = ORM::for_table('ttrss_users')->create(); + + $user->salt = self::get_salt(); + $user->login = mb_strtolower($login); + $user->pwd_hash = self::hash_password($password, $user->salt); + $user->access_level = $access_level; + $user->created = Db::NOW(); + + return $user->save(); + } + + return false; + } + + /** + * @param int $uid User ID to modify + * @param string $new_password set password to this value if its not blank + * @param UserHelper::ACCESS_LEVEL_* $access_level set user access level to this value if it is set (default ACCESS_LEVEL_KEEP_CURRENT) + * @return bool true if user record has been saved + * + * NOTE: $access_level is of mixed type because of intellephense + */ + static function user_modify(int $uid, string $new_password = '', $access_level = self::ACCESS_LEVEL_KEEP_CURRENT) : bool { + $user = ORM::for_table('ttrss_users')->find_one($uid); + + if ($user) { + if ($new_password != '') { + $new_salt = self::get_salt(); + $pwd_hash = self::hash_password($new_password, $new_salt, self::HASH_ALGOS[0]); + + $user->pwd_hash = $pwd_hash; + $user->salt = $new_salt; + } + + if ($access_level != self::ACCESS_LEVEL_KEEP_CURRENT) { + $user->access_level = (int)$access_level; + } + + return $user->save(); + } + + return false; + } + + /** + * @param int $uid user ID to delete (this won't delete built-in admin user with UID 1) + * @return bool true if user has been deleted + */ + static function user_delete(int $uid) : bool { + if ($uid != 1) { + + $user = ORM::for_table('ttrss_users')->find_one($uid); + + if ($user) { + // TODO: is it still necessary to split those queries? + + ORM::for_table('ttrss_tags') + ->where('owner_uid', $uid) + ->delete_many(); + + ORM::for_table('ttrss_feeds') + ->where('owner_uid', $uid) + ->delete_many(); + + return $user->delete(); + } + } + + return false; + } + + /** + * @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only + * @param string $password password to compare hash against + * @return bool + */ + static function user_has_password(?int $owner_uid, string $password) : bool { + if ($owner_uid) { + $authenticator = new Auth_Internal(); + + return $authenticator->check_password($owner_uid, $password); + } else { + /** @var Auth_Internal|false $authenticator -- this is only here to make check_password() visible to static analyzer */ + $authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]); + + if ($authenticator && + method_exists($authenticator, "check_password") && + $authenticator->check_password($_SESSION["uid"], $password)) { + + return true; + } + } + + return false; + } + } |