From cf1eaeedf3026948cf2210af1c747bdc3522d6ff Mon Sep 17 00:00:00 2001
From: Andrew Dolgov <noreply@fakecake.org>
Date: Fri, 10 Jun 2022 13:39:00 +0300
Subject: [PATCH]  * add UserHelper methods to manipulate user database (add,
 modify, delete)  * expose said methods via CLI (update.php)  * fix several
 invocations of deprecated functions  * set stricter type hints on several
 method arguments

---
 classes/debug.php              |  20 ++++-
 classes/feeds.php              |   2 +-
 classes/handler/public.php     |  14 ++--
 classes/prefs.php              |   6 +-
 classes/userhelper.php         | 114 +++++++++++++++++++++++++++-
 plugins/auth_internal/init.php |  12 +--
 update.php                     | 133 +++++++++++++++++++++++++++++++--
 update_daemon2.php             |   4 +-
 8 files changed, 275 insertions(+), 30 deletions(-)

diff --git a/classes/debug.php b/classes/debug.php
index fbdf260e0..4777e8c74 100644
--- a/classes/debug.php
+++ b/classes/debug.php
@@ -68,9 +68,9 @@ class Debug {
     }
 
 	/**
-	 * @param int $level Debug::LOG_*
+	 * @param Debug::LOG_* $level
 	 */
-    public static function set_loglevel(int $level): void {
+    public static function set_loglevel($level): void {
         self::$loglevel = $level;
     }
 
@@ -82,7 +82,21 @@ class Debug {
     }
 
 	/**
-	 * @param int $level Debug::LOG_*
+	 * @param int $level integer loglevel value
+	 * @return Debug::LOG_* if valid, warn and return LOG_DISABLED otherwise
+	 */
+	public static function map_loglevel(int $level) : int {
+		if (in_array($level, self::ALL_LOG_LEVELS)) {
+			/** @phpstan-ignore-next-line */
+			return $level;
+		} else {
+			user_error("Passed invalid debug log level: $level", E_USER_WARNING);
+			return self::LOG_DISABLED;
+		}
+	}
+
+	/**
+	 * @param Debug::LOG_* $level log level
 	 */
     public static function log(string $message, int $level = Debug::LOG_NORMAL): bool {
 
diff --git a/classes/feeds.php b/classes/feeds.php
index a06486883..197caeedc 100755
--- a/classes/feeds.php
+++ b/classes/feeds.php
@@ -665,7 +665,7 @@ class Feeds extends Handler_Protected {
 		}
 
 		Debug::set_enabled(true);
-		Debug::set_loglevel($xdebug);
+		Debug::set_loglevel(Debug::map_loglevel($xdebug));
 
 		$feed_id = (int)$_REQUEST["feed_id"];
 		$do_update = ($_REQUEST["action"] ?? "") == "do_update";
diff --git a/classes/handler/public.php b/classes/handler/public.php
index 3fef4c2b9..499cf8db2 100755
--- a/classes/handler/public.php
+++ b/classes/handler/public.php
@@ -76,7 +76,7 @@ class Handler_Public extends Handler {
 			"/public.php?op=rss&id=$feed&key=" .
 			Feeds::_get_access_key($feed, false, $owner_uid);
 
-		if (!$feed_site_url) $feed_site_url = get_self_url_prefix();
+		if (!$feed_site_url) $feed_site_url = Config::get_self_url();
 
 		if ($format == 'atom') {
 			$tpl = new Templator();
@@ -87,7 +87,7 @@ class Handler_Public extends Handler {
 			$tpl->setVariable('VERSION', Config::get_version(), true);
 			$tpl->setVariable('FEED_URL', htmlspecialchars($feed_self_url), true);
 
-			$tpl->setVariable('SELF_URL', htmlspecialchars(get_self_url_prefix()), true);
+			$tpl->setVariable('SELF_URL', htmlspecialchars(Config::get_self_url()), true);
 			while ($line = $result->fetch()) {
 
 				$line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...'));
@@ -134,7 +134,7 @@ class Handler_Public extends Handler {
 
 				$tpl->setVariable('ARTICLE_AUTHOR', htmlspecialchars($line['author']), true);
 
-				$tpl->setVariable('ARTICLE_SOURCE_LINK', htmlspecialchars($line['site_url'] ? $line["site_url"] : get_self_url_prefix()), true);
+				$tpl->setVariable('ARTICLE_SOURCE_LINK', htmlspecialchars($line['site_url'] ? $line["site_url"] : Config::get_self_url()), true);
 				$tpl->setVariable('ARTICLE_SOURCE_TITLE', htmlspecialchars($line['feed_title'] ?? $feed_title), true);
 
 				foreach ($line["tags"] as $tag) {
@@ -312,7 +312,7 @@ class Handler_Public extends Handler {
 				$login, $user_id);
 
 			if (!$redirect_url)
-				$redirect_url = get_self_url_prefix() . "/index.php";
+				$redirect_url = Config::get_self_url() . "/index.php";
 
 			header("Location: " . $redirect_url);
 		} else {
@@ -389,11 +389,11 @@ class Handler_Public extends Handler {
 			if (UserHelper::authenticate($login, $password)) {
 				$_POST["password"] = "";
 
-				if (get_schema_version() >= 120) {
+				if (Config::get_schema_version() >= 120) {
 					$_SESSION["language"] = get_pref(Prefs::USER_LANGUAGE, $_SESSION["uid"]);
 				}
 
-				$_SESSION["ref_schema_version"] = get_schema_version();
+				$_SESSION["ref_schema_version"] = Config::get_schema_version();
 				$_SESSION["bw_limit"] = !!clean($_POST["bw_limit"] ?? false);
 				$_SESSION["safe_mode"] = $safe_mode;
 
@@ -563,7 +563,7 @@ class Handler_Public extends Handler {
 					print_notice("Password reset instructions are being sent to your email address.");
 
 					$resetpass_token = sha1(get_random_bytes(128));
-					$resetpass_link = get_self_url_prefix() . "/public.php?op=forgotpass&hash=" . $resetpass_token .
+					$resetpass_link = Config::get_self_url() . "/public.php?op=forgotpass&hash=" . $resetpass_token .
 						"&login=" . urlencode($login);
 
 					$tpl = new Templator();
diff --git a/classes/prefs.php b/classes/prefs.php
index 7e6033f4d..378fea293 100644
--- a/classes/prefs.php
+++ b/classes/prefs.php
@@ -230,7 +230,7 @@ class Prefs {
 			}
 		}
 
-		if (get_schema_version() >= 141) {
+		if (Config::get_schema_version() >= 141) {
 			// fill in any overrides from the database
 			$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2
 									WHERE owner_uid = :uid AND
@@ -265,7 +265,7 @@ class Prefs {
 			if ($this->_is_cached($pref_name, $owner_uid, $profile_id)) {
 				$cached_value = $this->_get_cache($pref_name, $owner_uid, $profile_id);
 				return Config::cast_to($cached_value, $type_hint);
-			} else if (get_schema_version() >= 141) {
+			} else if (Config::get_schema_version() >= 141) {
 				$sth = $this->pdo->prepare("SELECT value FROM ttrss_user_prefs2
 								WHERE pref_name = :name AND owner_uid = :uid AND
 								(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
@@ -390,7 +390,7 @@ class Prefs {
 	}
 
 	function migrate(int $owner_uid, ?int $profile_id): void {
-		if (get_schema_version() < 141)
+		if (Config::get_schema_version() < 141)
 			return;
 
 		if (!$profile_id) $profile_id = null;
diff --git a/classes/userhelper.php b/classes/userhelper.php
index 91e40665d..caa32a36e 100644
--- a/classes/userhelper.php
+++ b/classes/userhelper.php
@@ -17,6 +17,15 @@ class UserHelper {
 		self::HASH_ALGO_SHA1
 	];
 
+	const ACCESS_LEVELS = [
+		self::ACCESS_LEVEL_DISABLED,
+		self::ACCESS_LEVEL_READONLY,
+		self::ACCESS_LEVEL_USER,
+		self::ACCESS_LEVEL_POWERUSER,
+		self::ACCESS_LEVEL_ADMIN,
+		self::ACCESS_LEVEL_KEEP_CURRENT
+	];
+
 	/** forbidden to login */
 	const ACCESS_LEVEL_DISABLED 		= -2;
 
@@ -32,6 +41,23 @@ class UserHelper {
 	/** has administrator permissions */
 	const ACCESS_LEVEL_ADMIN			= 10;
 
+	/** used by self::user_modify() to keep current access level */
+	const ACCESS_LEVEL_KEEP_CURRENT = -1024;
+
+	/**
+	 * @param int $level integer loglevel value
+	 * @return UserHelper::ACCESS_LEVEL_* if valid, warn and return ACCESS_LEVEL_KEEP_CURRENT otherwise
+	 */
+	public static function map_access_level(int $level) : int {
+		if (in_array($level, self::ACCESS_LEVELS)) {
+			/** @phpstan-ignore-next-line */
+			return $level;
+		} else {
+			user_error("Passed invalid user access level: $level", E_USER_WARNING);
+			return self::ACCESS_LEVEL_KEEP_CURRENT;
+		}
+	}
+
 	static function authenticate(string $login = null, string $password = null, bool $check_only = false, string $service = null): bool {
 		if (!Config::get(Config::SINGLE_USER_MODE)) {
 			$user_id = false;
@@ -133,7 +159,7 @@ class UserHelper {
 			if (empty($_SESSION["uid"])) {
 
 				if (Config::get(Config::AUTH_AUTO_LOGIN) && self::authenticate(null, null)) {
-					$_SESSION["ref_schema_version"] = get_schema_version();
+					$_SESSION["ref_schema_version"] = Config::get_schema_version();
 				} else {
 					 self::authenticate(null, null, true);
 				}
@@ -217,6 +243,7 @@ class UserHelper {
 		return substr(bin2hex(get_random_bytes(125)), 0, 250);
 	}
 
+	/** TODO: this should invoke UserHelper::user_modify() */
 	static function reset_password(int $uid, bool $format_output = false, string $new_password = ""): void {
 
 		$user = ORM::for_table('ttrss_users')->find_one($uid);
@@ -380,4 +407,89 @@ class UserHelper {
 		else
 			return false;
 	}
+
+	/**
+	 * @param string $login Login for new user (case-insensitive)
+	 * @param string $password Password for new user (may not be blank)
+	 * @param UserHelper::ACCESS_LEVEL_* $access_level Access level for new user
+	 * @return bool true if user has been created
+	 */
+	static function user_add(string $login, string $password, int $access_level) : bool {
+		$login = clean($login);
+
+		if ($login &&
+			$password &&
+			!self::find_user_by_login($login) &&
+			self::map_access_level((int)$access_level) != self::ACCESS_LEVEL_KEEP_CURRENT) {
+
+			$user = ORM::for_table('ttrss_users')->create();
+
+			$user->salt = self::get_salt();
+			$user->login = mb_strtolower($login);
+			$user->pwd_hash = self::hash_password($password, $user->salt);
+			$user->access_level = $access_level;
+			$user->created = Db::NOW();
+
+			return $user->save();
+		}
+
+		return false;
+	}
+
+	/**
+	 * @param int $uid User ID to modify
+	 * @param string $new_password set password to this value if its not blank
+	 * @param UserHelper::ACCESS_LEVEL_* $access_level set user access level to this value if it is set (default ACCESS_LEVEL_KEEP_CURRENT)
+	 * @return bool true if user record has been saved
+	 *
+	 * NOTE: $access_level is of mixed type because of intellephense
+	 */
+	static function user_modify(int $uid, string $new_password = '', $access_level = self::ACCESS_LEVEL_KEEP_CURRENT) : bool {
+		$user = ORM::for_table('ttrss_users')->find_one($uid);
+
+		if ($user) {
+			if ($new_password != '') {
+				$new_salt = self::get_salt();
+				$pwd_hash = self::hash_password($new_password, $new_salt, self::HASH_ALGOS[0]);
+
+				$user->pwd_hash = $pwd_hash;
+				$user->salt = $new_salt;
+			}
+
+			if ($access_level != self::ACCESS_LEVEL_KEEP_CURRENT) {
+				$user->access_level = (int)$access_level;
+			}
+
+			return $user->save();
+		}
+
+		return false;
+	}
+
+	/**
+	 * @param int $uid user ID to delete (this won't delete built-in admin user with UID 1)
+	 * @return bool true if user has been deleted
+	 */
+	static function user_delete(int $uid) : bool {
+		if ($uid != 1) {
+
+			$user = ORM::for_table('ttrss_users')->find_one($uid);
+
+			if ($user) {
+				// TODO: is it still necessary to split those queries?
+
+				ORM::for_table('ttrss_tags')
+					->where('owner_uid', $uid)
+					->delete_many();
+
+				ORM::for_table('ttrss_feeds')
+					->where('owner_uid', $uid)
+					->delete_many();
+
+				return $user->delete();
+			}
+		}
+
+		return false;
+	}
 }
diff --git a/plugins/auth_internal/init.php b/plugins/auth_internal/init.php
index 688a0f5d8..882b5506a 100644
--- a/plugins/auth_internal/init.php
+++ b/plugins/auth_internal/init.php
@@ -18,14 +18,14 @@ class Auth_Internal extends Auth_Base {
 		$otp = (int) ($_REQUEST["otp"] ?? 0);
 
 		// don't bother with null/null logins for auth_external etc
-		if ($login && get_schema_version() > 96) {
+		if ($login && Config::get_schema_version() > 96) {
 
 			$user_id = UserHelper::find_user_by_login($login);
 
 			if ($user_id && UserHelper::is_otp_enabled($user_id)) {
 
 				// only allow app passwords for service logins if OTP is enabled
-				if ($service && get_schema_version() > 138) {
+				if ($service && Config::get_schema_version() > 138) {
 					return $this->check_app_password($login, $password, $service);
 				}
 
@@ -106,7 +106,7 @@ class Auth_Internal extends Auth_Base {
 		// service logins: check app passwords first but allow regular password
 		// as a fallback if OTP is not enabled
 
-		if ($service && get_schema_version() > 138) {
+		if ($service && Config::get_schema_version() > 138) {
 			$user_id = $this->check_app_password($login, $password, $service);
 
 			if ($user_id)
@@ -119,7 +119,7 @@ class Auth_Internal extends Auth_Base {
 				->find_one();
 
 			if ($user) {
-				if (get_schema_version() >= 145) {
+				if (Config::get_schema_version() >= 145) {
 					if ($user->last_auth_attempt) {
 						$last_auth_attempt = strtotime($user->last_auth_attempt);
 
@@ -145,7 +145,7 @@ class Auth_Internal extends Auth_Base {
 				if ($auth_result) {
 					return $auth_result;
 				} else {
-					if (get_schema_version() >= 145) {
+					if (Config::get_schema_version() >= 145) {
 						$user->last_auth_attempt = Db::NOW();
 						$user->save();
 					}
@@ -176,7 +176,7 @@ class Auth_Internal extends Auth_Base {
 			list ($pwd_algo, $raw_hash) = explode(":", $pwd_hash, 2);
 
 			// check app password only if service is specified
-			if ($service && get_schema_version() > 138) {
+			if ($service && Config::get_schema_version() > 138) {
 				return $this->check_app_password($login, $password, $service);
 			}
 
diff --git a/update.php b/update.php
index f33458c2e..10f7f230a 100755
--- a/update.php
+++ b/update.php
@@ -99,8 +99,12 @@
 		"opml-export:" => ["USER:FILE", "export OPML of USER to FILE"],
 		"opml-import:" => ["USER:FILE", "import OPML for USER from FILE"],
 		"user-list" => "list all users",
-#		"user-add:" => ["USER[:PASSWORD]", "add USER, optionally without prompting for PASSWORD"],
-#		"user-remove:" => ["USERNAME", "remove specified user"],
+		"user-add:" => ["USER[:PASSWORD[:ACCESS_LEVEL=0]]", "add USER, prompts for password if unset"],
+		"user-remove:" => ["USERNAME", "remove USER"],
+		"user-set-password:" => ["USER:PASSWORD", "sets PASSWORD of specified USER"],
+		"user-set-access-level:" => ["USER:LEVEL", "sets access LEVEL of specified USER"],
+		"user-exists:" => ["USER", "returns 0 if specified USER exists in the database"],
+		"force-yes" => "assume 'yes' to all queries",
 		"help" => "",
 	];
 
@@ -150,7 +154,7 @@
 	Debug::set_enabled(true);
 
 	if (isset($options["log-level"])) {
-	    Debug::set_loglevel((int)$options["log-level"]);
+	    Debug::set_loglevel(Debug::map_loglevel((int)$options["log-level"]));
     }
 
 	if (isset($options["log"])) {
@@ -159,7 +163,7 @@
         Debug::log("Logging to " . $options["log"]);
     } else {
 	    if (isset($options['quiet'])) {
-			Debug::set_loglevel(Debug::$LOG_DISABLED);
+			Debug::set_loglevel(Debug::LOG_DISABLED);
         }
     }
 
@@ -265,7 +269,7 @@
 	if (isset($options["update-schema"])) {
 		if (Config::is_migration_needed()) {
 
-			if ($options["update-schema"] != "force-yes") {
+			if (!isset($options['force-yes']) || $options["update-schema"] != "force-yes") {
 				Debug::log("Type 'yes' to continue.");
 
 				if (read_stdin() != 'yes')
@@ -275,7 +279,7 @@
 			}
 
 			if (!isset($options["log-level"])) {
-				Debug::set_loglevel(Debug::$LOG_VERBOSE);
+				Debug::set_loglevel(Debug::LOG_VERBOSE);
 			}
 
 			$migrations = Config::get_migrations();
@@ -352,7 +356,7 @@
 		if (isset($options["force-refetch"])) $_REQUEST["force_refetch"] = true;
 		if (isset($options["force-rehash"])) $_REQUEST["force_rehash"] = true;
 
-		Debug::set_loglevel(Debug::$LOG_EXTENDED);
+		Debug::set_loglevel(Debug::LOG_EXTENDED);
 
 		$rc = RSSUtils::update_rss_feed($feed) != false ? 0 : 1;
 
@@ -407,6 +411,121 @@
 
 	}
 
+	if (isset($options["user-add"])) {
+		list ($login, $password, $access_level) = explode(":", $options["user-add"], 3);
+
+		$uid = UserHelper::find_user_by_login($login);
+
+		if ($uid) {
+			Debug::log("Error: User already exists: $login");
+			exit(1);
+		}
+
+		if (!$access_level)
+			$access_level = UserHelper::ACCESS_LEVEL_USER;
+
+		if (!in_array($access_level, UserHelper::ACCESS_LEVELS)) {
+			Debug::log("Error: Invalid access level value: $access_level");
+			exit(1);
+		}
+
+		if (!$password) {
+			Debug::log("Please enter password for user $login: ");
+			$password = read_stdin();
+
+			if (!$password) {
+				Debug::log("Error: password may not be blank.");
+				exit(1);
+			}
+		}
+
+		Debug::log("Adding user $login with access level $access_level...");
+
+		if (UserHelper::user_add($login, $password, $access_level)) {
+			Debug::log("Success.");
+		} else {
+			Debug::log("Operation failed, check the logs for more information.");
+		}
+	}
+
+	if (isset($options["user-set-password"])) {
+		list ($login, $password) = explode(":", $options["user-set-password"], 2);
+
+		$uid = UserHelper::find_user_by_login($login);
+
+		if (!$uid) {
+			Debug::log("Error: User not found: $login");
+			exit(1);
+		}
+
+		Debug::log("Changing password of user $login...");
+
+		if (UserHelper::user_modify($uid, $password)) {
+			Debug::log("Success.");
+		} else {
+			Debug::log("Operation failed, check the logs for more information.");
+		}
+	}
+
+	if (isset($options["user-set-access-level"])) {
+		list ($login, $access_level) = explode(":", $options["user-set-access-level"], 2);
+
+		$uid = UserHelper::find_user_by_login($login);
+
+		if (!$uid) {
+			Debug::log("Error: User not found: $login");
+			exit(1);
+		}
+
+		if (!in_array($access_level, UserHelper::ACCESS_LEVELS)) {
+			Debug::log("Error: Invalid access level value: $access_level");
+			exit(1);
+		}
+
+		Debug::log("Changing access level of user $login...");
+
+		if (UserHelper::user_modify($uid, '', UserHelper::map_access_level((int)$access_level))) {
+			Debug::log("Success.");
+		} else {
+			Debug::log("Operation failed, check the logs for more information.");
+		}
+	}
+
+	if (isset($options["user-remove"])) {
+		$login = $options["user-remove"];
+
+		$uid = UserHelper::find_user_by_login($login);
+
+		if (!$uid) {
+			Debug::log("Error: User not found: $login");
+			exit(1);
+		}
+
+		if (!isset($options['force-yes'])) {
+			Debug::log("About to remove user $login. Type 'yes' to continue.");
+
+			if (read_stdin() != 'yes')
+				exit(1);
+		}
+
+		Debug::log("Removing user $login...");
+
+		if (UserHelper::user_delete($uid)) {
+			Debug::log("Success.");
+		} else {
+			Debug::log("Operation failed, check the logs for more information.");
+		}
+	}
+
+	if (isset($options["user-exists"])) {
+		$login = $options["user-exists"];
+
+		if (UserHelper::find_user_by_login($login))
+			exit(0);
+		else
+			exit(1);
+	}
+
 	PluginHost::getInstance()->run_commands($options);
 
 	if (file_exists(Config::get(Config::LOCK_DIRECTORY) . "/$lock_filename"))
diff --git a/update_daemon2.php b/update_daemon2.php
index eea790c8b..72c13c874 100755
--- a/update_daemon2.php
+++ b/update_daemon2.php
@@ -146,7 +146,7 @@
     Debug::set_enabled(true);
 
     if (isset($options["log-level"])) {
-        Debug::set_loglevel((int)$options["log-level"]);
+        Debug::set_loglevel(Debug::map_loglevel((int)$options["log-level"]));
     }
 
     if (isset($options["log"])) {
@@ -155,7 +155,7 @@
         Debug::log("Logging to " . $options["log"]);
     } else {
         if (isset($options['quiet'])) {
-            Debug::set_loglevel(Debug::$LOG_DISABLED);
+            Debug::set_loglevel(Debug::LOG_DISABLED);
         }
     }
 
-- 
GitLab