Files
FreshRSS/app/Controllers/userController.php
Alexandre Alapetite 78e40c6fe3 Scaling of user statistics (#8277)
Fix https://github.com/FreshRSS/FreshRSS/issues/8268
To better support user management on FreshRSS instance with many users.

SQL speed improved. On a reduced test with 5 users, including some large accounts (PostgreSQL on a very tiny and slow server), improving from ~2.3s to ~1.8s, which gives ~20% speed improvement.

Then tested with 1000 users, with only the default feed (on my old desktop computer):

```sh
for i in {1..1000}; do ./cli/create-user.php --user=freshrss$i --password=freshrss; done
app/actualize_script.php
cli/access-permissions.sh
```

SQLite:

```console
$ time cli/user-info.php | wc -l
1001

real    0m1.366s
user    0m0.908s
sys     0m0.475s
```

PostgreSQL:

```console
$ time cli/user-info.php | wc -l
1001

real    0m28.498s
user    0m12.137s
sys     0m2.217s
```

MariaDB:

```console
# time ./cli/user-info.php | wc -l
1001

real    0m49.485s
user    0m1.276s
sys     0m2.258s
```

Yes, SQLite is much faster - not a surprise for such use-cases, where the TCP connection is not re-used.

I have added some CLI options to disable some statistics:

```sh
cli/user-info.php --no-db-size --no-db-counts
```

For the Web UI, I have disabled detailed user statistics if it takes too long, and retrieve missing user statistics asynchronously via JavaScript. Lazy loading of the user details based on IntersectionObserver, with maximum 10 requests in parallel.
Web UI tested on 1000 users as well. Checked with SeaMonkey.
2025-12-04 19:11:31 +01:00

839 lines
24 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Controller to handle user actions.
*/
class FreshRSS_user_Controller extends FreshRSS_ActionController {
/**
* The username is also used as folder name, file name, and part of SQL table name.
* '_' is a reserved internal username.
*/
public const USERNAME_PATTERN = '([0-9a-zA-Z_][0-9a-zA-Z_.@\-]{1,38}|[0-9a-zA-Z])';
public static function checkUsername(string $username): bool {
return preg_match('/^' . self::USERNAME_PATTERN . '$/', $username) === 1;
}
/**
* Validate an email address, supports internationalized addresses.
*
* @param string $email The address to validate
* @return bool true if email is valid, else false
*/
private static function validateEmailAddress(string $email): bool {
$mailer = new PHPMailer\PHPMailer\PHPMailer();
$mailer->CharSet = 'utf-8';
$punyemail = $mailer->punyencodeAddress($email);
return PHPMailer\PHPMailer\PHPMailer::validateAddress($punyemail, 'html5');
}
/**
* @return list<string>
*/
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<string,mixed> $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<string,mixed> $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, "<?php\n return " . var_export($userConfig, true) . ';') !== false);
}
}
if ($ok) {
$newUserDAO = FreshRSS_Factory::createUserDao($new_user_name);
$ok &= $newUserDAO->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 hes already logged in
// and we dont 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
* its ok.
*
* It returns 404 error if `force_email_validation` is disabled or if the
* user doesnt exist.
*
* It returns 403 if user isnt logged in and `username` param isnt 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 doesnt 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 its 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,
];
}
}