CharSet = 'utf-8'; $punyemail = $mailer->punyencodeAddress($email); return PHPMailer\PHPMailer\PHPMailer::validateAddress($punyemail, 'html5'); } /** * @return list */ public static function listUsers(): array { $final_list = []; $base_path = join_path(DATA_PATH, 'users'); $dir_list = array_values(array_diff( scandir($base_path) ?: [], ['..', '.', Minz_User::INTERNAL_USER] )); foreach ($dir_list as $file) { if ($file[0] !== '.' && is_dir(join_path($base_path, $file)) && file_exists(join_path($base_path, $file, 'config.php'))) { $final_list[] = $file; } } return $final_list; } public static function userExists(string $username): bool { $config_path = USERS_PATH . '/' . $username . '/config.php'; if (@file_exists($config_path)) { return true; } elseif (@file_exists($config_path . '.bak.php')) { Minz_Log::warning('Config for user “' . $username . '” not found. Attempting to restore from backup.', ADMIN_LOG); if (!copy($config_path . '.bak.php', $config_path)) { @unlink($config_path); return false; } return @file_exists($config_path); } return false; } /** * Return if the maximum number of registrations has been reached. * Note a max_registrations of 0 means there is no limit. * * @return bool true if number of users >= max registrations, false else. */ public static function max_registrations_reached(): bool { $limit_registrations = FreshRSS_Context::systemConf()->limits['max_registrations']; $number_accounts = count(self::listUsers()); return $limit_registrations > 0 && $number_accounts >= $limit_registrations; } /** @param array $userConfigUpdated */ public static function updateUser(string $user, ?string $email, string $passwordPlain, array $userConfigUpdated = []): bool { $userConfig = FreshRSS_UserConfiguration::getForUser($user); if ($userConfig === null) { return false; } if ($email !== null && $userConfig->mail_login !== $email) { $userConfig->mail_login = $email; if (FreshRSS_Context::systemConf()->force_email_validation) { $userConfig->email_validation_token = hash('sha256', FreshRSS_Context::systemConf()->salt . $email . random_bytes(32)); $mailer = new FreshRSS_User_Mailer(); $mailer->send_email_need_validation($user, $userConfig); } } if ($passwordPlain != '') { $passwordHash = FreshRSS_password_Util::hash($passwordPlain); $userConfig->passwordHash = $passwordHash; if ($user === Minz_User::name()) { FreshRSS_Context::userConf()->passwordHash = $passwordHash; } } foreach ($userConfigUpdated as $configName => $configValue) { if ($configName !== '' && $configValue !== null) { $userConfig->_attribute($configName, $configValue); } } $ok = $userConfig->save(); return $ok; } public function updateAction(): void { if (!FreshRSS_Auth::hasAccess('admin')) { Minz_Error::error(403); } if (Minz_Request::isPost()) { if (self::reauthRedirect()) { return; } $username = Minz_Request::paramString('username'); $newPasswordPlain = Minz_User::name() !== $username ? Minz_Request::paramString('newPasswordPlain', true) : ''; $ok = self::updateUser($username, null, $newPasswordPlain, [ 'token' => Minz_Request::paramString('token') ?: null, ]); if ($ok) { $isSelfUpdate = Minz_User::name() === $username; if ($newPasswordPlain == '' || !$isSelfUpdate) { Minz_Request::good( _t('feedback.user.updated', $username), ['c' => 'user', 'a' => 'manage'], showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0 ); } else { Minz_Request::good( _t('feedback.profile.updated'), ['c' => 'index', 'a' => 'index'], showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0 ); } } else { Minz_Request::bad(_t('feedback.user.updated.error', $username), ['c' => 'user', 'a' => 'manage']); } } } /** * This action displays the user profile page. */ public function profileAction(): void { if (!FreshRSS_Auth::hasAccess()) { Minz_Error::error(403); } $email_not_verified = FreshRSS_Context::userConf()->email_validation_token != ''; $this->view->disable_aside = false; if ($email_not_verified) { $this->view->_layout('simple'); $this->view->disable_aside = true; } FreshRSS_View::prependTitle(_t('conf.profile.title') . ' · '); FreshRSS_View::appendScript(Minz_Url::display('/scripts/vendor/bcrypt.js?' . @filemtime(PUBLIC_PATH . '/scripts/vendor/bcrypt.js'))); if (Minz_Request::isPost() && Minz_User::name() != null) { $old_email = FreshRSS_Context::userConf()->mail_login; $email = Minz_Request::paramString('email'); $challenge = Minz_Request::paramString('challenge'); $newPasswordPlain = ''; if ($challenge !== '') { $username = Minz_User::name(); $nonce = Minz_Session::paramString('nonce'); $newPasswordPlain = Minz_Request::paramString('newPasswordPlain', plaintext: true); $confirmPasswordPlain = Minz_Request::paramString('confirmPasswordPlain', plaintext: true); if (!FreshRSS_FormAuth::checkCredentials( $username, FreshRSS_Context::userConf()->passwordHash, $nonce, $challenge ) || strlen($newPasswordPlain) < 7) { Minz_Session::_param('open', true); // Auto-expand `change password` section Minz_Request::bad( _t('feedback.auth.login.invalid'), ['c' => 'user', 'a' => 'profile'] ); return; } if ($newPasswordPlain !== $confirmPasswordPlain) { Minz_Session::_param('open', true); // Auto-expand `change password` section Minz_Request::bad( _t('feedback.profile.passwords_dont_match'), ['c' => 'user', 'a' => 'profile'] ); return; } Minz_Session::regenerateID('FreshRSS'); } if (FreshRSS_Context::systemConf()->force_email_validation && empty($email)) { Minz_Request::bad( _t('user.email.feedback.required'), ['c' => 'user', 'a' => 'profile'] ); } if (!empty($email) && !self::validateEmailAddress($email)) { Minz_Request::bad( _t('user.email.feedback.invalid'), ['c' => 'user', 'a' => 'profile'] ); } $ok = self::updateUser( Minz_User::name(), $email, $newPasswordPlain, [ 'token' => Minz_Request::paramString('token'), ] ); Minz_Session::_param('passwordHash', FreshRSS_Context::userConf()->passwordHash); if ($ok) { if (FreshRSS_Context::systemConf()->force_email_validation && $email !== $old_email) { Minz_Request::good( _t('feedback.profile.updated'), ['c' => 'user', 'a' => 'validateEmail'], showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0 ); } else { Minz_Request::good( _t('feedback.profile.updated'), ['c' => 'user', 'a' => 'profile'], showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0 ); } } else { Minz_Request::bad(_t('feedback.profile.error'), ['c' => 'user', 'a' => 'profile']); } } } public static function reauthRedirect(): bool { $url_redirect = [ 'c' => 'user', 'a' => 'manage', 'params' => [], ]; $username = Minz_Request::paramStringNull('username'); if ($username !== null) { $url_redirect['a'] = 'details'; $url_redirect['params']['username'] = $username; } return FreshRSS_Auth::requestReauth($url_redirect); } public function purgeAction(): void { if (!FreshRSS_Auth::hasAccess('admin')) { Minz_Error::error(403); } if (!Minz_Request::isPost()) { Minz_Error::error(403); } if (self::reauthRedirect()) { return; } $username = Minz_Request::paramString('username'); if (!FreshRSS_UserDAO::exists($username)) { Minz_Error::error(404); } $feedDAO = FreshRSS_Factory::createFeedDao($username); $feedDAO->purge(); } /** * This action displays the user management page. */ public function manageAction(): void { if (!FreshRSS_Auth::hasAccess('admin')) { Minz_Error::error(403); } if (self::reauthRedirect()) { return; } FreshRSS_View::prependTitle(_t('admin.user.title') . ' · '); if (Minz_Request::isPost()) { $action = Minz_Request::paramString('action'); switch ($action) { case 'delete': $this->deleteAction(); break; case 'update': $this->updateAction(); break; case 'purge': $this->purgeAction(); break; case 'promote': $this->promoteAction(); break; case 'demote': $this->demoteAction(); break; case 'enable': $this->enableAction(); break; case 'disable': $this->disableAction(); break; } } $this->view->show_email_field = FreshRSS_Context::systemConf()->force_email_validation; $this->view->current_user = Minz_Request::paramString('u'); $fast = false; $startTime = time(); foreach (self::listUsers() as $user) { if (!$fast && (time() - $startTime >= 3)) { // Disable detailed user statistics if it takes too long, and will retrieve them asynchronously via JavaScript $fast = true; } $this->view->users[$user] = $this->retrieveUserDetails($user, $fast); } } /** * @param array $userConfigOverride * @throws Minz_ConfigurationNamespaceException * @throws Minz_PDOConnectionException */ public static function createUser(string $new_user_name, ?string $email, string $passwordPlain, array $userConfigOverride = [], bool $insertDefaultFeeds = true): bool { $userConfig = []; $customUserConfigPath = join_path(DATA_PATH, 'config-user.custom.php'); if (file_exists($customUserConfigPath)) { $customUserConfig = include $customUserConfigPath; if (is_array($customUserConfig)) { $userConfig = $customUserConfig; } } $userConfig = array_merge($userConfig, $userConfigOverride); $ok = self::checkUsername($new_user_name); $homeDir = join_path(DATA_PATH, 'users', $new_user_name); // create basepath if missing if (!is_dir(join_path(DATA_PATH, 'users'))) { $ok &= mkdir(join_path(DATA_PATH, 'users'), 0770, true); } $configPath = ''; if ($ok) { if (!Minz_Translate::exists(is_string($userConfig['language'] ?? null) ? $userConfig['language'] : '')) { $userConfig['language'] = Minz_Translate::DEFAULT_LANGUAGE; } $ok &= !in_array(strtoupper($new_user_name), array_map('strtoupper', self::listUsers()), true); //Not an existing user, case-insensitive $configPath = join_path($homeDir, 'config.php'); $ok &= !file_exists($configPath); } if ($ok) { // $homeDir must not exist beforehand, // otherwise it might be multiple remote parties racing to register one username $ok = mkdir($homeDir, 0770, true); if ($ok) { $ok &= (file_put_contents($configPath, "createUser(); if ($ok && $insertDefaultFeeds) { $opmlPath = DATA_PATH . '/opml.xml'; if (!file_exists($opmlPath)) { $opmlPath = FRESHRSS_PATH . '/opml.default.xml'; } $importController = new FreshRSS_importExport_Controller(); try { $importController->importFile($opmlPath, $opmlPath, $new_user_name); } catch (Exception $e) { Minz_Log::error('Error while importing default OPML for user ' . $new_user_name . ': ' . $e->getMessage()); } } $ok &= self::updateUser($new_user_name, $email, $passwordPlain); } return (bool)$ok; } /** * This action creates a new user. * * Request parameters are: * - new_user_language * - new_user_name * - new_user_email * - new_user_passwordPlain * - r (i.e. a redirection url, optional) * * @todo clean up this method. Idea: write a method to init a user with basic information. */ public function createAction(): void { if (!FreshRSS_Auth::hasAccess('admin') && self::max_registrations_reached()) { Minz_Error::error(403); } if (FreshRSS_Auth::hasAccess('admin') && self::reauthRedirect()) { return; } if (Minz_Request::isPost()) { $new_user_name = Minz_Request::paramString('new_user_name'); $email = Minz_Request::paramString('new_user_email'); $passwordPlain = Minz_Request::paramString('new_user_passwordPlain', true); $badRedirectUrl = [ 'c' => Minz_Request::paramString('originController') ?: 'auth', 'a' => Minz_Request::paramString('originAction') ?: 'register', ]; if (!self::checkUsername($new_user_name)) { Minz_Request::bad( _t('user.username.invalid'), $badRedirectUrl ); } if (FreshRSS_UserDAO::exists($new_user_name)) { Minz_Request::bad( _t('user.username.taken', $new_user_name), $badRedirectUrl ); } if (!FreshRSS_password_Util::check($passwordPlain)) { Minz_Request::bad( _t('user.password.invalid'), $badRedirectUrl ); } if (!FreshRSS_Auth::hasAccess('admin')) { // TODO: We may want to ask the user to accept TOS before first login $tos_enabled = file_exists(TOS_FILENAME); $accept_tos = Minz_Request::paramBoolean('accept_tos'); if ($tos_enabled && !$accept_tos) { Minz_Request::bad(_t('user.tos.feedback.invalid'), $badRedirectUrl); } } if (FreshRSS_Context::systemConf()->force_email_validation && empty($email)) { Minz_Request::bad( _t('user.email.feedback.required'), $badRedirectUrl ); } if (!empty($email) && !self::validateEmailAddress($email)) { Minz_Request::bad( _t('user.email.feedback.invalid'), $badRedirectUrl ); } $is_admin = false; if (FreshRSS_Auth::hasAccess('admin')) { $is_admin = Minz_Request::paramBoolean('new_user_is_admin'); } $ok = self::createUser($new_user_name, $email, $passwordPlain, [ 'language' => Minz_Request::paramString('new_user_language') ?: FreshRSS_Context::userConf()->language, 'timezone' => Minz_Request::paramString('new_user_timezone'), 'is_admin' => $is_admin, 'enabled' => true, ]); Minz_Request::_param('new_user_passwordPlain'); //Discard plain-text password ASAP $_POST['new_user_passwordPlain'] = ''; invalidateHttpCache(); // If the user has admin access, it means he’s already logged in // and we don’t want to login with the new account. Otherwise, the // user just created its account himself so he probably wants to // get started immediately. if ($ok && !FreshRSS_Auth::hasAccess('admin')) { $user_conf = FreshRSS_UserConfiguration::getForUser($new_user_name); if ($user_conf !== null) { Minz_Session::_params([ Minz_User::CURRENT_USER => $new_user_name, 'passwordHash' => $user_conf->passwordHash, 'csrf' => false, ]); FreshRSS_Auth::giveAccess(); } else { $ok = false; } } if ($ok) { Minz_Request::setGoodNotification(_t('feedback.user.created', $new_user_name)); } else { Minz_Request::setBadNotification(_t('feedback.user.created.error', $new_user_name)); } } if (FreshRSS_Auth::hasAccess('admin')) { $redirect_url = ['c' => 'user', 'a' => 'manage']; } else { $redirect_url = ['c' => 'index', 'a' => 'index']; } Minz_Request::forward($redirect_url, true); } public static function deleteUser(string $username): bool { $ok = self::checkUsername($username); if ($ok) { $default_user = FreshRSS_Context::systemConf()->default_user; $ok &= (strcasecmp($username, $default_user) !== 0); //It is forbidden to delete the default user } $user_data = join_path(DATA_PATH, 'users', $username); $ok &= is_dir($user_data); if ($ok) { FreshRSS_fever_Util::deleteKey($username); Minz_ModelPdo::$usesSharedPdo = false; $oldUserDAO = FreshRSS_Factory::createUserDao($username); $ok &= $oldUserDAO->deleteUser(); Minz_ModelPdo::$usesSharedPdo = true; $ok &= recursive_unlink($user_data); $filenames = glob(PSHB_PATH . '/feeds/*/' . $username . '.txt'); if (!empty($filenames)) { array_map('unlink', $filenames); } } return (bool)$ok; } /** * This action validates an email address, based on the token sent by email. * It also serves the main page when user is blocked. * * Request parameters are: * - username * - token * * This route works with GET requests since the URL is provided by email. * The security risks (e.g. forged URL by an attacker) are not very high so * it’s ok. * * It returns 404 error if `force_email_validation` is disabled or if the * user doesn’t exist. * * It returns 403 if user isn’t logged in and `username` param isn’t passed. */ public function validateEmailAction(): void { if (!FreshRSS_Context::systemConf()->force_email_validation) { Minz_Error::error(404); } FreshRSS_View::prependTitle(_t('user.email.validation.title') . ' · '); $this->view->_layout('simple'); $username = Minz_Request::paramString('username'); $token = Minz_Request::paramString('token'); if ($username !== '') { $user_config = FreshRSS_UserConfiguration::getForUser($username); } elseif (FreshRSS_Auth::hasAccess()) { $user_config = FreshRSS_Context::userConf(); } else { Minz_Error::error(403); return; } if (!FreshRSS_UserDAO::exists($username) || $user_config === null) { Minz_Error::error(404); return; } if ($user_config->email_validation_token === '') { Minz_Request::good( _t('user.email.validation.feedback.unnecessary'), ['c' => 'index', 'a' => 'index'], showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0 ); } if ($token != '') { if ($user_config->email_validation_token !== $token) { Minz_Request::bad( _t('user.email.validation.feedback.wrong_token'), ['c' => 'user', 'a' => 'validateEmail'] ); } $user_config->email_validation_token = ''; if ($user_config->save()) { Minz_Request::good( _t('user.email.validation.feedback.ok'), ['c' => 'index', 'a' => 'index'], showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0 ); } else { Minz_Request::bad( _t('user.email.validation.feedback.error'), ['c' => 'user', 'a' => 'validateEmail'] ); } } } /** * This action resends a validation email to the current user. * * It only acts on POST requests but doesn’t require any param (except the * CSRF token). * * It returns 403 error if the user is not logged in or 404 if request is * not POST. Else it redirects silently to the index if user has already * validated its email, or to the user#validateEmail route. */ public function sendValidationEmailAction(): void { if (!FreshRSS_Auth::hasAccess()) { Minz_Error::error(403); } if (!Minz_Request::isPost()) { Minz_Error::error(404); } $username = Minz_User::name(); if (FreshRSS_Context::userConf()->email_validation_token === '') { Minz_Request::forward([ 'c' => 'index', 'a' => 'index', ], true); } $mailer = new FreshRSS_User_Mailer(); $ok = $username != null && $mailer->send_email_need_validation($username, FreshRSS_Context::userConf()); $redirect_url = ['c' => 'user', 'a' => 'validateEmail']; if ($ok) { Minz_Request::good( _t('user.email.validation.feedback.email_sent'), $redirect_url, showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0 ); } else { Minz_Request::bad( _t('user.email.validation.feedback.email_failed'), $redirect_url ); } } /** * This action delete an existing user. * * Request parameter is: * - username * * @todo clean up this method. Idea: create a User->clean() method. */ public function deleteAction(): void { $username = Minz_Request::paramString('username'); $self_deletion = Minz_User::name() === $username; if (!FreshRSS_Auth::hasAccess('admin') && !$self_deletion) { Minz_Error::error(403); } $redirect_url = ['c' => 'user', 'a' => 'manage']; if (Minz_Request::isPost()) { $ok = true; if ($self_deletion) { // We check the password if it’s a self-destruction $nonce = Minz_Session::paramString('nonce'); $challenge = Minz_Request::paramString('challenge'); $ok &= FreshRSS_FormAuth::checkCredentials( $username, FreshRSS_Context::userConf()->passwordHash, $nonce, $challenge ); if (!$ok) { Minz_Request::bad(_t('feedback.auth.login.invalid'), ['c' => 'user', 'a' => 'profile']); return; } } elseif (self::reauthRedirect()) { return; } $ok &= self::deleteUser($username); if ($ok && $self_deletion) { FreshRSS_Auth::removeAccess(); $redirect_url = ['c' => 'index', 'a' => 'index']; } invalidateHttpCache(); if ($ok) { Minz_Request::setGoodNotification(_t('feedback.user.deleted', $username)); } else { Minz_Request::setBadNotification(_t('feedback.user.deleted.error', $username)); } } Minz_Request::forward($redirect_url, true); } public function promoteAction(): void { $this->toggleAction('is_admin', true); } public function demoteAction(): void { $this->toggleAction('is_admin', false); } public function enableAction(): void { $this->toggleAction('enabled', true); } public function disableAction(): void { $this->toggleAction('enabled', false); } private function toggleAction(string $field, bool $value): void { if (!FreshRSS_Auth::hasAccess('admin')) { Minz_Error::error(403); } if (!Minz_Request::isPost()) { Minz_Error::error(403); } if (self::reauthRedirect()) { return; } $username = Minz_Request::paramString('username'); if (!FreshRSS_UserDAO::exists($username)) { Minz_Error::error(404); } if (null === $userConfig = FreshRSS_UserConfiguration::getForUser($username)) { Minz_Error::error(500); return; } if ($field === '') { Minz_Error::error(400, 'Invalid field name'); return; } $userConfig->_attribute($field, $value); $ok = $userConfig->save(); FreshRSS_UserDAO::touch($username); if ($ok) { Minz_Request::good( _t('feedback.user.updated', $username), ['c' => 'user', 'a' => 'manage'], showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0 ); } else { Minz_Request::bad( _t('feedback.user.updated.error', $username), ['c' => 'user', 'a' => 'manage'] ); } } public function detailsAction(): void { if (!FreshRSS_Auth::hasAccess('admin')) { Minz_Error::error(403); } if (self::reauthRedirect()) { return; } $username = Minz_Request::paramString('username'); if (!FreshRSS_UserDAO::exists($username)) { Minz_Error::error(404); } if (Minz_Request::paramBoolean('ajax')) { $this->view->_layout(null); } $this->view->username = $username; $this->view->details = $this->retrieveUserDetails($username); FreshRSS_View::prependTitle($username . ' · ' . _t('gen.menu.user_management') . ' · '); } /** @return array{feed_count:?int,article_count:?int,database_size:?int,language:string,mail_login:string,enabled:bool,is_admin:bool,last_user_activity:string,is_default:bool} */ private function retrieveUserDetails(string $username, bool $fast = false): array { $feedDAO = $fast ? null : FreshRSS_Factory::createFeedDao($username); $entryDAO = $fast ? null : FreshRSS_Factory::createEntryDao($username); $databaseDAO = $fast ? null : FreshRSS_Factory::createDatabaseDAO($username); $userConfiguration = FreshRSS_UserConfiguration::getForUser($username); if ($userConfiguration === null) { throw new Exception('Error loading user configuration!'); } return [ 'feed_count' => isset($feedDAO) ? $feedDAO->count() : null, 'article_count' => isset($entryDAO) ? $entryDAO->count() : null, 'database_size' => isset($databaseDAO) ? $databaseDAO->size() : null, 'language' => $userConfiguration->language, 'mail_login' => $userConfiguration->mail_login, 'enabled' => $userConfiguration->enabled, 'is_admin' => $userConfiguration->is_admin, 'last_user_activity' => date('c', FreshRSS_UserDAO::mtime($username)) ?: '', 'is_default' => FreshRSS_Context::systemConf()->default_user === $username, ]; } }