3 * @copyright Copyright (C) 2010-2023, the Friendica project
5 * @license GNU AGPL version 3 or any later version
7 * This program is free software: you can redistribute it and/or modify
8 * it under the terms of the GNU Affero General Public License as
9 * published by the Free Software Foundation, either version 3 of the
10 * License, or (at your option) any later version.
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU Affero General Public License for more details.
17 * You should have received a copy of the GNU Affero General Public License
18 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22 namespace Friendica\Model;
24 use DivineOmega\DOFileCachePSR6\CacheItemPool;
25 use DivineOmega\PasswordExposed;
28 use Friendica\Content\Pager;
29 use Friendica\Core\Hook;
30 use Friendica\Core\L10n;
31 use Friendica\Core\Logger;
32 use Friendica\Core\Protocol;
33 use Friendica\Core\Search;
34 use Friendica\Core\System;
35 use Friendica\Core\Worker;
36 use Friendica\Database\DBA;
39 use Friendica\Network\HTTPClient\Client\HttpClientAccept;
40 use Friendica\Network\HTTPException\InternalServerErrorException;
41 use Friendica\Security\TwoFactor\Model\AppSpecificPassword;
42 use Friendica\Network\HTTPException;
43 use Friendica\Object\Image;
44 use Friendica\Protocol\Delivery;
45 use Friendica\Util\Crypto;
46 use Friendica\Util\DateTimeFormat;
47 use Friendica\Util\Images;
48 use Friendica\Util\Network;
49 use Friendica\Util\Proxy;
50 use Friendica\Util\Strings;
55 * This class handles User related functions
62 * PAGE_FLAGS_NORMAL is a typical personal profile account
63 * PAGE_FLAGS_SOAPBOX automatically approves all friend requests as Contact::SHARING, (readonly)
64 * PAGE_FLAGS_COMMUNITY automatically approves all friend requests as Contact::SHARING, but with
65 * write access to wall and comments (no email and not included in page owner's ACL lists)
66 * PAGE_FLAGS_FREELOVE automatically approves all friend requests as full friends (Contact::FRIEND).
70 const PAGE_FLAGS_NORMAL = 0;
71 const PAGE_FLAGS_SOAPBOX = 1;
72 const PAGE_FLAGS_COMMUNITY = 2;
73 const PAGE_FLAGS_FREELOVE = 3;
74 const PAGE_FLAGS_BLOG = 4;
75 const PAGE_FLAGS_PRVGROUP = 5;
83 * ACCOUNT_TYPE_PERSON - the account belongs to a person
84 * Associated page types: PAGE_FLAGS_NORMAL, PAGE_FLAGS_SOAPBOX, PAGE_FLAGS_FREELOVE
86 * ACCOUNT_TYPE_ORGANISATION - the account belongs to an organisation
87 * Associated page type: PAGE_FLAGS_SOAPBOX
89 * ACCOUNT_TYPE_NEWS - the account is a news reflector
90 * Associated page type: PAGE_FLAGS_SOAPBOX
92 * ACCOUNT_TYPE_COMMUNITY - the account is community group
93 * Associated page types: PAGE_COMMUNITY, PAGE_FLAGS_PRVGROUP
95 * ACCOUNT_TYPE_RELAY - the account is a relay
96 * This will only be assigned to contacts, not to user accounts
99 const ACCOUNT_TYPE_PERSON = 0;
100 const ACCOUNT_TYPE_ORGANISATION = 1;
101 const ACCOUNT_TYPE_NEWS = 2;
102 const ACCOUNT_TYPE_COMMUNITY = 3;
103 const ACCOUNT_TYPE_RELAY = 4;
104 const ACCOUNT_TYPE_DELETED = 127;
109 private static $owner;
112 * Returns the numeric account type by their string
114 * @param string $accounttype as string constant
115 * @return int|null Numeric account type - or null when not set
117 public static function getAccountTypeByString(string $accounttype)
119 switch ($accounttype) {
121 return User::ACCOUNT_TYPE_PERSON;
124 return User::ACCOUNT_TYPE_ORGANISATION;
127 return User::ACCOUNT_TYPE_NEWS;
130 return User::ACCOUNT_TYPE_COMMUNITY;
137 * Get the Uri-Id of the system account
141 public static function getSystemUriId(): int
143 $system = self::getSystemAccount();
144 return $system['uri-id'] ?? 0;
148 * Fetch the system account
150 * @return array system account
152 public static function getSystemAccount(): array
154 $system = Contact::selectFirst([], ['self' => true, 'uid' => 0]);
155 if (!DBA::isResult($system)) {
156 self::createSystemAccount();
157 $system = Contact::selectFirst([], ['self' => true, 'uid' => 0]);
158 if (!DBA::isResult($system)) {
163 $system['sprvkey'] = $system['uprvkey'] = $system['prvkey'];
164 $system['spubkey'] = $system['upubkey'] = $system['pubkey'];
165 $system['nickname'] = $system['nick'];
166 $system['page-flags'] = User::PAGE_FLAGS_SOAPBOX;
167 $system['account-type'] = $system['contact-type'];
168 $system['guid'] = '';
169 $system['picdate'] = '';
170 $system['theme'] = '';
171 $system['publish'] = false;
172 $system['net-publish'] = false;
173 $system['hide-friends'] = true;
174 $system['hidewall'] = true;
175 $system['prv_keywords'] = '';
176 $system['pub_keywords'] = '';
177 $system['address'] = '';
178 $system['locality'] = '';
179 $system['region'] = '';
180 $system['postal-code'] = '';
181 $system['country-name'] = '';
182 $system['homepage'] = (string)DI::baseUrl();
183 $system['dob'] = '0000-00-00';
185 // Ensure that the user contains data
186 $user = DBA::selectFirst('user', ['prvkey', 'guid', 'language'], ['uid' => 0]);
187 if (empty($user['prvkey']) || empty($user['guid'])) {
189 'username' => $system['name'],
190 'nickname' => $system['nick'],
191 'register_date' => $system['created'],
192 'pubkey' => $system['pubkey'],
193 'prvkey' => $system['prvkey'],
194 'spubkey' => $system['spubkey'],
195 'sprvkey' => $system['sprvkey'],
196 'guid' => System::createUUID(),
198 'page-flags' => User::PAGE_FLAGS_SOAPBOX,
199 'account-type' => User::ACCOUNT_TYPE_RELAY,
202 DBA::update('user', $fields, ['uid' => 0]);
204 $system['guid'] = $fields['guid'];
206 $system['guid'] = $user['guid'];
207 $system['language'] = $user['language'];
214 * Create the system account
218 private static function createSystemAccount()
220 $system_actor_name = self::getActorName();
221 if (empty($system_actor_name)) {
225 $keys = Crypto::newKeypair(4096);
226 if ($keys === false) {
227 throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
232 'created' => DateTimeFormat::utcNow(),
234 'network' => Protocol::ACTIVITYPUB,
235 'name' => 'System Account',
236 'addr' => $system_actor_name . '@' . DI::baseUrl()->getHost(),
237 'nick' => $system_actor_name,
238 'url' => DI::baseUrl() . '/friendica',
239 'pubkey' => $keys['pubkey'],
240 'prvkey' => $keys['prvkey'],
243 'contact-type' => Contact::TYPE_RELAY, // In AP this is translated to 'Application'
244 'name-date' => DateTimeFormat::utcNow(),
245 'uri-date' => DateTimeFormat::utcNow(),
246 'avatar-date' => DateTimeFormat::utcNow(),
248 'baseurl' => DI::baseUrl(),
251 $system['avatar'] = $system['photo'] = Contact::getDefaultAvatar($system, Proxy::SIZE_SMALL);
252 $system['thumb'] = Contact::getDefaultAvatar($system, Proxy::SIZE_THUMB);
253 $system['micro'] = Contact::getDefaultAvatar($system, Proxy::SIZE_MICRO);
254 $system['nurl'] = Strings::normaliseLink($system['url']);
255 $system['gsid'] = GServer::getID($system['baseurl']);
257 Contact::insert($system);
261 * Detect a usable actor name
263 * @return string actor account name
265 public static function getActorName(): string
267 $system_actor_name = DI::config()->get('system', 'actor_name');
268 if (!empty($system_actor_name)) {
269 $self = Contact::selectFirst(['nick'], ['uid' => 0, 'self' => true]);
270 if (!empty($self['nick'])) {
271 if ($self['nick'] != $system_actor_name) {
272 // Reset the actor name to the already used name
273 DI::config()->set('system', 'actor_name', $self['nick']);
274 $system_actor_name = $self['nick'];
277 return $system_actor_name;
280 // List of possible actor names
281 $possible_accounts = ['friendica', 'actor', 'system', 'internal'];
282 foreach ($possible_accounts as $name) {
283 if (!DBA::exists('user', ['nickname' => $name]) && !DBA::exists('userd', ['username' => $name])) {
284 DI::config()->set('system', 'actor_name', $name);
292 * Returns true if a user record exists with the provided id
299 public static function exists(int $uid): bool
301 return DBA::exists('user', ['uid' => $uid]);
305 * @param integer $uid
306 * @param array $fields
307 * @return array|boolean User record if it exists, false otherwise
310 public static function getById(int $uid, array $fields = [])
312 return !empty($uid) ? DBA::selectFirst('user', $fields, ['uid' => $uid]) : [];
316 * Returns a user record based on it's GUID
318 * @param string $guid The guid of the user
319 * @param array $fields The fields to retrieve
320 * @param bool $active True, if only active records are searched
322 * @return array|boolean User record if it exists, false otherwise
325 public static function getByGuid(string $guid, array $fields = [], bool $active = true)
328 $cond = ['guid' => $guid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false];
330 $cond = ['guid' => $guid];
333 return DBA::selectFirst('user', $fields, $cond);
337 * @param string $nickname
338 * @param array $fields
339 * @return array|boolean User record if it exists, false otherwise
342 public static function getByNickname(string $nickname, array $fields = [])
344 return DBA::selectFirst('user', $fields, ['nickname' => $nickname]);
348 * Returns the user id of a given profile URL
352 * @return integer user id
355 public static function getIdForURL(string $url): int
357 // Avoid database queries when the local node hostname isn't even part of the url.
358 if (!Contact::isLocal($url)) {
362 $self = Contact::selectFirst(['uid'], ['self' => true, 'nurl' => Strings::normaliseLink($url)]);
363 if (!empty($self['uid'])) {
367 $self = Contact::selectFirst(['uid'], ['self' => true, 'addr' => $url]);
368 if (!empty($self['uid'])) {
372 $self = Contact::selectFirst(['uid'], ['self' => true, 'alias' => [$url, Strings::normaliseLink($url)]]);
373 if (!empty($self['uid'])) {
381 * Get a user based on its email
383 * @param string $email
384 * @param array $fields
385 * @return array|boolean User record if it exists, false otherwise
388 public static function getByEmail(string $email, array $fields = [])
390 return DBA::selectFirst('user', $fields, ['email' => $email]);
394 * Fetch the user array of the administrator. The first one if there are several.
396 * @param array $fields
400 public static function getFirstAdmin(array $fields = []) : array
402 if (!empty(DI::config()->get('config', 'admin_nickname'))) {
403 return self::getByNickname(DI::config()->get('config', 'admin_nickname'), $fields);
406 return self::getAdminList()[0] ?? [];
410 * Get owner data by user id
413 * @param boolean $repairMissing Repair the owner data if it's missing
414 * @return boolean|array
417 public static function getOwnerDataById(int $uid, bool $repairMissing = true)
420 return self::getSystemAccount();
423 if (!empty(self::$owner[$uid])) {
424 return self::$owner[$uid];
427 $owner = DBA::selectFirst('owner-view', [], ['uid' => $uid]);
428 if (!DBA::isResult($owner)) {
429 if (!self::exists($uid) || !$repairMissing) {
432 if (!DBA::exists('profile', ['uid' => $uid])) {
433 DBA::insert('profile', ['uid' => $uid]);
435 if (!DBA::exists('contact', ['uid' => $uid, 'self' => true])) {
436 Contact::createSelfFromUserId($uid);
438 $owner = self::getOwnerDataById($uid, false);
441 if (empty($owner['nickname'])) {
445 if (!$repairMissing || $owner['account_expired']) {
449 // Check if the returned data is valid, otherwise fix it. See issue #6122
451 // Check for correct url and normalised nurl
452 $url = DI::baseUrl() . '/profile/' . $owner['nickname'];
453 $repair = empty($owner['network']) || ($owner['url'] != $url) || ($owner['nurl'] != Strings::normaliseLink($owner['url']));
456 // Check if "addr" is present and correct
457 $addr = $owner['nickname'] . '@' . substr(DI::baseUrl(), strpos(DI::baseUrl(), '://') + 3);
458 $repair = ($addr != $owner['addr']) || empty($owner['prvkey']) || empty($owner['pubkey']);
462 // Check if the avatar field is filled and the photo directs to the correct path
463 $avatar = Photo::selectFirst(['resource-id'], ['uid' => $uid, 'profile' => true]);
464 if (DBA::isResult($avatar)) {
465 $repair = empty($owner['avatar']) || !strpos($owner['photo'], $avatar['resource-id']);
470 Contact::updateSelfFromUserID($uid);
471 // Return the corrected data and avoid a loop
472 $owner = self::getOwnerDataById($uid, false);
475 self::$owner[$uid] = $owner;
480 * Get owner data by nick name
483 * @return boolean|array
486 public static function getOwnerDataByNick(string $nick)
488 $user = DBA::selectFirst('user', ['uid'], ['nickname' => $nick]);
490 if (!DBA::isResult($user)) {
494 return self::getOwnerDataById($user['uid']);
498 * Returns the default circle for a given user
500 * @param int $uid User id
502 * @return int circle id
505 public static function getDefaultCircle(int $uid): int
507 $user = DBA::selectFirst('user', ['def_gid'], ['uid' => $uid]);
508 if (DBA::isResult($user)) {
509 $default_circle = $user['def_gid'];
514 return $default_circle;
518 * Returns the default circle for groups for a given user
520 * @param int $uid User id
522 * @return int circle id
525 public static function getDefaultGroupCircle(int $uid): int
527 $default_circle = DI::pConfig()->get($uid, 'system', 'default-group-gid');
528 if (empty($default_circle)) {
529 $default_circle = self::getDefaultCircle($uid);
532 return $default_circle;
536 * Fetch the language code from the given user. If the code is invalid, return the system language
538 * @param integer $uid User-Id
541 public static function getLanguageCode(int $uid): string
543 $owner = self::getOwnerDataById($uid);
544 $languages = DI::l10n()->getAvailableLanguages(true);
545 if (in_array($owner['language'], array_keys($languages))) {
546 $language = $owner['language'];
548 $language = DI::config()->get('system', 'language');
554 * Authenticate a user with a clear text password
556 * Returns the user id associated with a successful password authentication
558 * @param mixed $user_info
559 * @param string $password
560 * @param bool $third_party
561 * @return int User Id if authentication is successful
562 * @throws HTTPException\ForbiddenException
563 * @throws HTTPException\NotFoundException
565 public static function getIdFromPasswordAuthentication($user_info, string $password, bool $third_party = false): int
567 // Addons registered with the "authenticate" hook may create the user on the
568 // fly. `getAuthenticationInfo` will fail if the user doesn't exist yet. If
569 // the user doesn't exist, we should give the addons a chance to create the
570 // user in our database, if applicable, before re-throwing the exception if
573 $user = self::getAuthenticationInfo($user_info);
574 } catch (Exception $e) {
575 $username = (is_string($user_info) ? $user_info : $user_info['nickname'] ?? '');
577 // Addons can create users, and since this 'catch' branch should only
578 // execute if getAuthenticationInfo can't find an existing user, that's
579 // exactly what will happen here. Creating a numeric username would create
580 // ambiguity with user IDs, possibly opening up an attack vector.
581 // So let's be very careful about that.
582 if (empty($username) || is_numeric($username)) {
586 return self::getIdFromAuthenticateHooks($username, $password);
589 if ($third_party && DI::pConfig()->get($user['uid'], '2fa', 'verified')) {
590 // Third-party apps can't verify two-factor authentication, we use app-specific passwords instead
591 if (AppSpecificPassword::authenticateUser($user['uid'], $password)) {
594 } elseif (strpos($user['password'], '$') === false) {
595 //Legacy hash that has not been replaced by a new hash yet
596 if (self::hashPasswordLegacy($password) === $user['password']) {
597 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
601 } elseif (!empty($user['legacy_password'])) {
602 //Legacy hash that has been double-hashed and not replaced by a new hash yet
603 //Warning: `legacy_password` is not necessary in sync with the content of `password`
604 if (password_verify(self::hashPasswordLegacy($password), $user['password'])) {
605 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
609 } elseif (password_verify($password, $user['password'])) {
611 if (password_needs_rehash($user['password'], PASSWORD_DEFAULT)) {
612 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
617 return self::getIdFromAuthenticateHooks($user['nickname'], $password); // throws
620 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
624 * Try to obtain a user ID via "authenticate" hook addons
626 * Returns the user id associated with a successful password authentication
628 * @param string $username
629 * @param string $password
630 * @return int User Id if authentication is successful
631 * @throws HTTPException\ForbiddenException
633 public static function getIdFromAuthenticateHooks(string $username, string $password): int
636 'username' => $username,
637 'password' => $password,
638 'authenticated' => 0,
639 'user_record' => null
643 * An addon indicates successful login by setting 'authenticated' to non-zero value and returning a user record
644 * Addons should never set 'authenticated' except to indicate success - as hooks may be chained
645 * and later addons should not interfere with an earlier one that succeeded.
647 Hook::callAll('authenticate', $addon_auth);
649 if ($addon_auth['authenticated'] && $addon_auth['user_record']) {
650 return $addon_auth['user_record']['uid'];
653 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
657 * Returns authentication info from various parameters types
659 * User info can be any of the following:
662 * - User email or username or nickname
663 * - User array with at least the uid and the hashed password
665 * @param mixed $user_info
666 * @return array|null Null if not found/determined
667 * @throws HTTPException\NotFoundException
669 public static function getAuthenticationInfo($user_info)
673 if (is_object($user_info) || is_array($user_info)) {
674 if (is_object($user_info)) {
675 $user = (array) $user_info;
682 || !isset($user['password'])
683 || !isset($user['legacy_password'])
685 throw new Exception(DI::l10n()->t('Not enough information to authenticate'));
687 } elseif (is_int($user_info) || is_string($user_info)) {
688 if (is_int($user_info)) {
689 $user = DBA::selectFirst(
691 ['uid', 'nickname', 'password', 'legacy_password'],
695 'account_expired' => 0,
696 'account_removed' => 0,
701 $fields = ['uid', 'nickname', 'password', 'legacy_password'];
703 "(`email` = ? OR `username` = ? OR `nickname` = ?)
704 AND `verified` AND NOT `blocked` AND NOT `account_removed` AND NOT `account_expired`",
705 $user_info, $user_info, $user_info
707 $user = DBA::selectFirst('user', $fields, $condition);
710 if (!DBA::isResult($user)) {
711 throw new HTTPException\NotFoundException(DI::l10n()->t('User not found'));
719 * Update the day of the last activity of the given user
721 * @param integer $uid
724 public static function updateLastActivity(int $uid)
730 $user = User::getById($uid, ['last-activity']);
735 $current_day = DateTimeFormat::utcNow('Y-m-d');
737 if ($user['last-activity'] != $current_day) {
738 User::update(['last-activity' => $current_day], $uid);
739 // Set the last activity for all identities of the user
740 DBA::update('user', ['last-activity' => $current_day], ['parent-uid' => $uid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]);
745 * Generates a human-readable random password
750 public static function generateNewPassword(): string
752 return ucfirst(Strings::getRandomName(8)) . random_int(1000, 9999);
756 * Checks if the provided plaintext password has been exposed or not
758 * @param string $password
762 public static function isPasswordExposed(string $password): bool
764 $cache = new CacheItemPool();
765 $cache->changeConfig([
766 'cacheDirectory' => System::getTempPath() . '/password-exposed-cache/',
770 $passwordExposedChecker = new PasswordExposed\PasswordExposedChecker(null, $cache);
772 return $passwordExposedChecker->passwordExposed($password) === PasswordExposed\PasswordStatus::EXPOSED;
773 } catch (Exception $e) {
774 Logger::error('Password Exposed Exception: ' . $e->getMessage(), [
775 'code' => $e->getCode(),
776 'file' => $e->getFile(),
777 'line' => $e->getLine(),
778 'trace' => $e->getTraceAsString()
786 * Legacy hashing function, kept for password migration purposes
788 * @param string $password
791 private static function hashPasswordLegacy(string $password): string
793 return hash('whirlpool', $password);
797 * Global user password hashing function
799 * @param string $password
803 public static function hashPassword(string $password): string
805 if (!trim($password)) {
806 throw new Exception(DI::l10n()->t('Password can\'t be empty'));
809 return password_hash($password, PASSWORD_DEFAULT);
813 * Allowed characters are a-z, A-Z, 0-9 and special characters except white spaces and accentuated letters.
815 * Password length is limited to 72 characters if the current default password hashing algorithm is Blowfish.
816 * From the manual: "Using the PASSWORD_BCRYPT as the algorithm, will result in the password parameter being
817 * truncated to a maximum length of 72 bytes."
819 * @see https://www.php.net/manual/en/function.password-hash.php#refsect1-function.password-hash-parameters
821 * @param string|null $delimiter Whether the regular expression is meant to be wrapper in delimiter characters
824 public static function getPasswordRegExp(string $delimiter = null): string
826 $allowed_characters = ':!"#$%&\'()*+,-./;<=>?@[\]^_`{|}~';
829 $allowed_characters = preg_quote($allowed_characters, $delimiter);
832 return '^[a-zA-Z0-9' . $allowed_characters . ']' . (PASSWORD_DEFAULT === PASSWORD_BCRYPT ? '{1,72}' : '+') . '$';
836 * Updates a user row with a new plaintext password
839 * @param string $password
843 public static function updatePassword(int $uid, string $password): bool
845 $password = trim($password);
847 if (empty($password)) {
848 throw new Exception(DI::l10n()->t('Empty passwords are not allowed.'));
851 if (!DI::config()->get('system', 'disable_password_exposed', false) && self::isPasswordExposed($password)) {
852 throw new Exception(DI::l10n()->t('The new password has been exposed in a public data dump, please choose another.'));
855 if (PASSWORD_DEFAULT === PASSWORD_BCRYPT && strlen($password) > 72) {
856 throw new Exception(DI::l10n()->t('The password length is limited to 72 characters.'));
859 if (!preg_match('/' . self::getPasswordRegExp('/') . '/', $password)) {
860 throw new Exception(DI::l10n()->t("The password can't contain white spaces nor accentuated letters"));
863 return self::updatePasswordHashed($uid, self::hashPassword($password));
867 * Updates a user row with a new hashed password.
868 * Empties the password reset token field just in case.
871 * @param string $password_hashed
875 private static function updatePasswordHashed(int $uid, string $password_hashed): bool
878 'password' => $password_hashed,
880 'pwdreset_time' => null,
881 'legacy_password' => false
883 return DBA::update('user', $fields, ['uid' => $uid]);
887 * Returns if the given uid is valid and in the admin list
894 public static function isSiteAdmin(int $uid): bool
896 return DBA::exists('user', [
898 'email' => self::getAdminEmailList()
903 * Returns if the given uid is valid and a moderator
910 public static function isModerator(int $uid): bool
912 // @todo Replace with a moderator check in the future
913 return self::isSiteAdmin($uid);
917 * Checks if a nickname is in the list of the forbidden nicknames
919 * Check if a nickname is forbidden from registration on the node by the
920 * admin. Forbidden nicknames (e.g. role names) can be configured in the
923 * @param string $nickname The nickname that should be checked
924 * @return boolean True is the nickname is blocked on the node
926 public static function isNicknameBlocked(string $nickname): bool
928 $forbidden_nicknames = DI::config()->get('system', 'forbidden_nicknames', '');
929 if (!empty($forbidden_nicknames)) {
930 $forbidden = explode(',', $forbidden_nicknames);
931 $forbidden = array_map('trim', $forbidden);
936 // Add the name of the internal actor to the "forbidden" list
937 $actor_name = self::getActorName();
938 if (!empty($actor_name)) {
939 $forbidden[] = $actor_name;
942 if (empty($forbidden)) {
946 // check if the nickname is in the list of blocked nicknames
947 if (in_array(strtolower($nickname), $forbidden)) {
956 * Get avatar link for given user
959 * @param string $size One of the Proxy::SIZE_* constants
960 * @return string avatar link
963 public static function getAvatarUrl(array $user, string $size = ''): string
965 if (empty($user['nickname'])) {
966 DI::logger()->warning('Missing user nickname key', ['trace' => System::callstack(20)]);
969 $url = DI::baseUrl() . '/photo/';
972 case Proxy::SIZE_MICRO:
976 case Proxy::SIZE_THUMB:
989 $photo = Photo::selectFirst(['type', 'created', 'edited', 'updated'], ["scale" => $scale, 'uid' => $user['uid'], 'profile' => true]);
990 if (!empty($photo)) {
991 $updated = max($photo['created'], $photo['edited'], $photo['updated']);
992 $mimetype = $photo['type'];
995 return $url . $user['nickname'] . Images::getExtensionByMimeType($mimetype) . ($updated ? '?ts=' . strtotime($updated) : '');
999 * Get banner link for given user
1001 * @param array $user
1002 * @return string banner link
1005 public static function getBannerUrl(array $user): string
1007 if (empty($user['nickname'])) {
1008 DI::logger()->warning('Missing user nickname key', ['trace' => System::callstack(20)]);
1011 $url = DI::baseUrl() . '/photo/banner/';
1016 $photo = Photo::selectFirst(['type', 'created', 'edited', 'updated'], ["scale" => 3, 'uid' => $user['uid'], 'photo-type' => Photo::USER_BANNER]);
1017 if (!empty($photo)) {
1018 $updated = max($photo['created'], $photo['edited'], $photo['updated']);
1019 $mimetype = $photo['type'];
1021 // Only for the RC phase: Don't return an image link for the default picture
1025 return $url . $user['nickname'] . Images::getExtensionByMimeType($mimetype) . ($updated ? '?ts=' . strtotime($updated) : '');
1029 * Catch-all user creation function
1031 * Creates a user from the provided data array, either form fields or OpenID.
1032 * Required: { username, nickname, email } or { openid_url }
1034 * Performs the following:
1035 * - Sends to the OpenId auth URL (if relevant)
1036 * - Creates new key pairs for crypto
1037 * - Create self-contact
1038 * - Create profile image
1040 * @param array $data
1042 * @throws ErrorException
1043 * @throws HTTPException\InternalServerErrorException
1044 * @throws ImagickException
1047 public static function create(array $data): array
1049 $return = ['user' => null, 'password' => ''];
1051 $using_invites = DI::config()->get('system', 'invitation_only');
1053 $invite_id = !empty($data['invite_id']) ? trim($data['invite_id']) : '';
1054 $username = !empty($data['username']) ? trim($data['username']) : '';
1055 $nickname = !empty($data['nickname']) ? trim($data['nickname']) : '';
1056 $email = !empty($data['email']) ? trim($data['email']) : '';
1057 $openid_url = !empty($data['openid_url']) ? trim($data['openid_url']) : '';
1058 $photo = !empty($data['photo']) ? trim($data['photo']) : '';
1059 $password = !empty($data['password']) ? trim($data['password']) : '';
1060 $password1 = !empty($data['password1']) ? trim($data['password1']) : '';
1061 $confirm = !empty($data['confirm']) ? trim($data['confirm']) : '';
1062 $blocked = !empty($data['blocked']);
1063 $verified = !empty($data['verified']);
1064 $language = !empty($data['language']) ? trim($data['language']) : 'en';
1066 $netpublish = $publish = !empty($data['profile_publish_reg']);
1068 if ($password1 != $confirm) {
1069 throw new Exception(DI::l10n()->t('Passwords do not match. Password unchanged.'));
1070 } elseif ($password1 != '') {
1071 $password = $password1;
1074 if ($using_invites) {
1076 throw new Exception(DI::l10n()->t('An invitation is required.'));
1079 if (!Register::existsByHash($invite_id)) {
1080 throw new Exception(DI::l10n()->t('Invitation could not be verified.'));
1084 /// @todo Check if this part is really needed. We should have fetched all this data in advance
1085 if (empty($username) || empty($email) || empty($nickname)) {
1087 if (!Network::isUrlValid($openid_url)) {
1088 throw new Exception(DI::l10n()->t('Invalid OpenID url'));
1090 $_SESSION['register'] = 1;
1091 $_SESSION['openid'] = $openid_url;
1093 $openid = new LightOpenID(DI::baseUrl()->getHost());
1094 $openid->identity = $openid_url;
1095 $openid->returnUrl = DI::baseUrl() . '/openid';
1096 $openid->required = ['namePerson/friendly', 'contact/email', 'namePerson'];
1097 $openid->optional = ['namePerson/first', 'media/image/aspect11', 'media/image/default'];
1099 $authurl = $openid->authUrl();
1100 } catch (Exception $e) {
1101 throw new Exception(DI::l10n()->t('We encountered a problem while logging in with the OpenID you provided. Please check the correct spelling of the ID.') . '<br />' . DI::l10n()->t('The error message was:') . $e->getMessage(), 0, $e);
1103 System::externalRedirect($authurl);
1107 throw new Exception(DI::l10n()->t('Please enter the required information.'));
1110 if (!Network::isUrlValid($openid_url)) {
1114 // collapse multiple spaces in name
1115 $username = preg_replace('/ +/', ' ', $username);
1117 $username_min_length = max(1, min(64, intval(DI::config()->get('system', 'username_min_length', 3))));
1118 $username_max_length = max(1, min(64, intval(DI::config()->get('system', 'username_max_length', 48))));
1120 if ($username_min_length > $username_max_length) {
1121 Logger::error(DI::l10n()->t('system.username_min_length (%s) and system.username_max_length (%s) are excluding each other, swapping values.', $username_min_length, $username_max_length));
1122 $tmp = $username_min_length;
1123 $username_min_length = $username_max_length;
1124 $username_max_length = $tmp;
1127 if (mb_strlen($username) < $username_min_length) {
1128 throw new Exception(DI::l10n()->tt('Username should be at least %s character.', 'Username should be at least %s characters.', $username_min_length));
1131 if (mb_strlen($username) > $username_max_length) {
1132 throw new Exception(DI::l10n()->tt('Username should be at most %s character.', 'Username should be at most %s characters.', $username_max_length));
1135 // So now we are just looking for a space in the full name.
1136 $loose_reg = DI::config()->get('system', 'no_regfullname');
1138 $username = mb_convert_case($username, MB_CASE_TITLE, 'UTF-8');
1139 if (strpos($username, ' ') === false) {
1140 throw new Exception(DI::l10n()->t("That doesn't appear to be your full (First Last) name."));
1144 if (!Network::isEmailDomainAllowed($email)) {
1145 throw new Exception(DI::l10n()->t('Your email domain is not among those allowed on this site.'));
1148 if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !Network::isEmailDomainValid($email)) {
1149 throw new Exception(DI::l10n()->t('Not a valid email address.'));
1151 if (self::isNicknameBlocked($nickname)) {
1152 throw new Exception(DI::l10n()->t('The nickname was blocked from registration by the nodes admin.'));
1155 if (DI::config()->get('system', 'block_extended_register', false) && DBA::exists('user', ['email' => $email])) {
1156 throw new Exception(DI::l10n()->t('Cannot use that email.'));
1159 // Disallow somebody creating an account using openid that uses the admin email address,
1160 // since openid bypasses email verification. We'll allow it if there is not yet an admin account.
1161 if (strlen($openid_url) && in_array(strtolower($email), self::getAdminEmailList())) {
1162 throw new Exception(DI::l10n()->t('Cannot use that email.'));
1165 $nickname = $data['nickname'] = strtolower($nickname);
1167 if (!preg_match('/^[a-z0-9][a-z0-9_]*$/', $nickname)) {
1168 throw new Exception(DI::l10n()->t('Your nickname can only contain a-z, 0-9 and _.'));
1171 // Check existing and deleted accounts for this nickname.
1173 DBA::exists('user', ['nickname' => $nickname])
1174 || DBA::exists('userd', ['username' => $nickname])
1176 throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1179 $new_password = strlen($password) ? $password : User::generateNewPassword();
1180 $new_password_encoded = self::hashPassword($new_password);
1182 $return['password'] = $new_password;
1184 $keys = Crypto::newKeypair(4096);
1185 if ($keys === false) {
1186 throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
1189 $prvkey = $keys['prvkey'];
1190 $pubkey = $keys['pubkey'];
1192 // Create another keypair for signing/verifying salmon protocol messages.
1193 $sres = Crypto::newKeypair(512);
1194 $sprvkey = $sres['prvkey'];
1195 $spubkey = $sres['pubkey'];
1197 $insert_result = DBA::insert('user', [
1198 'guid' => System::createUUID(),
1199 'username' => $username,
1200 'password' => $new_password_encoded,
1202 'openid' => $openid_url,
1203 'nickname' => $nickname,
1204 'pubkey' => $pubkey,
1205 'prvkey' => $prvkey,
1206 'spubkey' => $spubkey,
1207 'sprvkey' => $sprvkey,
1208 'verified' => $verified,
1209 'blocked' => $blocked,
1210 'language' => $language,
1211 'timezone' => 'UTC',
1212 'register_date' => DateTimeFormat::utcNow(),
1213 'default-location' => ''
1216 if ($insert_result) {
1217 $uid = DBA::lastInsertId();
1218 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1220 throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1224 throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1227 // if somebody clicked submit twice very quickly, they could end up with two accounts
1228 // due to race condition. Remove this one.
1229 $user_count = DBA::count('user', ['nickname' => $nickname]);
1230 if ($user_count > 1) {
1231 DBA::delete('user', ['uid' => $uid]);
1233 throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1236 $insert_result = DBA::insert('profile', [
1238 'name' => $username,
1239 'photo' => self::getAvatarUrl($user),
1240 'thumb' => self::getAvatarUrl($user, Proxy::SIZE_THUMB),
1241 'publish' => $publish,
1242 'net-publish' => $netpublish,
1244 if (!$insert_result) {
1245 DBA::delete('user', ['uid' => $uid]);
1247 throw new Exception(DI::l10n()->t('An error occurred creating your default profile. Please try again.'));
1250 // Create the self contact
1251 if (!Contact::createSelfFromUserId($uid)) {
1252 DBA::delete('user', ['uid' => $uid]);
1254 throw new Exception(DI::l10n()->t('An error occurred creating your self contact. Please try again.'));
1257 // Create a circle with no members. This allows somebody to use it
1258 // right away as a default circle for new contacts.
1259 $def_gid = Circle::create($uid, DI::l10n()->t('Friends'));
1261 DBA::delete('user', ['uid' => $uid]);
1263 throw new Exception(DI::l10n()->t('An error occurred creating your default contact circle. Please try again.'));
1266 $fields = ['def_gid' => $def_gid];
1267 if (DI::config()->get('system', 'newuser_private') && $def_gid) {
1268 $fields['allow_gid'] = '<' . $def_gid . '>';
1271 DBA::update('user', $fields, ['uid' => $uid]);
1273 $def_gid_groups = Circle::create($uid, DI::l10n()->t('Groups'));
1274 if ($def_gid_groups) {
1275 DI::pConfig()->set($uid, 'system', 'default-group-gid', $def_gid_groups);
1278 // if we have no OpenID photo try to look up an avatar
1279 if (!strlen($photo)) {
1280 $photo = Network::lookupAvatarByEmail($email);
1283 // unless there is no avatar-addon loaded
1284 if (strlen($photo)) {
1285 $photo_failure = false;
1287 $filename = basename($photo);
1288 $curlResult = DI::httpClient()->get($photo, HttpClientAccept::IMAGE);
1289 if ($curlResult->isSuccess()) {
1290 Logger::debug('Got picture', ['Content-Type' => $curlResult->getHeader('Content-Type'), 'url' => $photo]);
1291 $img_str = $curlResult->getBody();
1292 $type = $curlResult->getContentType();
1298 $type = Images::getMimeTypeByData($img_str, $photo, $type);
1300 $image = new Image($img_str, $type);
1301 if ($image->isValid()) {
1302 $image->scaleToSquare(300);
1304 $resource_id = Photo::newResource();
1306 // Not using Photo::PROFILE_PHOTOS here, so that it is discovered as translatable string
1307 $profile_album = DI::l10n()->t('Profile Photos');
1309 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 4);
1312 $photo_failure = true;
1315 $image->scaleDown(80);
1317 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 5);
1320 $photo_failure = true;
1323 $image->scaleDown(48);
1325 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 6);
1328 $photo_failure = true;
1331 if (!$photo_failure) {
1332 Photo::update(['profile' => true, 'photo-type' => Photo::USER_AVATAR], ['resource-id' => $resource_id]);
1336 Contact::updateSelfFromUserID($uid, true);
1339 Hook::callAll('register_account', $uid);
1341 self::setRegisterMethodByUserCount();
1343 $return['user'] = $user;
1348 * Update a user entry and distribute the changes if needed
1350 * @param array $fields
1351 * @param integer $uid
1355 public static function update(array $fields, int $uid): bool
1357 if (!DBA::update('user', $fields, ['uid' => $uid])) {
1361 if (Contact::updateSelfFromUserID($uid)) {
1362 Profile::publishUpdate($uid);
1369 * Sets block state for a given user
1371 * @param int $uid The user id
1372 * @param bool $block Block state (default is true)
1374 * @return bool True, if successfully blocked
1378 public static function block(int $uid, bool $block = true): bool
1380 return DBA::update('user', ['blocked' => $block], ['uid' => $uid]);
1384 * Allows a registration based on a hash
1386 * @param string $hash
1388 * @return bool True, if the allow was successful
1390 * @throws HTTPException\InternalServerErrorException
1393 public static function allow(string $hash): bool
1395 $register = Register::getByHash($hash);
1396 if (!DBA::isResult($register)) {
1400 $user = User::getById($register['uid']);
1401 if (!DBA::isResult($user)) {
1405 Register::deleteByHash($hash);
1407 DBA::update('user', ['blocked' => false, 'verified' => true], ['uid' => $register['uid']]);
1409 $profile = DBA::selectFirst('profile', ['net-publish'], ['uid' => $register['uid']]);
1411 if (DBA::isResult($profile) && $profile['net-publish'] && Search::getGlobalDirectory()) {
1412 $url = DI::baseUrl() . '/profile/' . $user['nickname'];
1413 Worker::add(Worker::PRIORITY_LOW, "Directory", $url);
1416 $l10n = DI::l10n()->withLang($register['language']);
1418 return User::sendRegisterOpenEmail(
1421 DI::config()->get('config', 'sitename'),
1423 ($register['password'] ?? '') ?: 'Sent in a previous email'
1428 * Denys a pending registration
1430 * @param string $hash The hash of the pending user
1432 * This does not have to go through user_remove() and save the nickname
1433 * permanently against re-registration, as the person was not yet
1434 * allowed to have friends on this system
1436 * @return bool True, if the deny was successful
1439 public static function deny(string $hash): bool
1441 $register = Register::getByHash($hash);
1442 if (!DBA::isResult($register)) {
1446 $user = User::getById($register['uid']);
1447 if (!DBA::isResult($user)) {
1451 // Delete the avatar
1452 Photo::delete(['uid' => $register['uid']]);
1454 return DBA::delete('user', ['uid' => $register['uid']]) &&
1455 Register::deleteByHash($register['hash']);
1459 * Creates a new user based on a minimal set and sends an email to this user
1461 * @param string $name The user's name
1462 * @param string $email The user's email address
1463 * @param string $nick The user's nick name
1464 * @param string $lang The user's language (default is english)
1465 * @return bool True, if the user was created successfully
1466 * @throws HTTPException\InternalServerErrorException
1467 * @throws ErrorException
1468 * @throws ImagickException
1470 public static function createMinimal(string $name, string $email, string $nick, string $lang = L10n::DEFAULT): bool
1475 throw new HTTPException\InternalServerErrorException('Invalid arguments.');
1478 $result = self::create([
1479 'username' => $name,
1481 'nickname' => $nick,
1486 $user = $result['user'];
1487 $preamble = Strings::deindent(DI::l10n()->t('
1489 the administrator of %2$s has set up an account for you.'));
1490 $body = Strings::deindent(DI::l10n()->t('
1491 The login details are as follows:
1497 You may change your password from your account "Settings" page after logging
1500 Please take a few moments to review the other account settings on that page.
1502 You may also wish to add some basic information to your default profile
1503 (on the "Profiles" page) so that other people can easily find you.
1505 We recommend setting your full name, adding a profile photo,
1506 adding some profile "keywords" (very useful in making new friends) - and
1507 perhaps what country you live in; if you do not wish to be more specific
1510 We fully respect your right to privacy, and none of these items are necessary.
1511 If you are new and do not know anybody here, they may help
1512 you to make some new and interesting friends.
1514 If you ever want to delete your account, you can do so at %1$s/settings/removeme
1516 Thank you and welcome to %4$s.'));
1518 $preamble = sprintf($preamble, $user['username'], DI::config()->get('config', 'sitename'));
1519 $body = sprintf($body, DI::baseUrl(), $user['nickname'], $result['password'], DI::config()->get('config', 'sitename'));
1521 $email = DI::emailer()
1523 ->withMessage(DI::l10n()->t('Registration details for %s', DI::config()->get('config', 'sitename')), $preamble, $body)
1525 ->withRecipient($user['email'])
1527 return DI::emailer()->send($email);
1531 * Sends pending registration confirmation email
1533 * @param array $user User record array
1534 * @param string $sitename
1535 * @param string $siteurl
1536 * @param string $password Plaintext password
1537 * @return NULL|boolean from notification() and email() inherited
1538 * @throws HTTPException\InternalServerErrorException
1540 public static function sendRegisterPendingEmail(array $user, string $sitename, string $siteurl, string $password)
1542 $body = Strings::deindent(DI::l10n()->t(
1545 Thank you for registering at %2$s. Your account is pending for approval by the administrator.
1547 Your login details are as follows:
1560 $email = DI::emailer()
1562 ->withMessage(DI::l10n()->t('Registration at %s', $sitename), $body)
1564 ->withRecipient($user['email'])
1566 return DI::emailer()->send($email);
1570 * Sends registration confirmation
1572 * It's here as a function because the mail is sent from different parts
1574 * @param L10n $l10n The used language
1575 * @param array $user User record array
1576 * @param string $sitename
1577 * @param string $siteurl
1578 * @param string $password Plaintext password
1580 * @return NULL|boolean from notification() and email() inherited
1581 * @throws HTTPException\InternalServerErrorException
1583 public static function sendRegisterOpenEmail(L10n $l10n, array $user, string $sitename, string $siteurl, string $password)
1585 $preamble = Strings::deindent($l10n->t(
1588 Thank you for registering at %2$s. Your account has been created.
1593 $body = Strings::deindent($l10n->t(
1595 The login details are as follows:
1601 You may change your password from your account "Settings" page after logging
1604 Please take a few moments to review the other account settings on that page.
1606 You may also wish to add some basic information to your default profile
1607 ' . "\x28" . 'on the "Profiles" page' . "\x29" . ' so that other people can easily find you.
1609 We recommend setting your full name, adding a profile photo,
1610 adding some profile "keywords" ' . "\x28" . 'very useful in making new friends' . "\x29" . ' - and
1611 perhaps what country you live in; if you do not wish to be more specific
1614 We fully respect your right to privacy, and none of these items are necessary.
1615 If you are new and do not know anybody here, they may help
1616 you to make some new and interesting friends.
1618 If you ever want to delete your account, you can do so at %3$s/settings/removeme
1620 Thank you and welcome to %2$s.',
1628 $email = DI::emailer()
1630 ->withMessage(DI::l10n()->t('Registration details for %s', $sitename), $preamble, $body)
1632 ->withRecipient($user['email'])
1634 return DI::emailer()->send($email);
1638 * @param int $uid user to remove
1640 * @throws HTTPException\InternalServerErrorException
1642 public static function remove(int $uid): bool
1648 Logger::notice('Removing user', ['user' => $uid]);
1650 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1652 Hook::callAll('remove_user', $user);
1654 // save username (actually the nickname as it is guaranteed
1655 // unique), so it cannot be re-registered in the future.
1656 DBA::insert('userd', ['username' => $user['nickname']]);
1658 // Remove all personal settings, especially connector settings
1659 DBA::delete('pconfig', ['uid' => $uid]);
1661 // The user and related data will be deleted in Friendica\Worker\ExpireAndRemoveUsers
1662 DBA::update('user', ['account_removed' => true, 'account_expires_on' => DateTimeFormat::utc('now + 7 day')], ['uid' => $uid]);
1663 Worker::add(Worker::PRIORITY_HIGH, 'Notifier', Delivery::REMOVAL, $uid);
1665 // Send an update to the directory
1666 $self = DBA::selectFirst('contact', ['url'], ['uid' => $uid, 'self' => true]);
1667 Worker::add(Worker::PRIORITY_LOW, 'Directory', $self['url']);
1669 // Remove the user relevant data
1670 Worker::add(Worker::PRIORITY_NEGLIGIBLE, 'RemoveUser', $uid);
1672 self::setRegisterMethodByUserCount();
1677 * Return all identities to a user
1679 * @param int $uid The user id
1680 * @return array All identities for this user
1682 * Example for a return:
1686 * 'username' => 'maxmuster',
1687 * 'nickname' => 'Max Mustermann'
1691 * 'username' => 'johndoe',
1692 * 'nickname' => 'John Doe'
1697 public static function identities(int $uid): array
1705 $user = DBA::selectFirst('user', ['uid', 'nickname', 'username', 'parent-uid'], ['uid' => $uid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]);
1706 if (!DBA::isResult($user)) {
1710 if (!$user['parent-uid']) {
1711 // First add our own entry
1713 'uid' => $user['uid'],
1714 'username' => $user['username'],
1715 'nickname' => $user['nickname']
1718 // Then add all the children
1721 ['uid', 'username', 'nickname'],
1722 ['parent-uid' => $user['uid'], 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]
1724 if (DBA::isResult($r)) {
1725 $identities = array_merge($identities, DBA::toArray($r));
1728 // First entry is our parent
1731 ['uid', 'username', 'nickname'],
1732 ['uid' => $user['parent-uid'], 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]
1734 if (DBA::isResult($r)) {
1735 $identities = DBA::toArray($r);
1738 // Then add all siblings
1741 ['uid', 'username', 'nickname'],
1742 ['parent-uid' => $user['parent-uid'], 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]
1744 if (DBA::isResult($r)) {
1745 $identities = array_merge($identities, DBA::toArray($r));
1750 "SELECT `user`.`uid`, `user`.`username`, `user`.`nickname`
1752 INNER JOIN `user` ON `manage`.`mid` = `user`.`uid`
1753 WHERE NOT `user`.`account_removed` AND `manage`.`uid` = ?",
1756 if (DBA::isResult($r)) {
1757 $identities = array_merge($identities, DBA::toArray($r));
1764 * Check if the given user id has delegations or is delegated
1769 public static function hasIdentities(int $uid): bool
1775 $user = DBA::selectFirst('user', ['parent-uid'], ['uid' => $uid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]);
1776 if (!DBA::isResult($user)) {
1780 if ($user['parent-uid']) {
1784 if (DBA::exists('user', ['parent-uid' => $uid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false])) {
1788 if (DBA::exists('manage', ['uid' => $uid])) {
1796 * Returns statistical information about the current users of this node
1802 public static function getStatistics(): array
1806 'active_users_halfyear' => 0,
1807 'active_users_monthly' => 0,
1808 'active_users_weekly' => 0,
1811 $userStmt = DBA::select('owner-view', ['uid', 'last-activity', 'last-item'],
1812 ["`verified` AND `last-activity` > ? AND NOT `blocked`
1813 AND NOT `account_removed` AND NOT `account_expired`",
1814 DBA::NULL_DATETIME]);
1815 if (!DBA::isResult($userStmt)) {
1819 $halfyear = time() - (180 * 24 * 60 * 60);
1820 $month = time() - (30 * 24 * 60 * 60);
1821 $week = time() - (7 * 24 * 60 * 60);
1823 while ($user = DBA::fetch($userStmt)) {
1824 $statistics['total_users']++;
1826 if ((strtotime($user['last-activity']) > $halfyear) || (strtotime($user['last-item']) > $halfyear)
1828 $statistics['active_users_halfyear']++;
1831 if ((strtotime($user['last-activity']) > $month) || (strtotime($user['last-item']) > $month)
1833 $statistics['active_users_monthly']++;
1836 if ((strtotime($user['last-activity']) > $week) || (strtotime($user['last-item']) > $week)
1838 $statistics['active_users_weekly']++;
1841 DBA::close($userStmt);
1847 * Get all users of the current node
1849 * @param int $start Start count (Default is 0)
1850 * @param int $count Count of the items per page (Default is @see Pager::ITEMS_PER_PAGE)
1851 * @param string $type The type of users, which should get (all, blocked, removed)
1852 * @param string $order Order of the user list (Default is 'contact.name')
1853 * @param bool $descending Order direction (Default is ascending)
1854 * @return array|bool The list of the users
1857 public static function getList(int $start = 0, int $count = Pager::ITEMS_PER_PAGE, string $type = 'all', string $order = 'name', bool $descending = false)
1859 $param = ['limit' => [$start, $count], 'order' => [$order => $descending]];
1863 $condition['account_removed'] = false;
1864 $condition['blocked'] = false;
1868 $condition['account_removed'] = false;
1869 $condition['blocked'] = true;
1870 $condition['verified'] = true;
1874 $condition['account_removed'] = true;
1878 return DBA::selectToArray('owner-view', [], $condition, $param);
1882 * Returns a list of lowercase admin email addresses from the comma-separated list in the config
1886 public static function getAdminEmailList(): array
1888 $adminEmails = strtolower(str_replace(' ', '', DI::config()->get('config', 'admin_email')));
1889 if (!$adminEmails) {
1893 return explode(',', $adminEmails);
1897 * Returns the complete list of admin user accounts
1899 * @param array $fields
1903 public static function getAdminList(array $fields = []): array
1906 'email' => self::getAdminEmailList(),
1907 'parent-uid' => null,
1910 'account_removed' => false,
1911 'account_expired' => false,
1914 return DBA::selectToArray('user', $fields, $condition, ['order' => ['uid']]);
1918 * Return a list of admin user accounts where each unique email address appears only once.
1920 * This method is meant for admin notifications that do not need to be sent multiple times to the same email address.
1922 * @param array $fields
1926 public static function getAdminListForEmailing(array $fields = []): array
1928 return array_filter(self::getAdminList($fields), function ($user) {
1929 static $emails = [];
1931 if (in_array($user['email'], $emails)) {
1935 $emails[] = $user['email'];
1941 public static function setRegisterMethodByUserCount()
1943 $max_registered_users = DI::config()->get('config', 'max_registered_users');
1944 if ($max_registered_users <= 0) {
1948 $register_policy = DI::config()->get('config', 'register_policy');
1949 if (!in_array($register_policy, [Module\Register::OPEN, Module\Register::CLOSED])) {
1950 Logger::debug('Unsupported register policy.', ['policy' => $register_policy]);
1954 $users = DBA::count('user', ['blocked' => false, 'account_removed' => false, 'account_expired' => false]);
1955 if (($users >= $max_registered_users) && ($register_policy == Module\Register::OPEN)) {
1956 DI::config()->set('config', 'register_policy', Module\Register::CLOSED);
1957 Logger::notice('Max users reached, registration is closed.', ['users' => $users, 'max' => $max_registered_users]);
1958 } elseif (($users < $max_registered_users) && ($register_policy == Module\Register::CLOSED)) {
1959 DI::config()->set('config', 'register_policy', Module\Register::OPEN);
1960 Logger::notice('Below maximum users, registration is opened.', ['users' => $users, 'max' => $max_registered_users]);
1962 Logger::debug('Unchanged register policy', ['policy' => $register_policy, 'users' => $users, 'max' => $max_registered_users]);