3 * @copyright Copyright (C) 2010-2024, 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;
41 use Friendica\Object\Image;
42 use Friendica\Protocol\Delivery;
43 use Friendica\Security\TwoFactor\Model\AppSpecificPassword;
44 use Friendica\Util\Crypto;
45 use Friendica\Util\DateTimeFormat;
46 use Friendica\Util\Images;
47 use Friendica\Util\Network;
48 use Friendica\Util\Proxy;
49 use Friendica\Util\Strings;
54 * This class handles User related functions
61 * PAGE_FLAGS_NORMAL is a typical personal profile account
62 * PAGE_FLAGS_SOAPBOX automatically approves all friend requests as Contact::SHARING, (readonly)
63 * PAGE_FLAGS_COMMUNITY automatically approves all friend requests as Contact::SHARING, but with
64 * write access to wall and comments (no email and not included in page owner's ACL lists)
65 * PAGE_FLAGS_FREELOVE automatically approves all friend requests as full friends (Contact::FRIEND).
69 const PAGE_FLAGS_NORMAL = 0;
70 const PAGE_FLAGS_SOAPBOX = 1;
71 const PAGE_FLAGS_COMMUNITY = 2;
72 const PAGE_FLAGS_FREELOVE = 3;
73 const PAGE_FLAGS_BLOG = 4;
74 const PAGE_FLAGS_PRVGROUP = 5;
82 * ACCOUNT_TYPE_PERSON - the account belongs to a person
83 * Associated page types: PAGE_FLAGS_NORMAL, PAGE_FLAGS_SOAPBOX, PAGE_FLAGS_FREELOVE
85 * ACCOUNT_TYPE_ORGANISATION - the account belongs to an organisation
86 * Associated page type: PAGE_FLAGS_SOAPBOX
88 * ACCOUNT_TYPE_NEWS - the account is a news reflector
89 * Associated page type: PAGE_FLAGS_SOAPBOX
91 * ACCOUNT_TYPE_COMMUNITY - the account is community group
92 * Associated page types: PAGE_COMMUNITY, PAGE_FLAGS_PRVGROUP
94 * ACCOUNT_TYPE_RELAY - the account is a relay
95 * This will only be assigned to contacts, not to user accounts
98 const ACCOUNT_TYPE_PERSON = 0;
99 const ACCOUNT_TYPE_ORGANISATION = 1;
100 const ACCOUNT_TYPE_NEWS = 2;
101 const ACCOUNT_TYPE_COMMUNITY = 3;
102 const ACCOUNT_TYPE_RELAY = 4;
103 const ACCOUNT_TYPE_DELETED = 127;
108 private static $owner;
111 * Returns the numeric account type by their string
113 * @param string $accounttype as string constant
114 * @return int|null Numeric account type - or null when not set
116 public static function getAccountTypeByString(string $accounttype)
118 switch ($accounttype) {
120 return self::ACCOUNT_TYPE_PERSON;
123 return self::ACCOUNT_TYPE_ORGANISATION;
126 return self::ACCOUNT_TYPE_NEWS;
129 return self::ACCOUNT_TYPE_COMMUNITY;
135 * Get the Uri-Id of the system account
139 public static function getSystemUriId(): int
141 $system = self::getSystemAccount();
142 return $system['uri-id'] ?? 0;
146 * Fetch the system account
148 * @return array system account
150 public static function getSystemAccount(): array
152 $system = Contact::selectFirst([], ['self' => true, 'uid' => 0]);
153 if (!DBA::isResult($system)) {
154 self::createSystemAccount();
155 $system = Contact::selectFirst([], ['self' => true, 'uid' => 0]);
156 if (!DBA::isResult($system)) {
161 $system['sprvkey'] = $system['uprvkey'] = $system['prvkey'];
162 $system['spubkey'] = $system['upubkey'] = $system['pubkey'];
163 $system['nickname'] = $system['nick'];
164 $system['page-flags'] = self::PAGE_FLAGS_SOAPBOX;
165 $system['account-type'] = $system['contact-type'];
166 $system['guid'] = '';
167 $system['picdate'] = '';
168 $system['theme'] = '';
169 $system['publish'] = false;
170 $system['net-publish'] = false;
171 $system['hide-friends'] = true;
172 $system['hidewall'] = true;
173 $system['prv_keywords'] = '';
174 $system['pub_keywords'] = '';
175 $system['address'] = '';
176 $system['locality'] = '';
177 $system['region'] = '';
178 $system['postal-code'] = '';
179 $system['country-name'] = '';
180 $system['homepage'] = (string)DI::baseUrl();
181 $system['dob'] = '0000-00-00';
183 // Ensure that the user contains data
184 $user = DBA::selectFirst('user', ['prvkey', 'guid', 'language'], ['uid' => 0]);
185 if (empty($user['prvkey']) || empty($user['guid'])) {
187 'username' => $system['name'],
188 'nickname' => $system['nick'],
189 'register_date' => $system['created'],
190 'pubkey' => $system['pubkey'],
191 'prvkey' => $system['prvkey'],
192 'spubkey' => $system['spubkey'],
193 'sprvkey' => $system['sprvkey'],
194 'guid' => System::createUUID(),
196 'page-flags' => self::PAGE_FLAGS_SOAPBOX,
197 'account-type' => self::ACCOUNT_TYPE_RELAY,
200 DBA::update('user', $fields, ['uid' => 0]);
202 $system['guid'] = $fields['guid'];
204 $system['guid'] = $user['guid'];
205 $system['language'] = $user['language'];
212 * Create the system account
216 private static function createSystemAccount()
218 $system_actor_name = self::getActorName();
219 if (empty($system_actor_name)) {
223 $keys = Crypto::newKeypair(4096);
224 if ($keys === false) {
225 throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
230 'created' => DateTimeFormat::utcNow(),
232 'network' => Protocol::ACTIVITYPUB,
233 'name' => 'System Account',
234 'addr' => $system_actor_name . '@' . DI::baseUrl()->getHost(),
235 'nick' => $system_actor_name,
236 'url' => DI::baseUrl() . '/friendica',
237 'pubkey' => $keys['pubkey'],
238 'prvkey' => $keys['prvkey'],
241 'contact-type' => Contact::TYPE_RELAY, // In AP this is translated to 'Application'
242 'name-date' => DateTimeFormat::utcNow(),
243 'uri-date' => DateTimeFormat::utcNow(),
244 'avatar-date' => DateTimeFormat::utcNow(),
246 'baseurl' => DI::baseUrl(),
249 $system['avatar'] = $system['photo'] = Contact::getDefaultAvatar($system, Proxy::SIZE_SMALL);
250 $system['thumb'] = Contact::getDefaultAvatar($system, Proxy::SIZE_THUMB);
251 $system['micro'] = Contact::getDefaultAvatar($system, Proxy::SIZE_MICRO);
252 $system['nurl'] = Strings::normaliseLink($system['url']);
253 $system['gsid'] = GServer::getID($system['baseurl']);
255 Contact::insert($system);
259 * Detect a usable actor name
261 * @return string actor account name
263 public static function getActorName(): string
265 $system_actor_name = DI::config()->get('system', 'actor_name');
266 if (!empty($system_actor_name)) {
267 $self = Contact::selectFirst(['nick'], ['uid' => 0, 'self' => true]);
268 if (!empty($self['nick'])) {
269 if ($self['nick'] != $system_actor_name) {
270 // Reset the actor name to the already used name
271 DI::config()->set('system', 'actor_name', $self['nick']);
272 $system_actor_name = $self['nick'];
275 return $system_actor_name;
278 // List of possible actor names
279 $possible_accounts = ['friendica', 'actor', 'system', 'internal'];
280 foreach ($possible_accounts as $name) {
281 if (!DBA::exists('user', ['nickname' => $name]) && !DBA::exists('userd', ['username' => $name])) {
282 DI::config()->set('system', 'actor_name', $name);
290 * Returns true if a user record exists with the provided id
297 public static function exists(int $uid): bool
299 return DBA::exists('user', ['uid' => $uid]);
303 * @param integer $uid
304 * @param array $fields
305 * @return array|boolean User record if it exists, false otherwise
308 public static function getById(int $uid, array $fields = [])
310 return !empty($uid) ? DBA::selectFirst('user', $fields, ['uid' => $uid]) : [];
314 * Returns a user record based on it's GUID
316 * @param string $guid The guid of the user
317 * @param array $fields The fields to retrieve
318 * @param bool $active True, if only active records are searched
320 * @return array|boolean User record if it exists, false otherwise
323 public static function getByGuid(string $guid, array $fields = [], bool $active = true)
326 $cond = ['guid' => $guid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false];
328 $cond = ['guid' => $guid];
331 return DBA::selectFirst('user', $fields, $cond);
335 * @param string $nickname
336 * @param array $fields
337 * @return array|boolean User record if it exists, false otherwise
340 public static function getByNickname(string $nickname, array $fields = [])
342 return DBA::selectFirst('user', $fields, ['nickname' => $nickname]);
346 * Set static settings for community user accounts
348 * @param integer $uid
351 public static function setCommunityUserSettings(int $uid)
353 $user = self::getById($uid, ['account-type', 'page-flags']);
354 if ($user['account-type'] != self::ACCOUNT_TYPE_COMMUNITY) {
358 DI::pConfig()->set($uid, 'system', 'unlisted', true);
362 'allow_gid' => $user['page-flags'] == self::PAGE_FLAGS_PRVGROUP ? '<' . Circle::FOLLOWERS . '>' : '',
369 self::update($fields, $uid);
371 Profile::update(['hide-friends' => true], $uid);
375 * Returns the user id of a given profile URL
379 * @return integer user id
382 public static function getIdForURL(string $url): int
384 // Avoid database queries when the local node hostname isn't even part of the url.
385 if (!Contact::isLocal($url)) {
389 $self = Contact::selectFirst(['uid'], ['self' => true, 'nurl' => Strings::normaliseLink($url)]);
390 if (!empty($self['uid'])) {
394 $self = Contact::selectFirst(['uid'], ['self' => true, 'addr' => $url]);
395 if (!empty($self['uid'])) {
399 $self = Contact::selectFirst(['uid'], ['self' => true, 'alias' => [$url, Strings::normaliseLink($url)]]);
400 if (!empty($self['uid'])) {
408 * Get a user based on its email
410 * @param string $email
411 * @param array $fields
412 * @return array|boolean User record if it exists, false otherwise
415 public static function getByEmail(string $email, array $fields = [])
417 return DBA::selectFirst('user', $fields, ['email' => $email]);
421 * Fetch the user array of the administrator. The first one if there are several.
423 * @param array $fields
427 public static function getFirstAdmin(array $fields = []): array
429 if (!empty(DI::config()->get('config', 'admin_nickname'))) {
430 return self::getByNickname(DI::config()->get('config', 'admin_nickname'), $fields);
433 return self::getAdminList()[0] ?? [];
437 * Get owner data by user id
440 * @param boolean $repairMissing Repair the owner data if it's missing
441 * @return boolean|array
444 public static function getOwnerDataById(int $uid, bool $repairMissing = true)
447 return self::getSystemAccount();
450 if (!empty(self::$owner[$uid])) {
451 return self::$owner[$uid];
454 $owner = DBA::selectFirst('owner-view', [], ['uid' => $uid]);
455 if (!DBA::isResult($owner)) {
456 if (!self::exists($uid) || !$repairMissing) {
459 if (!DBA::exists('profile', ['uid' => $uid])) {
460 DBA::insert('profile', ['uid' => $uid]);
462 if (!DBA::exists('contact', ['uid' => $uid, 'self' => true])) {
463 Contact::createSelfFromUserId($uid);
465 $owner = self::getOwnerDataById($uid, false);
468 if (empty($owner['nickname'])) {
472 if (!$repairMissing || $owner['account_expired']) {
476 // Check if the returned data is valid, otherwise fix it. See issue #6122
478 // Check for correct url and normalised nurl
479 $url = DI::baseUrl() . '/profile/' . $owner['nickname'];
480 $repair = empty($owner['network']) || ($owner['url'] != $url) || ($owner['nurl'] != Strings::normaliseLink($owner['url']));
483 // Check if "addr" is present and correct
484 $addr = $owner['nickname'] . '@' . substr(DI::baseUrl(), strpos(DI::baseUrl(), '://') + 3);
485 $repair = ($addr != $owner['addr']) || empty($owner['prvkey']) || empty($owner['pubkey']);
489 // Check if the avatar field is filled and the photo directs to the correct path
490 $avatar = Photo::selectFirst(['resource-id'], ['uid' => $uid, 'profile' => true]);
491 if (DBA::isResult($avatar)) {
492 $repair = empty($owner['avatar']) || !strpos($owner['photo'], $avatar['resource-id']);
497 Contact::updateSelfFromUserID($uid);
498 // Return the corrected data and avoid a loop
499 $owner = self::getOwnerDataById($uid, false);
502 self::$owner[$uid] = $owner;
507 * Get owner data by nick name
510 * @return boolean|array
513 public static function getOwnerDataByNick(string $nick)
515 $user = DBA::selectFirst('user', ['uid'], ['nickname' => $nick]);
517 if (!DBA::isResult($user)) {
521 return self::getOwnerDataById($user['uid']);
525 * Returns the default circle for a given user
527 * @param int $uid User id
529 * @return int circle id
532 public static function getDefaultCircle(int $uid): int
534 $user = DBA::selectFirst('user', ['def_gid'], ['uid' => $uid]);
535 if (DBA::isResult($user)) {
536 $default_circle = $user['def_gid'];
541 return $default_circle;
545 * Returns the default circle for groups for a given user
547 * @param int $uid User id
549 * @return int circle id
552 public static function getDefaultGroupCircle(int $uid): int
554 $default_circle = DI::pConfig()->get($uid, 'system', 'default-group-gid');
555 if (empty($default_circle)) {
556 $default_circle = self::getDefaultCircle($uid);
559 return $default_circle;
563 * Fetch the language code from the given user. If the code is invalid, return the system language
565 * @param integer $uid User-Id
568 public static function getLanguageCode(int $uid): string
570 $owner = self::getOwnerDataById($uid);
571 if (!empty($owner['language'])) {
572 $language = DI::l10n()->toISO6391($owner['language']);
573 if (in_array($language, array_keys(DI::l10n()->getLanguageCodes()))) {
577 return DI::l10n()->toISO6391(DI::config()->get('system', 'language'));
581 * Fetch the wanted languages for a given user
583 * @param integer $uid
586 public static function getWantedLanguages(int $uid): array
588 return DI::pConfig()->get($uid, 'channel', 'languages', [self::getLanguageCode($uid)]) ?? [];
592 * Get a list of all languages that are used by the users
596 public static function getLanguages(): array
598 $cachekey = 'user:getLanguages';
599 $languages = DI::cache()->get($cachekey);
600 if (!is_null($languages)) {
604 $supported = array_keys(DI::l10n()->getLanguageCodes());
608 $condition = ["`verified` AND NOT `blocked` AND NOT `account_removed` AND NOT `account_expired` AND `uid` > ?", 0];
610 $abandon_days = intval(DI::config()->get('system', 'account_abandon_days'));
611 if (!empty($abandon_days)) {
612 $condition = DBA::mergeConditions($condition, ["`last-activity` > ?", DateTimeFormat::utc('now - ' . $abandon_days . ' days')]);
615 $users = DBA::select('user', ['uid', 'language'], $condition);
616 while ($user = DBA::fetch($users)) {
617 $uids[] = $user['uid'];
618 $code = DI::l10n()->toISO6391($user['language']);
619 if (!in_array($code, $supported)) {
622 $languages[$code] = $code;
626 $channels = DBA::select('pconfig', ['uid', 'v'], ["`cat` = ? AND `k` = ? AND `v` != ?", 'channel', 'languages', '']);
627 while ($channel = DBA::fetch($channels)) {
628 if (!in_array($channel['uid'], $uids)) {
631 $values = unserialize($channel['v']);
632 if (!empty($values) && is_array($values)) {
633 foreach ($values as $language) {
634 $language = DI::l10n()->toISO6391($language);
635 $languages[$language] = $language;
639 DBA::close($channels);
642 $languages = array_keys($languages);
643 DI::cache()->set($cachekey, $languages);
649 * Authenticate a user with a clear text password
651 * Returns the user id associated with a successful password authentication
653 * @param mixed $user_info
654 * @param string $password
655 * @param bool $third_party
656 * @return int User Id if authentication is successful
657 * @throws HTTPException\ForbiddenException
658 * @throws HTTPException\NotFoundException
660 public static function getIdFromPasswordAuthentication($user_info, string $password, bool $third_party = false): int
662 // Addons registered with the "authenticate" hook may create the user on the
663 // fly. `getAuthenticationInfo` will fail if the user doesn't exist yet. If
664 // the user doesn't exist, we should give the addons a chance to create the
665 // user in our database, if applicable, before re-throwing the exception if
668 $user = self::getAuthenticationInfo($user_info);
669 } catch (Exception $e) {
670 $username = (is_string($user_info) ? $user_info : $user_info['nickname'] ?? '');
672 // Addons can create users, and since this 'catch' branch should only
673 // execute if getAuthenticationInfo can't find an existing user, that's
674 // exactly what will happen here. Creating a numeric username would create
675 // ambiguity with user IDs, possibly opening up an attack vector.
676 // So let's be very careful about that.
677 if (empty($username) || is_numeric($username)) {
681 return self::getIdFromAuthenticateHooks($username, $password);
684 if ($third_party && DI::pConfig()->get($user['uid'], '2fa', 'verified')) {
685 // Third-party apps can't verify two-factor authentication, we use app-specific passwords instead
686 if (AppSpecificPassword::authenticateUser($user['uid'], $password)) {
689 } elseif (strpos($user['password'], '$') === false) {
690 //Legacy hash that has not been replaced by a new hash yet
691 if (self::hashPasswordLegacy($password) === $user['password']) {
692 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
696 } elseif (!empty($user['legacy_password'])) {
697 //Legacy hash that has been double-hashed and not replaced by a new hash yet
698 //Warning: `legacy_password` is not necessary in sync with the content of `password`
699 if (password_verify(self::hashPasswordLegacy($password), $user['password'])) {
700 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
704 } elseif (password_verify($password, $user['password'])) {
706 if (password_needs_rehash($user['password'], PASSWORD_DEFAULT)) {
707 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
712 return self::getIdFromAuthenticateHooks($user['nickname'], $password); // throws
715 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
719 * Try to obtain a user ID via "authenticate" hook addons
721 * Returns the user id associated with a successful password authentication
723 * @param string $username
724 * @param string $password
725 * @return int User Id if authentication is successful
726 * @throws HTTPException\ForbiddenException
728 public static function getIdFromAuthenticateHooks(string $username, string $password): int
731 'username' => $username,
732 'password' => $password,
733 'authenticated' => 0,
734 'user_record' => null
738 * An addon indicates successful login by setting 'authenticated' to non-zero value and returning a user record
739 * Addons should never set 'authenticated' except to indicate success - as hooks may be chained
740 * and later addons should not interfere with an earlier one that succeeded.
742 Hook::callAll('authenticate', $addon_auth);
744 if ($addon_auth['authenticated'] && $addon_auth['user_record']) {
745 return $addon_auth['user_record']['uid'];
748 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
752 * Returns authentication info from various parameters types
754 * User info can be any of the following:
757 * - User email or username or nickname
758 * - User array with at least the uid and the hashed password
760 * @param mixed $user_info
761 * @return array|null Null if not found/determined
762 * @throws HTTPException\NotFoundException
764 public static function getAuthenticationInfo($user_info)
768 if (is_object($user_info) || is_array($user_info)) {
769 if (is_object($user_info)) {
770 $user = (array) $user_info;
777 || !isset($user['password'])
778 || !isset($user['legacy_password'])
780 throw new Exception(DI::l10n()->t('Not enough information to authenticate'));
782 } elseif (is_int($user_info) || is_string($user_info)) {
783 if (is_int($user_info)) {
784 $user = DBA::selectFirst(
786 ['uid', 'nickname', 'password', 'legacy_password'],
790 'account_expired' => 0,
791 'account_removed' => 0,
796 $fields = ['uid', 'nickname', 'password', 'legacy_password'];
798 "(`email` = ? OR `username` = ? OR `nickname` = ?)
799 AND `verified` AND NOT `blocked` AND NOT `account_removed` AND NOT `account_expired`",
800 $user_info, $user_info, $user_info
802 $user = DBA::selectFirst('user', $fields, $condition);
805 if (!DBA::isResult($user)) {
806 throw new HTTPException\NotFoundException(DI::l10n()->t('User not found'));
814 * Update the day of the last activity of the given user
816 * @param integer $uid
819 public static function updateLastActivity(int $uid)
825 $user = self::getById($uid, ['last-activity']);
830 $current_day = DateTimeFormat::utcNow('Y-m-d');
832 if ($user['last-activity'] != $current_day) {
833 self::update(['last-activity' => $current_day], $uid);
834 // Set the last activity for all identities of the user
835 DBA::update('user', ['last-activity' => $current_day], ['parent-uid' => $uid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]);
840 * Generates a human-readable random password
845 public static function generateNewPassword(): string
847 return ucfirst(Strings::getRandomName(8)) . random_int(1000, 9999);
851 * Checks if the provided plaintext password has been exposed or not
853 * @param string $password
857 public static function isPasswordExposed(string $password): bool
859 $cache = new CacheItemPool();
860 $cache->changeConfig([
861 'cacheDirectory' => System::getTempPath() . '/password-exposed-cache/',
865 $passwordExposedChecker = new PasswordExposed\PasswordExposedChecker(null, $cache);
867 return $passwordExposedChecker->passwordExposed($password) === PasswordExposed\PasswordStatus::EXPOSED;
868 } catch (Exception $e) {
869 Logger::error('Password Exposed Exception: ' . $e->getMessage(), [
870 'code' => $e->getCode(),
871 'file' => $e->getFile(),
872 'line' => $e->getLine(),
873 'trace' => $e->getTraceAsString()
881 * Legacy hashing function, kept for password migration purposes
883 * @param string $password
886 private static function hashPasswordLegacy(string $password): string
888 return hash('whirlpool', $password);
892 * Global user password hashing function
894 * @param string $password
898 public static function hashPassword(string $password): string
900 if (!trim($password)) {
901 throw new Exception(DI::l10n()->t('Password can\'t be empty'));
904 return password_hash($password, PASSWORD_DEFAULT);
908 * Allowed characters are a-z, A-Z, 0-9 and special characters except white spaces and accentuated letters.
910 * Password length is limited to 72 characters if the current default password hashing algorithm is Blowfish.
911 * From the manual: "Using the PASSWORD_BCRYPT as the algorithm, will result in the password parameter being
912 * truncated to a maximum length of 72 bytes."
914 * @see https://www.php.net/manual/en/function.password-hash.php#refsect1-function.password-hash-parameters
916 * @param string|null $delimiter Whether the regular expression is meant to be wrapper in delimiter characters
919 public static function getPasswordRegExp(string $delimiter = null): string
921 $allowed_characters = ':!"#$%&\'()*+,-./;<=>?@[\]^_`{|}~';
924 $allowed_characters = preg_quote($allowed_characters, $delimiter);
927 return '^[a-zA-Z0-9' . $allowed_characters . ']' . (PASSWORD_DEFAULT === PASSWORD_BCRYPT ? '{1,72}' : '+') . '$';
931 * Updates a user row with a new plaintext password
934 * @param string $password
938 public static function updatePassword(int $uid, string $password): bool
940 $password = trim($password);
942 if (empty($password)) {
943 throw new Exception(DI::l10n()->t('Empty passwords are not allowed.'));
946 if (!DI::config()->get('system', 'disable_password_exposed', false) && self::isPasswordExposed($password)) {
947 throw new Exception(DI::l10n()->t('The new password has been exposed in a public data dump, please choose another.'));
950 if (PASSWORD_DEFAULT === PASSWORD_BCRYPT && strlen($password) > 72) {
951 throw new Exception(DI::l10n()->t('The password length is limited to 72 characters.'));
954 if (!preg_match('/' . self::getPasswordRegExp('/') . '/', $password)) {
955 throw new Exception(DI::l10n()->t("The password can't contain white spaces nor accentuated letters"));
958 return self::updatePasswordHashed($uid, self::hashPassword($password));
962 * Updates a user row with a new hashed password.
963 * Empties the password reset token field just in case.
966 * @param string $password_hashed
970 private static function updatePasswordHashed(int $uid, string $password_hashed): bool
973 'password' => $password_hashed,
975 'pwdreset_time' => null,
976 'legacy_password' => false
978 return DBA::update('user', $fields, ['uid' => $uid]);
982 * Returns if the given uid is valid and in the admin list
989 public static function isSiteAdmin(int $uid): bool
991 return DBA::exists('user', [
993 'email' => self::getAdminEmailList()
998 * Returns if the given uid is valid and a moderator
1005 public static function isModerator(int $uid): bool
1007 // @todo Replace with a moderator check in the future
1008 return self::isSiteAdmin($uid);
1012 * Checks if a nickname is in the list of the forbidden nicknames
1014 * Check if a nickname is forbidden from registration on the node by the
1015 * admin. Forbidden nicknames (e.g. role names) can be configured in the
1018 * @param string $nickname The nickname that should be checked
1019 * @return boolean True is the nickname is blocked on the node
1021 public static function isNicknameBlocked(string $nickname): bool
1023 $forbidden_nicknames = DI::config()->get('system', 'forbidden_nicknames', '');
1024 if (!empty($forbidden_nicknames)) {
1025 $forbidden = explode(',', $forbidden_nicknames);
1026 $forbidden = array_map('trim', $forbidden);
1031 // Add the name of the internal actor to the "forbidden" list
1032 $actor_name = self::getActorName();
1033 if (!empty($actor_name)) {
1034 $forbidden[] = $actor_name;
1037 if (empty($forbidden)) {
1041 // check if the nickname is in the list of blocked nicknames
1042 if (in_array(strtolower($nickname), $forbidden)) {
1046 // else return false
1051 * Get avatar link for given user
1053 * @param array $user
1054 * @param string $size One of the Proxy::SIZE_* constants
1055 * @return string avatar link
1058 public static function getAvatarUrl(array $user, string $size = ''): string
1060 if (empty($user['nickname'])) {
1061 DI::logger()->warning('Missing user nickname key');
1064 $url = DI::baseUrl() . '/photo/';
1067 case Proxy::SIZE_MICRO:
1071 case Proxy::SIZE_THUMB:
1084 $photo = Photo::selectFirst(['type', 'created', 'edited', 'updated'], ["scale" => $scale, 'uid' => $user['uid'], 'profile' => true]);
1085 if (!empty($photo)) {
1086 $updated = max($photo['created'], $photo['edited'], $photo['updated']);
1087 $mimetype = $photo['type'];
1090 return $url . $user['nickname'] . Images::getExtensionByMimeType($mimetype) . ($updated ? '?ts=' . strtotime($updated) : '');
1094 * Get banner link for given user
1096 * @param array $user
1097 * @return string banner link
1100 public static function getBannerUrl(array $user): string
1102 if (empty($user['nickname'])) {
1103 DI::logger()->warning('Missing user nickname key');
1106 $url = DI::baseUrl() . '/photo/banner/';
1111 $photo = Photo::selectFirst(['type', 'created', 'edited', 'updated'], ["scale" => 3, 'uid' => $user['uid'], 'photo-type' => Photo::USER_BANNER]);
1112 if (!empty($photo)) {
1113 $updated = max($photo['created'], $photo['edited'], $photo['updated']);
1114 $mimetype = $photo['type'];
1116 // Only for the RC phase: Don't return an image link for the default picture
1120 return $url . $user['nickname'] . Images::getExtensionByMimeType($mimetype) . ($updated ? '?ts=' . strtotime($updated) : '');
1124 * Catch-all user creation function
1126 * Creates a user from the provided data array, either form fields or OpenID.
1127 * Required: { username, nickname, email } or { openid_url }
1129 * Performs the following:
1130 * - Sends to the OpenId auth URL (if relevant)
1131 * - Creates new key pairs for crypto
1132 * - Create self-contact
1133 * - Create profile image
1135 * @param array $data
1137 * @throws ErrorException
1138 * @throws HTTPException\InternalServerErrorException
1139 * @throws ImagickException
1142 public static function create(array $data): array
1144 $return = ['user' => null, 'password' => ''];
1146 $using_invites = DI::config()->get('system', 'invitation_only');
1148 $invite_id = !empty($data['invite_id']) ? trim($data['invite_id']) : '';
1149 $username = !empty($data['username']) ? trim($data['username']) : '';
1150 $nickname = !empty($data['nickname']) ? trim($data['nickname']) : '';
1151 $email = !empty($data['email']) ? trim($data['email']) : '';
1152 $openid_url = !empty($data['openid_url']) ? trim($data['openid_url']) : '';
1153 $photo = !empty($data['photo']) ? trim($data['photo']) : '';
1154 $password = !empty($data['password']) ? trim($data['password']) : '';
1155 $password1 = !empty($data['password1']) ? trim($data['password1']) : '';
1156 $confirm = !empty($data['confirm']) ? trim($data['confirm']) : '';
1157 $blocked = !empty($data['blocked']);
1158 $verified = !empty($data['verified']);
1159 $language = !empty($data['language']) ? trim($data['language']) : 'en';
1161 $netpublish = $publish = !empty($data['profile_publish_reg']);
1163 if ($password1 != $confirm) {
1164 throw new Exception(DI::l10n()->t('Passwords do not match. Password unchanged.'));
1165 } elseif ($password1 != '') {
1166 $password = $password1;
1169 if ($using_invites) {
1171 throw new Exception(DI::l10n()->t('An invitation is required.'));
1174 if (!Register::existsByHash($invite_id)) {
1175 throw new Exception(DI::l10n()->t('Invitation could not be verified.'));
1179 /// @todo Check if this part is really needed. We should have fetched all this data in advance
1180 if (empty($username) || empty($email) || empty($nickname)) {
1182 if (!Network::isUrlValid($openid_url)) {
1183 throw new Exception(DI::l10n()->t('Invalid OpenID url'));
1185 $_SESSION['register'] = 1;
1186 $_SESSION['openid'] = $openid_url;
1188 $openid = new LightOpenID(DI::baseUrl()->getHost());
1189 $openid->identity = $openid_url;
1190 $openid->returnUrl = DI::baseUrl() . '/openid';
1191 $openid->required = ['namePerson/friendly', 'contact/email', 'namePerson'];
1192 $openid->optional = ['namePerson/first', 'media/image/aspect11', 'media/image/default'];
1194 $authurl = $openid->authUrl();
1195 } catch (Exception $e) {
1196 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);
1198 System::externalRedirect($authurl);
1202 throw new Exception(DI::l10n()->t('Please enter the required information.'));
1205 if (!Network::isUrlValid($openid_url)) {
1209 // collapse multiple spaces in name
1210 $username = preg_replace('/ +/', ' ', $username);
1212 $username_min_length = max(1, min(64, intval(DI::config()->get('system', 'username_min_length', 3))));
1213 $username_max_length = max(1, min(64, intval(DI::config()->get('system', 'username_max_length', 48))));
1215 if ($username_min_length > $username_max_length) {
1216 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));
1217 $tmp = $username_min_length;
1218 $username_min_length = $username_max_length;
1219 $username_max_length = $tmp;
1222 if (mb_strlen($username) < $username_min_length) {
1223 throw new Exception(DI::l10n()->tt('Username should be at least %s character.', 'Username should be at least %s characters.', $username_min_length));
1226 if (mb_strlen($username) > $username_max_length) {
1227 throw new Exception(DI::l10n()->tt('Username should be at most %s character.', 'Username should be at most %s characters.', $username_max_length));
1230 // So now we are just looking for a space in the display name.
1231 $loose_reg = DI::config()->get('system', 'no_regfullname');
1233 $username = mb_convert_case($username, MB_CASE_TITLE, 'UTF-8');
1234 if (strpos($username, ' ') === false) {
1235 throw new Exception(DI::l10n()->t("That doesn't appear to be your full (First Last) name."));
1239 if (!Network::isEmailDomainAllowed($email)) {
1240 throw new Exception(DI::l10n()->t('Your email domain is not among those allowed on this site.'));
1243 if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !Network::isEmailDomainValid($email)) {
1244 throw new Exception(DI::l10n()->t('Not a valid email address.'));
1246 if (self::isNicknameBlocked($nickname)) {
1247 throw new Exception(DI::l10n()->t('The nickname was blocked from registration by the nodes admin.'));
1250 if (DI::config()->get('system', 'block_extended_register', false) && DBA::exists('user', ['email' => $email])) {
1251 throw new Exception(DI::l10n()->t('Cannot use that email.'));
1254 // Disallow somebody creating an account using openid that uses the admin email address,
1255 // since openid bypasses email verification. We'll allow it if there is not yet an admin account.
1256 if (strlen($openid_url) && in_array(strtolower($email), self::getAdminEmailList())) {
1257 throw new Exception(DI::l10n()->t('Cannot use that email.'));
1260 $nickname = $data['nickname'] = strtolower($nickname);
1262 if (!preg_match('/^[a-z0-9][a-z0-9_]*$/', $nickname)) {
1263 throw new Exception(DI::l10n()->t('Your nickname can only contain a-z, 0-9 and _.'));
1266 // Check existing and deleted accounts for this nickname.
1268 DBA::exists('user', ['nickname' => $nickname])
1269 || DBA::exists('userd', ['username' => $nickname])
1271 throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1274 $new_password = strlen($password) ? $password : self::generateNewPassword();
1275 $new_password_encoded = self::hashPassword($new_password);
1277 $return['password'] = $new_password;
1279 $keys = Crypto::newKeypair(4096);
1280 if ($keys === false) {
1281 throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
1284 $prvkey = $keys['prvkey'];
1285 $pubkey = $keys['pubkey'];
1287 // Create another keypair for signing/verifying salmon protocol messages.
1288 $sres = Crypto::newKeypair(512);
1289 $sprvkey = $sres['prvkey'];
1290 $spubkey = $sres['pubkey'];
1292 $insert_result = DBA::insert('user', [
1293 'guid' => System::createUUID(),
1294 'username' => $username,
1295 'password' => $new_password_encoded,
1297 'openid' => $openid_url,
1298 'nickname' => $nickname,
1299 'pubkey' => $pubkey,
1300 'prvkey' => $prvkey,
1301 'spubkey' => $spubkey,
1302 'sprvkey' => $sprvkey,
1303 'verified' => $verified,
1304 'blocked' => $blocked,
1305 'language' => $language,
1306 'timezone' => 'UTC',
1307 'register_date' => DateTimeFormat::utcNow(),
1308 'default-location' => ''
1311 if ($insert_result) {
1312 $uid = DBA::lastInsertId();
1313 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1315 throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1319 throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1322 // if somebody clicked submit twice very quickly, they could end up with two accounts
1323 // due to race condition. Remove this one.
1324 $user_count = DBA::count('user', ['nickname' => $nickname]);
1325 if ($user_count > 1) {
1326 DBA::delete('user', ['uid' => $uid]);
1328 throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1331 $insert_result = DBA::insert('profile', [
1333 'name' => $username,
1334 'photo' => self::getAvatarUrl($user),
1335 'thumb' => self::getAvatarUrl($user, Proxy::SIZE_THUMB),
1336 'publish' => $publish,
1337 'net-publish' => $netpublish,
1339 if (!$insert_result) {
1340 DBA::delete('user', ['uid' => $uid]);
1342 throw new Exception(DI::l10n()->t('An error occurred creating your default profile. Please try again.'));
1345 // Create the self contact
1346 if (!Contact::createSelfFromUserId($uid)) {
1347 DBA::delete('user', ['uid' => $uid]);
1349 throw new Exception(DI::l10n()->t('An error occurred creating your self contact. Please try again.'));
1352 // Create a circle with no members. This allows somebody to use it
1353 // right away as a default circle for new contacts.
1354 $def_gid = Circle::create($uid, DI::l10n()->t('Friends'));
1356 DBA::delete('user', ['uid' => $uid]);
1358 throw new Exception(DI::l10n()->t('An error occurred creating your default contact circle. Please try again.'));
1361 $fields = ['def_gid' => $def_gid];
1362 if (DI::config()->get('system', 'newuser_private') && $def_gid) {
1363 $fields['allow_gid'] = '<' . $def_gid . '>';
1366 DBA::update('user', $fields, ['uid' => $uid]);
1368 $def_gid_groups = Circle::create($uid, DI::l10n()->t('Groups'));
1369 if ($def_gid_groups) {
1370 DI::pConfig()->set($uid, 'system', 'default-group-gid', $def_gid_groups);
1373 // if we have no OpenID photo try to look up an avatar
1374 if (!strlen($photo)) {
1375 $photo = Network::lookupAvatarByEmail($email);
1378 // unless there is no avatar-addon loaded
1379 if (strlen($photo)) {
1380 $photo_failure = false;
1382 $filename = basename($photo);
1383 $curlResult = DI::httpClient()->get($photo, HttpClientAccept::IMAGE);
1384 if ($curlResult->isSuccess()) {
1385 Logger::debug('Got picture', ['Content-Type' => $curlResult->getHeader('Content-Type'), 'url' => $photo]);
1386 $img_str = $curlResult->getBody();
1387 $type = $curlResult->getContentType();
1393 $type = Images::getMimeTypeByData($img_str, $photo, $type);
1395 $image = new Image($img_str, $type);
1396 if ($image->isValid()) {
1397 $image->scaleToSquare(300);
1399 $resource_id = Photo::newResource();
1401 // Not using Photo::PROFILE_PHOTOS here, so that it is discovered as translatable string
1402 $profile_album = DI::l10n()->t('Profile Photos');
1404 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 4);
1407 $photo_failure = true;
1410 $image->scaleDown(80);
1412 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 5);
1415 $photo_failure = true;
1418 $image->scaleDown(48);
1420 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 6);
1423 $photo_failure = true;
1426 if (!$photo_failure) {
1427 Photo::update(['profile' => true, 'photo-type' => Photo::USER_AVATAR], ['resource-id' => $resource_id]);
1431 Contact::updateSelfFromUserID($uid, true);
1434 Hook::callAll('register_account', $uid);
1436 self::setRegisterMethodByUserCount();
1438 $return['user'] = $user;
1443 * Update a user entry and distribute the changes if needed
1445 * @param array $fields
1446 * @param integer $uid
1450 public static function update(array $fields, int $uid): bool
1452 if (!DBA::update('user', $fields, ['uid' => $uid])) {
1456 if (Contact::updateSelfFromUserID($uid)) {
1457 Profile::publishUpdate($uid);
1464 * Sets block state for a given user
1466 * @param int $uid The user id
1467 * @param bool $block Block state (default is true)
1469 * @return bool True, if successfully blocked
1473 public static function block(int $uid, bool $block = true): bool
1475 return DBA::update('user', ['blocked' => $block], ['uid' => $uid]);
1479 * Allows a registration based on a hash
1481 * @param string $hash
1483 * @return bool True, if the allow was successful
1485 * @throws HTTPException\InternalServerErrorException
1488 public static function allow(string $hash): bool
1490 $register = Register::getByHash($hash);
1491 if (!DBA::isResult($register)) {
1495 $user = self::getById($register['uid']);
1496 if (!DBA::isResult($user)) {
1500 Register::deleteByHash($hash);
1502 DBA::update('user', ['blocked' => false, 'verified' => true], ['uid' => $register['uid']]);
1504 $profile = DBA::selectFirst('profile', ['net-publish'], ['uid' => $register['uid']]);
1506 if (DBA::isResult($profile) && $profile['net-publish'] && Search::getGlobalDirectory()) {
1507 $url = DI::baseUrl() . '/profile/' . $user['nickname'];
1508 Worker::add(Worker::PRIORITY_LOW, "Directory", $url);
1511 $l10n = DI::l10n()->withLang($register['language']);
1513 return self::sendRegisterOpenEmail(
1516 DI::config()->get('config', 'sitename'),
1518 ($register['password'] ?? '') ?: 'Sent in a previous email'
1523 * Denys a pending registration
1525 * @param string $hash The hash of the pending user
1527 * This does not have to go through user_remove() and save the nickname
1528 * permanently against re-registration, as the person was not yet
1529 * allowed to have friends on this system
1531 * @return bool True, if the deny was successful
1534 public static function deny(string $hash): bool
1536 $register = Register::getByHash($hash);
1537 if (!DBA::isResult($register)) {
1541 $user = self::getById($register['uid']);
1542 if (!DBA::isResult($user)) {
1546 // Delete the avatar
1547 Photo::delete(['uid' => $register['uid']]);
1549 return DBA::delete('user', ['uid' => $register['uid']]) &&
1550 Register::deleteByHash($register['hash']);
1554 * Creates a new user based on a minimal set and sends an email to this user
1556 * @param string $name The user's name
1557 * @param string $email The user's email address
1558 * @param string $nick The user's nick name
1559 * @param string $lang The user's language (default is english)
1560 * @return bool True, if the user was created successfully
1561 * @throws HTTPException\InternalServerErrorException
1562 * @throws ErrorException
1563 * @throws ImagickException
1565 public static function createMinimal(string $name, string $email, string $nick, string $lang = L10n::DEFAULT): bool
1570 throw new HTTPException\InternalServerErrorException('Invalid arguments.');
1573 $result = self::create([
1574 'username' => $name,
1576 'nickname' => $nick,
1581 $user = $result['user'];
1582 $preamble = Strings::deindent(DI::l10n()->t('
1584 the administrator of %2$s has set up an account for you.'));
1585 $body = Strings::deindent(DI::l10n()->t('
1586 The login details are as follows:
1592 You may change your password from your account "Settings" page after logging
1595 Please take a few moments to review the other account settings on that page.
1597 You may also wish to add some basic information to your default profile
1598 (on the "Profiles" page) so that other people can easily find you.
1600 We recommend adding a profile photo, adding some profile "keywords"
1601 (very useful in making new friends) - and perhaps what country you live in;
1602 if you do not wish to be more specific than that.
1604 We fully respect your right to privacy, and none of these items are necessary.
1605 If you are new and do not know anybody here, they may help
1606 you to make some new and interesting friends.
1608 If you ever want to delete your account, you can do so at %1$s/settings/removeme
1610 Thank you and welcome to %4$s.'));
1612 $preamble = sprintf($preamble, $user['username'], DI::config()->get('config', 'sitename'));
1613 $body = sprintf($body, DI::baseUrl(), $user['nickname'], $result['password'], DI::config()->get('config', 'sitename'));
1615 $email = DI::emailer()
1617 ->withMessage(DI::l10n()->t('Registration details for %s', DI::config()->get('config', 'sitename')), $preamble, $body)
1619 ->withRecipient($user['email'])
1621 return DI::emailer()->send($email);
1625 * Sends pending registration confirmation email
1627 * @param array $user User record array
1628 * @param string $sitename
1629 * @param string $siteurl
1630 * @param string $password Plaintext password
1631 * @return NULL|boolean from notification() and email() inherited
1632 * @throws HTTPException\InternalServerErrorException
1634 public static function sendRegisterPendingEmail(array $user, string $sitename, string $siteurl, string $password)
1636 $body = Strings::deindent(DI::l10n()->t(
1639 Thank you for registering at %2$s. Your account is pending for approval by the administrator.
1641 Your login details are as follows:
1654 $email = DI::emailer()
1656 ->withMessage(DI::l10n()->t('Registration at %s', $sitename), $body)
1658 ->withRecipient($user['email'])
1660 return DI::emailer()->send($email);
1664 * Sends registration confirmation
1666 * It's here as a function because the mail is sent from different parts
1668 * @param L10n $l10n The used language
1669 * @param array $user User record array
1670 * @param string $sitename
1671 * @param string $siteurl
1672 * @param string $password Plaintext password
1674 * @return NULL|boolean from notification() and email() inherited
1675 * @throws HTTPException\InternalServerErrorException
1677 public static function sendRegisterOpenEmail(L10n $l10n, array $user, string $sitename, string $siteurl, string $password)
1679 $preamble = Strings::deindent($l10n->t(
1682 Thank you for registering at %2$s. Your account has been created.
1687 $body = Strings::deindent($l10n->t(
1689 The login details are as follows:
1695 You may change your password from your account "Settings" page after logging
1698 Please take a few moments to review the other account settings on that page.
1700 You may also wish to add some basic information to your default profile
1701 ' . "\x28" . 'on the "Profiles" page' . "\x29" . ' so that other people can easily find you.
1703 We recommend adding a profile photo, adding some profile "keywords" ' . "\x28" . 'very useful
1704 in making new friends' . "\x29" . ' - and perhaps what country you live in; if you do not wish
1705 to be more specific than that.
1707 We fully respect your right to privacy, and none of these items are necessary.
1708 If you are new and do not know anybody here, they may help
1709 you to make some new and interesting friends.
1711 If you ever want to delete your account, you can do so at %3$s/settings/removeme
1713 Thank you and welcome to %2$s.',
1721 $email = DI::emailer()
1723 ->withMessage(DI::l10n()->t('Registration details for %s', $sitename), $preamble, $body)
1725 ->withRecipient($user['email'])
1727 return DI::emailer()->send($email);
1731 * @param int $uid user to remove
1733 * @throws HTTPException\InternalServerErrorException
1734 * @throws HTTPException\NotFoundException
1736 public static function remove(int $uid): bool
1739 throw new \InvalidArgumentException('uid needs to be greater than 0');
1742 Logger::notice('Removing user', ['user' => $uid]);
1744 $user = self::getById($uid);
1746 throw new HTTPException\NotFoundException('User not found with uid: ' . $uid);
1749 if (DBA::exists('user', ['parent-uid' => $uid])) {
1750 throw new \RuntimeException(DI::l10n()->t("User with delegates can't be removed, please remove delegate users first"));
1753 Hook::callAll('remove_user', $user);
1755 // save username (actually the nickname as it is guaranteed
1756 // unique), so it cannot be re-registered in the future.
1757 DBA::insert('userd', ['username' => $user['nickname']]);
1759 // Remove all personal settings, especially connector settings
1760 DBA::delete('pconfig', ['uid' => $uid]);
1762 // The user and related data will be deleted in Friendica\Worker\ExpireAndRemoveUsers
1763 DBA::update('user', ['account_removed' => true, 'account_expires_on' => DateTimeFormat::utc('now + 7 day')], ['uid' => $uid]);
1764 Worker::add(Worker::PRIORITY_HIGH, 'Notifier', Delivery::REMOVAL, $uid);
1766 // Send an update to the directory
1767 $self = DBA::selectFirst('contact', ['url'], ['uid' => $uid, 'self' => true]);
1768 Worker::add(Worker::PRIORITY_LOW, 'Directory', $self['url']);
1770 // Remove the user relevant data
1771 Worker::add(Worker::PRIORITY_NEGLIGIBLE, 'RemoveUser', $uid);
1773 self::setRegisterMethodByUserCount();
1778 * Return all identities to a user
1780 * @param int $uid The user id
1781 * @return array All identities for this user
1783 * Example for a return:
1787 * 'username' => 'maxmuster',
1788 * 'nickname' => 'Max Mustermann'
1792 * 'username' => 'johndoe',
1793 * 'nickname' => 'John Doe'
1798 public static function identities(int $uid): array
1806 $user = DBA::selectFirst('user', ['uid', 'nickname', 'username', 'parent-uid'], ['uid' => $uid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]);
1807 if (!DBA::isResult($user)) {
1811 if (!$user['parent-uid']) {
1812 // First add our own entry
1814 'uid' => $user['uid'],
1815 'username' => $user['username'],
1816 'nickname' => $user['nickname']
1819 // Then add all the children
1822 ['uid', 'username', 'nickname'],
1823 ['parent-uid' => $user['uid'], 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]
1825 if (DBA::isResult($r)) {
1826 $identities = array_merge($identities, DBA::toArray($r));
1829 // First entry is our parent
1832 ['uid', 'username', 'nickname'],
1833 ['uid' => $user['parent-uid'], 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]
1835 if (DBA::isResult($r)) {
1836 $identities = DBA::toArray($r);
1839 // Then add all siblings
1842 ['uid', 'username', 'nickname'],
1843 ['parent-uid' => $user['parent-uid'], 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]
1845 if (DBA::isResult($r)) {
1846 $identities = array_merge($identities, DBA::toArray($r));
1851 "SELECT `user`.`uid`, `user`.`username`, `user`.`nickname`
1853 INNER JOIN `user` ON `manage`.`mid` = `user`.`uid`
1854 WHERE NOT `user`.`account_removed` AND `manage`.`uid` = ?",
1857 if (DBA::isResult($r)) {
1858 $identities = array_merge($identities, DBA::toArray($r));
1865 * Check if the given user id has delegations or is delegated
1870 public static function hasIdentities(int $uid): bool
1876 $user = DBA::selectFirst('user', ['parent-uid'], ['uid' => $uid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]);
1877 if (!DBA::isResult($user)) {
1881 if ($user['parent-uid']) {
1885 if (DBA::exists('user', ['parent-uid' => $uid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false])) {
1889 if (DBA::exists('manage', ['uid' => $uid])) {
1897 * Returns statistical information about the current users of this node
1903 public static function getStatistics(): array
1907 'active_users_halfyear' => 0,
1908 'active_users_monthly' => 0,
1909 'active_users_weekly' => 0,
1912 $userStmt = DBA::select('owner-view', ['uid', 'last-activity', 'last-item'],
1913 ["`verified` AND `last-activity` > ? AND NOT `blocked`
1914 AND NOT `account_removed` AND NOT `account_expired`",
1915 DBA::NULL_DATETIME]);
1916 if (!DBA::isResult($userStmt)) {
1920 $halfyear = time() - (180 * 24 * 60 * 60);
1921 $month = time() - (30 * 24 * 60 * 60);
1922 $week = time() - (7 * 24 * 60 * 60);
1924 while ($user = DBA::fetch($userStmt)) {
1925 $statistics['total_users']++;
1927 if ((strtotime($user['last-activity']) > $halfyear) || (strtotime($user['last-item']) > $halfyear)
1929 $statistics['active_users_halfyear']++;
1932 if ((strtotime($user['last-activity']) > $month) || (strtotime($user['last-item']) > $month)
1934 $statistics['active_users_monthly']++;
1937 if ((strtotime($user['last-activity']) > $week) || (strtotime($user['last-item']) > $week)
1939 $statistics['active_users_weekly']++;
1942 DBA::close($userStmt);
1948 * Get all users of the current node
1950 * @param int $start Start count (Default is 0)
1951 * @param int $count Count of the items per page (Default is @see Pager::ITEMS_PER_PAGE)
1952 * @param string $type The type of users, which should get (all, blocked, removed)
1953 * @param string $order Order of the user list (Default is 'contact.name')
1954 * @param bool $descending Order direction (Default is ascending)
1955 * @return array|bool The list of the users
1958 public static function getList(int $start = 0, int $count = Pager::ITEMS_PER_PAGE, string $type = 'all', string $order = 'name', bool $descending = false)
1960 $param = ['limit' => [$start, $count], 'order' => [$order => $descending]];
1964 $condition['account_removed'] = false;
1965 $condition['blocked'] = false;
1969 $condition['account_removed'] = false;
1970 $condition['blocked'] = true;
1971 $condition['verified'] = true;
1975 $condition['account_removed'] = true;
1979 return DBA::selectToArray('owner-view', [], $condition, $param);
1983 * Returns a list of lowercase admin email addresses from the comma-separated list in the config
1987 public static function getAdminEmailList(): array
1989 $adminEmails = strtolower(str_replace(' ', '', DI::config()->get('config', 'admin_email')));
1990 if (!$adminEmails) {
1994 return explode(',', $adminEmails);
1998 * Returns the complete list of admin user accounts
2000 * @param array $fields
2004 public static function getAdminList(array $fields = []): array
2007 'email' => self::getAdminEmailList(),
2008 'parent-uid' => null,
2011 'account_removed' => false,
2012 'account_expired' => false,
2015 return DBA::selectToArray('user', $fields, $condition, ['order' => ['uid']]);
2019 * Return a list of admin user accounts where each unique email address appears only once.
2021 * This method is meant for admin notifications that do not need to be sent multiple times to the same email address.
2023 * @param array $fields
2027 public static function getAdminListForEmailing(array $fields = []): array
2029 return array_filter(self::getAdminList($fields), function ($user) {
2030 static $emails = [];
2032 if (in_array($user['email'], $emails)) {
2036 $emails[] = $user['email'];
2042 public static function setRegisterMethodByUserCount()
2044 $max_registered_users = DI::config()->get('config', 'max_registered_users');
2045 if ($max_registered_users <= 0) {
2049 $register_policy = DI::config()->get('config', 'register_policy');
2050 if (!in_array($register_policy, [Module\Register::OPEN, Module\Register::CLOSED])) {
2051 Logger::debug('Unsupported register policy.', ['policy' => $register_policy]);
2055 $users = DBA::count('user', ['blocked' => false, 'account_removed' => false, 'account_expired' => false]);
2056 if (($users >= $max_registered_users) && ($register_policy == Module\Register::OPEN)) {
2057 DI::config()->set('config', 'register_policy', Module\Register::CLOSED);
2058 Logger::notice('Max users reached, registration is closed.', ['users' => $users, 'max' => $max_registered_users]);
2059 } elseif (($users < $max_registered_users) && ($register_policy == Module\Register::CLOSED)) {
2060 DI::config()->set('config', 'register_policy', Module\Register::OPEN);
2061 Logger::notice('Below maximum users, registration is opened.', ['users' => $users, 'max' => $max_registered_users]);
2063 Logger::debug('Unchanged register policy', ['policy' => $register_policy, 'users' => $users, 'max' => $max_registered_users]);