From 74568df4ff7b7788991636f6fb2ed62012f85c3b Mon Sep 17 00:00:00 2001
From: Andrew Dolgov <noreply@fakecake.org>
Date: Tue, 22 Sep 2020 09:04:33 +0300
Subject: [PATCH] remove a lot of stuff from global context (functions.php),
 add a few helper classes instead

---
 api/index.php                                 |    2 +-
 backend.php                                   |    4 +-
 classes/api.php                               |   10 +-
 classes/backend.php                           |    4 +-
 classes/db.php                                |    9 +
 classes/diskcache.php                         |   54 +-
 classes/feeds.php                             |   10 +-
 classes/handler/public.php                    |   32 +-
 classes/pref/feeds.php                        |    2 +-
 classes/pref/prefs.php                        |   59 +-
 classes/pref/users.php                        |   23 +-
 classes/rpc.php                               |  300 +++-
 classes/rssutils.php                          |   16 +-
 classes/sanitizer.php                         |  217 +++
 classes/urlhelper.php                         |  474 ++++++
 classes/userhelper.php                        |  141 ++
 include/functions.php                         | 1400 +----------------
 index.php                                     |    4 +-
 plugins/af_comics/filters/af_comics_cad.php   |    2 +-
 .../filters/af_comics_comicclass.php          |    2 +-
 .../filters/af_comics_comicpress.php          |    4 +-
 .../filters/af_comics_darklegacy.php          |    2 +-
 .../af_comics/filters/af_comics_dilbert.php   |    2 +-
 .../af_comics/filters/af_comics_explosm.php   |    2 +-
 .../af_comics/filters/af_comics_gocomics.php  |    2 +-
 .../filters/af_comics_gocomics_farside.php    |    2 +-
 plugins/af_comics/filters/af_comics_pa.php    |    4 +-
 plugins/af_comics/filters/af_comics_pvp.php   |    2 +-
 plugins/af_comics/filters/af_comics_tfd.php   |    4 +-
 plugins/af_comics/filters/af_comics_twp.php   |    2 +-
 plugins/af_comics/filters/af_comics_whomp.php |    2 +-
 plugins/af_proxy_http/init.php                |    6 +-
 plugins/af_readability/init.php               |    8 +-
 plugins/af_redditimgur/init.php               |   10 +-
 plugins/cache_starred_images/init.php         |    8 +-
 plugins/no_iframes/init.php                   |    4 +-
 prefs.php                                     |    4 +-
 register.php                                  |    2 +-
 update.php                                    |   13 +
 39 files changed, 1377 insertions(+), 1471 deletions(-)
 create mode 100644 classes/sanitizer.php
 create mode 100644 classes/urlhelper.php
 create mode 100644 classes/userhelper.php

diff --git a/api/index.php b/api/index.php
index 363f2ae13..8e85919c6 100644
--- a/api/index.php
+++ b/api/index.php
@@ -67,7 +67,7 @@
 			return;
 		}
 
-		load_user_plugins( $_SESSION["uid"]);
+		UserHelper::load_user_plugins( $_SESSION["uid"]);
 	}
 
 	$method = strtolower($_REQUEST["op"]);
diff --git a/backend.php b/backend.php
index 8cdeafdb7..9e6751af1 100644
--- a/backend.php
+++ b/backend.php
@@ -41,7 +41,7 @@
 	}
 
 	if (SINGLE_USER_MODE) {
-		authenticate_user( "admin", null);
+		UserHelper::authenticate( "admin", null);
 	}
 
 	if ($_SESSION["uid"]) {
@@ -50,7 +50,7 @@
 			print error_json(6);
 			return;
 		}
-		load_user_plugins( $_SESSION["uid"]);
+		UserHelper::load_user_plugins($_SESSION["uid"]);
 	}
 
 	$purge_intervals = array(
diff --git a/classes/api.php b/classes/api.php
index 7b0c58a98..ce046e66a 100755
--- a/classes/api.php
+++ b/classes/api.php
@@ -74,10 +74,10 @@ class API extends Handler {
 		}
 
 		if (get_pref("ENABLE_API_ACCESS", $uid)) {
-			if (authenticate_user($login, $password, false,  Auth_Base::AUTH_SERVICE_API)) {               // try login with normal password
+			if (UserHelper::authenticate($login, $password, false,  Auth_Base::AUTH_SERVICE_API)) {               // try login with normal password
 				$this->wrap(self::STATUS_OK, array("session_id" => session_id(),
 					"api_level" => self::API_LEVEL));
-			} else if (authenticate_user($login, $password_base64, false, Auth_Base::AUTH_SERVICE_API)) { // else try with base64_decoded password
+			} else if (UserHelper::authenticate($login, $password_base64, false, Auth_Base::AUTH_SERVICE_API)) { // else try with base64_decoded password
 				$this->wrap(self::STATUS_OK,	array("session_id" => session_id(),
 					"api_level" => self::API_LEVEL));
 			} else {                                                         // else we are not logged in
@@ -91,7 +91,7 @@ class API extends Handler {
 	}
 
 	function logout() {
-		logout_user();
+		Pref_Users::logout_user();
 		$this->wrap(self::STATUS_OK, array("status" => "OK"));
 	}
 
@@ -343,7 +343,7 @@ class API extends Handler {
 				);
 
 				if ($sanitize_content) {
-					$article["content"] = sanitize(
+					$article["content"] = Sanitizer::sanitize(
 						$line["content"],
 						API::param_to_bool($line['hide_images']),
 						false, $line["site_url"], false, $line["id"]);
@@ -748,7 +748,7 @@ class API extends Handler {
 					if ($show_content) {
 
 						if ($sanitize_content) {
-							$headline_row["content"] = sanitize(
+							$headline_row["content"] = Sanitizer::sanitize(
 								$line["content"],
 								API::param_to_bool($line['hide_images']),
 								false, $line["site_url"], false, $line["id"]);
diff --git a/classes/backend.php b/classes/backend.php
index 27abfbf5f..16c20660a 100644
--- a/classes/backend.php
+++ b/classes/backend.php
@@ -19,8 +19,8 @@ class Backend extends Handler_Protected {
 		$topic = basename(clean($_REQUEST["topic"])); // only one for now
 
 		if ($topic == "main") {
-			$info = get_hotkeys_info();
-			$imap = get_hotkeys_map();
+			$info = RPC::get_hotkeys_info();
+			$imap = RPC::get_hotkeys_map();
 			$omap = array();
 
 			foreach ($imap[1] as $sequence => $action) {
diff --git a/classes/db.php b/classes/db.php
index ac493f6d6..70b97d0ee 100755
--- a/classes/db.php
+++ b/classes/db.php
@@ -113,4 +113,13 @@ class Db
 
 		return self::$instance->pdo;
 	}
+
+	public static function sql_random_function() {
+		if (DB_TYPE == "mysql") {
+			return "RAND()";
+		} else {
+			return "RANDOM()";
+		}
+	}
+
 }
diff --git a/classes/diskcache.php b/classes/diskcache.php
index da7722bf0..24e45ea8b 100644
--- a/classes/diskcache.php
+++ b/classes/diskcache.php
@@ -269,7 +269,7 @@ class DiskCache {
 
 		header("Content-Disposition: inline; filename=\"${filename}${fake_extension}\"");
 
-		return send_local_file($this->getFullPath($filename));
+		return $this->send_local_file($this->getFullPath($filename));
 	}
 
 	public function getUrl($filename) {
@@ -359,4 +359,56 @@ class DiskCache {
 			}
 		}
 	}
+
+	/*	this is essentially a wrapper for readfile() which allows plugins to hook
+		output with httpd-specific "fast" implementation i.e. X-Sendfile or whatever else
+
+		hook function should return true if request was handled (or at least attempted to)
+
+		note that this can be called without user context so the plugin to handle this
+		should be loaded systemwide in config.php */
+	function send_local_file($filename) {
+		if (file_exists($filename)) {
+
+			if (is_writable($filename)) touch($filename);
+
+			$mimetype = mime_content_type($filename);
+
+			// this is hardly ideal but 1) only media is cached in images/ and 2) seemingly only mp4
+			// video files are detected as octet-stream by mime_content_type()
+
+			if ($mimetype == "application/octet-stream")
+				$mimetype = "video/mp4";
+
+			# block SVG because of possible embedded javascript (.....)
+			$mimetype_blacklist = [ "image/svg+xml" ];
+
+			/* only serve video and images */
+			if (!preg_match("/(image|video)\//", $mimetype) || in_array($mimetype, $mimetype_blacklist)) {
+				http_response_code(400);
+				header("Content-type: text/plain");
+
+				print "Stored file has disallowed content type ($mimetype)";
+				return false;
+			}
+
+			$tmppluginhost = new PluginHost();
+
+			$tmppluginhost->load(PLUGINS, PluginHost::KIND_SYSTEM);
+			$tmppluginhost->load_data();
+
+			foreach ($tmppluginhost->get_hooks(PluginHost::HOOK_SEND_LOCAL_FILE) as $plugin) {
+				if ($plugin->hook_send_local_file($filename)) return true;
+			}
+
+			header("Content-type: $mimetype");
+
+			$stamp = gmdate("D, d M Y H:i:s", filemtime($filename)) . " GMT";
+			header("Last-Modified: $stamp", true);
+
+			return readfile($filename);
+		} else {
+			return false;
+		}
+	}
 }
diff --git a/classes/feeds.php b/classes/feeds.php
index 4714e4c9a..b428252d8 100755
--- a/classes/feeds.php
+++ b/classes/feeds.php
@@ -305,7 +305,7 @@ class Feeds extends Handler_Protected {
                     $line["buttons"] .= $p->hook_article_button($line);
                 }
 
-                $line["content"] = sanitize($line["content"],
+                $line["content"] = Sanitizer::sanitize($line["content"],
                     $line['hide_images'], false, $line["site_url"], $highlight_words, $line["id"]);
 
                 foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_RENDER_ARTICLE_CDM) as $p) {
@@ -550,7 +550,7 @@ class Feeds extends Handler_Protected {
             						"disable_cache" => (bool) $disable_cache];
 
 		// this is parsed by handleRpcJson() on first viewfeed() to set cdm expanded, etc
-		$reply['runtime-info'] = make_runtime_info();
+		$reply['runtime-info'] = RPC::make_runtime_info();
 
 		$reply_json = json_encode($reply);
 
@@ -1124,11 +1124,11 @@ class Feeds extends Handler_Protected {
 
 		$pdo = Db::pdo();
 
-		$url = validate_url($url);
+		$url = UrlHelper::validate($url);
 
 		if (!$url) return array("code" => 2);
 
-		$contents = @fetch_file_contents($url, false, $auth_login, $auth_pass);
+		$contents = @UrlHelper::fetch($url, false, $auth_login, $auth_pass);
 
 		foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_SUBSCRIBE_FEED) as $plugin) {
 			$contents = $plugin->hook_subscribe_feed($contents, $url, $auth_login, $auth_pass);
@@ -1924,7 +1924,7 @@ class Feeds extends Handler_Protected {
 	}
 
 	static function get_feeds_from_html($url, $content) {
-		$url     = validate_url($url);
+		$url     = UrlHelper::validate($url);
 		$baseUrl = substr($url, 0, strrpos($url, '/') + 1);
 
 		$feedUrls = [];
diff --git a/classes/handler/public.php b/classes/handler/public.php
index 822ad1fd9..9671b18b2 100755
--- a/classes/handler/public.php
+++ b/classes/handler/public.php
@@ -81,7 +81,7 @@ class Handler_Public extends Handler {
 			$tpl->setVariable('SELF_URL', htmlspecialchars(get_self_url_prefix()), true);
 			while ($line = $result->fetch()) {
 
-				$line["content_preview"] = sanitize(truncate_string(strip_tags($line["content"]), 100, '...'));
+				$line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...'));
 
 				foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_QUERY_HEADLINES) as $p) {
 					$line = $p->hook_query_headlines($line);
@@ -98,7 +98,7 @@ class Handler_Public extends Handler {
 				$tpl->setVariable('ARTICLE_TITLE', htmlspecialchars($line['title']), true);
 				$tpl->setVariable('ARTICLE_EXCERPT', $line["content_preview"], true);
 
-				$content = sanitize($line["content"], false, $owner_uid,
+				$content = Sanitizer::sanitize($line["content"], false, $owner_uid,
 					$feed_site_url, false, $line["id"]);
 
 				$content = DiskCache::rewriteUrls($content);
@@ -180,7 +180,7 @@ class Handler_Public extends Handler {
 
 			while ($line = $result->fetch()) {
 
-				$line["content_preview"] = sanitize(truncate_string(strip_tags($line["content_preview"]), 100, '...'));
+				$line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content_preview"]), 100, '...'));
 
 				foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_QUERY_HEADLINES) as $p) {
 					$line = $p->hook_query_headlines($line, 100);
@@ -196,7 +196,7 @@ class Handler_Public extends Handler {
 				$article['link']	= $line['link'];
 				$article['title'] = $line['title'];
 				$article['excerpt'] = $line["content_preview"];
-				$article['content'] = sanitize($line["content"], false, $owner_uid, $feed_site_url, false, $line["id"]);
+				$article['content'] = Sanitizer::sanitize($line["content"], false, $owner_uid, $feed_site_url, false, $line["id"]);
 				$article['updated'] = date('c', strtotime($line["updated"]));
 
 				if ($line['note']) $article['note'] = $line['note'];
@@ -284,7 +284,7 @@ class Handler_Public extends Handler {
 
 	function logout() {
 		if (validate_csrf($_POST["csrf_token"])) {
-			logout_user();
+			Pref_Users::logout_user();
 			header("Location: index.php");
 		} else {
 			header("Content-Type: text/json");
@@ -343,7 +343,7 @@ class Handler_Public extends Handler {
 			$line["tags"] = Article::get_article_tags($id, $owner_uid, $line["tag_cache"]);
 			unset($line["tag_cache"]);
 
-			$line["content"] = sanitize($line["content"],
+			$line["content"] = Sanitizer::sanitize($line["content"],
 				$line['hide_images'],
 				$owner_uid, $line["site_url"], false, $line["id"]);
 
@@ -470,7 +470,7 @@ class Handler_Public extends Handler {
 		if (!$format) $format = 'atom';
 
 		if (SINGLE_USER_MODE) {
-			authenticate_user("admin", null);
+			UserHelper::authenticate("admin", null);
 		}
 
 		$owner_id = false;
@@ -508,7 +508,7 @@ class Handler_Public extends Handler {
 
 	function sharepopup() {
 		if (SINGLE_USER_MODE) {
-			login_sequence();
+			UserHelper::login_sequence();
 		}
 
 		header('Content-Type: text/html; charset=utf-8');
@@ -681,7 +681,7 @@ class Handler_Public extends Handler {
 				@session_set_cookie_params(0);
 			}
 
-			if (authenticate_user($login, $password)) {
+			if (UserHelper::authenticate($login, $password)) {
 				$_POST["password"] = "";
 
 				if (get_schema_version() >= 120) {
@@ -729,7 +729,7 @@ class Handler_Public extends Handler {
 
 	function subscribe() {
 		if (SINGLE_USER_MODE) {
-			login_sequence();
+			UserHelper::login_sequence();
 		}
 
 		if ($_SESSION["uid"]) {
@@ -878,7 +878,7 @@ class Handler_Public extends Handler {
 			print "</div></div></body></html>";
 
 		} else {
-			render_login_form();
+			$this->render_login_form();
 		}
 	}
 
@@ -1092,7 +1092,7 @@ class Handler_Public extends Handler {
 
 		if (!SINGLE_USER_MODE && $_SESSION["access_level"] < 10) {
 			$_SESSION["login_error_msg"] = __("Your access level is insufficient to run this script.");
-			render_login_form();
+			$this->render_login_form();
 			exit;
 		}
 
@@ -1272,5 +1272,13 @@ class Handler_Public extends Handler {
 			print error_json(14);
 		}
 	}
+
+	static function render_login_form() {
+		header('Cache-Control: public');
+
+		require_once "login_form.php";
+		exit;
+	}
+
 }
 ?>
diff --git a/classes/pref/feeds.php b/classes/pref/feeds.php
index a6a543b76..4ff6ac9c2 100755
--- a/classes/pref/feeds.php
+++ b/classes/pref/feeds.php
@@ -1703,7 +1703,7 @@ class Pref_Feeds extends Handler_Protected {
 		foreach ($feeds as $feed) {
 			$feed = trim($feed);
 
-			if (validate_url($feed)) {
+			if (UrlHelper::validate($feed)) {
 
 				$this->pdo->beginTransaction();
 
diff --git a/classes/pref/prefs.php b/classes/pref/prefs.php
index ac2684683..f825454dd 100644
--- a/classes/pref/prefs.php
+++ b/classes/pref/prefs.php
@@ -257,7 +257,7 @@ class Pref_Prefs extends Handler_Protected {
 				AND owner_uid = :uid");
 		$sth->execute([":profile" => $_SESSION['profile'], ":uid" => $_SESSION['uid']]);
 
-		initialize_user_prefs($_SESSION["uid"], $_SESSION["profile"]);
+		$this->initialize_user_prefs($_SESSION["uid"], $_SESSION["profile"]);
 
 		echo __("Your preferences are now set to default values.");
 	}
@@ -590,9 +590,9 @@ class Pref_Prefs extends Handler_Protected {
 		if ($profile) {
 			print_notice(__("Some preferences are only available in default profile."));
 
-			initialize_user_prefs($_SESSION["uid"], $profile);
+			$this->initialize_user_prefs($_SESSION["uid"], $profile);
 		} else {
-			initialize_user_prefs($_SESSION["uid"]);
+			$this->initialize_user_prefs($_SESSION["uid"]);
 		}
 
 		$prefs_available = [];
@@ -1366,4 +1366,57 @@ class Pref_Prefs extends Handler_Protected {
 
 		$this->appPasswordList();
 	}
+
+	static function initialize_user_prefs($uid, $profile = false) {
+
+		if (get_schema_version() < 63) $profile_qpart = "";
+
+		$pdo = Db::pdo();
+		$in_nested_tr = false;
+
+		try {
+			$pdo->beginTransaction();
+		} catch (Exception $e) {
+			$in_nested_tr = true;
+		}
+
+		$sth = $pdo->query("SELECT pref_name,def_value FROM ttrss_prefs");
+
+		if (!is_numeric($profile) || !$profile || get_schema_version() < 63) $profile = null;
+
+		$u_sth = $pdo->prepare("SELECT pref_name
+			FROM ttrss_user_prefs WHERE owner_uid = :uid AND
+				(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
+		$u_sth->execute([':uid' => $uid, ':profile' => $profile]);
+
+		$active_prefs = array();
+
+		while ($line = $u_sth->fetch()) {
+			array_push($active_prefs, $line["pref_name"]);
+		}
+
+		while ($line = $sth->fetch()) {
+			if (array_search($line["pref_name"], $active_prefs) === false) {
+//				print "adding " . $line["pref_name"] . "<br>";
+
+				if (get_schema_version() < 63) {
+					$i_sth = $pdo->prepare("INSERT INTO ttrss_user_prefs
+						(owner_uid,pref_name,value) VALUES
+						(?, ?, ?)");
+					$i_sth->execute([$uid, $line["pref_name"], $line["def_value"]]);
+
+				} else {
+					$i_sth = $pdo->prepare("INSERT INTO ttrss_user_prefs
+						(owner_uid,pref_name,value, profile) VALUES
+						(?, ?, ?, ?)");
+					$i_sth->execute([$uid, $line["pref_name"], $line["def_value"], $profile]);
+				}
+
+			}
+		}
+
+		if (!$in_nested_tr) $pdo->commit();
+
+	}
+
 }
diff --git a/classes/pref/users.php b/classes/pref/users.php
index 851d4fa9e..aeabc4502 100644
--- a/classes/pref/users.php
+++ b/classes/pref/users.php
@@ -259,7 +259,7 @@ class Pref_Users extends Handler_Protected {
 					print T_sprintf("Added user %s with password %s",
 						$login, $tmp_user_pwd);
 
-					initialize_user($new_uid);
+					$this->initialize_user($new_uid);
 
 				} else {
 
@@ -443,4 +443,25 @@ class Pref_Users extends Handler_Protected {
 				return $default;
 		}
 
+	// this is called after user is created to initialize default feeds, labels
+	// or whatever else
+	// user preferences are checked on every login, not here
+	static function initialize_user($uid) {
+
+		$pdo = Db::pdo();
+
+		$sth = $pdo->prepare("insert into ttrss_feeds (owner_uid,title,feed_url)
+			values (?, 'Tiny Tiny RSS: Forum',
+				'https://tt-rss.org/forum/rss.php')");
+		$sth->execute([$uid]);
+	}
+
+	static function logout_user() {
+		@session_destroy();
+		if (isset($_COOKIE[session_name()])) {
+		   setcookie(session_name(), '', time()-42000, '/');
+		}
+		session_commit();
+	}
+
 }
diff --git a/classes/rpc.php b/classes/rpc.php
index 55651cc27..a7123566c 100755
--- a/classes/rpc.php
+++ b/classes/rpc.php
@@ -52,7 +52,7 @@ class RPC extends Handler_Protected {
 					$profile_id = $row['id'];
 
 					if ($profile_id) {
-						initialize_user_prefs($_SESSION["uid"], $profile_id);
+						Pref_Prefs::initialize_user_prefs($_SESSION["uid"], $profile_id);
 					}
 				}
 			}
@@ -279,7 +279,7 @@ class RPC extends Handler_Protected {
 		];
 
 		if ($seq % 2 == 0)
-			$reply['runtime-info'] = make_runtime_info();
+			$reply['runtime-info'] = $this->make_runtime_info();
 
 		print json_encode($reply);
 	}
@@ -323,8 +323,8 @@ class RPC extends Handler_Protected {
 		$reply['error'] = sanity_check();
 
 		if ($reply['error']['code'] == 0) {
-			$reply['init-params'] = make_init_params();
-			$reply['runtime-info'] = make_runtime_info();
+			$reply['init-params'] = $this->make_init_params();
+			$reply['runtime-info'] = $this->make_runtime_info();
 		}
 
 		print json_encode($reply);
@@ -461,7 +461,7 @@ class RPC extends Handler_Protected {
 			$updstart_thresh_qpart = "AND (ttrss_feeds.last_update_started IS NULL OR ttrss_feeds.last_update_started < DATE_SUB(NOW(), INTERVAL 5 MINUTE))";
 		}
 
-		$random_qpart = sql_random_function();
+		$random_qpart = Db::sql_random_function();
 
 		$pdo = Db::pdo();
 
@@ -602,7 +602,7 @@ class RPC extends Handler_Protected {
 		get_version($git_commit, $git_timestamp);
 
 		if (defined('CHECK_FOR_UPDATES') && CHECK_FOR_UPDATES && $_SESSION["access_level"] >= 10 && $git_timestamp) {
-			$content = @fetch_file_contents(["url" => "https://tt-rss.org/version.json"]);
+			$content = @UrlHelper::fetch(["url" => "https://tt-rss.org/version.json"]);
 
 			if ($content) {
 				$content = json_decode($content, true);
@@ -620,4 +620,292 @@ class RPC extends Handler_Protected {
 		print json_encode($rv);
 	}
 
+	private function make_init_params() {
+		$params = array();
+
+		foreach (array("ON_CATCHUP_SHOW_NEXT_FEED", "HIDE_READ_FEEDS",
+					 "ENABLE_FEED_CATS", "FEEDS_SORT_BY_UNREAD", "CONFIRM_FEED_CATCHUP",
+					 "CDM_AUTO_CATCHUP", "FRESH_ARTICLE_MAX_AGE",
+					 "HIDE_READ_SHOWS_SPECIAL", "COMBINED_DISPLAY_MODE") as $param) {
+
+			$params[strtolower($param)] = (int) get_pref($param);
+		}
+
+		$params["check_for_updates"] = CHECK_FOR_UPDATES;
+		$params["icons_url"] = ICONS_URL;
+		$params["cookie_lifetime"] = SESSION_COOKIE_LIFETIME;
+		$params["default_view_mode"] = get_pref("_DEFAULT_VIEW_MODE");
+		$params["default_view_limit"] = (int) get_pref("_DEFAULT_VIEW_LIMIT");
+		$params["default_view_order_by"] = get_pref("_DEFAULT_VIEW_ORDER_BY");
+		$params["bw_limit"] = (int) $_SESSION["bw_limit"];
+		$params["is_default_pw"] = Pref_Prefs::isdefaultpassword();
+		$params["label_base_index"] = (int) LABEL_BASE_INDEX;
+
+		$theme = get_pref( "USER_CSS_THEME", false, false);
+		$params["theme"] = theme_exists($theme) ? $theme : "";
+
+		$params["plugins"] = implode(", ", PluginHost::getInstance()->get_plugin_names());
+
+		$params["php_platform"] = PHP_OS;
+		$params["php_version"] = PHP_VERSION;
+
+		$params["sanity_checksum"] = sha1(file_get_contents("include/sanity_check.php"));
+
+		$pdo = Db::pdo();
+
+		$sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM
+				ttrss_feeds WHERE owner_uid = ?");
+		$sth->execute([$_SESSION['uid']]);
+		$row = $sth->fetch();
+
+		$max_feed_id = $row["mid"];
+		$num_feeds = $row["nf"];
+
+		$params["self_url_prefix"] = get_self_url_prefix();
+		$params["max_feed_id"] = (int) $max_feed_id;
+		$params["num_feeds"] = (int) $num_feeds;
+
+		$params["hotkeys"] = $this->get_hotkeys_map();
+
+		$params["widescreen"] = (int) $_COOKIE["ttrss_widescreen"];
+
+		$params['simple_update'] = defined('SIMPLE_UPDATE_MODE') && SIMPLE_UPDATE_MODE;
+
+		$params["icon_indicator_white"] = $this->image_to_base64("images/indicator_white.gif");
+
+		$params["labels"] = Labels::get_all_labels($_SESSION["uid"]);
+
+		return $params;
+	}
+
+	private function image_to_base64($filename) {
+		if (file_exists($filename)) {
+			$ext = pathinfo($filename, PATHINFO_EXTENSION);
+
+			return "data:image/$ext;base64," . base64_encode(file_get_contents($filename));
+		} else {
+			return "";
+		}
+	}
+
+	static function make_runtime_info() {
+		$data = array();
+
+		$pdo = Db::pdo();
+
+		$sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM
+				ttrss_feeds WHERE owner_uid = ?");
+		$sth->execute([$_SESSION['uid']]);
+		$row = $sth->fetch();
+
+		$max_feed_id = $row['mid'];
+		$num_feeds = $row['nf'];
+
+		$data["max_feed_id"] = (int) $max_feed_id;
+		$data["num_feeds"] = (int) $num_feeds;
+		$data['cdm_expanded'] = get_pref('CDM_EXPANDED');
+		$data["labels"] = Labels::get_all_labels($_SESSION["uid"]);
+
+		if (LOG_DESTINATION == 'sql' && $_SESSION['access_level'] >= 10) {
+			if (DB_TYPE == 'pgsql') {
+				$log_interval = "created_at > NOW() - interval '1 hour'";
+			} else {
+				$log_interval = "created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)";
+			}
+
+			$sth = $pdo->prepare("SELECT COUNT(id) AS cid FROM ttrss_error_log WHERE $log_interval");
+			$sth->execute();
+
+			if ($row = $sth->fetch()) {
+				$data['recent_log_events'] = $row['cid'];
+			}
+		}
+
+		if (file_exists(LOCK_DIRECTORY . "/update_daemon.lock")) {
+
+			$data['daemon_is_running'] = (int) file_is_locked("update_daemon.lock");
+
+			if (time() - $_SESSION["daemon_stamp_check"] > 30) {
+
+				$stamp = (int) @file_get_contents(LOCK_DIRECTORY . "/update_daemon.stamp");
+
+				if ($stamp) {
+					$stamp_delta = time() - $stamp;
+
+					if ($stamp_delta > 1800) {
+						$stamp_check = 0;
+					} else {
+						$stamp_check = 1;
+						$_SESSION["daemon_stamp_check"] = time();
+					}
+
+					$data['daemon_stamp_ok'] = $stamp_check;
+
+					$stamp_fmt = date("Y.m.d, G:i", $stamp);
+
+					$data['daemon_stamp'] = $stamp_fmt;
+				}
+			}
+		}
+
+		return $data;
+	}
+
+	static function get_hotkeys_info() {
+		$hotkeys = array(
+			__("Navigation") => array(
+				"next_feed" => __("Open next feed"),
+				"prev_feed" => __("Open previous feed"),
+				"next_article_or_scroll" => __("Open next article (in combined mode, scroll down)"),
+				"prev_article_or_scroll" => __("Open previous article (in combined mode, scroll up)"),
+				"next_headlines_page" => __("Scroll headlines by one page down"),
+				"prev_headlines_page" => __("Scroll headlines by one page up"),
+				"next_article_noscroll" => __("Open next article"),
+				"prev_article_noscroll" => __("Open previous article"),
+				"next_article_noexpand" => __("Move to next article (don't expand)"),
+				"prev_article_noexpand" => __("Move to previous article (don't expand)"),
+				"search_dialog" => __("Show search dialog"),
+				"cancel_search" => __("Cancel active search")),
+			__("Article") => array(
+				"toggle_mark" => __("Toggle starred"),
+				"toggle_publ" => __("Toggle published"),
+				"toggle_unread" => __("Toggle unread"),
+				"edit_tags" => __("Edit tags"),
+				"open_in_new_window" => __("Open in new window"),
+				"catchup_below" => __("Mark below as read"),
+				"catchup_above" => __("Mark above as read"),
+				"article_scroll_down" => __("Scroll down"),
+				"article_scroll_up" => __("Scroll up"),
+				"article_page_down" => __("Scroll down page"),
+				"article_page_up" => __("Scroll up page"),
+				"select_article_cursor" => __("Select article under cursor"),
+				"email_article" => __("Email article"),
+				"close_article" => __("Close/collapse article"),
+				"toggle_expand" => __("Toggle article expansion (combined mode)"),
+				"toggle_widescreen" => __("Toggle widescreen mode"),
+				"toggle_full_text" => __("Toggle full article text via Readability")),
+			__("Article selection") => array(
+				"select_all" => __("Select all articles"),
+				"select_unread" => __("Select unread"),
+				"select_marked" => __("Select starred"),
+				"select_published" => __("Select published"),
+				"select_invert" => __("Invert selection"),
+				"select_none" => __("Deselect everything")),
+			__("Feed") => array(
+				"feed_refresh" => __("Refresh current feed"),
+				"feed_unhide_read" => __("Un/hide read feeds"),
+				"feed_subscribe" => __("Subscribe to feed"),
+				"feed_edit" => __("Edit feed"),
+				"feed_catchup" => __("Mark as read"),
+				"feed_reverse" => __("Reverse headlines"),
+				"feed_toggle_vgroup" => __("Toggle headline grouping"),
+				"feed_debug_update" => __("Debug feed update"),
+				"feed_debug_viewfeed" => __("Debug viewfeed()"),
+				"catchup_all" => __("Mark all feeds as read"),
+				"cat_toggle_collapse" => __("Un/collapse current category"),
+				"toggle_cdm_expanded" => __("Toggle auto expand in combined mode"),
+				"toggle_combined_mode" => __("Toggle combined mode")),
+			__("Go to") => array(
+				"goto_all" => __("All articles"),
+				"goto_fresh" => __("Fresh"),
+				"goto_marked" => __("Starred"),
+				"goto_published" => __("Published"),
+				"goto_read" => __("Recently read"),
+				"goto_tagcloud" => __("Tag cloud"),
+				"goto_prefs" => __("Preferences")),
+			__("Other") => array(
+				"create_label" => __("Create label"),
+				"create_filter" => __("Create filter"),
+				"collapse_sidebar" => __("Un/collapse sidebar"),
+				"help_dialog" => __("Show help dialog"))
+		);
+
+		foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_HOTKEY_INFO) as $plugin) {
+			$hotkeys = $plugin->hook_hotkey_info($hotkeys);
+		}
+
+		return $hotkeys;
+	}
+
+	// {3} - 3 panel mode only
+	// {C} - combined mode only
+	static function get_hotkeys_map() {
+		$hotkeys = array(
+			"k" => "next_feed",
+			"j" => "prev_feed",
+			"n" => "next_article_noscroll",
+			"p" => "prev_article_noscroll",
+			"N" => "article_page_down",
+			"P" => "article_page_up",
+			"*(33)|Shift+PgUp" => "article_page_up",
+			"*(34)|Shift+PgDn" => "article_page_down",
+			"{3}(38)|Up" => "prev_article_or_scroll",
+			"{3}(40)|Down" => "next_article_or_scroll",
+			"*(38)|Shift+Up" => "article_scroll_up",
+			"*(40)|Shift+Down" => "article_scroll_down",
+			"^(38)|Ctrl+Up" => "prev_article_noscroll",
+			"^(40)|Ctrl+Down" => "next_article_noscroll",
+			"/" => "search_dialog",
+			"\\" => "cancel_search",
+			"s" => "toggle_mark",
+			"S" => "toggle_publ",
+			"u" => "toggle_unread",
+			"T" => "edit_tags",
+			"o" => "open_in_new_window",
+			"c p" => "catchup_below",
+			"c n" => "catchup_above",
+			"a W" => "toggle_widescreen",
+			"a e" => "toggle_full_text",
+			"e" => "email_article",
+			"a q" => "close_article",
+			"a a" => "select_all",
+			"a u" => "select_unread",
+			"a U" => "select_marked",
+			"a p" => "select_published",
+			"a i" => "select_invert",
+			"a n" => "select_none",
+			"f r" => "feed_refresh",
+			"f a" => "feed_unhide_read",
+			"f s" => "feed_subscribe",
+			"f e" => "feed_edit",
+			"f q" => "feed_catchup",
+			"f x" => "feed_reverse",
+			"f g" => "feed_toggle_vgroup",
+			"f D" => "feed_debug_update",
+			"f G" => "feed_debug_viewfeed",
+			"f C" => "toggle_combined_mode",
+			"f c" => "toggle_cdm_expanded",
+			"Q" => "catchup_all",
+			"x" => "cat_toggle_collapse",
+			"g a" => "goto_all",
+			"g f" => "goto_fresh",
+			"g s" => "goto_marked",
+			"g p" => "goto_published",
+			"g r" => "goto_read",
+			"g t" => "goto_tagcloud",
+			"g P" => "goto_prefs",
+			"r" => "select_article_cursor",
+			"c l" => "create_label",
+			"c f" => "create_filter",
+			"c s" => "collapse_sidebar",
+			"?" => "help_dialog",
+		);
+
+		foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_HOTKEY_MAP) as $plugin) {
+			$hotkeys = $plugin->hook_hotkey_map($hotkeys);
+		}
+
+		$prefixes = array();
+
+		foreach (array_keys($hotkeys) as $hotkey) {
+			$pair = explode(" ", $hotkey, 2);
+
+			if (count($pair) > 1 && !in_array($pair[0], $prefixes)) {
+				array_push($prefixes, $pair[0]);
+			}
+		}
+
+		return array($prefixes, $hotkeys);
+	}
+
 }
diff --git a/classes/rssutils.php b/classes/rssutils.php
index 7cfbca6d3..14a0a18a7 100755
--- a/classes/rssutils.php
+++ b/classes/rssutils.php
@@ -218,7 +218,7 @@ class RSSUtils {
 			}
 
 			if (!$basic_info) {
-				$feed_data = fetch_file_contents($fetch_url, false,
+				$feed_data = UrlHelper::fetch($fetch_url, false,
 					$auth_login, $auth_pass, false,
 					FEED_FETCH_TIMEOUT,
 					0);
@@ -268,8 +268,6 @@ class RSSUtils {
 	 */
 	static function update_rss_feed($feed, $no_cache = false) {
 
-		reset_fetch_domain_quota();
-
 		Debug::log("start", Debug::$LOG_VERBOSE);
 
 		$pdo = Db::pdo();
@@ -400,7 +398,7 @@ class RSSUtils {
 
 			Debug::log("fetching [$fetch_url] (force_refetch: $force_refetch)...", Debug::$LOG_VERBOSE);
 
-			$feed_data = fetch_file_contents([
+			$feed_data = UrlHelper::fetch([
 				"url" => $fetch_url,
 				"login" => $auth_login,
 				"pass" => $auth_pass,
@@ -1225,7 +1223,7 @@ class RSSUtils {
 						global $fetch_last_error_code;
 						global $fetch_last_error;
 
-						$file_content = fetch_file_contents(array("url" => $src,
+						$file_content = UrlHelper::fetch(array("url" => $src,
 							"http_referrer" => $src,
 							"max_size" => MAX_CACHE_FILE_SIZE));
 
@@ -1255,7 +1253,7 @@ class RSSUtils {
 			global $fetch_last_error_code;
 			global $fetch_last_error;
 
-			$file_content = fetch_file_contents(array("url" => $url,
+			$file_content = UrlHelper::fetch(array("url" => $url,
 				"http_referrer" => $url,
 				"max_size" => MAX_CACHE_FILE_SIZE));
 
@@ -1517,7 +1515,7 @@ class RSSUtils {
 	static function housekeeping_user($owner_uid) {
 		$tmph = new PluginHost();
 
-		load_user_plugins($owner_uid, $tmph);
+		UserHelper::load_user_plugins($owner_uid, $tmph);
 
 		$tmph->run_hooks(PluginHost::HOOK_HOUSE_KEEPING, "hook_house_keeping", "");
 	}
@@ -1546,7 +1544,7 @@ class RSSUtils {
 
 			if ($favicon_url) {
 				// Limiting to "image" type misses those served with text/plain
-				$contents = fetch_file_contents($favicon_url); // , "image");
+				$contents = UrlHelper::fetch($favicon_url); // , "image");
 
 				if ($contents) {
 					// Crude image type matching.
@@ -1719,7 +1717,7 @@ class RSSUtils {
 
 		$favicon_url = false;
 
-		if ($html = @fetch_file_contents($url)) {
+		if ($html = @UrlHelper::fetch($url)) {
 
 			$doc = new DOMDocument();
 			if ($doc->loadHTML($html)) {
diff --git a/classes/sanitizer.php b/classes/sanitizer.php
new file mode 100644
index 000000000..8e008ef85
--- /dev/null
+++ b/classes/sanitizer.php
@@ -0,0 +1,217 @@
+<?php
+class Sanitizer {
+	private static function strip_harmful_tags($doc, $allowed_elements, $disallowed_attributes) {
+		$xpath = new DOMXPath($doc);
+		$entries = $xpath->query('//*');
+
+		foreach ($entries as $entry) {
+			if (!in_array($entry->nodeName, $allowed_elements)) {
+				$entry->parentNode->removeChild($entry);
+			}
+
+			if ($entry->hasAttributes()) {
+				$attrs_to_remove = array();
+
+				foreach ($entry->attributes as $attr) {
+
+					if (strpos($attr->nodeName, 'on') === 0) {
+						array_push($attrs_to_remove, $attr);
+					}
+
+					if (strpos($attr->nodeName, "data-") === 0) {
+						array_push($attrs_to_remove, $attr);
+					}
+
+					if ($attr->nodeName == 'href' && stripos($attr->value, 'javascript:') === 0) {
+						array_push($attrs_to_remove, $attr);
+					}
+
+					if (in_array($attr->nodeName, $disallowed_attributes)) {
+						array_push($attrs_to_remove, $attr);
+					}
+				}
+
+				foreach ($attrs_to_remove as $attr) {
+					$entry->removeAttributeNode($attr);
+				}
+			}
+		}
+
+		return $doc;
+	}
+
+	public static function iframe_whitelisted($entry) {
+		@$src = parse_url($entry->getAttribute("src"), PHP_URL_HOST);
+
+		if ($src) {
+			foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_IFRAME_WHITELISTED) as $plugin) {
+				if ($plugin->hook_iframe_whitelisted($src))
+					return true;
+			}
+		}
+
+		return false;
+	}
+
+	public static function sanitize($str, $force_remove_images = false, $owner = false, $site_url = false, $highlight_words = false, $article_id = false) {
+		if (!$owner) $owner = $_SESSION["uid"];
+
+		$res = trim($str); if (!$res) return '';
+
+		$doc = new DOMDocument();
+		$doc->loadHTML('<?xml encoding="UTF-8">' . $res);
+		$xpath = new DOMXPath($doc);
+
+		$rewrite_base_url = $site_url ? $site_url : get_self_url_prefix();
+
+		$entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src])');
+
+		foreach ($entries as $entry) {
+
+			if ($entry->hasAttribute('href')) {
+				$entry->setAttribute('href',
+					rewrite_relative_url($rewrite_base_url, $entry->getAttribute('href')));
+
+				$entry->setAttribute('rel', 'noopener noreferrer');
+				$entry->setAttribute("target", "_blank");
+			}
+
+			if ($entry->hasAttribute('src')) {
+				$entry->setAttribute('src',
+					rewrite_relative_url($rewrite_base_url, $entry->getAttribute('src')));
+			}
+
+			if ($entry->nodeName == 'img') {
+				$entry->setAttribute('referrerpolicy', 'no-referrer');
+				$entry->setAttribute('loading', 'lazy');
+			}
+
+			if ($entry->hasAttribute('srcset')) {
+				$matches = RSSUtils::decode_srcset($entry->getAttribute('srcset'));
+
+				for ($i = 0; $i < count($matches); $i++) {
+					$matches[$i]["url"] = rewrite_relative_url($rewrite_base_url, $matches[$i]["url"]);
+				}
+
+				$entry->setAttribute("srcset", RSSUtils::encode_srcset($matches));
+			}
+
+			if ($entry->hasAttribute('src') &&
+					($owner && get_pref("STRIP_IMAGES", $owner)) || $force_remove_images || $_SESSION["bw_limit"]) {
+
+				$p = $doc->createElement('p');
+
+				$a = $doc->createElement('a');
+				$a->setAttribute('href', $entry->getAttribute('src'));
+
+				$a->appendChild(new DOMText($entry->getAttribute('src')));
+				$a->setAttribute('target', '_blank');
+				$a->setAttribute('rel', 'noopener noreferrer');
+
+				$p->appendChild($a);
+
+				if ($entry->nodeName == 'source') {
+
+					if ($entry->parentNode && $entry->parentNode->parentNode)
+						$entry->parentNode->parentNode->replaceChild($p, $entry->parentNode);
+
+				} else if ($entry->nodeName == 'img') {
+					if ($entry->parentNode)
+						$entry->parentNode->replaceChild($p, $entry);
+				}
+			}
+		}
+
+		$entries = $xpath->query('//iframe');
+		foreach ($entries as $entry) {
+			if (!Sanitizer::iframe_whitelisted($entry)) {
+				$entry->setAttribute('sandbox', 'allow-scripts');
+			} else {
+				if (is_prefix_https()) {
+					$entry->setAttribute("src",
+						str_replace("http://", "https://",
+							$entry->getAttribute("src")));
+				}
+			}
+		}
+
+		$allowed_elements = array('a', 'abbr', 'address', 'acronym', 'audio', 'article', 'aside',
+			'b', 'bdi', 'bdo', 'big', 'blockquote', 'body', 'br',
+			'caption', 'cite', 'center', 'code', 'col', 'colgroup',
+			'data', 'dd', 'del', 'details', 'description', 'dfn', 'div', 'dl', 'font',
+			'dt', 'em', 'footer', 'figure', 'figcaption',
+			'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'html', 'i',
+			'img', 'ins', 'kbd', 'li', 'main', 'mark', 'nav', 'noscript',
+			'ol', 'p', 'picture', 'pre', 'q', 'ruby', 'rp', 'rt', 's', 'samp', 'section',
+			'small', 'source', 'span', 'strike', 'strong', 'sub', 'summary',
+			'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time',
+			'tr', 'track', 'tt', 'u', 'ul', 'var', 'wbr', 'video', 'xml:namespace' );
+
+		if ($_SESSION['hasSandbox']) $allowed_elements[] = 'iframe';
+
+		$disallowed_attributes = array('id', 'style', 'class', 'width', 'height', 'allow');
+
+		foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_SANITIZE) as $plugin) {
+			$retval = $plugin->hook_sanitize($doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id);
+			if (is_array($retval)) {
+				$doc = $retval[0];
+				$allowed_elements = $retval[1];
+				$disallowed_attributes = $retval[2];
+			} else {
+				$doc = $retval;
+			}
+		}
+
+		$doc->removeChild($doc->firstChild); //remove doctype
+		$doc = Sanitizer::strip_harmful_tags($doc, $allowed_elements, $disallowed_attributes);
+
+		$entries = $xpath->query('//iframe');
+		foreach ($entries as $entry) {
+			$div = $doc->createElement('div');
+			$div->setAttribute('class', 'embed-responsive');
+			$entry->parentNode->replaceChild($div, $entry);
+			$div->appendChild($entry);
+		}
+
+		if ($highlight_words && is_array($highlight_words)) {
+			foreach ($highlight_words as $word) {
+
+				// http://stackoverflow.com/questions/4081372/highlight-keywords-in-a-paragraph
+
+				$elements = $xpath->query("//*/text()");
+
+				foreach ($elements as $child) {
+
+					$fragment = $doc->createDocumentFragment();
+					$text = $child->textContent;
+
+					while (($pos = mb_stripos($text, $word)) !== false) {
+						$fragment->appendChild(new DomText(mb_substr($text, 0, $pos)));
+						$word = mb_substr($text, $pos, mb_strlen($word));
+						$highlight = $doc->createElement('span');
+						$highlight->appendChild(new DomText($word));
+						$highlight->setAttribute('class', 'highlight');
+						$fragment->appendChild($highlight);
+						$text = mb_substr($text, $pos + mb_strlen($word));
+					}
+
+					if (!empty($text)) $fragment->appendChild(new DomText($text));
+
+					$child->parentNode->replaceChild($fragment, $child);
+				}
+			}
+		}
+
+		$res = $doc->saveHTML();
+
+		/* strip everything outside of <body>...</body> */
+
+		$res_frag = array();
+		if (preg_match('/<body>(.*)<\/body>/is', $res, $res_frag)) {
+			return $res_frag[1];
+		} else {
+			return $res;
+		}
+	}
+
+}
diff --git a/classes/urlhelper.php b/classes/urlhelper.php
new file mode 100644
index 000000000..c57d0b9d9
--- /dev/null
+++ b/classes/urlhelper.php
@@ -0,0 +1,474 @@
+<?php
+class UrlHelper {
+	static function build_url($parts) {
+		$tmp = $parts['scheme'] . "://" . $parts['host'] . $parts['path'];
+
+		if (isset($parts['query'])) $tmp .= '?' . $parts['query'];
+		if (isset($parts['fragment'])) $tmp .= '#' . $parts['fragment'];
+
+		return $tmp;
+	}
+
+	/**
+	 * Converts a (possibly) relative URL to a absolute one.
+	 *
+	 * @param string $url     Base URL (i.e. from where the document is)
+	 * @param string $rel_url Possibly relative URL in the document
+	 *
+	 * @return string Absolute URL
+	 */
+	public static function rewrite_relative($url, $rel_url) {
+
+		$rel_parts = parse_url($rel_url);
+
+		if ($rel_parts['host'] && $rel_parts['scheme']) {
+			return UrlHelper::validate($rel_url);
+		} else if (strpos($rel_url, "//") === 0) {
+			# protocol-relative URL (rare but they exist)
+			return UrlHelper::validate("https:" . $rel_url);
+		} else if (strpos($rel_url, "magnet:") === 0) {
+			# allow magnet links
+			return $rel_url;
+		} else {
+			$parts = parse_url($url);
+
+			$rel_parts['host'] = $parts['host'];
+			$rel_parts['scheme'] = $parts['scheme'];
+
+			if (strpos($rel_parts['path'], '/') !== 0)
+				$rel_parts['path'] = '/' . $rel_parts['path'];
+
+			$rel_parts['path'] = str_replace("/./", "/", $rel_parts['path']);
+			$rel_parts['path'] = str_replace("//", "/", $rel_parts['path']);
+
+			return UrlHelper::validate(UrlHelper::build_url($rel_parts));
+		}
+	}
+
+	// extended filtering involves validation for safe ports and loopback
+	static function validate($url, $extended_filtering = false) {
+
+		$url = clean($url);
+
+		# fix protocol-relative URLs
+		if (strpos($url, "//") === 0)
+			$url = "https:" . $url;
+
+		if (filter_var($url, FILTER_VALIDATE_URL) === false)
+			return false;
+
+		$tokens = parse_url($url);
+
+		if (!$tokens['host'])
+			return false;
+
+		if (!in_array(strtolower($tokens['scheme']), ['http', 'https']))
+			return false;
+
+		if ($extended_filtering) {
+			if (!in_array($tokens['port'], [80, 443, '']))
+				return false;
+
+			if (strtolower($tokens['host']) == 'localhost' || $tokens['host'] == '::1' || strpos($tokens['host'], '127.') === 0)
+				return false;
+		}
+
+		//convert IDNA hostname to punycode if possible
+		if (function_exists("idn_to_ascii")) {
+			if (mb_detect_encoding($tokens['host']) != 'ASCII') {
+				$parts['host'] = idn_to_ascii($tokens['host']);
+				$url = UrlHelper::build_url($tokens);
+			}
+		}
+
+		return $url;
+	}
+
+	static function resolve_redirects($url, $timeout, $nest = 0) {
+
+		// too many redirects
+		if ($nest > 10)
+			return false;
+
+		if (version_compare(PHP_VERSION, '7.1.0', '>=')) {
+			$context_options = array(
+				'http' => array(
+					 'header' => array(
+						 'Connection: close'
+					 ),
+					 'method' => 'HEAD',
+					 'timeout' => $timeout,
+					 'protocol_version'=> 1.1)
+				);
+
+			if (defined('_HTTP_PROXY')) {
+				$context_options['http']['request_fulluri'] = true;
+				$context_options['http']['proxy'] = _HTTP_PROXY;
+			}
+
+			$context = stream_context_create($context_options);
+
+			$headers = get_headers($url, 0, $context);
+		} else {
+			$headers = get_headers($url, 0);
+		}
+
+		if (is_array($headers)) {
+			$headers = array_reverse($headers); // last one is the correct one
+
+			foreach($headers as $header) {
+				if (stripos($header, 'Location:') === 0) {
+					$url = UrlHelper::rewrite_relative($url, trim(substr($header, strlen('Location:'))));
+
+					return resolve_redirects($url, $timeout, $nest + 1);
+				}
+			}
+
+			return $url;
+		}
+
+		// request failed?
+		return false;
+	}
+
+		// TODO: max_size currently only works for CURL transfers
+	// TODO: multiple-argument way is deprecated, first parameter is a hash now
+	public static function fetch($options /* previously: 0: $url , 1: $type = false, 2: $login = false, 3: $pass = false,
+				4: $post_query = false, 5: $timeout = false, 6: $timestamp = 0, 7: $useragent = false*/) {
+
+		global $fetch_last_error;
+		global $fetch_last_error_code;
+		global $fetch_last_error_content;
+		global $fetch_last_content_type;
+		global $fetch_last_modified;
+		global $fetch_effective_url;
+		global $fetch_effective_ip_addr;
+		global $fetch_curl_used;
+		global $fetch_domain_hits;
+
+		$fetch_last_error = false;
+		$fetch_last_error_code = -1;
+		$fetch_last_error_content = "";
+		$fetch_last_content_type = "";
+		$fetch_curl_used = false;
+		$fetch_last_modified = "";
+		$fetch_effective_url = "";
+		$fetch_effective_ip_addr = "";
+
+		if (!is_array($fetch_domain_hits))
+			$fetch_domain_hits = [];
+
+		if (!is_array($options)) {
+
+			// falling back on compatibility shim
+			$option_names = [ "url", "type", "login", "pass", "post_query", "timeout", "last_modified", "useragent" ];
+			$tmp = [];
+
+			for ($i = 0; $i < func_num_args(); $i++) {
+				$tmp[$option_names[$i]] = func_get_arg($i);
+			}
+
+			$options = $tmp;
+
+			/*$options = array(
+					"url" => func_get_arg(0),
+					"type" => @func_get_arg(1),
+					"login" => @func_get_arg(2),
+					"pass" => @func_get_arg(3),
+					"post_query" => @func_get_arg(4),
+					"timeout" => @func_get_arg(5),
+					"timestamp" => @func_get_arg(6),
+					"useragent" => @func_get_arg(7)
+			); */
+		}
+
+		$url = $options["url"];
+		$type = isset($options["type"]) ? $options["type"] : false;
+		$login = isset($options["login"]) ? $options["login"] : false;
+		$pass = isset($options["pass"]) ? $options["pass"] : false;
+		$post_query = isset($options["post_query"]) ? $options["post_query"] : false;
+		$timeout = isset($options["timeout"]) ? $options["timeout"] : false;
+		$last_modified = isset($options["last_modified"]) ? $options["last_modified"] : "";
+		$useragent = isset($options["useragent"]) ? $options["useragent"] : false;
+		$followlocation = isset($options["followlocation"]) ? $options["followlocation"] : true;
+		$max_size = isset($options["max_size"]) ? $options["max_size"] : MAX_DOWNLOAD_FILE_SIZE; // in bytes
+		$http_accept = isset($options["http_accept"]) ? $options["http_accept"] : false;
+		$http_referrer = isset($options["http_referrer"]) ? $options["http_referrer"] : false;
+
+		$url = ltrim($url, ' ');
+		$url = str_replace(' ', '%20', $url);
+
+		$url = UrlHelper::validate($url, true);
+
+		if (!$url) {
+			$fetch_last_error = "Requested URL failed extended validation.";
+			return false;
+		}
+
+		$url_host = parse_url($url, PHP_URL_HOST);
+		$ip_addr = gethostbyname($url_host);
+
+		if (!$ip_addr || strpos($ip_addr, "127.") === 0) {
+			$fetch_last_error = "URL hostname failed to resolve or resolved to a loopback address ($ip_addr)";
+			return false;
+		}
+
+		$fetch_domain_hits[$url_host] += 1;
+
+		/*if ($fetch_domain_hits[$url_host] > MAX_FETCH_REQUESTS_PER_HOST) {
+			user_error("Exceeded fetch request quota for $url_host: " . $fetch_domain_hits[$url_host], E_USER_WARNING);
+			#return false;
+		}*/
+
+		if (!defined('NO_CURL') && function_exists('curl_init') && !ini_get("open_basedir")) {
+
+			$fetch_curl_used = true;
+
+			$ch = curl_init($url);
+
+			$curl_http_headers = [];
+
+			if ($last_modified && !$post_query)
+				array_push($curl_http_headers, "If-Modified-Since: $last_modified");
+
+			if ($http_accept)
+				array_push($curl_http_headers, "Accept: " . $http_accept);
+
+			if (count($curl_http_headers) > 0)
+				curl_setopt($ch, CURLOPT_HTTPHEADER, $curl_http_headers);
+
+			curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout ? $timeout : FILE_FETCH_CONNECT_TIMEOUT);
+			curl_setopt($ch, CURLOPT_TIMEOUT, $timeout ? $timeout : FILE_FETCH_TIMEOUT);
+			curl_setopt($ch, CURLOPT_FOLLOWLOCATION, !ini_get("open_basedir") && $followlocation);
+			curl_setopt($ch, CURLOPT_MAXREDIRS, 20);
+			curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
+			curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+			curl_setopt($ch, CURLOPT_HEADER, true);
+			curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_ANY);
+			curl_setopt($ch, CURLOPT_USERAGENT, $useragent ? $useragent :
+				SELF_USER_AGENT);
+			curl_setopt($ch, CURLOPT_ENCODING, "");
+
+			if  ($http_referrer)
+				curl_setopt($ch, CURLOPT_REFERER, $http_referrer);
+
+			if ($max_size) {
+				curl_setopt($ch, CURLOPT_NOPROGRESS, false);
+				curl_setopt($ch, CURLOPT_BUFFERSIZE, 16384); // needed to get 5 arguments in progress function?
+
+				// holy shit closures in php
+				// download & upload are *expected* sizes respectively, could be zero
+				curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function($curl_handle, $download_size, $downloaded, $upload_size, $uploaded) use( &$max_size) {
+					Debug::log("[curl progressfunction] $downloaded $max_size", Debug::$LOG_EXTENDED);
+
+					return ($downloaded > $max_size) ? 1 : 0; // if max size is set, abort when exceeding it
+				});
+
+			}
+
+			if (!ini_get("open_basedir")) {
+				curl_setopt($ch, CURLOPT_COOKIEJAR, "/dev/null");
+			}
+
+			if (defined('_HTTP_PROXY')) {
+				curl_setopt($ch, CURLOPT_PROXY, _HTTP_PROXY);
+			}
+
+			if ($post_query) {
+				curl_setopt($ch, CURLOPT_POST, true);
+				curl_setopt($ch, CURLOPT_POSTFIELDS, $post_query);
+			}
+
+			if ($login && $pass)
+				curl_setopt($ch, CURLOPT_USERPWD, "$login:$pass");
+
+			$ret = @curl_exec($ch);
+
+			$headers_length = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
+			$headers = explode("\r\n", substr($ret, 0, $headers_length));
+			$contents = substr($ret, $headers_length);
+
+			foreach ($headers as $header) {
+				if (strstr($header, ": ") !== false) {
+					list ($key, $value) = explode(": ", $header);
+
+					if (strtolower($key) == "last-modified") {
+						$fetch_last_modified = $value;
+					}
+				}
+
+				if (substr(strtolower($header), 0, 7) == 'http/1.') {
+					$fetch_last_error_code = (int) substr($header, 9, 3);
+					$fetch_last_error = $header;
+				}
+			}
+
+			if (curl_errno($ch) === 23 || curl_errno($ch) === 61) {
+				curl_setopt($ch, CURLOPT_ENCODING, 'none');
+				$contents = @curl_exec($ch);
+			}
+
+			$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+			$fetch_last_content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
+
+			$fetch_effective_url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
+
+			if (!UrlHelper::validate($fetch_effective_url, true)) {
+				$fetch_last_error = "URL received after redirection failed extended validation.";
+
+				return false;
+			}
+
+			$fetch_effective_ip_addr = gethostbyname(parse_url($fetch_effective_url, PHP_URL_HOST));
+
+			if (!$fetch_effective_ip_addr || strpos($fetch_effective_ip_addr, "127.") === 0) {
+				$fetch_last_error = "URL hostname received after redirection failed to resolve or resolved to a loopback address ($fetch_effective_ip_addr)";
+
+				return false;
+			}
+
+			$fetch_last_error_code = $http_code;
+
+			if ($http_code != 200 || $type && strpos($fetch_last_content_type, "$type") === false) {
+
+				if (curl_errno($ch) != 0) {
+					$fetch_last_error .=  "; " . curl_errno($ch) . " " . curl_error($ch);
+				}
+
+				$fetch_last_error_content = $contents;
+				curl_close($ch);
+				return false;
+			}
+
+			if (!$contents) {
+				$fetch_last_error = curl_errno($ch) . " " . curl_error($ch);
+				curl_close($ch);
+				return false;
+			}
+
+			curl_close($ch);
+
+			$is_gzipped = RSSUtils::is_gzipped($contents);
+
+			if ($is_gzipped) {
+				$tmp = @gzdecode($contents);
+
+				if ($tmp) $contents = $tmp;
+			}
+
+			return $contents;
+		} else {
+
+			$fetch_curl_used = false;
+
+			if ($login && $pass){
+				$url_parts = array();
+
+				preg_match("/(^[^:]*):\/\/(.*)/", $url, $url_parts);
+
+				$pass = urlencode($pass);
+
+				if ($url_parts[1] && $url_parts[2]) {
+					$url = $url_parts[1] . "://$login:$pass@" . $url_parts[2];
+				}
+			}
+
+			// TODO: should this support POST requests or not? idk
+
+			 $context_options = array(
+				  'http' => array(
+						'header' => array(
+							'Connection: close'
+						),
+						'method' => 'GET',
+						'ignore_errors' => true,
+						'timeout' => $timeout ? $timeout : FILE_FETCH_TIMEOUT,
+						'protocol_version'=> 1.1)
+				  );
+
+			if (!$post_query && $last_modified)
+				array_push($context_options['http']['header'], "If-Modified-Since: $last_modified");
+
+			if ($http_accept)
+				array_push($context_options['http']['header'], "Accept: $http_accept");
+
+			if ($http_referrer)
+				array_push($context_options['http']['header'], "Referer: $http_referrer");
+
+			if (defined('_HTTP_PROXY')) {
+				$context_options['http']['request_fulluri'] = true;
+				$context_options['http']['proxy'] = _HTTP_PROXY;
+			}
+
+			$context = stream_context_create($context_options);
+
+			$old_error = error_get_last();
+
+			$fetch_effective_url = resolve_redirects($url, $timeout ? $timeout : FILE_FETCH_CONNECT_TIMEOUT);
+
+			if (!UrlHelper::validate($fetch_effective_url, true)) {
+				$fetch_last_error = "URL received after redirection failed extended validation.";
+
+				return false;
+			}
+
+			$fetch_effective_ip_addr = gethostbyname(parse_url($fetch_effective_url, PHP_URL_HOST));
+
+			if (!$fetch_effective_ip_addr || strpos($fetch_effective_ip_addr, "127.") === 0) {
+				$fetch_last_error = "URL hostname received after redirection failed to resolve or resolved to a loopback address ($fetch_effective_ip_addr)";
+
+				return false;
+			}
+
+			$data = @file_get_contents($url, false, $context);
+
+			if (isset($http_response_header) && is_array($http_response_header)) {
+				foreach ($http_response_header as $header) {
+					if (strstr($header, ": ") !== false) {
+						list ($key, $value) = explode(": ", $header);
+
+						$key = strtolower($key);
+
+						if ($key == 'content-type') {
+							$fetch_last_content_type = $value;
+							// don't abort here b/c there might be more than one
+							// e.g. if we were being redirected -- last one is the right one
+						} else if ($key == 'last-modified') {
+							$fetch_last_modified = $value;
+						} else if ($key == 'location') {
+							$fetch_effective_url = $value;
+						}
+					}
+
+					if (substr(strtolower($header), 0, 7) == 'http/1.') {
+						$fetch_last_error_code = (int) substr($header, 9, 3);
+						$fetch_last_error = $header;
+					}
+				}
+			}
+
+			if ($fetch_last_error_code != 200) {
+				$error = error_get_last();
+
+				if ($error['message'] != $old_error['message']) {
+					$fetch_last_error .= "; " . $error["message"];
+				}
+
+				$fetch_last_error_content = $data;
+
+				return false;
+			}
+
+			$is_gzipped = RSSUtils::is_gzipped($data);
+
+			if ($is_gzipped) {
+				$tmp = @gzdecode($data);
+
+				if ($tmp) $data = $tmp;
+			}
+
+			return $data;
+		}
+	}
+
+}
diff --git a/classes/userhelper.php b/classes/userhelper.php
new file mode 100644
index 000000000..2ae1f7b83
--- /dev/null
+++ b/classes/userhelper.php
@@ -0,0 +1,141 @@
+<?php
+class UserHelper {
+
+	static function authenticate($login, $password, $check_only = false, $service = false) {
+
+		if (!SINGLE_USER_MODE) {
+			$user_id = false;
+			$auth_module = false;
+
+			foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_AUTH_USER) as $plugin) {
+
+				$user_id = (int) $plugin->authenticate($login, $password, $service);
+
+				if ($user_id) {
+					$auth_module = strtolower(get_class($plugin));
+					break;
+				}
+			}
+
+			if ($user_id && !$check_only) {
+
+				session_start();
+				session_regenerate_id(true);
+
+				$_SESSION["uid"] = $user_id;
+				$_SESSION["auth_module"] = $auth_module;
+
+				$pdo = Db::pdo();
+				$sth = $pdo->prepare("SELECT login,access_level,pwd_hash FROM ttrss_users
+					WHERE id = ?");
+				$sth->execute([$user_id]);
+				$row = $sth->fetch();
+
+				$_SESSION["name"] = $row["login"];
+				$_SESSION["access_level"] = $row["access_level"];
+				$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
+
+				$usth = $pdo->prepare("UPDATE ttrss_users SET last_login = NOW() WHERE id = ?");
+				$usth->execute([$user_id]);
+
+				$_SESSION["ip_address"] = $_SERVER["REMOTE_ADDR"];
+				$_SESSION["user_agent"] = sha1($_SERVER['HTTP_USER_AGENT']);
+				$_SESSION["pwd_hash"] = $row["pwd_hash"];
+
+				Pref_Prefs::initialize_user_prefs($_SESSION["uid"]);
+
+				return true;
+			}
+
+			return false;
+
+		} else {
+
+			$_SESSION["uid"] = 1;
+			$_SESSION["name"] = "admin";
+			$_SESSION["access_level"] = 10;
+
+			$_SESSION["hide_hello"] = true;
+			$_SESSION["hide_logout"] = true;
+
+			$_SESSION["auth_module"] = false;
+
+			if (!$_SESSION["csrf_token"])
+				$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
+
+			$_SESSION["ip_address"] = $_SERVER["REMOTE_ADDR"];
+
+			Pref_Prefs::initialize_user_prefs($_SESSION["uid"]);
+
+			return true;
+		}
+	}
+
+	static function load_user_plugins($owner_uid, $pluginhost = false) {
+
+		if (!$pluginhost) $pluginhost = PluginHost::getInstance();
+
+		if ($owner_uid && SCHEMA_VERSION >= 100 && !$_SESSION["safe_mode"]) {
+			$plugins = get_pref("_ENABLED_PLUGINS", $owner_uid);
+
+			$pluginhost->load($plugins, PluginHost::KIND_USER, $owner_uid);
+
+			if (get_schema_version() > 100) {
+				$pluginhost->load_data();
+			}
+		}
+	}
+
+	static function login_sequence() {
+		$pdo = Db::pdo();
+
+		if (SINGLE_USER_MODE) {
+			@session_start();
+			UserHelper::authenticate("admin", null);
+			startup_gettext();
+			UserHelper::load_user_plugins($_SESSION["uid"]);
+		} else {
+			if (!validate_session()) $_SESSION["uid"] = false;
+
+			if (!$_SESSION["uid"]) {
+
+				if (AUTH_AUTO_LOGIN && UserHelper::authenticate(null, null)) {
+					$_SESSION["ref_schema_version"] = get_schema_version(true);
+				} else {
+					 UserHelper::authenticate(null, null, true);
+				}
+
+				if (!$_SESSION["uid"]) {
+					Pref_Users::logout_user();
+
+					Handler_Public::render_login_form();
+					exit;
+				}
+
+			} else {
+				/* bump login timestamp */
+				$sth = $pdo->prepare("UPDATE ttrss_users SET last_login = NOW() WHERE id = ?");
+				$sth->execute([$_SESSION['uid']]);
+
+				$_SESSION["last_login_update"] = time();
+			}
+
+			if ($_SESSION["uid"]) {
+				startup_gettext();
+				UserHelper::load_user_plugins($_SESSION["uid"]);
+			}
+		}
+	}
+
+	static function print_user_stylesheet() {
+		$value = get_pref('USER_STYLESHEET');
+
+		if ($value) {
+			print "<style type='text/css' id='user_css_style'>";
+			print str_replace("<br/>", "\n", $value);
+			print "</style>";
+		}
+
+	}
+
+}
diff --git a/include/functions.php b/include/functions.php
index f3379f441..506d426b1 100644
--- a/include/functions.php
+++ b/include/functions.php
@@ -162,410 +162,34 @@
 
 	$schema_version = false;
 
-	// TODO: compat wrapper, remove at some point
+	/* compat shims */
+
 	function _debug($msg) {
 	    Debug::log($msg);
 	}
 
-	function reset_fetch_domain_quota() {
-		global $fetch_domain_hits;
-
-		$fetch_domain_hits = [];
+	function sanitize($str, $force_remove_images = false, $owner = false, $site_url = false, $highlight_words = false, $article_id = false) {
+		return Sanitizer::sanitize($str, $force_remove_images, $owner, $site_url, $highlight_words, $article_id);
 	}
 
-	// TODO: max_size currently only works for CURL transfers
-	// TODO: multiple-argument way is deprecated, first parameter is a hash now
-	function fetch_file_contents($options /* previously: 0: $url , 1: $type = false, 2: $login = false, 3: $pass = false,
-				4: $post_query = false, 5: $timeout = false, 6: $timestamp = 0, 7: $useragent = false*/) {
-
-		global $fetch_last_error;
-		global $fetch_last_error_code;
-		global $fetch_last_error_content;
-		global $fetch_last_content_type;
-		global $fetch_last_modified;
-		global $fetch_effective_url;
-		global $fetch_effective_ip_addr;
-		global $fetch_curl_used;
-		global $fetch_domain_hits;
-
-		$fetch_last_error = false;
-		$fetch_last_error_code = -1;
-		$fetch_last_error_content = "";
-		$fetch_last_content_type = "";
-		$fetch_curl_used = false;
-		$fetch_last_modified = "";
-		$fetch_effective_url = "";
-		$fetch_effective_ip_addr = "";
-
-		if (!is_array($fetch_domain_hits))
-			$fetch_domain_hits = [];
-
-		if (!is_array($options)) {
-
-			// falling back on compatibility shim
-			$option_names = [ "url", "type", "login", "pass", "post_query", "timeout", "last_modified", "useragent" ];
-			$tmp = [];
-
-			for ($i = 0; $i < func_num_args(); $i++) {
-				$tmp[$option_names[$i]] = func_get_arg($i);
-			}
-
-			$options = $tmp;
-
-			/*$options = array(
-					"url" => func_get_arg(0),
-					"type" => @func_get_arg(1),
-					"login" => @func_get_arg(2),
-					"pass" => @func_get_arg(3),
-					"post_query" => @func_get_arg(4),
-					"timeout" => @func_get_arg(5),
-					"timestamp" => @func_get_arg(6),
-					"useragent" => @func_get_arg(7)
-			); */
-		}
-
-		$url = $options["url"];
-		$type = isset($options["type"]) ? $options["type"] : false;
-		$login = isset($options["login"]) ? $options["login"] : false;
-		$pass = isset($options["pass"]) ? $options["pass"] : false;
-		$post_query = isset($options["post_query"]) ? $options["post_query"] : false;
-		$timeout = isset($options["timeout"]) ? $options["timeout"] : false;
-		$last_modified = isset($options["last_modified"]) ? $options["last_modified"] : "";
-		$useragent = isset($options["useragent"]) ? $options["useragent"] : false;
-		$followlocation = isset($options["followlocation"]) ? $options["followlocation"] : true;
-		$max_size = isset($options["max_size"]) ? $options["max_size"] : MAX_DOWNLOAD_FILE_SIZE; // in bytes
-		$http_accept = isset($options["http_accept"]) ? $options["http_accept"] : false;
-		$http_referrer = isset($options["http_referrer"]) ? $options["http_referrer"] : false;
-
-		$url = ltrim($url, ' ');
-		$url = str_replace(' ', '%20', $url);
-
-		$url = validate_url($url, true);
-
-		if (!$url) {
-			$fetch_last_error = "Requested URL failed extended validation.";
-			return false;
-		}
-
-		$url_host = parse_url($url, PHP_URL_HOST);
-		$ip_addr = gethostbyname($url_host);
-
-		if (!$ip_addr || strpos($ip_addr, "127.") === 0) {
-			$fetch_last_error = "URL hostname failed to resolve or resolved to a loopback address ($ip_addr)";
-			return false;
-		}
-
-		$fetch_domain_hits[$url_host] += 1;
-
-		/*if ($fetch_domain_hits[$url_host] > MAX_FETCH_REQUESTS_PER_HOST) {
-			user_error("Exceeded fetch request quota for $url_host: " . $fetch_domain_hits[$url_host], E_USER_WARNING);
-			#return false;
-		}*/
-
-		if (!defined('NO_CURL') && function_exists('curl_init') && !ini_get("open_basedir")) {
-
-			$fetch_curl_used = true;
-
-			$ch = curl_init($url);
-
-			$curl_http_headers = [];
-
-			if ($last_modified && !$post_query)
-				array_push($curl_http_headers, "If-Modified-Since: $last_modified");
-
-			if ($http_accept)
-				array_push($curl_http_headers, "Accept: " . $http_accept);
-
-			if (count($curl_http_headers) > 0)
-				curl_setopt($ch, CURLOPT_HTTPHEADER, $curl_http_headers);
-
-			curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout ? $timeout : FILE_FETCH_CONNECT_TIMEOUT);
-			curl_setopt($ch, CURLOPT_TIMEOUT, $timeout ? $timeout : FILE_FETCH_TIMEOUT);
-			curl_setopt($ch, CURLOPT_FOLLOWLOCATION, !ini_get("open_basedir") && $followlocation);
-			curl_setopt($ch, CURLOPT_MAXREDIRS, 20);
-			curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
-			curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
-			curl_setopt($ch, CURLOPT_HEADER, true);
-			curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_ANY);
-			curl_setopt($ch, CURLOPT_USERAGENT, $useragent ? $useragent :
-				SELF_USER_AGENT);
-			curl_setopt($ch, CURLOPT_ENCODING, "");
-
-			if  ($http_referrer)
-				curl_setopt($ch, CURLOPT_REFERER, $http_referrer);
-
-			if ($max_size) {
-				curl_setopt($ch, CURLOPT_NOPROGRESS, false);
-				curl_setopt($ch, CURLOPT_BUFFERSIZE, 16384); // needed to get 5 arguments in progress function?
-
-				// holy shit closures in php
-				// download & upload are *expected* sizes respectively, could be zero
-				curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function($curl_handle, $download_size, $downloaded, $upload_size, $uploaded) use( &$max_size) {
-					Debug::log("[curl progressfunction] $downloaded $max_size", Debug::$LOG_EXTENDED);
-
-					return ($downloaded > $max_size) ? 1 : 0; // if max size is set, abort when exceeding it
-				});
-
-			}
-
-			if (!ini_get("open_basedir")) {
-				curl_setopt($ch, CURLOPT_COOKIEJAR, "/dev/null");
-			}
-
-			if (defined('_HTTP_PROXY')) {
-				curl_setopt($ch, CURLOPT_PROXY, _HTTP_PROXY);
-			}
-
-			if ($post_query) {
-				curl_setopt($ch, CURLOPT_POST, true);
-				curl_setopt($ch, CURLOPT_POSTFIELDS, $post_query);
-			}
-
-			if ($login && $pass)
-				curl_setopt($ch, CURLOPT_USERPWD, "$login:$pass");
-
-			$ret = @curl_exec($ch);
-
-			$headers_length = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
-			$headers = explode("\r\n", substr($ret, 0, $headers_length));
-			$contents = substr($ret, $headers_length);
-
-			foreach ($headers as $header) {
-				if (strstr($header, ": ") !== false) {
-					list ($key, $value) = explode(": ", $header);
-
-					if (strtolower($key) == "last-modified") {
-						$fetch_last_modified = $value;
-					}
-				}
-
-				if (substr(strtolower($header), 0, 7) == 'http/1.') {
-					$fetch_last_error_code = (int) substr($header, 9, 3);
-					$fetch_last_error = $header;
-				}
-			}
-
-			if (curl_errno($ch) === 23 || curl_errno($ch) === 61) {
-				curl_setopt($ch, CURLOPT_ENCODING, 'none');
-				$contents = @curl_exec($ch);
-			}
-
-			$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
-			$fetch_last_content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
-
-			$fetch_effective_url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
-
-			if (!validate_url($fetch_effective_url, true)) {
-				$fetch_last_error = "URL received after redirection failed extended validation.";
-
-				return false;
-			}
-
-			$fetch_effective_ip_addr = gethostbyname(parse_url($fetch_effective_url, PHP_URL_HOST));
-
-			if (!$fetch_effective_ip_addr || strpos($fetch_effective_ip_addr, "127.") === 0) {
-				$fetch_last_error = "URL hostname received after redirection failed to resolve or resolved to a loopback address ($fetch_effective_ip_addr)";
-
-				return false;
-			}
-
-			$fetch_last_error_code = $http_code;
-
-			if ($http_code != 200 || $type && strpos($fetch_last_content_type, "$type") === false) {
-
-				if (curl_errno($ch) != 0) {
-					$fetch_last_error .=  "; " . curl_errno($ch) . " " . curl_error($ch);
-				}
-
-				$fetch_last_error_content = $contents;
-				curl_close($ch);
-				return false;
-			}
-
-			if (!$contents) {
-				$fetch_last_error = curl_errno($ch) . " " . curl_error($ch);
-				curl_close($ch);
-				return false;
-			}
-
-			curl_close($ch);
-
-			$is_gzipped = RSSUtils::is_gzipped($contents);
-
-			if ($is_gzipped) {
-				$tmp = @gzdecode($contents);
-
-				if ($tmp) $contents = $tmp;
-			}
-
-			return $contents;
-		} else {
-
-			$fetch_curl_used = false;
-
-			if ($login && $pass){
-				$url_parts = array();
-
-				preg_match("/(^[^:]*):\/\/(.*)/", $url, $url_parts);
-
-				$pass = urlencode($pass);
-
-				if ($url_parts[1] && $url_parts[2]) {
-					$url = $url_parts[1] . "://$login:$pass@" . $url_parts[2];
-				}
-			}
-
-			// TODO: should this support POST requests or not? idk
-
-			 $context_options = array(
-				  'http' => array(
-						'header' => array(
-							'Connection: close'
-						),
-						'method' => 'GET',
-						'ignore_errors' => true,
-						'timeout' => $timeout ? $timeout : FILE_FETCH_TIMEOUT,
-						'protocol_version'=> 1.1)
-				  );
-
-			if (!$post_query && $last_modified)
-				array_push($context_options['http']['header'], "If-Modified-Since: $last_modified");
-
-			if ($http_accept)
-				array_push($context_options['http']['header'], "Accept: $http_accept");
-
-			if ($http_referrer)
-				array_push($context_options['http']['header'], "Referer: $http_referrer");
-
-			if (defined('_HTTP_PROXY')) {
-				$context_options['http']['request_fulluri'] = true;
-				$context_options['http']['proxy'] = _HTTP_PROXY;
-			}
-
-			$context = stream_context_create($context_options);
-
-			$old_error = error_get_last();
-
-			$fetch_effective_url = resolve_redirects($url, $timeout ? $timeout : FILE_FETCH_CONNECT_TIMEOUT);
-
-			if (!validate_url($fetch_effective_url, true)) {
-				$fetch_last_error = "URL received after redirection failed extended validation.";
-
-				return false;
-			}
-
-			$fetch_effective_ip_addr = gethostbyname(parse_url($fetch_effective_url, PHP_URL_HOST));
-
-			if (!$fetch_effective_ip_addr || strpos($fetch_effective_ip_addr, "127.") === 0) {
-				$fetch_last_error = "URL hostname received after redirection failed to resolve or resolved to a loopback address ($fetch_effective_ip_addr)";
-
-				return false;
-			}
-
-			$data = @file_get_contents($url, false, $context);
-
-			if (isset($http_response_header) && is_array($http_response_header)) {
-				foreach ($http_response_header as $header) {
-					if (strstr($header, ": ") !== false) {
-						list ($key, $value) = explode(": ", $header);
-
-						$key = strtolower($key);
-
-						if ($key == 'content-type') {
-							$fetch_last_content_type = $value;
-							// don't abort here b/c there might be more than one
-							// e.g. if we were being redirected -- last one is the right one
-						} else if ($key == 'last-modified') {
-							$fetch_last_modified = $value;
-						} else if ($key == 'location') {
-							$fetch_effective_url = $value;
-						}
-					}
-
-					if (substr(strtolower($header), 0, 7) == 'http/1.') {
-						$fetch_last_error_code = (int) substr($header, 9, 3);
-						$fetch_last_error = $header;
-					}
-				}
-			}
-
-			if ($fetch_last_error_code != 200) {
-				$error = error_get_last();
-
-				if ($error['message'] != $old_error['message']) {
-					$fetch_last_error .= "; " . $error["message"];
-				}
-
-				$fetch_last_error_content = $data;
-
-				return false;
-			}
-
-			$is_gzipped = RSSUtils::is_gzipped($data);
-
-			if ($is_gzipped) {
-				$tmp = @gzdecode($data);
-
-				if ($tmp) $data = $tmp;
-			}
-
-			return $data;
-		}
-
+	function fetch_file_contents($params) {
+		return UrlHelper::fetch($params);
 	}
 
-	function initialize_user_prefs($uid, $profile = false) {
-
-		if (get_schema_version() < 63) $profile_qpart = "";
-
-		$pdo = Db::pdo();
-		$in_nested_tr = false;
-
-		try {
-			$pdo->beginTransaction();
-		} catch (Exception $e) {
-			$in_nested_tr = true;
-		}
-
-		$sth = $pdo->query("SELECT pref_name,def_value FROM ttrss_prefs");
-
-		if (!is_numeric($profile) || !$profile || get_schema_version() < 63) $profile = null;
-
-		$u_sth = $pdo->prepare("SELECT pref_name
-			FROM ttrss_user_prefs WHERE owner_uid = :uid AND
-				(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
-		$u_sth->execute([':uid' => $uid, ':profile' => $profile]);
-
-		$active_prefs = array();
-
-		while ($line = $u_sth->fetch()) {
-			array_push($active_prefs, $line["pref_name"]);
-		}
-
-		while ($line = $sth->fetch()) {
-			if (array_search($line["pref_name"], $active_prefs) === false) {
-//				print "adding " . $line["pref_name"] . "<br>";
-
-				if (get_schema_version() < 63) {
-					$i_sth = $pdo->prepare("INSERT INTO ttrss_user_prefs
-						(owner_uid,pref_name,value) VALUES
-						(?, ?, ?)");
-					$i_sth->execute([$uid, $line["pref_name"], $line["def_value"]]);
-
-				} else {
-					$i_sth = $pdo->prepare("INSERT INTO ttrss_user_prefs
-						(owner_uid,pref_name,value, profile) VALUES
-						(?, ?, ?, ?)");
-					$i_sth->execute([$uid, $line["pref_name"], $line["def_value"], $profile]);
-				}
-
-			}
-		}
+	function rewrite_relative_url($url, $rel_url) {
+		return UrlHelper::rewrite_relative($url, $rel_url);
+	}
 
-		if (!$in_nested_tr) $pdo->commit();
+	function validate_url($url) {
+		return UrlHelper::validate($url);
+	}
 
+	function authenticate_user($login, $password, $check_only = false, $service = false) {
+		return UserHelper::authenticate($login, $password, $check_only, $service);
 	}
 
+	/* end compat shims */
+
 	function get_ssl_certificate_id() {
 		if ($_SERVER["REDIRECT_SSL_CLIENT_M_SERIAL"]) {
 			return sha1($_SERVER["REDIRECT_SSL_CLIENT_M_SERIAL"] .
@@ -582,76 +206,6 @@
 		return "";
 	}
 
-	function authenticate_user($login, $password, $check_only = false, $service = false) {
-
-		if (!SINGLE_USER_MODE) {
-			$user_id = false;
-			$auth_module = false;
-
-			foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_AUTH_USER) as $plugin) {
-
-				$user_id = (int) $plugin->authenticate($login, $password, $service);
-
-				if ($user_id) {
-					$auth_module = strtolower(get_class($plugin));
-					break;
-				}
-			}
-
-			if ($user_id && !$check_only) {
-
-				session_start();
-				session_regenerate_id(true);
-
-				$_SESSION["uid"] = $user_id;
-				$_SESSION["auth_module"] = $auth_module;
-
-				$pdo = Db::pdo();
-				$sth = $pdo->prepare("SELECT login,access_level,pwd_hash FROM ttrss_users
-					WHERE id = ?");
-				$sth->execute([$user_id]);
-				$row = $sth->fetch();
-
-				$_SESSION["name"] = $row["login"];
-				$_SESSION["access_level"] = $row["access_level"];
-				$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
-
-				$usth = $pdo->prepare("UPDATE ttrss_users SET last_login = NOW() WHERE id = ?");
-				$usth->execute([$user_id]);
-
-				$_SESSION["ip_address"] = $_SERVER["REMOTE_ADDR"];
-				$_SESSION["user_agent"] = sha1($_SERVER['HTTP_USER_AGENT']);
-				$_SESSION["pwd_hash"] = $row["pwd_hash"];
-
-				initialize_user_prefs($_SESSION["uid"]);
-
-				return true;
-			}
-
-			return false;
-
-		} else {
-
-			$_SESSION["uid"] = 1;
-			$_SESSION["name"] = "admin";
-			$_SESSION["access_level"] = 10;
-
-			$_SESSION["hide_hello"] = true;
-			$_SESSION["hide_logout"] = true;
-
-			$_SESSION["auth_module"] = false;
-
-			if (!$_SESSION["csrf_token"])
-				$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
-
-			$_SESSION["ip_address"] = $_SERVER["REMOTE_ADDR"];
-
-			initialize_user_prefs($_SESSION["uid"]);
-
-			return true;
-		}
-	}
-
 	// this is used for user http parameters unless HTML code is actually needed
 	function clean($param) {
 		if (is_array($param)) {
@@ -688,89 +242,10 @@
 		return $password;
 	}
 
-	// this is called after user is created to initialize default feeds, labels
-	// or whatever else
-
-	// user preferences are checked on every login, not here
-
-	function initialize_user($uid) {
-
-		$pdo = Db::pdo();
-
-		$sth = $pdo->prepare("insert into ttrss_feeds (owner_uid,title,feed_url)
-			values (?, 'Tiny Tiny RSS: Forum',
-				'https://tt-rss.org/forum/rss.php')");
-		$sth->execute([$uid]);
-	}
-
-	function logout_user() {
-		@session_destroy();
-		if (isset($_COOKIE[session_name()])) {
-		   setcookie(session_name(), '', time()-42000, '/');
-		}
-		session_commit();
-	}
-
 	function validate_csrf($csrf_token) {
 		return isset($csrf_token) && hash_equals($_SESSION['csrf_token'], $csrf_token);
 	}
 
-	function load_user_plugins($owner_uid, $pluginhost = false) {
-
-		if (!$pluginhost) $pluginhost = PluginHost::getInstance();
-
-		if ($owner_uid && SCHEMA_VERSION >= 100 && !$_SESSION["safe_mode"]) {
-			$plugins = get_pref("_ENABLED_PLUGINS", $owner_uid);
-
-			$pluginhost->load($plugins, PluginHost::KIND_USER, $owner_uid);
-
-			if (get_schema_version() > 100) {
-				$pluginhost->load_data();
-			}
-		}
-	}
-
-	function login_sequence() {
-		$pdo = Db::pdo();
-
-		if (SINGLE_USER_MODE) {
-			@session_start();
-			authenticate_user("admin", null);
-			startup_gettext();
-			load_user_plugins($_SESSION["uid"]);
-		} else {
-			if (!validate_session()) $_SESSION["uid"] = false;
-
-			if (!$_SESSION["uid"]) {
-
-				if (AUTH_AUTO_LOGIN && authenticate_user(null, null)) {
-					$_SESSION["ref_schema_version"] = get_schema_version(true);
-				} else {
-					 authenticate_user(null, null, true);
-				}
-
-				if (!$_SESSION["uid"]) {
-					logout_user();
-
-					render_login_form();
-					exit;
-				}
-
-			} else {
-				/* bump login timestamp */
-				$sth = $pdo->prepare("UPDATE ttrss_users SET last_login = NOW() WHERE id = ?");
-				$sth->execute([$_SESSION['uid']]);
-
-				$_SESSION["last_login_update"] = time();
-			}
-
-			if ($_SESSION["uid"]) {
-				startup_gettext();
-				load_user_plugins($_SESSION["uid"]);
-			}
-		}
-	}
-
 	function truncate_string($str, $max_len, $suffix = '&hellip;') {
 		if (mb_strlen($str, "utf-8") > $max_len) {
 			return mb_substr($str, 0, $max_len, "utf-8") . $suffix;
@@ -942,7 +417,6 @@
 		}
 	}
 
-
 	function make_lockfile($filename) {
 		$fp = fopen(LOCK_DIRECTORY . "/$filename", "w");
 
@@ -967,27 +441,6 @@
 		}
 	}
 
-	function make_stampfile($filename) {
-		$fp = fopen(LOCK_DIRECTORY . "/$filename", "w");
-
-		if (flock($fp, LOCK_EX | LOCK_NB)) {
-			fwrite($fp, time() . "\n");
-			flock($fp, LOCK_UN);
-			fclose($fp);
-			return true;
-		} else {
-			return false;
-		}
-	}
-
-	function sql_random_function() {
-		if (DB_TYPE == "mysql") {
-			return "RAND()";
-		} else {
-			return "RANDOM()";
-		}
-	}
-
 	function getFeedUnread($feed, $is_cat = false) {
 		return Feeds::getFeedArticles($feed, $is_cat, true, $_SESSION["uid"]);
 	}
@@ -1000,522 +453,17 @@
 		return uniqid(base_convert(rand(), 10, 36));
 	}
 
-	function make_init_params() {
-		$params = array();
-
-		foreach (array("ON_CATCHUP_SHOW_NEXT_FEED", "HIDE_READ_FEEDS",
-					 "ENABLE_FEED_CATS", "FEEDS_SORT_BY_UNREAD", "CONFIRM_FEED_CATCHUP",
-					 "CDM_AUTO_CATCHUP", "FRESH_ARTICLE_MAX_AGE",
-					 "HIDE_READ_SHOWS_SPECIAL", "COMBINED_DISPLAY_MODE") as $param) {
-
-			$params[strtolower($param)] = (int) get_pref($param);
-		}
-
-		$params["check_for_updates"] = CHECK_FOR_UPDATES;
-		$params["icons_url"] = ICONS_URL;
-		$params["cookie_lifetime"] = SESSION_COOKIE_LIFETIME;
-		$params["default_view_mode"] = get_pref("_DEFAULT_VIEW_MODE");
-		$params["default_view_limit"] = (int) get_pref("_DEFAULT_VIEW_LIMIT");
-		$params["default_view_order_by"] = get_pref("_DEFAULT_VIEW_ORDER_BY");
-		$params["bw_limit"] = (int) $_SESSION["bw_limit"];
-		$params["is_default_pw"] = Pref_Prefs::isdefaultpassword();
-		$params["label_base_index"] = (int) LABEL_BASE_INDEX;
-
-		$theme = get_pref( "USER_CSS_THEME", false, false);
-		$params["theme"] = theme_exists($theme) ? $theme : "";
-
-		$params["plugins"] = implode(", ", PluginHost::getInstance()->get_plugin_names());
-
-		$params["php_platform"] = PHP_OS;
-		$params["php_version"] = PHP_VERSION;
-
-		$params["sanity_checksum"] = sha1(file_get_contents("include/sanity_check.php"));
-
-		$pdo = Db::pdo();
-
-		$sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM
-				ttrss_feeds WHERE owner_uid = ?");
-		$sth->execute([$_SESSION['uid']]);
-		$row = $sth->fetch();
-
-		$max_feed_id = $row["mid"];
-		$num_feeds = $row["nf"];
-
-		$params["self_url_prefix"] = get_self_url_prefix();
-		$params["max_feed_id"] = (int) $max_feed_id;
-		$params["num_feeds"] = (int) $num_feeds;
-
-		$params["hotkeys"] = get_hotkeys_map();
-
-		$params["widescreen"] = (int) $_COOKIE["ttrss_widescreen"];
-
-		$params['simple_update'] = defined('SIMPLE_UPDATE_MODE') && SIMPLE_UPDATE_MODE;
-
-		$params["icon_indicator_white"] = image_to_base64("images/indicator_white.gif");
-
-		$params["labels"] = Labels::get_all_labels($_SESSION["uid"]);
-
-		return $params;
-	}
-
-	function get_hotkeys_info() {
-		$hotkeys = array(
-			__("Navigation") => array(
-				"next_feed" => __("Open next feed"),
-				"prev_feed" => __("Open previous feed"),
-				"next_article_or_scroll" => __("Open next article (in combined mode, scroll down)"),
-				"prev_article_or_scroll" => __("Open previous article (in combined mode, scroll up)"),
-				"next_headlines_page" => __("Scroll headlines by one page down"),
-				"prev_headlines_page" => __("Scroll headlines by one page up"),
-				"next_article_noscroll" => __("Open next article"),
-				"prev_article_noscroll" => __("Open previous article"),
-				"next_article_noexpand" => __("Move to next article (don't expand)"),
-				"prev_article_noexpand" => __("Move to previous article (don't expand)"),
-				"search_dialog" => __("Show search dialog"),
-				"cancel_search" => __("Cancel active search")),
-			__("Article") => array(
-				"toggle_mark" => __("Toggle starred"),
-				"toggle_publ" => __("Toggle published"),
-				"toggle_unread" => __("Toggle unread"),
-				"edit_tags" => __("Edit tags"),
-				"open_in_new_window" => __("Open in new window"),
-				"catchup_below" => __("Mark below as read"),
-				"catchup_above" => __("Mark above as read"),
-				"article_scroll_down" => __("Scroll down"),
-				"article_scroll_up" => __("Scroll up"),
-				"article_page_down" => __("Scroll down page"),
-				"article_page_up" => __("Scroll up page"),
-				"select_article_cursor" => __("Select article under cursor"),
-				"email_article" => __("Email article"),
-				"close_article" => __("Close/collapse article"),
-				"toggle_expand" => __("Toggle article expansion (combined mode)"),
-				"toggle_widescreen" => __("Toggle widescreen mode"),
-				"toggle_full_text" => __("Toggle full article text via Readability")),
-			__("Article selection") => array(
-				"select_all" => __("Select all articles"),
-				"select_unread" => __("Select unread"),
-				"select_marked" => __("Select starred"),
-				"select_published" => __("Select published"),
-				"select_invert" => __("Invert selection"),
-				"select_none" => __("Deselect everything")),
-			__("Feed") => array(
-				"feed_refresh" => __("Refresh current feed"),
-				"feed_unhide_read" => __("Un/hide read feeds"),
-				"feed_subscribe" => __("Subscribe to feed"),
-				"feed_edit" => __("Edit feed"),
-				"feed_catchup" => __("Mark as read"),
-				"feed_reverse" => __("Reverse headlines"),
-				"feed_toggle_vgroup" => __("Toggle headline grouping"),
-				"feed_debug_update" => __("Debug feed update"),
-				"feed_debug_viewfeed" => __("Debug viewfeed()"),
-				"catchup_all" => __("Mark all feeds as read"),
-				"cat_toggle_collapse" => __("Un/collapse current category"),
-				"toggle_cdm_expanded" => __("Toggle auto expand in combined mode"),
-				"toggle_combined_mode" => __("Toggle combined mode")),
-			__("Go to") => array(
-				"goto_all" => __("All articles"),
-				"goto_fresh" => __("Fresh"),
-				"goto_marked" => __("Starred"),
-				"goto_published" => __("Published"),
-				"goto_read" => __("Recently read"),
-				"goto_tagcloud" => __("Tag cloud"),
-				"goto_prefs" => __("Preferences")),
-			__("Other") => array(
-				"create_label" => __("Create label"),
-				"create_filter" => __("Create filter"),
-				"collapse_sidebar" => __("Un/collapse sidebar"),
-				"help_dialog" => __("Show help dialog"))
-		);
-
-		foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_HOTKEY_INFO) as $plugin) {
-			$hotkeys = $plugin->hook_hotkey_info($hotkeys);
-		}
-
-		return $hotkeys;
-	}
-
-	// {3} - 3 panel mode only
-	// {C} - combined mode only
-	function get_hotkeys_map() {
-		$hotkeys = array(
-			"k" => "next_feed",
-			"j" => "prev_feed",
-			"n" => "next_article_noscroll",
-			"p" => "prev_article_noscroll",
-			"N" => "article_page_down",
-			"P" => "article_page_up",
-			"*(33)|Shift+PgUp" => "article_page_up",
-			"*(34)|Shift+PgDn" => "article_page_down",
-			"{3}(38)|Up" => "prev_article_or_scroll",
-			"{3}(40)|Down" => "next_article_or_scroll",
-			"*(38)|Shift+Up" => "article_scroll_up",
-			"*(40)|Shift+Down" => "article_scroll_down",
-			"^(38)|Ctrl+Up" => "prev_article_noscroll",
-			"^(40)|Ctrl+Down" => "next_article_noscroll",
-			"/" => "search_dialog",
-			"\\" => "cancel_search",
-			"s" => "toggle_mark",
-			"S" => "toggle_publ",
-			"u" => "toggle_unread",
-			"T" => "edit_tags",
-			"o" => "open_in_new_window",
-			"c p" => "catchup_below",
-			"c n" => "catchup_above",
-			"a W" => "toggle_widescreen",
-			"a e" => "toggle_full_text",
-			"e" => "email_article",
-			"a q" => "close_article",
-			"a a" => "select_all",
-			"a u" => "select_unread",
-			"a U" => "select_marked",
-			"a p" => "select_published",
-			"a i" => "select_invert",
-			"a n" => "select_none",
-			"f r" => "feed_refresh",
-			"f a" => "feed_unhide_read",
-			"f s" => "feed_subscribe",
-			"f e" => "feed_edit",
-			"f q" => "feed_catchup",
-			"f x" => "feed_reverse",
-			"f g" => "feed_toggle_vgroup",
-			"f D" => "feed_debug_update",
-			"f G" => "feed_debug_viewfeed",
-			"f C" => "toggle_combined_mode",
-			"f c" => "toggle_cdm_expanded",
-			"Q" => "catchup_all",
-			"x" => "cat_toggle_collapse",
-			"g a" => "goto_all",
-			"g f" => "goto_fresh",
-			"g s" => "goto_marked",
-			"g p" => "goto_published",
-			"g r" => "goto_read",
-			"g t" => "goto_tagcloud",
-			"g P" => "goto_prefs",
-			"r" => "select_article_cursor",
-			"c l" => "create_label",
-			"c f" => "create_filter",
-			"c s" => "collapse_sidebar",
-			"?" => "help_dialog",
-		);
-
-		foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_HOTKEY_MAP) as $plugin) {
-			$hotkeys = $plugin->hook_hotkey_map($hotkeys);
-		}
-
-		$prefixes = array();
-
-		foreach (array_keys($hotkeys) as $hotkey) {
-			$pair = explode(" ", $hotkey, 2);
-
-			if (count($pair) > 1 && !in_array($pair[0], $prefixes)) {
-				array_push($prefixes, $pair[0]);
-			}
-		}
-
-		return array($prefixes, $hotkeys);
-	}
-
-	function make_runtime_info() {
-		$data = array();
-
-		$pdo = Db::pdo();
-
-		$sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM
-				ttrss_feeds WHERE owner_uid = ?");
-		$sth->execute([$_SESSION['uid']]);
-		$row = $sth->fetch();
-
-		$max_feed_id = $row['mid'];
-		$num_feeds = $row['nf'];
-
-		$data["max_feed_id"] = (int) $max_feed_id;
-		$data["num_feeds"] = (int) $num_feeds;
-		$data['cdm_expanded'] = get_pref('CDM_EXPANDED');
-		$data["labels"] = Labels::get_all_labels($_SESSION["uid"]);
-
-		if (LOG_DESTINATION == 'sql' && $_SESSION['access_level'] >= 10) {
-			if (DB_TYPE == 'pgsql') {
-				$log_interval = "created_at > NOW() - interval '1 hour'";
-			} else {
-				$log_interval = "created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)";
-			}
-
-			$sth = $pdo->prepare("SELECT COUNT(id) AS cid FROM ttrss_error_log WHERE $log_interval");
-			$sth->execute();
-
-			if ($row = $sth->fetch()) {
-				$data['recent_log_events'] = $row['cid'];
-			}
-		}
-
-		if (file_exists(LOCK_DIRECTORY . "/update_daemon.lock")) {
-
-			$data['daemon_is_running'] = (int) file_is_locked("update_daemon.lock");
-
-			if (time() - $_SESSION["daemon_stamp_check"] > 30) {
-
-				$stamp = (int) @file_get_contents(LOCK_DIRECTORY . "/update_daemon.stamp");
-
-				if ($stamp) {
-					$stamp_delta = time() - $stamp;
-
-					if ($stamp_delta > 1800) {
-						$stamp_check = 0;
-					} else {
-						$stamp_check = 1;
-						$_SESSION["daemon_stamp_check"] = time();
-					}
-
-					$data['daemon_stamp_ok'] = $stamp_check;
-
-					$stamp_fmt = date("Y.m.d, G:i", $stamp);
-
-					$data['daemon_stamp'] = $stamp_fmt;
-				}
-			}
-		}
-
-		return $data;
-	}
-
-	function iframe_whitelisted($entry) {
-		@$src = parse_url($entry->getAttribute("src"), PHP_URL_HOST);
-
-		if ($src) {
-			foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_IFRAME_WHITELISTED) as $plugin) {
-				if ($plugin->hook_iframe_whitelisted($src))
-					return true;
-			}
-		}
-
-		return false;
-	}
-
-	function sanitize($str, $force_remove_images = false, $owner = false, $site_url = false, $highlight_words = false, $article_id = false) {
-		if (!$owner) $owner = $_SESSION["uid"];
-
-		$res = trim($str); if (!$res) return '';
-
-		$doc = new DOMDocument();
-		$doc->loadHTML('<?xml encoding="UTF-8">' . $res);
-		$xpath = new DOMXPath($doc);
-
-		$rewrite_base_url = $site_url ? $site_url : get_self_url_prefix();
-
-		$entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src])');
-
-		foreach ($entries as $entry) {
-
-			if ($entry->hasAttribute('href')) {
-				$entry->setAttribute('href',
-					rewrite_relative_url($rewrite_base_url, $entry->getAttribute('href')));
-
-				$entry->setAttribute('rel', 'noopener noreferrer');
-				$entry->setAttribute("target", "_blank");
-			}
-
-			if ($entry->hasAttribute('src')) {
-				$entry->setAttribute('src',
-					rewrite_relative_url($rewrite_base_url, $entry->getAttribute('src')));
-			}
-
-			if ($entry->nodeName == 'img') {
-				$entry->setAttribute('referrerpolicy', 'no-referrer');
-				$entry->setAttribute('loading', 'lazy');
-			}
-
-			if ($entry->hasAttribute('srcset')) {
-				$matches = RSSUtils::decode_srcset($entry->getAttribute('srcset'));
-
-				for ($i = 0; $i < count($matches); $i++) {
-					$matches[$i]["url"] = rewrite_relative_url($rewrite_base_url, $matches[$i]["url"]);
-				}
-
-				$entry->setAttribute("srcset", RSSUtils::encode_srcset($matches));
-			}
-
-			if ($entry->hasAttribute('src') &&
-					($owner && get_pref("STRIP_IMAGES", $owner)) || $force_remove_images || $_SESSION["bw_limit"]) {
-
-				$p = $doc->createElement('p');
-
-				$a = $doc->createElement('a');
-				$a->setAttribute('href', $entry->getAttribute('src'));
-
-				$a->appendChild(new DOMText($entry->getAttribute('src')));
-				$a->setAttribute('target', '_blank');
-				$a->setAttribute('rel', 'noopener noreferrer');
-
-				$p->appendChild($a);
-
-				if ($entry->nodeName == 'source') {
-
-					if ($entry->parentNode && $entry->parentNode->parentNode)
-						$entry->parentNode->parentNode->replaceChild($p, $entry->parentNode);
-
-				} else if ($entry->nodeName == 'img') {
-					if ($entry->parentNode)
-						$entry->parentNode->replaceChild($p, $entry);
-				}
-			}
-		}
-
-		$entries = $xpath->query('//iframe');
-		foreach ($entries as $entry) {
-			if (!iframe_whitelisted($entry)) {
-				$entry->setAttribute('sandbox', 'allow-scripts');
-			} else {
-				if (is_prefix_https()) {
-					$entry->setAttribute("src",
-						str_replace("http://", "https://",
-							$entry->getAttribute("src")));
-				}
-			}
-		}
-
-		$allowed_elements = array('a', 'abbr', 'address', 'acronym', 'audio', 'article', 'aside',
-			'b', 'bdi', 'bdo', 'big', 'blockquote', 'body', 'br',
-			'caption', 'cite', 'center', 'code', 'col', 'colgroup',
-			'data', 'dd', 'del', 'details', 'description', 'dfn', 'div', 'dl', 'font',
-			'dt', 'em', 'footer', 'figure', 'figcaption',
-			'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'html', 'i',
-			'img', 'ins', 'kbd', 'li', 'main', 'mark', 'nav', 'noscript',
-			'ol', 'p', 'picture', 'pre', 'q', 'ruby', 'rp', 'rt', 's', 'samp', 'section',
-			'small', 'source', 'span', 'strike', 'strong', 'sub', 'summary',
-			'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time',
-			'tr', 'track', 'tt', 'u', 'ul', 'var', 'wbr', 'video', 'xml:namespace' );
-
-		if ($_SESSION['hasSandbox']) $allowed_elements[] = 'iframe';
-
-		$disallowed_attributes = array('id', 'style', 'class', 'width', 'height', 'allow');
-
-		foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_SANITIZE) as $plugin) {
-			$retval = $plugin->hook_sanitize($doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id);
-			if (is_array($retval)) {
-				$doc = $retval[0];
-				$allowed_elements = $retval[1];
-				$disallowed_attributes = $retval[2];
-			} else {
-				$doc = $retval;
-			}
-		}
-
-		$doc->removeChild($doc->firstChild); //remove doctype
-		$doc = strip_harmful_tags($doc, $allowed_elements, $disallowed_attributes);
-
-		$entries = $xpath->query('//iframe');
-		foreach ($entries as $entry) {
-			$div = $doc->createElement('div');
-			$div->setAttribute('class', 'embed-responsive');
-			$entry->parentNode->replaceChild($div, $entry);
-			$div->appendChild($entry);
-		}
-
-		if ($highlight_words && is_array($highlight_words)) {
-			foreach ($highlight_words as $word) {
-
-				// http://stackoverflow.com/questions/4081372/highlight-keywords-in-a-paragraph
-
-				$elements = $xpath->query("//*/text()");
-
-				foreach ($elements as $child) {
-
-					$fragment = $doc->createDocumentFragment();
-					$text = $child->textContent;
-
-					while (($pos = mb_stripos($text, $word)) !== false) {
-						$fragment->appendChild(new DomText(mb_substr($text, 0, $pos)));
-						$word = mb_substr($text, $pos, mb_strlen($word));
-						$highlight = $doc->createElement('span');
-						$highlight->appendChild(new DomText($word));
-						$highlight->setAttribute('class', 'highlight');
-						$fragment->appendChild($highlight);
-						$text = mb_substr($text, $pos + mb_strlen($word));
-					}
-
-					if (!empty($text)) $fragment->appendChild(new DomText($text));
-
-					$child->parentNode->replaceChild($fragment, $child);
-				}
-			}
-		}
-
-		$res = $doc->saveHTML();
-
-		/* strip everything outside of <body>...</body> */
-
-		$res_frag = array();
-		if (preg_match('/<body>(.*)<\/body>/is', $res, $res_frag)) {
-			return $res_frag[1];
-		} else {
-			return $res;
-		}
-	}
-
-	function strip_harmful_tags($doc, $allowed_elements, $disallowed_attributes) {
-		$xpath = new DOMXPath($doc);
-		$entries = $xpath->query('//*');
-
-		foreach ($entries as $entry) {
-			if (!in_array($entry->nodeName, $allowed_elements)) {
-				$entry->parentNode->removeChild($entry);
-			}
-
-			if ($entry->hasAttributes()) {
-				$attrs_to_remove = array();
-
-				foreach ($entry->attributes as $attr) {
-
-					if (strpos($attr->nodeName, 'on') === 0) {
-						array_push($attrs_to_remove, $attr);
-					}
-
-					if (strpos($attr->nodeName, "data-") === 0) {
-						array_push($attrs_to_remove, $attr);
-					}
-
-					if ($attr->nodeName == 'href' && stripos($attr->value, 'javascript:') === 0) {
-						array_push($attrs_to_remove, $attr);
-					}
-
-					if (in_array($attr->nodeName, $disallowed_attributes)) {
-						array_push($attrs_to_remove, $attr);
-					}
-				}
-
-				foreach ($attrs_to_remove as $attr) {
-					$entry->removeAttributeNode($attr);
-				}
-			}
-		}
-
-		return $doc;
-	}
-
 	function trim_array($array) {
 		$tmp = $array;
 		array_walk($tmp, 'trim');
 		return $tmp;
 	}
 
-	function render_login_form() {
-		header('Cache-Control: public');
-
-		require_once "login_form.php";
-		exit;
-	}
-
 	function T_sprintf() {
 		$args = func_get_args();
 		return vsprintf(__(array_shift($args)), $args);
 	}
 
-	function print_checkpoint($n, $s) {
-		$ts = microtime(true);
-		echo sprintf("<!-- CP[$n] %.4f seconds -->\n", $ts - $s);
-		return $ts;
-	}
-
 	function is_server_https() {
 		return (!empty($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] != 'off')) || $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https';
 	}
@@ -1549,150 +497,6 @@
 		return true;
 	}
 
-	function build_url($parts) {
-		$tmp = $parts['scheme'] . "://" . $parts['host'] . $parts['path'];
-
-		if (isset($parts['query'])) $tmp .= '?' . $parts['query'];
-		if (isset($parts['fragment'])) $tmp .= '#' . $parts['fragment'];
-
-		return $tmp;
-	}
-
-	/**
-	 * Converts a (possibly) relative URL to a absolute one.
-	 *
-	 * @param string $url     Base URL (i.e. from where the document is)
-	 * @param string $rel_url Possibly relative URL in the document
-	 *
-	 * @return string Absolute URL
-	 */
-	function rewrite_relative_url($url, $rel_url) {
-
-		$rel_parts = parse_url($rel_url);
-
-		if ($rel_parts['host'] && $rel_parts['scheme']) {
-			return validate_url($rel_url);
-		} else if (strpos($rel_url, "//") === 0) {
-			# protocol-relative URL (rare but they exist)
-			return validate_url("https:" . $rel_url);
-		} else if (strpos($rel_url, "magnet:") === 0) {
-			# allow magnet links
-			return $rel_url;
-		} else {
-			$parts = parse_url($url);
-
-			$rel_parts['host'] = $parts['host'];
-			$rel_parts['scheme'] = $parts['scheme'];
-
-			if (strpos($rel_parts['path'], '/') !== 0)
-				$rel_parts['path'] = '/' . $rel_parts['path'];
-
-			$rel_parts['path'] = str_replace("/./", "/", $rel_parts['path']);
-			$rel_parts['path'] = str_replace("//", "/", $rel_parts['path']);
-
-			return validate_url(build_url($rel_parts));
-		}
-	}
-
-	function print_user_stylesheet() {
-		$value = get_pref('USER_STYLESHEET');
-
-		if ($value) {
-			print "<style type='text/css' id='user_css_style'>";
-			print str_replace("<br/>", "\n", $value);
-			print "</style>";
-		}
-
-	}
-
-	/* function filter_to_sql($filter, $owner_uid) {
-		$query = array();
-
-		$pdo = Db::pdo();
-
-		if (DB_TYPE == "pgsql")
-			$reg_qpart = "~";
-		else
-			$reg_qpart = "REGEXP";
-
-		foreach ($filter["rules"] AS $rule) {
-			$rule['reg_exp'] = str_replace('/', '\/', $rule["reg_exp"]);
-			$regexp_valid = preg_match('/' . $rule['reg_exp'] . '/',
-					$rule['reg_exp']) !== false;
-
-			if ($regexp_valid) {
-
-				$rule['reg_exp'] = $pdo->quote($rule['reg_exp']);
-
-				switch ($rule["type"]) {
-					case "title":
-						$qpart = "LOWER(ttrss_entries.title) $reg_qpart LOWER('".
-							$rule['reg_exp'] . "')";
-						break;
-					case "content":
-						$qpart = "LOWER(ttrss_entries.content) $reg_qpart LOWER('".
-							$rule['reg_exp'] . "')";
-						break;
-					case "both":
-						$qpart = "LOWER(ttrss_entries.title) $reg_qpart LOWER('".
-							$rule['reg_exp'] . "') OR LOWER(" .
-							"ttrss_entries.content) $reg_qpart LOWER('" . $rule['reg_exp'] . "')";
-						break;
-					case "tag":
-						$qpart = "LOWER(ttrss_user_entries.tag_cache) $reg_qpart LOWER('".
-							$rule['reg_exp'] . "')";
-						break;
-					case "link":
-						$qpart = "LOWER(ttrss_entries.link) $reg_qpart LOWER('".
-							$rule['reg_exp'] . "')";
-						break;
-					case "author":
-						$qpart = "LOWER(ttrss_entries.author) $reg_qpart LOWER('".
-							$rule['reg_exp'] . "')";
-						break;
-				}
-
-				if (isset($rule['inverse'])) $qpart = "NOT ($qpart)";
-
-				if (isset($rule["feed_id"]) && $rule["feed_id"] > 0) {
-					$qpart .= " AND feed_id = " . $pdo->quote($rule["feed_id"]);
-				}
-
-				if (isset($rule["cat_id"])) {
-
-					if ($rule["cat_id"] > 0) {
-						$children = Feeds::getChildCategories($rule["cat_id"], $owner_uid);
-						array_push($children, $rule["cat_id"]);
-						$children = array_map("intval", $children);
-
-						$children = join(",", $children);
-
-						$cat_qpart = "cat_id IN ($children)";
-					} else {
-						$cat_qpart = "cat_id IS NULL";
-					}
-
-					$qpart .= " AND $cat_qpart";
-				}
-
-				$qpart .= " AND feed_id IS NOT NULL";
-
-				array_push($query, "($qpart)");
-
-			}
-		}
-
-		if (count($query) > 0) {
-			$fullquery = "(" . join($filter["match_any_rule"] ? "OR" : "AND", $query) . ")";
-		} else {
-			$fullquery = "(false)";
-		}
-
-		if ($filter['inverse']) $fullquery = "(NOT $fullquery)";
-
-		return $fullquery;
-	} */
-
 	if (!function_exists('gzdecode')) {
 		function gzdecode($string) { // no support for 2nd argument
 			return file_get_contents('compress.zlib://data:who/cares;base64,'.
@@ -1805,92 +609,6 @@
 
 	}
 
-	/*function abs_to_rel_path($dir) {
-		$tmp = str_replace(dirname(__DIR__), "", $dir);
-
-		if (strlen($tmp) > 0 && substr($tmp, 0, 1) == "/") $tmp = substr($tmp, 1);
-
-		return $tmp;
-	}*/
-
-	function get_upload_error_message($code) {
-
-		$errors = array(
-			0 => __('There is no error, the file uploaded with success'),
-			1 => __('The uploaded file exceeds the upload_max_filesize directive in php.ini'),
-			2 => __('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'),
-			3 => __('The uploaded file was only partially uploaded'),
-			4 => __('No file was uploaded'),
-			6 => __('Missing a temporary folder'),
-			7 => __('Failed to write file to disk.'),
-			8 => __('A PHP extension stopped the file upload.'),
-		);
-
-		return $errors[$code];
-	}
-
-	function image_to_base64($filename) {
-		if (file_exists($filename)) {
-			$ext = pathinfo($filename, PATHINFO_EXTENSION);
-
-			return "data:image/$ext;base64," . base64_encode(file_get_contents($filename));
-		} else {
-			return "";
-		}
-	}
-
-	/*	this is essentially a wrapper for readfile() which allows plugins to hook
-		output with httpd-specific "fast" implementation i.e. X-Sendfile or whatever else
-
-		hook function should return true if request was handled (or at least attempted to)
-
-		note that this can be called without user context so the plugin to handle this
-		should be loaded systemwide in config.php */
-	function send_local_file($filename) {
-		if (file_exists($filename)) {
-
-			if (is_writable($filename)) touch($filename);
-
-			$mimetype = mime_content_type($filename);
-
-			// this is hardly ideal but 1) only media is cached in images/ and 2) seemingly only mp4
-			// video files are detected as octet-stream by mime_content_type()
-
-			if ($mimetype == "application/octet-stream")
-				$mimetype = "video/mp4";
-
-			# block SVG because of possible embedded javascript (.....)
-			$mimetype_blacklist = [ "image/svg+xml" ];
-
-			/* only serve video and images */
-			if (!preg_match("/(image|video)\//", $mimetype) || in_array($mimetype, $mimetype_blacklist)) {
-				http_response_code(400);
-				header("Content-type: text/plain");
-
-				print "Stored file has disallowed content type ($mimetype)";
-				return false;
-			}
-
-			$tmppluginhost = new PluginHost();
-
-			$tmppluginhost->load(PLUGINS, PluginHost::KIND_SYSTEM);
-			$tmppluginhost->load_data();
-
-			foreach ($tmppluginhost->get_hooks(PluginHost::HOOK_SEND_LOCAL_FILE) as $plugin) {
-				if ($plugin->hook_send_local_file($filename)) return true;
-			}
-
-			header("Content-type: $mimetype");
-
-			$stamp = gmdate("D, d M Y H:i:s", filemtime($filename)) . " GMT";
-			header("Last-Modified: $stamp", true);
-
-			return readfile($filename);
-		} else {
-			return false;
-		}
-	}
-
 	function arr_qmarks($arr) {
 		return str_repeat('?,', count($arr) - 1) . '?';
 	}
@@ -1967,89 +685,3 @@
 
 		return $ttrss_version['version'];
 	}
-
-	// extended filtering involves validation for safe ports and loopback
-	function validate_url($url, $extended_filtering = false) {
-
-		$url = clean($url);
-
-		# fix protocol-relative URLs
-		if (strpos($url, "//") === 0)
-			$url = "https:" . $url;
-
-		if (filter_var($url, FILTER_VALIDATE_URL) === false)
-			return false;
-
-		$tokens = parse_url($url);
-
-		if (!$tokens['host'])
-			return false;
-
-		if (!in_array(strtolower($tokens['scheme']), ['http', 'https']))
-			return false;
-
-		if ($extended_filtering) {
-			if (!in_array($tokens['port'], [80, 443, '']))
-				return false;
-
-			if (strtolower($tokens['host']) == 'localhost' || $tokens['host'] == '::1' || strpos($tokens['host'], '127.') === 0)
-				return false;
-		}
-
-		//convert IDNA hostname to punycode if possible
-		if (function_exists("idn_to_ascii")) {
-			if (mb_detect_encoding($tokens['host']) != 'ASCII') {
-				$parts['host'] = idn_to_ascii($tokens['host']);
-				$url = build_url($tokens);
-			}
-		}
-
-		return $url;
-	}
-
-	function resolve_redirects($url, $timeout, $nest = 0) {
-
-		// too many redirects
-		if ($nest > 10)
-			return false;
-
-		if (version_compare(PHP_VERSION, '7.1.0', '>=')) {
-			$context_options = array(
-				'http' => array(
-					 'header' => array(
-						 'Connection: close'
-					 ),
-					 'method' => 'HEAD',
-					 'timeout' => $timeout,
-					 'protocol_version'=> 1.1)
-				);
-
-			if (defined('_HTTP_PROXY')) {
-				$context_options['http']['request_fulluri'] = true;
-				$context_options['http']['proxy'] = _HTTP_PROXY;
-			}
-
-			$context = stream_context_create($context_options);
-
-			$headers = get_headers($url, 0, $context);
-		} else {
-			$headers = get_headers($url, 0);
-		}
-
-		if (is_array($headers)) {
-			$headers = array_reverse($headers); // last one is the correct one
-
-			foreach($headers as $header) {
-				if (stripos($header, 'Location:') === 0) {
-					$url = rewrite_relative_url($url, trim(substr($header, strlen('Location:'))));
-
-					return resolve_redirects($url, $timeout, $nest + 1);
-				}
-			}
-
-			return $url;
-		}
-
-		// request failed?
-		return false;
-	}
diff --git a/index.php b/index.php
index 21f727cb3..e402c1402 100644
--- a/index.php
+++ b/index.php
@@ -28,7 +28,7 @@
 
 	if (!init_plugins()) return;
 
-	login_sequence();
+	UserHelper::login_sequence();
 
 	header('Content-Type: text/html; charset=utf-8');
 
@@ -51,7 +51,7 @@
 		const __csrf_token = "<?php echo $_SESSION["csrf_token"]; ?>";
 	</script>
 
-	<?php print_user_stylesheet() ?>
+	<?php UserHelper::print_user_stylesheet() ?>
 
 	<style type="text/css">
 	<?php
diff --git a/plugins/af_comics/filters/af_comics_cad.php b/plugins/af_comics/filters/af_comics_cad.php
index cbd9ae3fb..5da82ae3f 100644
--- a/plugins/af_comics/filters/af_comics_cad.php
+++ b/plugins/af_comics/filters/af_comics_cad.php
@@ -13,7 +13,7 @@ class Af_Comics_Cad extends Af_ComicFilter {
 
 				$doc = new DOMDocument();
 
-				$res = fetch_file_contents($article["link"], false, false, false,
+				$res = UrlHelper::fetch($article["link"], false, false, false,
 					false, false, 0,
 					"Mozilla/5.0 (Windows NT 6.1; WOW64; rv:50.0) Gecko/20100101 Firefox/50.0");
 
diff --git a/plugins/af_comics/filters/af_comics_comicclass.php b/plugins/af_comics/filters/af_comics_comicclass.php
index 07b4be712..ff32375d3 100644
--- a/plugins/af_comics/filters/af_comics_comicclass.php
+++ b/plugins/af_comics/filters/af_comics_comicclass.php
@@ -11,7 +11,7 @@ class Af_Comics_ComicClass extends Af_ComicFilter {
 				// lol at people who block clients by user agent
 				// oh noes my ad revenue Q_Q
 
-				$res = fetch_file_contents($article["link"], false, false, false,
+				$res = UrlHelper::fetch($article["link"], false, false, false,
 					 false, false, 0,
 					 "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)");
 
diff --git a/plugins/af_comics/filters/af_comics_comicpress.php b/plugins/af_comics/filters/af_comics_comicpress.php
index 6a2036645..29b064612 100755
--- a/plugins/af_comics/filters/af_comics_comicpress.php
+++ b/plugins/af_comics/filters/af_comics_comicpress.php
@@ -18,7 +18,7 @@ class Af_Comics_ComicPress extends Af_ComicFilter {
 				// lol at people who block clients by user agent
 				// oh noes my ad revenue Q_Q
 
-				$res = fetch_file_contents(["url" => $article["link"],
+				$res = UrlHelper::fetch(["url" => $article["link"],
 					"useragent" => "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)"]);
 
 				$doc = new DOMDocument();
@@ -37,7 +37,7 @@ class Af_Comics_ComicPress extends Af_ComicFilter {
 
 					if ($webtoon_link) {
 
-						$res = fetch_file_contents(["url" => $webtoon_link->getAttribute("href"),
+						$res = UrlHelper::fetch(["url" => $webtoon_link->getAttribute("href"),
 							"useragent" => "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)"]);
 
 						if (@$doc->loadHTML($res)) {
diff --git a/plugins/af_comics/filters/af_comics_darklegacy.php b/plugins/af_comics/filters/af_comics_darklegacy.php
index ee9ce2d77..978545431 100644
--- a/plugins/af_comics/filters/af_comics_darklegacy.php
+++ b/plugins/af_comics/filters/af_comics_darklegacy.php
@@ -9,7 +9,7 @@ class Af_Comics_DarkLegacy extends Af_ComicFilter {
 
 		if (strpos($article["guid"], "darklegacycomics.com") !== false) {
 
-				$res = fetch_file_contents($article["link"], false, false, false,
+				$res = UrlHelper::fetch($article["link"], false, false, false,
 					 false, false, 0,
 					 "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)");
 
diff --git a/plugins/af_comics/filters/af_comics_dilbert.php b/plugins/af_comics/filters/af_comics_dilbert.php
index 6b194960c..31a72d88d 100644
--- a/plugins/af_comics/filters/af_comics_dilbert.php
+++ b/plugins/af_comics/filters/af_comics_dilbert.php
@@ -10,7 +10,7 @@ class Af_Comics_Dilbert extends Af_ComicFilter {
 		if (strpos($article["link"], "dilbert.com") !== false ||
 			strpos($article["link"], "/DilbertDailyStrip") !== false) {
 
-				$res = fetch_file_contents($article["link"], false, false, false,
+				$res = UrlHelper::fetch($article["link"], false, false, false,
 					 false, false, 0,
 					 "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:50.0) Gecko/20100101 Firefox/50.0");
 
diff --git a/plugins/af_comics/filters/af_comics_explosm.php b/plugins/af_comics/filters/af_comics_explosm.php
index dd1c8e723..bfe38e53b 100644
--- a/plugins/af_comics/filters/af_comics_explosm.php
+++ b/plugins/af_comics/filters/af_comics_explosm.php
@@ -11,7 +11,7 @@ class Af_Comics_Explosm extends Af_ComicFilter {
 
 				$doc = new DOMDocument();
 
-				if (@$doc->loadHTML(fetch_file_contents($article["link"]))) {
+				if (@$doc->loadHTML(UrlHelper::fetch($article["link"]))) {
 					$xpath = new DOMXPath($doc);
 					$basenode = $xpath->query('(//img[@id="main-comic"])')->item(0);
 
diff --git a/plugins/af_comics/filters/af_comics_gocomics.php b/plugins/af_comics/filters/af_comics_gocomics.php
index 2cb49b9bc..949b7a235 100644
--- a/plugins/af_comics/filters/af_comics_gocomics.php
+++ b/plugins/af_comics/filters/af_comics_gocomics.php
@@ -29,7 +29,7 @@ class Af_Comics_Gocomics extends Af_ComicFilter {
 
 			$article_link = $site_url . date('/Y/m/d');
 
-			$body = fetch_file_contents(array('url' => $article_link, 'type' => 'text/html', 'followlocation' => false));
+			$body = UrlHelper::fetch(array('url' => $article_link, 'type' => 'text/html', 'followlocation' => false));
 
 			$feed_title = htmlspecialchars($comic[1]);
 			$site_url = htmlspecialchars($site_url);
diff --git a/plugins/af_comics/filters/af_comics_gocomics_farside.php b/plugins/af_comics/filters/af_comics_gocomics_farside.php
index 71c5e0059..9663da8f9 100644
--- a/plugins/af_comics/filters/af_comics_gocomics_farside.php
+++ b/plugins/af_comics/filters/af_comics_gocomics_farside.php
@@ -37,7 +37,7 @@ class Af_Comics_Gocomics_FarSide extends Af_ComicFilter {
 			$tpl->setVariable('FEED_URL', htmlspecialchars($url), true);
 			$tpl->setVariable('SELF_URL', htmlspecialchars($url), true);
 
-			$body = fetch_file_contents(['url' => $article_link, 'type' => 'text/html', 'followlocation' => false]);
+			$body = UrlHelper::fetch(['url' => $article_link, 'type' => 'text/html', 'followlocation' => false]);
 
 			if ($body) {
 				$doc = new DOMDocument();
diff --git a/plugins/af_comics/filters/af_comics_pa.php b/plugins/af_comics/filters/af_comics_pa.php
index c70a39f6e..380e84596 100644
--- a/plugins/af_comics/filters/af_comics_pa.php
+++ b/plugins/af_comics/filters/af_comics_pa.php
@@ -10,7 +10,7 @@ class Af_Comics_Pa extends Af_ComicFilter {
 
 				$doc = new DOMDocument();
 
-				if ($doc->loadHTML(fetch_file_contents($article["link"]))) {
+				if ($doc->loadHTML(UrlHelper::fetch($article["link"]))) {
 					$xpath = new DOMXPath($doc);
 					$basenode = $xpath->query('(//div[@id="comicFrame"])')->item(0);
 
@@ -25,7 +25,7 @@ class Af_Comics_Pa extends Af_ComicFilter {
 		if (strpos($article["link"], "penny-arcade.com") !== false && strpos($article["title"], "News Post:") !== false) {
 				$doc = new DOMDocument();
 
-				if ($doc->loadHTML(fetch_file_contents($article["link"]))) {
+				if ($doc->loadHTML(UrlHelper::fetch($article["link"]))) {
 					$xpath = new DOMXPath($doc);
 					$entries = $xpath->query('(//div[@class="post"])');
 
diff --git a/plugins/af_comics/filters/af_comics_pvp.php b/plugins/af_comics/filters/af_comics_pvp.php
index e3a92de6b..105d3f490 100644
--- a/plugins/af_comics/filters/af_comics_pvp.php
+++ b/plugins/af_comics/filters/af_comics_pvp.php
@@ -8,7 +8,7 @@ class Af_Comics_Pvp extends Af_ComicFilter {
 	function process(&$article) {
 		if (strpos($article["guid"], "pvponline.com") !== false) {
 
-				$res = fetch_file_contents($article["link"], false, false, false,
+				$res = UrlHelper::fetch($article["link"], false, false, false,
 					 false, false, 0,
 					 "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)");
 
diff --git a/plugins/af_comics/filters/af_comics_tfd.php b/plugins/af_comics/filters/af_comics_tfd.php
index 38ac054c0..e0216705c 100644
--- a/plugins/af_comics/filters/af_comics_tfd.php
+++ b/plugins/af_comics/filters/af_comics_tfd.php
@@ -8,7 +8,7 @@ class Af_Comics_Tfd extends Af_ComicFilter {
 	function process(&$article) {
 		if (strpos($article["link"], "toothpastefordinner.com") !== false ||
 		    strpos($article["link"], "marriedtothesea.com") !== false) {
-			$res = fetch_file_contents($article["link"], false, false, false,
+			$res = UrlHelper::fetch($article["link"], false, false, false,
 				false, false, 0,
 				"Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)");
 
@@ -16,7 +16,7 @@ class Af_Comics_Tfd extends Af_ComicFilter {
 
 			$doc = new DOMDocument();
 
-			if (@$doc->loadHTML(fetch_file_contents($article["link"]))) {
+			if (@$doc->loadHTML(UrlHelper::fetch($article["link"]))) {
 				$xpath = new DOMXPath($doc);
 				$basenode = $xpath->query('//img[contains(@src, ".gif")]')->item(0);
 
diff --git a/plugins/af_comics/filters/af_comics_twp.php b/plugins/af_comics/filters/af_comics_twp.php
index 84e5ce428..38a8ca32b 100644
--- a/plugins/af_comics/filters/af_comics_twp.php
+++ b/plugins/af_comics/filters/af_comics_twp.php
@@ -11,7 +11,7 @@ class Af_Comics_Twp extends Af_ComicFilter {
 
 				$doc = new DOMDocument();
 
-				if (@$doc->loadHTML(fetch_file_contents($article["link"]))) {
+				if (@$doc->loadHTML(UrlHelper::fetch($article["link"]))) {
 					$xpath = new DOMXpath($doc);
 
 					$basenode = $xpath->query("//td/center/img")->item(0);
diff --git a/plugins/af_comics/filters/af_comics_whomp.php b/plugins/af_comics/filters/af_comics_whomp.php
index ddd80a0ee..021a2952a 100644
--- a/plugins/af_comics/filters/af_comics_whomp.php
+++ b/plugins/af_comics/filters/af_comics_whomp.php
@@ -8,7 +8,7 @@ class Af_Comics_Whomp extends Af_ComicFilter {
 	function process(&$article) {
 		if (strpos($article["guid"], "whompcomic.com") !== false) {
 
-			$res = fetch_file_contents($article["link"], false, false, false,
+			$res = UrlHelper::fetch($article["link"], false, false, false,
 				 false, false, 0,
 				 "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)");
 
diff --git a/plugins/af_proxy_http/init.php b/plugins/af_proxy_http/init.php
index 7cb9355f3..2d9ae59b5 100644
--- a/plugins/af_proxy_http/init.php
+++ b/plugins/af_proxy_http/init.php
@@ -48,7 +48,7 @@ class Af_Proxy_Http extends Plugin {
 	}
 
 	public function imgproxy() {
-		$url = validate_url(clean($_REQUEST["url"]));
+		$url = UrlHelper::validate(clean($_REQUEST["url"]));
 
 		// called without user context, let's just redirect to original URL
 		if (!$_SESSION["uid"] || $_REQUEST['af_proxy_http_token'] != $_SESSION['af_proxy_http_token']) {
@@ -62,7 +62,7 @@ class Af_Proxy_Http extends Plugin {
 			header("Location: " . $this->cache->getUrl($local_filename));
 			return;
 		} else {
-			$data = fetch_file_contents(["url" => $url, "max_size" => MAX_CACHE_FILE_SIZE]);
+			$data = UrlHelper::fetch(["url" => $url, "max_size" => MAX_CACHE_FILE_SIZE]);
 
 			if ($data) {
 				if ($this->cache->put($local_filename, $data)) {
@@ -125,7 +125,7 @@ class Af_Proxy_Http extends Plugin {
 					foreach (explode(" " , $this->ssl_known_whitelist) as $host) {
 						if (substr(strtolower($parts['host']), -strlen($host)) === strtolower($host)) {
 							$parts['scheme'] = 'https';
-							$url = build_url($parts);
+							$url = UrlHelper::build_url($parts);
 							if ($all_remote && $is_remote) {
 								break;
 							} else {
diff --git a/plugins/af_readability/init.php b/plugins/af_readability/init.php
index ce8f8d2a4..111462c4b 100755
--- a/plugins/af_readability/init.php
+++ b/plugins/af_readability/init.php
@@ -176,7 +176,7 @@ class Af_Readability extends Plugin {
 
 		global $fetch_effective_url;
 
-		$tmp = fetch_file_contents([
+		$tmp = UrlHelper::fetch([
 			"url" => $url,
 			"http_accept" => "text/*",
 			"type" => "text/html"]);
@@ -235,7 +235,7 @@ class Af_Readability extends Plugin {
 		$extracted_content = $this->extract_content($article["link"]);
 
 		# let's see if there's anything of value in there
-		$content_test = trim(strip_tags(sanitize($extracted_content)));
+		$content_test = trim(strip_tags(Sanitizer::sanitize($extracted_content)));
 
 		if ($content_test) {
 			$article["content"] = $extracted_content;
@@ -264,7 +264,7 @@ class Af_Readability extends Plugin {
 			$extracted_content = $this->extract_content($link);
 
 			# let's see if there's anything of value in there
-			$content_test = trim(strip_tags(sanitize($extracted_content)));
+			$content_test = trim(strip_tags(Sanitizer::sanitize($extracted_content)));
 
 			if ($content_test) {
 				return $extracted_content;
@@ -303,7 +303,7 @@ class Af_Readability extends Plugin {
 		$ret = [];
 
 		if ($row = $sth->fetch()) {
-			$ret["content"] = sanitize($this->extract_content($row["link"]));
+			$ret["content"] = Sanitizer::sanitize($this->extract_content($row["link"]));
 		}
 
 		print json_encode($ret);
diff --git a/plugins/af_redditimgur/init.php b/plugins/af_redditimgur/init.php
index b332ec588..2c213c10b 100755
--- a/plugins/af_redditimgur/init.php
+++ b/plugins/af_redditimgur/init.php
@@ -103,7 +103,7 @@ class Af_RedditImgur extends Plugin {
 				if (!$found && preg_match("/^https?:\/\/twitter.com\/(.*?)\/status\/(.*)/", $entry->getAttribute("href"), $matches)) {
 					Debug::log("handling as twitter: " . $matches[1] . " " . $matches[2], Debug::$LOG_VERBOSE);
 
-					$oembed_result = fetch_file_contents("https://publish.twitter.com/oembed?url=" . urlencode($entry->getAttribute("href")));
+					$oembed_result = UrlHelper::fetch("https://publish.twitter.com/oembed?url=" . urlencode($entry->getAttribute("href")));
 
 					if ($oembed_result) {
 						$oembed_result = json_decode($oembed_result, true);
@@ -165,7 +165,7 @@ class Af_RedditImgur extends Plugin {
 					$source_stream = false;
 
 					if ($source_article_url) {
-						$j = json_decode(fetch_file_contents($source_article_url.".json"), true);
+						$j = json_decode(UrlHelper::fetch($source_article_url.".json"), true);
 
 						if ($j) {
 							foreach ($j as $listing) {
@@ -195,7 +195,7 @@ class Af_RedditImgur extends Plugin {
 
 					Debug::log("Handling as Streamable", Debug::$LOG_VERBOSE);
 
-					$tmp = fetch_file_contents($entry->getAttribute("href"));
+					$tmp = UrlHelper::fetch($entry->getAttribute("href"));
 
 					if ($tmp) {
 						$tmpdoc = new DOMDocument();
@@ -285,7 +285,7 @@ class Af_RedditImgur extends Plugin {
 
 					Debug::log("handling as imgur page/whatever", Debug::$LOG_VERBOSE);
 
-					$content = fetch_file_contents(["url" => $entry->getAttribute("href"),
+					$content = UrlHelper::fetch(["url" => $entry->getAttribute("href"),
 						"http_accept" => "text/*"]);
 
 					if ($content) {
@@ -331,7 +331,7 @@ class Af_RedditImgur extends Plugin {
 				if (!$found) {
 					Debug::log("looking for meta og:image", Debug::$LOG_VERBOSE);
 
-					$content = fetch_file_contents(["url" => $entry->getAttribute("href"),
+					$content = UrlHelper::fetch(["url" => $entry->getAttribute("href"),
 						"http_accept" => "text/*"]);
 
 					if ($content) {
diff --git a/plugins/cache_starred_images/init.php b/plugins/cache_starred_images/init.php
index ae369f56e..5fe963e32 100755
--- a/plugins/cache_starred_images/init.php
+++ b/plugins/cache_starred_images/init.php
@@ -40,16 +40,16 @@ class Cache_Starred_Images extends Plugin {
 
 		Debug::log("caching media of starred articles for user " . $this->host->get_owner_uid() . "...");
 
-		$sth = $this->pdo->prepare("SELECT content, ttrss_entries.title, 
+		$sth = $this->pdo->prepare("SELECT content, ttrss_entries.title,
        		ttrss_user_entries.owner_uid, link, site_url, ttrss_entries.id, plugin_data
 			FROM ttrss_entries, ttrss_user_entries LEFT JOIN ttrss_feeds ON
 				(ttrss_user_entries.feed_id = ttrss_feeds.id)
 			WHERE ref_id = ttrss_entries.id AND
 				marked = true AND
-				site_url != '' AND 
+				site_url != '' AND
 			    ttrss_user_entries.owner_uid = ? AND
 				plugin_data NOT LIKE '%starred_cache_images%'
-			ORDER BY ".sql_random_function()." LIMIT 100");
+			ORDER BY ".Db::sql_random_function()." LIMIT 100");
 
 		if ($sth->execute([$this->host->get_owner_uid()])) {
 
@@ -139,7 +139,7 @@ class Cache_Starred_Images extends Plugin {
 		if (!$this->cache->exists($local_filename)) {
 			Debug::log("cache_images: downloading: $url to $local_filename", Debug::$LOG_VERBOSE);
 
-			$data = fetch_file_contents(["url" => $url, "max_size" => MAX_CACHE_FILE_SIZE]);
+			$data = UrlHelper::fetch(["url" => $url, "max_size" => MAX_CACHE_FILE_SIZE]);
 
 			if ($data)
 				return $this->cache->put($local_filename, $data);;
diff --git a/plugins/no_iframes/init.php b/plugins/no_iframes/init.php
index 18cc3ba17..9711eeb24 100644
--- a/plugins/no_iframes/init.php
+++ b/plugins/no_iframes/init.php
@@ -23,7 +23,7 @@ class No_Iframes extends Plugin {
 		$entries = $xpath->query('//iframe');
 
 		foreach ($entries as $entry) {
-			if (!iframe_whitelisted($entry))
+			if (!Sanitizer::iframe_whitelisted($entry))
 				$entry->parentNode->removeChild($entry);
 		}
 
@@ -34,4 +34,4 @@ class No_Iframes extends Plugin {
 		return 2;
 	}
 
-}
\ No newline at end of file
+}
diff --git a/prefs.php b/prefs.php
index d55f1bc52..2a51c6c38 100644
--- a/prefs.php
+++ b/prefs.php
@@ -21,7 +21,7 @@
 
 	if (!init_plugins()) return;
 
-	login_sequence();
+	UserHelper::login_sequence();
 
 	header('Content-Type: text/html; charset=utf-8');
 ?>
@@ -43,7 +43,7 @@
 		const __csrf_token = "<?php echo $_SESSION["csrf_token"]; ?>";
 	</script>
 
-	<?php print_user_stylesheet() ?>
+	<?php UserHelper::print_user_stylesheet() ?>
 
 	<link rel="shortcut icon" type="image/png" href="images/favicon.png"/>
 	<link rel="icon" type="image/png" sizes="72x72" href="images/favicon-72px.png" />
diff --git a/register.php b/register.php
index 027a0f2b8..47aa39a09 100644
--- a/register.php
+++ b/register.php
@@ -288,7 +288,7 @@
 
 					$new_uid = db_fetch_result($result, 0, "id");
 
-					initialize_user( $new_uid);
+					Pref_Users::initialize_user($new_uid);
 
 					$reg_text = "Hi!\n".
 						"\n".
diff --git a/update.php b/update.php
index 881db3621..8b325d7bb 100755
--- a/update.php
+++ b/update.php
@@ -14,6 +14,19 @@
 	require_once "db.php";
 	require_once "db-prefs.php";
 
+	function make_stampfile($filename) {
+		$fp = fopen(LOCK_DIRECTORY . "/$filename", "w");
+
+		if (flock($fp, LOCK_EX | LOCK_NB)) {
+			fwrite($fp, time() . "\n");
+			flock($fp, LOCK_UN);
+			fclose($fp);
+			return true;
+		} else {
+			return false;
+		}
+	}
+
 	function cleanup_tags($days = 14, $limit = 1000) {
 
 		$days = (int) $days;
-- 
GitLab