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, 'account_removed' => false, 'account_expired' => false]) &&
284 !DBA::exists('userd', ['username' => $name])) {
285 DI::config()->set('system', 'actor_name', $name);
293 * Returns true if a user record exists with the provided id
300 public static function exists(int $uid): bool
302 return DBA::exists('user', ['uid' => $uid]);
306 * @param integer $uid
307 * @param array $fields
308 * @return array|boolean User record if it exists, false otherwise
311 public static function getById(int $uid, array $fields = [])
313 return !empty($uid) ? DBA::selectFirst('user', $fields, ['uid' => $uid]) : [];
317 * Returns a user record based on it's GUID
319 * @param string $guid The guid of the user
320 * @param array $fields The fields to retrieve
321 * @param bool $active True, if only active records are searched
323 * @return array|boolean User record if it exists, false otherwise
326 public static function getByGuid(string $guid, array $fields = [], bool $active = true)
329 $cond = ['guid' => $guid, 'account_expired' => false, 'account_removed' => false];
331 $cond = ['guid' => $guid];
334 return DBA::selectFirst('user', $fields, $cond);
338 * @param string $nickname
339 * @param array $fields
340 * @return array|boolean User record if it exists, false otherwise
343 public static function getByNickname(string $nickname, array $fields = [])
345 return DBA::selectFirst('user', $fields, ['nickname' => $nickname]);
349 * Returns the user id of a given profile URL
353 * @return integer user id
356 public static function getIdForURL(string $url): int
358 // Avoid database queries when the local node hostname isn't even part of the url.
359 if (!Contact::isLocal($url)) {
363 $self = Contact::selectFirst(['uid'], ['self' => true, 'nurl' => Strings::normaliseLink($url)]);
364 if (!empty($self['uid'])) {
368 $self = Contact::selectFirst(['uid'], ['self' => true, 'addr' => $url]);
369 if (!empty($self['uid'])) {
373 $self = Contact::selectFirst(['uid'], ['self' => true, 'alias' => [$url, Strings::normaliseLink($url)]]);
374 if (!empty($self['uid'])) {
382 * Get a user based on its email
384 * @param string $email
385 * @param array $fields
386 * @return array|boolean User record if it exists, false otherwise
389 public static function getByEmail(string $email, array $fields = [])
391 return DBA::selectFirst('user', $fields, ['email' => $email]);
395 * Fetch the user array of the administrator. The first one if there are several.
397 * @param array $fields
401 public static function getFirstAdmin(array $fields = []) : array
403 if (!empty(DI::config()->get('config', 'admin_nickname'))) {
404 return self::getByNickname(DI::config()->get('config', 'admin_nickname'), $fields);
407 return self::getAdminList()[0] ?? [];
411 * Get owner data by user id
414 * @param boolean $repairMissing Repair the owner data if it's missing
415 * @return boolean|array
418 public static function getOwnerDataById(int $uid, bool $repairMissing = true)
421 return self::getSystemAccount();
424 if (!empty(self::$owner[$uid])) {
425 return self::$owner[$uid];
428 $owner = DBA::selectFirst('owner-view', [], ['uid' => $uid]);
429 if (!DBA::isResult($owner)) {
430 if (!self::exists($uid) || !$repairMissing) {
433 if (!DBA::exists('profile', ['uid' => $uid])) {
434 DBA::insert('profile', ['uid' => $uid]);
436 if (!DBA::exists('contact', ['uid' => $uid, 'self' => true])) {
437 Contact::createSelfFromUserId($uid);
439 $owner = self::getOwnerDataById($uid, false);
442 if (empty($owner['nickname'])) {
446 if (!$repairMissing || $owner['account_expired']) {
450 // Check if the returned data is valid, otherwise fix it. See issue #6122
452 // Check for correct url and normalised nurl
453 $url = DI::baseUrl() . '/profile/' . $owner['nickname'];
454 $repair = empty($owner['network']) || ($owner['url'] != $url) || ($owner['nurl'] != Strings::normaliseLink($owner['url']));
457 // Check if "addr" is present and correct
458 $addr = $owner['nickname'] . '@' . substr(DI::baseUrl(), strpos(DI::baseUrl(), '://') + 3);
459 $repair = ($addr != $owner['addr']) || empty($owner['prvkey']) || empty($owner['pubkey']);
463 // Check if the avatar field is filled and the photo directs to the correct path
464 $avatar = Photo::selectFirst(['resource-id'], ['uid' => $uid, 'profile' => true]);
465 if (DBA::isResult($avatar)) {
466 $repair = empty($owner['avatar']) || !strpos($owner['photo'], $avatar['resource-id']);
471 Contact::updateSelfFromUserID($uid);
472 // Return the corrected data and avoid a loop
473 $owner = self::getOwnerDataById($uid, false);
476 self::$owner[$uid] = $owner;
481 * Get owner data by nick name
484 * @return boolean|array
487 public static function getOwnerDataByNick(string $nick)
489 $user = DBA::selectFirst('user', ['uid'], ['nickname' => $nick]);
491 if (!DBA::isResult($user)) {
495 return self::getOwnerDataById($user['uid']);
499 * Returns the default circle for a given user
501 * @param int $uid User id
503 * @return int circle id
506 public static function getDefaultCircle(int $uid): int
508 $user = DBA::selectFirst('user', ['def_gid'], ['uid' => $uid]);
509 if (DBA::isResult($user)) {
510 $default_circle = $user['def_gid'];
515 return $default_circle;
519 * Returns the default circle for groups for a given user
521 * @param int $uid User id
523 * @return int circle id
526 public static function getDefaultGroupCircle(int $uid): int
528 $default_circle = DI::pConfig()->get($uid, 'system', 'default-group-gid');
529 if (empty($default_circle)) {
530 $default_circle = self::getDefaultCircle($uid);
533 return $default_circle;
537 * Fetch the language code from the given user. If the code is invalid, return the system language
539 * @param integer $uid User-Id
540 * @param boolean $short If true, return the short form g.g. "en", otherwise the long form e.g. "en-gb"
543 public static function getLanguageCode(int $uid, bool $short): string
545 $owner = self::getOwnerDataById($uid);
546 $languages = DI::l10n()->getAvailableLanguages($short);
547 if (in_array($owner['language'], array_keys($languages))) {
548 $language = $owner['language'];
550 $language = DI::config()->get('system', 'language');
556 * Authenticate a user with a clear text password
558 * Returns the user id associated with a successful password authentication
560 * @param mixed $user_info
561 * @param string $password
562 * @param bool $third_party
563 * @return int User Id if authentication is successful
564 * @throws HTTPException\ForbiddenException
565 * @throws HTTPException\NotFoundException
567 public static function getIdFromPasswordAuthentication($user_info, string $password, bool $third_party = false): int
569 // Addons registered with the "authenticate" hook may create the user on the
570 // fly. `getAuthenticationInfo` will fail if the user doesn't exist yet. If
571 // the user doesn't exist, we should give the addons a chance to create the
572 // user in our database, if applicable, before re-throwing the exception if
575 $user = self::getAuthenticationInfo($user_info);
576 } catch (Exception $e) {
577 $username = (is_string($user_info) ? $user_info : $user_info['nickname'] ?? '');
579 // Addons can create users, and since this 'catch' branch should only
580 // execute if getAuthenticationInfo can't find an existing user, that's
581 // exactly what will happen here. Creating a numeric username would create
582 // ambiguity with user IDs, possibly opening up an attack vector.
583 // So let's be very careful about that.
584 if (empty($username) || is_numeric($username)) {
588 return self::getIdFromAuthenticateHooks($username, $password);
591 if ($third_party && DI::pConfig()->get($user['uid'], '2fa', 'verified')) {
592 // Third-party apps can't verify two-factor authentication, we use app-specific passwords instead
593 if (AppSpecificPassword::authenticateUser($user['uid'], $password)) {
596 } elseif (strpos($user['password'], '$') === false) {
597 //Legacy hash that has not been replaced by a new hash yet
598 if (self::hashPasswordLegacy($password) === $user['password']) {
599 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
603 } elseif (!empty($user['legacy_password'])) {
604 //Legacy hash that has been double-hashed and not replaced by a new hash yet
605 //Warning: `legacy_password` is not necessary in sync with the content of `password`
606 if (password_verify(self::hashPasswordLegacy($password), $user['password'])) {
607 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
611 } elseif (password_verify($password, $user['password'])) {
613 if (password_needs_rehash($user['password'], PASSWORD_DEFAULT)) {
614 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
619 return self::getIdFromAuthenticateHooks($user['nickname'], $password); // throws
622 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
626 * Try to obtain a user ID via "authenticate" hook addons
628 * Returns the user id associated with a successful password authentication
630 * @param string $username
631 * @param string $password
632 * @return int User Id if authentication is successful
633 * @throws HTTPException\ForbiddenException
635 public static function getIdFromAuthenticateHooks(string $username, string $password): int
638 'username' => $username,
639 'password' => $password,
640 'authenticated' => 0,
641 'user_record' => null
645 * An addon indicates successful login by setting 'authenticated' to non-zero value and returning a user record
646 * Addons should never set 'authenticated' except to indicate success - as hooks may be chained
647 * and later addons should not interfere with an earlier one that succeeded.
649 Hook::callAll('authenticate', $addon_auth);
651 if ($addon_auth['authenticated'] && $addon_auth['user_record']) {
652 return $addon_auth['user_record']['uid'];
655 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
659 * Returns authentication info from various parameters types
661 * User info can be any of the following:
664 * - User email or username or nickname
665 * - User array with at least the uid and the hashed password
667 * @param mixed $user_info
668 * @return array|null Null if not found/determined
669 * @throws HTTPException\NotFoundException
671 public static function getAuthenticationInfo($user_info)
675 if (is_object($user_info) || is_array($user_info)) {
676 if (is_object($user_info)) {
677 $user = (array) $user_info;
684 || !isset($user['password'])
685 || !isset($user['legacy_password'])
687 throw new Exception(DI::l10n()->t('Not enough information to authenticate'));
689 } elseif (is_int($user_info) || is_string($user_info)) {
690 if (is_int($user_info)) {
691 $user = DBA::selectFirst(
693 ['uid', 'nickname', 'password', 'legacy_password'],
697 'account_expired' => 0,
698 'account_removed' => 0,
703 $fields = ['uid', 'nickname', 'password', 'legacy_password'];
705 "(`email` = ? OR `username` = ? OR `nickname` = ?)
706 AND NOT `blocked` AND NOT `account_expired` AND NOT `account_removed` AND `verified`",
707 $user_info, $user_info, $user_info
709 $user = DBA::selectFirst('user', $fields, $condition);
712 if (!DBA::isResult($user)) {
713 throw new HTTPException\NotFoundException(DI::l10n()->t('User not found'));
721 * Update the day of the last activity of the given user
723 * @param integer $uid
726 public static function updateLastActivity(int $uid)
732 $user = User::getById($uid, ['last-activity']);
737 $current_day = DateTimeFormat::utcNow('Y-m-d');
739 if ($user['last-activity'] != $current_day) {
740 User::update(['last-activity' => $current_day], $uid);
741 // Set the last activity for all identities of the user
742 DBA::update('user', ['last-activity' => $current_day], ['parent-uid' => $uid, 'account_removed' => false]);
747 * Generates a human-readable random password
752 public static function generateNewPassword(): string
754 return ucfirst(Strings::getRandomName(8)) . random_int(1000, 9999);
758 * Checks if the provided plaintext password has been exposed or not
760 * @param string $password
764 public static function isPasswordExposed(string $password): bool
766 $cache = new CacheItemPool();
767 $cache->changeConfig([
768 'cacheDirectory' => System::getTempPath() . '/password-exposed-cache/',
772 $passwordExposedChecker = new PasswordExposed\PasswordExposedChecker(null, $cache);
774 return $passwordExposedChecker->passwordExposed($password) === PasswordExposed\PasswordStatus::EXPOSED;
775 } catch (Exception $e) {
776 Logger::error('Password Exposed Exception: ' . $e->getMessage(), [
777 'code' => $e->getCode(),
778 'file' => $e->getFile(),
779 'line' => $e->getLine(),
780 'trace' => $e->getTraceAsString()
788 * Legacy hashing function, kept for password migration purposes
790 * @param string $password
793 private static function hashPasswordLegacy(string $password): string
795 return hash('whirlpool', $password);
799 * Global user password hashing function
801 * @param string $password
805 public static function hashPassword(string $password): string
807 if (!trim($password)) {
808 throw new Exception(DI::l10n()->t('Password can\'t be empty'));
811 return password_hash($password, PASSWORD_DEFAULT);
815 * Allowed characters are a-z, A-Z, 0-9 and special characters except white spaces and accentuated letters.
817 * Password length is limited to 72 characters if the current default password hashing algorithm is Blowfish.
818 * From the manual: "Using the PASSWORD_BCRYPT as the algorithm, will result in the password parameter being
819 * truncated to a maximum length of 72 bytes."
821 * @see https://www.php.net/manual/en/function.password-hash.php#refsect1-function.password-hash-parameters
823 * @param string|null $delimiter Whether the regular expression is meant to be wrapper in delimiter characters
826 public static function getPasswordRegExp(string $delimiter = null): string
828 $allowed_characters = ':!"#$%&\'()*+,-./;<=>?@[\]^_`{|}~';
831 $allowed_characters = preg_quote($allowed_characters, $delimiter);
834 return '^[a-zA-Z0-9' . $allowed_characters . ']' . (PASSWORD_DEFAULT === PASSWORD_BCRYPT ? '{1,72}' : '+') . '$';
838 * Updates a user row with a new plaintext password
841 * @param string $password
845 public static function updatePassword(int $uid, string $password): bool
847 $password = trim($password);
849 if (empty($password)) {
850 throw new Exception(DI::l10n()->t('Empty passwords are not allowed.'));
853 if (!DI::config()->get('system', 'disable_password_exposed', false) && self::isPasswordExposed($password)) {
854 throw new Exception(DI::l10n()->t('The new password has been exposed in a public data dump, please choose another.'));
857 if (PASSWORD_DEFAULT === PASSWORD_BCRYPT && strlen($password) > 72) {
858 throw new Exception(DI::l10n()->t('The password length is limited to 72 characters.'));
861 if (!preg_match('/' . self::getPasswordRegExp('/') . '/', $password)) {
862 throw new Exception(DI::l10n()->t("The password can't contain white spaces nor accentuated letters"));
865 return self::updatePasswordHashed($uid, self::hashPassword($password));
869 * Updates a user row with a new hashed password.
870 * Empties the password reset token field just in case.
873 * @param string $password_hashed
877 private static function updatePasswordHashed(int $uid, string $password_hashed): bool
880 'password' => $password_hashed,
882 'pwdreset_time' => null,
883 'legacy_password' => false
885 return DBA::update('user', $fields, ['uid' => $uid]);
889 * Returns if the given uid is valid and in the admin list
896 public static function isSiteAdmin(int $uid): bool
898 return DBA::exists('user', [
900 'email' => self::getAdminEmailList()
905 * Returns if the given uid is valid and a moderator
912 public static function isModerator(int $uid): bool
914 // @todo Replace with a moderator check in the future
915 return self::isSiteAdmin($uid);
919 * Checks if a nickname is in the list of the forbidden nicknames
921 * Check if a nickname is forbidden from registration on the node by the
922 * admin. Forbidden nicknames (e.g. role names) can be configured in the
925 * @param string $nickname The nickname that should be checked
926 * @return boolean True is the nickname is blocked on the node
928 public static function isNicknameBlocked(string $nickname): bool
930 $forbidden_nicknames = DI::config()->get('system', 'forbidden_nicknames', '');
931 if (!empty($forbidden_nicknames)) {
932 $forbidden = explode(',', $forbidden_nicknames);
933 $forbidden = array_map('trim', $forbidden);
938 // Add the name of the internal actor to the "forbidden" list
939 $actor_name = self::getActorName();
940 if (!empty($actor_name)) {
941 $forbidden[] = $actor_name;
944 if (empty($forbidden)) {
948 // check if the nickname is in the list of blocked nicknames
949 if (in_array(strtolower($nickname), $forbidden)) {
958 * Get avatar link for given user
961 * @param string $size One of the Proxy::SIZE_* constants
962 * @return string avatar link
965 public static function getAvatarUrl(array $user, string $size = ''): string
967 if (empty($user['nickname'])) {
968 DI::logger()->warning('Missing user nickname key', ['trace' => System::callstack(20)]);
971 $url = DI::baseUrl() . '/photo/';
974 case Proxy::SIZE_MICRO:
978 case Proxy::SIZE_THUMB:
991 $photo = Photo::selectFirst(['type', 'created', 'edited', 'updated'], ["scale" => $scale, 'uid' => $user['uid'], 'profile' => true]);
992 if (!empty($photo)) {
993 $updated = max($photo['created'], $photo['edited'], $photo['updated']);
994 $mimetype = $photo['type'];
997 return $url . $user['nickname'] . Images::getExtensionByMimeType($mimetype) . ($updated ? '?ts=' . strtotime($updated) : '');
1001 * Get banner link for given user
1003 * @param array $user
1004 * @return string banner link
1007 public static function getBannerUrl(array $user): string
1009 if (empty($user['nickname'])) {
1010 DI::logger()->warning('Missing user nickname key', ['trace' => System::callstack(20)]);
1013 $url = DI::baseUrl() . '/photo/banner/';
1018 $photo = Photo::selectFirst(['type', 'created', 'edited', 'updated'], ["scale" => 3, 'uid' => $user['uid'], 'photo-type' => Photo::USER_BANNER]);
1019 if (!empty($photo)) {
1020 $updated = max($photo['created'], $photo['edited'], $photo['updated']);
1021 $mimetype = $photo['type'];
1023 // Only for the RC phase: Don't return an image link for the default picture
1027 return $url . $user['nickname'] . Images::getExtensionByMimeType($mimetype) . ($updated ? '?ts=' . strtotime($updated) : '');
1031 * Catch-all user creation function
1033 * Creates a user from the provided data array, either form fields or OpenID.
1034 * Required: { username, nickname, email } or { openid_url }
1036 * Performs the following:
1037 * - Sends to the OpenId auth URL (if relevant)
1038 * - Creates new key pairs for crypto
1039 * - Create self-contact
1040 * - Create profile image
1042 * @param array $data
1044 * @throws ErrorException
1045 * @throws HTTPException\InternalServerErrorException
1046 * @throws ImagickException
1049 public static function create(array $data): array
1051 $return = ['user' => null, 'password' => ''];
1053 $using_invites = DI::config()->get('system', 'invitation_only');
1055 $invite_id = !empty($data['invite_id']) ? trim($data['invite_id']) : '';
1056 $username = !empty($data['username']) ? trim($data['username']) : '';
1057 $nickname = !empty($data['nickname']) ? trim($data['nickname']) : '';
1058 $email = !empty($data['email']) ? trim($data['email']) : '';
1059 $openid_url = !empty($data['openid_url']) ? trim($data['openid_url']) : '';
1060 $photo = !empty($data['photo']) ? trim($data['photo']) : '';
1061 $password = !empty($data['password']) ? trim($data['password']) : '';
1062 $password1 = !empty($data['password1']) ? trim($data['password1']) : '';
1063 $confirm = !empty($data['confirm']) ? trim($data['confirm']) : '';
1064 $blocked = !empty($data['blocked']);
1065 $verified = !empty($data['verified']);
1066 $language = !empty($data['language']) ? trim($data['language']) : 'en';
1068 $netpublish = $publish = !empty($data['profile_publish_reg']);
1070 if ($password1 != $confirm) {
1071 throw new Exception(DI::l10n()->t('Passwords do not match. Password unchanged.'));
1072 } elseif ($password1 != '') {
1073 $password = $password1;
1076 if ($using_invites) {
1078 throw new Exception(DI::l10n()->t('An invitation is required.'));
1081 if (!Register::existsByHash($invite_id)) {
1082 throw new Exception(DI::l10n()->t('Invitation could not be verified.'));
1086 /// @todo Check if this part is really needed. We should have fetched all this data in advance
1087 if (empty($username) || empty($email) || empty($nickname)) {
1089 if (!Network::isUrlValid($openid_url)) {
1090 throw new Exception(DI::l10n()->t('Invalid OpenID url'));
1092 $_SESSION['register'] = 1;
1093 $_SESSION['openid'] = $openid_url;
1095 $openid = new LightOpenID(DI::baseUrl()->getHost());
1096 $openid->identity = $openid_url;
1097 $openid->returnUrl = DI::baseUrl() . '/openid';
1098 $openid->required = ['namePerson/friendly', 'contact/email', 'namePerson'];
1099 $openid->optional = ['namePerson/first', 'media/image/aspect11', 'media/image/default'];
1101 $authurl = $openid->authUrl();
1102 } catch (Exception $e) {
1103 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);
1105 System::externalRedirect($authurl);
1109 throw new Exception(DI::l10n()->t('Please enter the required information.'));
1112 if (!Network::isUrlValid($openid_url)) {
1116 // collapse multiple spaces in name
1117 $username = preg_replace('/ +/', ' ', $username);
1119 $username_min_length = max(1, min(64, intval(DI::config()->get('system', 'username_min_length', 3))));
1120 $username_max_length = max(1, min(64, intval(DI::config()->get('system', 'username_max_length', 48))));
1122 if ($username_min_length > $username_max_length) {
1123 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));
1124 $tmp = $username_min_length;
1125 $username_min_length = $username_max_length;
1126 $username_max_length = $tmp;
1129 if (mb_strlen($username) < $username_min_length) {
1130 throw new Exception(DI::l10n()->tt('Username should be at least %s character.', 'Username should be at least %s characters.', $username_min_length));
1133 if (mb_strlen($username) > $username_max_length) {
1134 throw new Exception(DI::l10n()->tt('Username should be at most %s character.', 'Username should be at most %s characters.', $username_max_length));
1137 // So now we are just looking for a space in the full name.
1138 $loose_reg = DI::config()->get('system', 'no_regfullname');
1140 $username = mb_convert_case($username, MB_CASE_TITLE, 'UTF-8');
1141 if (strpos($username, ' ') === false) {
1142 throw new Exception(DI::l10n()->t("That doesn't appear to be your full (First Last) name."));
1146 if (!Network::isEmailDomainAllowed($email)) {
1147 throw new Exception(DI::l10n()->t('Your email domain is not among those allowed on this site.'));
1150 if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !Network::isEmailDomainValid($email)) {
1151 throw new Exception(DI::l10n()->t('Not a valid email address.'));
1153 if (self::isNicknameBlocked($nickname)) {
1154 throw new Exception(DI::l10n()->t('The nickname was blocked from registration by the nodes admin.'));
1157 if (DI::config()->get('system', 'block_extended_register', false) && DBA::exists('user', ['email' => $email])) {
1158 throw new Exception(DI::l10n()->t('Cannot use that email.'));
1161 // Disallow somebody creating an account using openid that uses the admin email address,
1162 // since openid bypasses email verification. We'll allow it if there is not yet an admin account.
1163 if (strlen($openid_url) && in_array(strtolower($email), self::getAdminEmailList())) {
1164 throw new Exception(DI::l10n()->t('Cannot use that email.'));
1167 $nickname = $data['nickname'] = strtolower($nickname);
1169 if (!preg_match('/^[a-z0-9][a-z0-9_]*$/', $nickname)) {
1170 throw new Exception(DI::l10n()->t('Your nickname can only contain a-z, 0-9 and _.'));
1173 // Check existing and deleted accounts for this nickname.
1175 DBA::exists('user', ['nickname' => $nickname])
1176 || DBA::exists('userd', ['username' => $nickname])
1178 throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1181 $new_password = strlen($password) ? $password : User::generateNewPassword();
1182 $new_password_encoded = self::hashPassword($new_password);
1184 $return['password'] = $new_password;
1186 $keys = Crypto::newKeypair(4096);
1187 if ($keys === false) {
1188 throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
1191 $prvkey = $keys['prvkey'];
1192 $pubkey = $keys['pubkey'];
1194 // Create another keypair for signing/verifying salmon protocol messages.
1195 $sres = Crypto::newKeypair(512);
1196 $sprvkey = $sres['prvkey'];
1197 $spubkey = $sres['pubkey'];
1199 $insert_result = DBA::insert('user', [
1200 'guid' => System::createUUID(),
1201 'username' => $username,
1202 'password' => $new_password_encoded,
1204 'openid' => $openid_url,
1205 'nickname' => $nickname,
1206 'pubkey' => $pubkey,
1207 'prvkey' => $prvkey,
1208 'spubkey' => $spubkey,
1209 'sprvkey' => $sprvkey,
1210 'verified' => $verified,
1211 'blocked' => $blocked,
1212 'language' => $language,
1213 'timezone' => 'UTC',
1214 'register_date' => DateTimeFormat::utcNow(),
1215 'default-location' => ''
1218 if ($insert_result) {
1219 $uid = DBA::lastInsertId();
1220 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1222 throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1226 throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1229 // if somebody clicked submit twice very quickly, they could end up with two accounts
1230 // due to race condition. Remove this one.
1231 $user_count = DBA::count('user', ['nickname' => $nickname]);
1232 if ($user_count > 1) {
1233 DBA::delete('user', ['uid' => $uid]);
1235 throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1238 $insert_result = DBA::insert('profile', [
1240 'name' => $username,
1241 'photo' => self::getAvatarUrl($user),
1242 'thumb' => self::getAvatarUrl($user, Proxy::SIZE_THUMB),
1243 'publish' => $publish,
1244 'net-publish' => $netpublish,
1246 if (!$insert_result) {
1247 DBA::delete('user', ['uid' => $uid]);
1249 throw new Exception(DI::l10n()->t('An error occurred creating your default profile. Please try again.'));
1252 // Create the self contact
1253 if (!Contact::createSelfFromUserId($uid)) {
1254 DBA::delete('user', ['uid' => $uid]);
1256 throw new Exception(DI::l10n()->t('An error occurred creating your self contact. Please try again.'));
1259 // Create a circle with no members. This allows somebody to use it
1260 // right away as a default circle for new contacts.
1261 $def_gid = Circle::create($uid, DI::l10n()->t('Friends'));
1263 DBA::delete('user', ['uid' => $uid]);
1265 throw new Exception(DI::l10n()->t('An error occurred creating your default contact circle. Please try again.'));
1268 $fields = ['def_gid' => $def_gid];
1269 if (DI::config()->get('system', 'newuser_private') && $def_gid) {
1270 $fields['allow_gid'] = '<' . $def_gid . '>';
1273 DBA::update('user', $fields, ['uid' => $uid]);
1275 $def_gid_groups = Circle::create($uid, DI::l10n()->t('Groups'));
1276 if ($def_gid_groups) {
1277 DI::pConfig()->set($uid, 'system', 'default-group-gid', $def_gid_groups);
1280 // if we have no OpenID photo try to look up an avatar
1281 if (!strlen($photo)) {
1282 $photo = Network::lookupAvatarByEmail($email);
1285 // unless there is no avatar-addon loaded
1286 if (strlen($photo)) {
1287 $photo_failure = false;
1289 $filename = basename($photo);
1290 $curlResult = DI::httpClient()->get($photo, HttpClientAccept::IMAGE);
1291 if ($curlResult->isSuccess()) {
1292 Logger::debug('Got picture', ['Content-Type' => $curlResult->getHeader('Content-Type'), 'url' => $photo]);
1293 $img_str = $curlResult->getBody();
1294 $type = $curlResult->getContentType();
1300 $type = Images::getMimeTypeByData($img_str, $photo, $type);
1302 $image = new Image($img_str, $type);
1303 if ($image->isValid()) {
1304 $image->scaleToSquare(300);
1306 $resource_id = Photo::newResource();
1308 // Not using Photo::PROFILE_PHOTOS here, so that it is discovered as translatable string
1309 $profile_album = DI::l10n()->t('Profile Photos');
1311 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 4);
1314 $photo_failure = true;
1317 $image->scaleDown(80);
1319 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 5);
1322 $photo_failure = true;
1325 $image->scaleDown(48);
1327 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 6);
1330 $photo_failure = true;
1333 if (!$photo_failure) {
1334 Photo::update(['profile' => true, 'photo-type' => Photo::USER_AVATAR], ['resource-id' => $resource_id]);
1338 Contact::updateSelfFromUserID($uid, true);
1341 Hook::callAll('register_account', $uid);
1343 self::setRegisterMethodByUserCount();
1345 $return['user'] = $user;
1350 * Update a user entry and distribute the changes if needed
1352 * @param array $fields
1353 * @param integer $uid
1357 public static function update(array $fields, int $uid): bool
1359 if (!DBA::update('user', $fields, ['uid' => $uid])) {
1363 if (Contact::updateSelfFromUserID($uid)) {
1364 Profile::publishUpdate($uid);
1371 * Sets block state for a given user
1373 * @param int $uid The user id
1374 * @param bool $block Block state (default is true)
1376 * @return bool True, if successfully blocked
1380 public static function block(int $uid, bool $block = true): bool
1382 return DBA::update('user', ['blocked' => $block], ['uid' => $uid]);
1386 * Allows a registration based on a hash
1388 * @param string $hash
1390 * @return bool True, if the allow was successful
1392 * @throws HTTPException\InternalServerErrorException
1395 public static function allow(string $hash): bool
1397 $register = Register::getByHash($hash);
1398 if (!DBA::isResult($register)) {
1402 $user = User::getById($register['uid']);
1403 if (!DBA::isResult($user)) {
1407 Register::deleteByHash($hash);
1409 DBA::update('user', ['blocked' => false, 'verified' => true], ['uid' => $register['uid']]);
1411 $profile = DBA::selectFirst('profile', ['net-publish'], ['uid' => $register['uid']]);
1413 if (DBA::isResult($profile) && $profile['net-publish'] && Search::getGlobalDirectory()) {
1414 $url = DI::baseUrl() . '/profile/' . $user['nickname'];
1415 Worker::add(Worker::PRIORITY_LOW, "Directory", $url);
1418 $l10n = DI::l10n()->withLang($register['language']);
1420 return User::sendRegisterOpenEmail(
1423 DI::config()->get('config', 'sitename'),
1425 ($register['password'] ?? '') ?: 'Sent in a previous email'
1430 * Denys a pending registration
1432 * @param string $hash The hash of the pending user
1434 * This does not have to go through user_remove() and save the nickname
1435 * permanently against re-registration, as the person was not yet
1436 * allowed to have friends on this system
1438 * @return bool True, if the deny was successful
1441 public static function deny(string $hash): bool
1443 $register = Register::getByHash($hash);
1444 if (!DBA::isResult($register)) {
1448 $user = User::getById($register['uid']);
1449 if (!DBA::isResult($user)) {
1453 // Delete the avatar
1454 Photo::delete(['uid' => $register['uid']]);
1456 return DBA::delete('user', ['uid' => $register['uid']]) &&
1457 Register::deleteByHash($register['hash']);
1461 * Creates a new user based on a minimal set and sends an email to this user
1463 * @param string $name The user's name
1464 * @param string $email The user's email address
1465 * @param string $nick The user's nick name
1466 * @param string $lang The user's language (default is english)
1467 * @return bool True, if the user was created successfully
1468 * @throws HTTPException\InternalServerErrorException
1469 * @throws ErrorException
1470 * @throws ImagickException
1472 public static function createMinimal(string $name, string $email, string $nick, string $lang = L10n::DEFAULT): bool
1477 throw new HTTPException\InternalServerErrorException('Invalid arguments.');
1480 $result = self::create([
1481 'username' => $name,
1483 'nickname' => $nick,
1488 $user = $result['user'];
1489 $preamble = Strings::deindent(DI::l10n()->t('
1491 the administrator of %2$s has set up an account for you.'));
1492 $body = Strings::deindent(DI::l10n()->t('
1493 The login details are as follows:
1499 You may change your password from your account "Settings" page after logging
1502 Please take a few moments to review the other account settings on that page.
1504 You may also wish to add some basic information to your default profile
1505 (on the "Profiles" page) so that other people can easily find you.
1507 We recommend setting your full name, adding a profile photo,
1508 adding some profile "keywords" (very useful in making new friends) - and
1509 perhaps what country you live in; if you do not wish to be more specific
1512 We fully respect your right to privacy, and none of these items are necessary.
1513 If you are new and do not know anybody here, they may help
1514 you to make some new and interesting friends.
1516 If you ever want to delete your account, you can do so at %1$s/settings/removeme
1518 Thank you and welcome to %4$s.'));
1520 $preamble = sprintf($preamble, $user['username'], DI::config()->get('config', 'sitename'));
1521 $body = sprintf($body, DI::baseUrl(), $user['nickname'], $result['password'], DI::config()->get('config', 'sitename'));
1523 $email = DI::emailer()
1525 ->withMessage(DI::l10n()->t('Registration details for %s', DI::config()->get('config', 'sitename')), $preamble, $body)
1527 ->withRecipient($user['email'])
1529 return DI::emailer()->send($email);
1533 * Sends pending registration confirmation email
1535 * @param array $user User record array
1536 * @param string $sitename
1537 * @param string $siteurl
1538 * @param string $password Plaintext password
1539 * @return NULL|boolean from notification() and email() inherited
1540 * @throws HTTPException\InternalServerErrorException
1542 public static function sendRegisterPendingEmail(array $user, string $sitename, string $siteurl, string $password)
1544 $body = Strings::deindent(DI::l10n()->t(
1547 Thank you for registering at %2$s. Your account is pending for approval by the administrator.
1549 Your login details are as follows:
1562 $email = DI::emailer()
1564 ->withMessage(DI::l10n()->t('Registration at %s', $sitename), $body)
1566 ->withRecipient($user['email'])
1568 return DI::emailer()->send($email);
1572 * Sends registration confirmation
1574 * It's here as a function because the mail is sent from different parts
1576 * @param L10n $l10n The used language
1577 * @param array $user User record array
1578 * @param string $sitename
1579 * @param string $siteurl
1580 * @param string $password Plaintext password
1582 * @return NULL|boolean from notification() and email() inherited
1583 * @throws HTTPException\InternalServerErrorException
1585 public static function sendRegisterOpenEmail(L10n $l10n, array $user, string $sitename, string $siteurl, string $password)
1587 $preamble = Strings::deindent($l10n->t(
1590 Thank you for registering at %2$s. Your account has been created.
1595 $body = Strings::deindent($l10n->t(
1597 The login details are as follows:
1603 You may change your password from your account "Settings" page after logging
1606 Please take a few moments to review the other account settings on that page.
1608 You may also wish to add some basic information to your default profile
1609 ' . "\x28" . 'on the "Profiles" page' . "\x29" . ' so that other people can easily find you.
1611 We recommend setting your full name, adding a profile photo,
1612 adding some profile "keywords" ' . "\x28" . 'very useful in making new friends' . "\x29" . ' - and
1613 perhaps what country you live in; if you do not wish to be more specific
1616 We fully respect your right to privacy, and none of these items are necessary.
1617 If you are new and do not know anybody here, they may help
1618 you to make some new and interesting friends.
1620 If you ever want to delete your account, you can do so at %3$s/settings/removeme
1622 Thank you and welcome to %2$s.',
1630 $email = DI::emailer()
1632 ->withMessage(DI::l10n()->t('Registration details for %s', $sitename), $preamble, $body)
1634 ->withRecipient($user['email'])
1636 return DI::emailer()->send($email);
1640 * @param int $uid user to remove
1642 * @throws HTTPException\InternalServerErrorException
1644 public static function remove(int $uid): bool
1650 Logger::notice('Removing user', ['user' => $uid]);
1652 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1654 Hook::callAll('remove_user', $user);
1656 // save username (actually the nickname as it is guaranteed
1657 // unique), so it cannot be re-registered in the future.
1658 DBA::insert('userd', ['username' => $user['nickname']]);
1660 // Remove all personal settings, especially connector settings
1661 DBA::delete('pconfig', ['uid' => $uid]);
1663 // The user and related data will be deleted in Friendica\Worker\ExpireAndRemoveUsers
1664 DBA::update('user', ['account_removed' => true, 'account_expires_on' => DateTimeFormat::utc('now + 7 day')], ['uid' => $uid]);
1665 Worker::add(Worker::PRIORITY_HIGH, 'Notifier', Delivery::REMOVAL, $uid);
1667 // Send an update to the directory
1668 $self = DBA::selectFirst('contact', ['url'], ['uid' => $uid, 'self' => true]);
1669 Worker::add(Worker::PRIORITY_LOW, 'Directory', $self['url']);
1671 // Remove the user relevant data
1672 Worker::add(Worker::PRIORITY_NEGLIGIBLE, 'RemoveUser', $uid);
1674 self::setRegisterMethodByUserCount();
1679 * Return all identities to a user
1681 * @param int $uid The user id
1682 * @return array All identities for this user
1684 * Example for a return:
1688 * 'username' => 'maxmuster',
1689 * 'nickname' => 'Max Mustermann'
1693 * 'username' => 'johndoe',
1694 * 'nickname' => 'John Doe'
1699 public static function identities(int $uid): array
1707 $user = DBA::selectFirst('user', ['uid', 'nickname', 'username', 'parent-uid'], ['uid' => $uid]);
1708 if (!DBA::isResult($user)) {
1712 if (!$user['parent-uid']) {
1713 // First add our own entry
1715 'uid' => $user['uid'],
1716 'username' => $user['username'],
1717 'nickname' => $user['nickname']
1720 // Then add all the children
1723 ['uid', 'username', 'nickname'],
1724 ['parent-uid' => $user['uid'], 'account_removed' => false]
1726 if (DBA::isResult($r)) {
1727 $identities = array_merge($identities, DBA::toArray($r));
1730 // First entry is our parent
1733 ['uid', 'username', 'nickname'],
1734 ['uid' => $user['parent-uid'], 'account_removed' => false]
1736 if (DBA::isResult($r)) {
1737 $identities = DBA::toArray($r);
1740 // Then add all siblings
1743 ['uid', 'username', 'nickname'],
1744 ['parent-uid' => $user['parent-uid'], 'account_removed' => false]
1746 if (DBA::isResult($r)) {
1747 $identities = array_merge($identities, DBA::toArray($r));
1752 "SELECT `user`.`uid`, `user`.`username`, `user`.`nickname`
1754 INNER JOIN `user` ON `manage`.`mid` = `user`.`uid`
1755 WHERE `user`.`account_removed` = 0 AND `manage`.`uid` = ?",
1758 if (DBA::isResult($r)) {
1759 $identities = array_merge($identities, DBA::toArray($r));
1766 * Check if the given user id has delegations or is delegated
1771 public static function hasIdentities(int $uid): bool
1777 $user = DBA::selectFirst('user', ['parent-uid'], ['uid' => $uid, 'account_removed' => false]);
1778 if (!DBA::isResult($user)) {
1782 if ($user['parent-uid']) {
1786 if (DBA::exists('user', ['parent-uid' => $uid, 'account_removed' => false])) {
1790 if (DBA::exists('manage', ['uid' => $uid])) {
1798 * Returns statistical information about the current users of this node
1804 public static function getStatistics(): array
1808 'active_users_halfyear' => 0,
1809 'active_users_monthly' => 0,
1810 'active_users_weekly' => 0,
1813 $userStmt = DBA::select('owner-view', ['uid', 'last-activity', 'last-item'],
1814 ["`verified` AND `last-activity` > ? AND NOT `blocked`
1815 AND NOT `account_removed` AND NOT `account_expired`",
1816 DBA::NULL_DATETIME]);
1817 if (!DBA::isResult($userStmt)) {
1821 $halfyear = time() - (180 * 24 * 60 * 60);
1822 $month = time() - (30 * 24 * 60 * 60);
1823 $week = time() - (7 * 24 * 60 * 60);
1825 while ($user = DBA::fetch($userStmt)) {
1826 $statistics['total_users']++;
1828 if ((strtotime($user['last-activity']) > $halfyear) || (strtotime($user['last-item']) > $halfyear)
1830 $statistics['active_users_halfyear']++;
1833 if ((strtotime($user['last-activity']) > $month) || (strtotime($user['last-item']) > $month)
1835 $statistics['active_users_monthly']++;
1838 if ((strtotime($user['last-activity']) > $week) || (strtotime($user['last-item']) > $week)
1840 $statistics['active_users_weekly']++;
1843 DBA::close($userStmt);
1849 * Get all users of the current node
1851 * @param int $start Start count (Default is 0)
1852 * @param int $count Count of the items per page (Default is @see Pager::ITEMS_PER_PAGE)
1853 * @param string $type The type of users, which should get (all, blocked, removed)
1854 * @param string $order Order of the user list (Default is 'contact.name')
1855 * @param bool $descending Order direction (Default is ascending)
1856 * @return array|bool The list of the users
1859 public static function getList(int $start = 0, int $count = Pager::ITEMS_PER_PAGE, string $type = 'all', string $order = 'name', bool $descending = false)
1861 $param = ['limit' => [$start, $count], 'order' => [$order => $descending]];
1865 $condition['account_removed'] = false;
1866 $condition['blocked'] = false;
1870 $condition['account_removed'] = false;
1871 $condition['blocked'] = true;
1872 $condition['verified'] = true;
1876 $condition['account_removed'] = true;
1880 return DBA::selectToArray('owner-view', [], $condition, $param);
1884 * Returns a list of lowercase admin email addresses from the comma-separated list in the config
1888 public static function getAdminEmailList(): array
1890 $adminEmails = strtolower(str_replace(' ', '', DI::config()->get('config', 'admin_email')));
1891 if (!$adminEmails) {
1895 return explode(',', $adminEmails);
1899 * Returns the complete list of admin user accounts
1901 * @param array $fields
1905 public static function getAdminList(array $fields = []): array
1908 'email' => self::getAdminEmailList(),
1909 'parent-uid' => null,
1912 'account_removed' => false,
1913 'account_expired' => false,
1916 return DBA::selectToArray('user', $fields, $condition, ['order' => ['uid']]);
1920 * Return a list of admin user accounts where each unique email address appears only once.
1922 * This method is meant for admin notifications that do not need to be sent multiple times to the same email address.
1924 * @param array $fields
1928 public static function getAdminListForEmailing(array $fields = []): array
1930 return array_filter(self::getAdminList($fields), function ($user) {
1931 static $emails = [];
1933 if (in_array($user['email'], $emails)) {
1937 $emails[] = $user['email'];
1943 public static function setRegisterMethodByUserCount()
1945 $max_registered_users = DI::config()->get('config', 'max_registered_users');
1946 if ($max_registered_users <= 0) {
1950 $register_policy = DI::config()->get('config', 'register_policy');
1951 if (!in_array($register_policy, [Module\Register::OPEN, Module\Register::CLOSED])) {
1952 Logger::debug('Unsupported register policy.', ['policy' => $register_policy]);
1956 $users = DBA::count('user', ['blocked' => false, 'account_removed' => false, 'account_expired' => false]);
1957 if (($users >= $max_registered_users) && ($register_policy == Module\Register::OPEN)) {
1958 DI::config()->set('config', 'register_policy', Module\Register::CLOSED);
1959 Logger::notice('Max users reached, registration is closed.', ['users' => $users, 'max' => $max_registered_users]);
1960 } elseif (($users < $max_registered_users) && ($register_policy == Module\Register::CLOSED)) {
1961 DI::config()->set('config', 'register_policy', Module\Register::OPEN);
1962 Logger::notice('Below maximum users, registration is opened.', ['users' => $users, 'max' => $max_registered_users]);
1964 Logger::debug('Unchanged register policy', ['policy' => $register_policy, 'users' => $users, 'max' => $max_registered_users]);