3 * @copyright Copyright (C) 2020, Friendica
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\System;
34 use Friendica\Core\Worker;
35 use Friendica\Database\DBA;
37 use Friendica\Model\TwoFactor\AppSpecificPassword;
38 use Friendica\Network\HTTPException;
39 use Friendica\Object\Image;
40 use Friendica\Util\Crypto;
41 use Friendica\Util\DateTimeFormat;
42 use Friendica\Util\Images;
43 use Friendica\Util\Network;
44 use Friendica\Util\Strings;
45 use Friendica\Worker\Delivery;
50 * This class handles User related functions
57 * PAGE_FLAGS_NORMAL is a typical personal profile account
58 * PAGE_FLAGS_SOAPBOX automatically approves all friend requests as Contact::SHARING, (readonly)
59 * PAGE_FLAGS_COMMUNITY automatically approves all friend requests as Contact::SHARING, but with
60 * write access to wall and comments (no email and not included in page owner's ACL lists)
61 * PAGE_FLAGS_FREELOVE automatically approves all friend requests as full friends (Contact::FRIEND).
65 const PAGE_FLAGS_NORMAL = 0;
66 const PAGE_FLAGS_SOAPBOX = 1;
67 const PAGE_FLAGS_COMMUNITY = 2;
68 const PAGE_FLAGS_FREELOVE = 3;
69 const PAGE_FLAGS_BLOG = 4;
70 const PAGE_FLAGS_PRVGROUP = 5;
78 * ACCOUNT_TYPE_PERSON - the account belongs to a person
79 * Associated page types: PAGE_FLAGS_NORMAL, PAGE_FLAGS_SOAPBOX, PAGE_FLAGS_FREELOVE
81 * ACCOUNT_TYPE_ORGANISATION - the account belongs to an organisation
82 * Associated page type: PAGE_FLAGS_SOAPBOX
84 * ACCOUNT_TYPE_NEWS - the account is a news reflector
85 * Associated page type: PAGE_FLAGS_SOAPBOX
87 * ACCOUNT_TYPE_COMMUNITY - the account is community forum
88 * Associated page types: PAGE_COMMUNITY, PAGE_FLAGS_PRVGROUP
90 * ACCOUNT_TYPE_RELAY - the account is a relay
91 * This will only be assigned to contacts, not to user accounts
94 const ACCOUNT_TYPE_PERSON = 0;
95 const ACCOUNT_TYPE_ORGANISATION = 1;
96 const ACCOUNT_TYPE_NEWS = 2;
97 const ACCOUNT_TYPE_COMMUNITY = 3;
98 const ACCOUNT_TYPE_RELAY = 4;
103 private static $owner;
106 * Fetch the system account
108 * @return array system account
110 public static function getSystemAccount()
112 $system = Contact::selectFirst([], ['self' => true, 'uid' => 0]);
113 if (!DBA::isResult($system)) {
114 self::createSystemAccount();
115 $system = Contact::selectFirst([], ['self' => true, 'uid' => 0]);
116 if (!DBA::isResult($system)) {
121 $system['sprvkey'] = $system['uprvkey'] = $system['prvkey'];
122 $system['spubkey'] = $system['upubkey'] = $system['pubkey'];
123 $system['nickname'] = $system['nick'];
128 * Create the system account
132 private static function createSystemAccount()
134 $system_actor_name = self::getActorName();
135 if (empty($system_actor_name)) {
139 $keys = Crypto::newKeypair(4096);
140 if ($keys === false) {
141 throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
146 $system['created'] = DateTimeFormat::utcNow();
147 $system['self'] = true;
148 $system['network'] = Protocol::ACTIVITYPUB;
149 $system['name'] = 'System Account';
150 $system['addr'] = $system_actor_name . '@' . DI::baseUrl()->getHostname();
151 $system['nick'] = $system_actor_name;
152 $system['avatar'] = DI::baseUrl() . Contact::DEFAULT_AVATAR_PHOTO;
153 $system['photo'] = DI::baseUrl() . Contact::DEFAULT_AVATAR_PHOTO;
154 $system['thumb'] = DI::baseUrl() . Contact::DEFAULT_AVATAR_THUMB;
155 $system['micro'] = DI::baseUrl() . Contact::DEFAULT_AVATAR_MICRO;
156 $system['url'] = DI::baseUrl() . '/friendica';
157 $system['nurl'] = Strings::normaliseLink($system['url']);
158 $system['pubkey'] = $keys['pubkey'];
159 $system['prvkey'] = $keys['prvkey'];
160 $system['blocked'] = 0;
161 $system['pending'] = 0;
162 $system['contact-type'] = Contact::TYPE_RELAY; // In AP this is translated to 'Application'
163 $system['name-date'] = DateTimeFormat::utcNow();
164 $system['uri-date'] = DateTimeFormat::utcNow();
165 $system['avatar-date'] = DateTimeFormat::utcNow();
166 $system['closeness'] = 0;
167 $system['baseurl'] = DI::baseUrl();
168 $system['gsid'] = GServer::getID($system['baseurl']);
169 DBA::insert('contact', $system);
173 * Detect a usable actor name
175 * @return string actor account name
177 public static function getActorName()
179 $system_actor_name = DI::config()->get('system', 'actor_name');
180 if (!empty($system_actor_name)) {
181 $self = Contact::selectFirst(['nick'], ['uid' => 0, 'self' => true]);
182 if (!empty($self['nick'])) {
183 if ($self['nick'] != $system_actor_name) {
184 // Reset the actor name to the already used name
185 DI::config()->set('system', 'actor_name', $self['nick']);
186 $system_actor_name = $self['nick'];
189 return $system_actor_name;
192 // List of possible actor names
193 $possible_accounts = ['friendica', 'actor', 'system', 'internal'];
194 foreach ($possible_accounts as $name) {
195 if (!DBA::exists('user', ['nickname' => $name, 'account_removed' => false, 'expire']) &&
196 !DBA::exists('userd', ['username' => $name])) {
197 DI::config()->set('system', 'actor_name', $name);
205 * Returns true if a user record exists with the provided id
207 * @param integer $uid
211 public static function exists($uid)
213 return DBA::exists('user', ['uid' => $uid]);
217 * @param integer $uid
218 * @param array $fields
219 * @return array|boolean User record if it exists, false otherwise
222 public static function getById($uid, array $fields = [])
224 return DBA::selectFirst('user', $fields, ['uid' => $uid]);
228 * Returns a user record based on it's GUID
230 * @param string $guid The guid of the user
231 * @param array $fields The fields to retrieve
232 * @param bool $active True, if only active records are searched
234 * @return array|boolean User record if it exists, false otherwise
237 public static function getByGuid(string $guid, array $fields = [], bool $active = true)
240 $cond = ['guid' => $guid, 'account_expired' => false, 'account_removed' => false];
242 $cond = ['guid' => $guid];
245 return DBA::selectFirst('user', $fields, $cond);
249 * @param string $nickname
250 * @param array $fields
251 * @return array|boolean User record if it exists, false otherwise
254 public static function getByNickname($nickname, array $fields = [])
256 return DBA::selectFirst('user', $fields, ['nickname' => $nickname]);
260 * Returns the user id of a given profile URL
264 * @return integer user id
267 public static function getIdForURL(string $url)
269 // Avoid any database requests when the hostname isn't even part of the url.
270 if (!strpos($url, DI::baseUrl()->getHostname())) {
274 $self = Contact::selectFirst(['uid'], ['self' => true, 'nurl' => Strings::normaliseLink($url)]);
275 if (!empty($self['uid'])) {
279 $self = Contact::selectFirst(['uid'], ['self' => true, 'addr' => $url]);
280 if (!empty($self['uid'])) {
284 $self = Contact::selectFirst(['uid'], ['self' => true, 'alias' => [$url, Strings::normaliseLink($url)]]);
285 if (!empty($self['uid'])) {
293 * Get a user based on its email
295 * @param string $email
296 * @param array $fields
298 * @return array|boolean User record if it exists, false otherwise
302 public static function getByEmail($email, array $fields = [])
304 return DBA::selectFirst('user', $fields, ['email' => $email]);
308 * Fetch the user array of the administrator. The first one if there are several.
310 * @param array $fields
313 public static function getFirstAdmin(array $fields = [])
315 if (!empty(DI::config()->get('config', 'admin_nickname'))) {
316 return self::getByNickname(DI::config()->get('config', 'admin_nickname'), $fields);
317 } elseif (!empty(DI::config()->get('config', 'admin_email'))) {
318 $adminList = explode(',', str_replace(' ', '', DI::config()->get('config', 'admin_email')));
319 return self::getByEmail($adminList[0], $fields);
326 * Get owner data by user id
329 * @param boolean $check_valid Test if data is invalid and correct it
330 * @return boolean|array
333 public static function getOwnerDataById(int $uid, bool $check_valid = true)
336 return self::getSystemAccount();
339 if (!empty(self::$owner[$uid])) {
340 return self::$owner[$uid];
343 $owner = DBA::selectFirst('owner-view', [], ['uid' => $uid]);
344 if (!DBA::isResult($owner)) {
345 if (!DBA::exists('user', ['uid' => $uid]) || !$check_valid) {
348 Contact::createSelfFromUserId($uid);
349 $owner = self::getOwnerDataById($uid, false);
352 if (empty($owner['nickname'])) {
360 // Check if the returned data is valid, otherwise fix it. See issue #6122
362 // Check for correct url and normalised nurl
363 $url = DI::baseUrl() . '/profile/' . $owner['nickname'];
364 $repair = ($owner['url'] != $url) || ($owner['nurl'] != Strings::normaliseLink($owner['url']));
367 // Check if "addr" is present and correct
368 $addr = $owner['nickname'] . '@' . substr(DI::baseUrl(), strpos(DI::baseUrl(), '://') + 3);
369 $repair = ($addr != $owner['addr']);
373 // Check if the avatar field is filled and the photo directs to the correct path
374 $avatar = Photo::selectFirst(['resource-id'], ['uid' => $uid, 'profile' => true]);
375 if (DBA::isResult($avatar)) {
376 $repair = empty($owner['avatar']) || !strpos($owner['photo'], $avatar['resource-id']);
381 Contact::updateSelfFromUserID($uid);
382 // Return the corrected data and avoid a loop
383 $owner = self::getOwnerDataById($uid, false);
386 self::$owner[$uid] = $owner;
391 * Get owner data by nick name
394 * @return boolean|array
397 public static function getOwnerDataByNick($nick)
399 $user = DBA::selectFirst('user', ['uid'], ['nickname' => $nick]);
401 if (!DBA::isResult($user)) {
405 return self::getOwnerDataById($user['uid']);
409 * Returns the default group for a given user and network
411 * @param int $uid User id
412 * @param string $network network name
414 * @return int group id
417 public static function getDefaultGroup($uid, $network = '')
421 if ($network == Protocol::OSTATUS) {
422 $default_group = DI::pConfig()->get($uid, "ostatus", "default_group");
425 if ($default_group != 0) {
426 return $default_group;
429 $user = DBA::selectFirst('user', ['def_gid'], ['uid' => $uid]);
431 if (DBA::isResult($user)) {
432 $default_group = $user["def_gid"];
435 return $default_group;
440 * Authenticate a user with a clear text password
442 * @param mixed $user_info
443 * @param string $password
444 * @param bool $third_party
445 * @return int|boolean
446 * @deprecated since version 3.6
447 * @see User::getIdFromPasswordAuthentication()
449 public static function authenticate($user_info, $password, $third_party = false)
452 return self::getIdFromPasswordAuthentication($user_info, $password, $third_party);
453 } catch (Exception $ex) {
459 * Authenticate a user with a clear text password
461 * Returns the user id associated with a successful password authentication
463 * @param mixed $user_info
464 * @param string $password
465 * @param bool $third_party
466 * @return int User Id if authentication is successful
467 * @throws HTTPException\ForbiddenException
468 * @throws HTTPException\NotFoundException
470 public static function getIdFromPasswordAuthentication($user_info, $password, $third_party = false)
472 $user = self::getAuthenticationInfo($user_info);
474 if ($third_party && DI::pConfig()->get($user['uid'], '2fa', 'verified')) {
475 // Third-party apps can't verify two-factor authentication, we use app-specific passwords instead
476 if (AppSpecificPassword::authenticateUser($user['uid'], $password)) {
479 } elseif (strpos($user['password'], '$') === false) {
480 //Legacy hash that has not been replaced by a new hash yet
481 if (self::hashPasswordLegacy($password) === $user['password']) {
482 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
486 } elseif (!empty($user['legacy_password'])) {
487 //Legacy hash that has been double-hashed and not replaced by a new hash yet
488 //Warning: `legacy_password` is not necessary in sync with the content of `password`
489 if (password_verify(self::hashPasswordLegacy($password), $user['password'])) {
490 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
494 } elseif (password_verify($password, $user['password'])) {
496 if (password_needs_rehash($user['password'], PASSWORD_DEFAULT)) {
497 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
503 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
507 * Returns authentication info from various parameters types
509 * User info can be any of the following:
512 * - User email or username or nickname
513 * - User array with at least the uid and the hashed password
515 * @param mixed $user_info
517 * @throws HTTPException\NotFoundException
519 private static function getAuthenticationInfo($user_info)
523 if (is_object($user_info) || is_array($user_info)) {
524 if (is_object($user_info)) {
525 $user = (array) $user_info;
532 || !isset($user['password'])
533 || !isset($user['legacy_password'])
535 throw new Exception(DI::l10n()->t('Not enough information to authenticate'));
537 } elseif (is_int($user_info) || is_string($user_info)) {
538 if (is_int($user_info)) {
539 $user = DBA::selectFirst(
541 ['uid', 'password', 'legacy_password'],
545 'account_expired' => 0,
546 'account_removed' => 0,
551 $fields = ['uid', 'password', 'legacy_password'];
553 "(`email` = ? OR `username` = ? OR `nickname` = ?)
554 AND NOT `blocked` AND NOT `account_expired` AND NOT `account_removed` AND `verified`",
555 $user_info, $user_info, $user_info
557 $user = DBA::selectFirst('user', $fields, $condition);
560 if (!DBA::isResult($user)) {
561 throw new HTTPException\NotFoundException(DI::l10n()->t('User not found'));
569 * Generates a human-readable random password
574 public static function generateNewPassword()
576 return ucfirst(Strings::getRandomName(8)) . random_int(1000, 9999);
580 * Checks if the provided plaintext password has been exposed or not
582 * @param string $password
586 public static function isPasswordExposed($password)
588 $cache = new CacheItemPool();
589 $cache->changeConfig([
590 'cacheDirectory' => get_temppath() . '/password-exposed-cache/',
594 $passwordExposedChecker = new PasswordExposed\PasswordExposedChecker(null, $cache);
596 return $passwordExposedChecker->passwordExposed($password) === PasswordExposed\PasswordStatus::EXPOSED;
597 } catch (Exception $e) {
598 Logger::error('Password Exposed Exception: ' . $e->getMessage(), [
599 'code' => $e->getCode(),
600 'file' => $e->getFile(),
601 'line' => $e->getLine(),
602 'trace' => $e->getTraceAsString()
610 * Legacy hashing function, kept for password migration purposes
612 * @param string $password
615 private static function hashPasswordLegacy($password)
617 return hash('whirlpool', $password);
621 * Global user password hashing function
623 * @param string $password
627 public static function hashPassword($password)
629 if (!trim($password)) {
630 throw new Exception(DI::l10n()->t('Password can\'t be empty'));
633 return password_hash($password, PASSWORD_DEFAULT);
637 * Updates a user row with a new plaintext password
640 * @param string $password
644 public static function updatePassword($uid, $password)
646 $password = trim($password);
648 if (empty($password)) {
649 throw new Exception(DI::l10n()->t('Empty passwords are not allowed.'));
652 if (!DI::config()->get('system', 'disable_password_exposed', false) && self::isPasswordExposed($password)) {
653 throw new Exception(DI::l10n()->t('The new password has been exposed in a public data dump, please choose another.'));
656 $allowed_characters = '!"#$%&\'()*+,-./;<=>?@[\]^_`{|}~';
658 if (!preg_match('/^[a-z0-9' . preg_quote($allowed_characters, '/') . ']+$/i', $password)) {
659 throw new Exception(DI::l10n()->t('The password can\'t contain accentuated letters, white spaces or colons (:)'));
662 return self::updatePasswordHashed($uid, self::hashPassword($password));
666 * Updates a user row with a new hashed password.
667 * Empties the password reset token field just in case.
670 * @param string $pasword_hashed
674 private static function updatePasswordHashed($uid, $pasword_hashed)
677 'password' => $pasword_hashed,
679 'pwdreset_time' => null,
680 'legacy_password' => false
682 return DBA::update('user', $fields, ['uid' => $uid]);
686 * Checks if a nickname is in the list of the forbidden nicknames
688 * Check if a nickname is forbidden from registration on the node by the
689 * admin. Forbidden nicknames (e.g. role namess) can be configured in the
692 * @param string $nickname The nickname that should be checked
693 * @return boolean True is the nickname is blocked on the node
695 public static function isNicknameBlocked($nickname)
697 $forbidden_nicknames = DI::config()->get('system', 'forbidden_nicknames', '');
698 if (!empty($forbidden_nicknames)) {
699 $forbidden = explode(',', $forbidden_nicknames);
700 $forbidden = array_map('trim', $forbidden);
705 // Add the name of the internal actor to the "forbidden" list
706 $actor_name = self::getActorName();
707 if (!empty($actor_name)) {
708 $forbidden[] = $actor_name;
711 if (empty($forbidden)) {
715 // check if the nickname is in the list of blocked nicknames
716 if (in_array(strtolower($nickname), $forbidden)) {
725 * Catch-all user creation function
727 * Creates a user from the provided data array, either form fields or OpenID.
728 * Required: { username, nickname, email } or { openid_url }
730 * Performs the following:
731 * - Sends to the OpenId auth URL (if relevant)
732 * - Creates new key pairs for crypto
733 * - Create self-contact
734 * - Create profile image
738 * @throws ErrorException
739 * @throws HTTPException\InternalServerErrorException
740 * @throws ImagickException
743 public static function create(array $data)
745 $return = ['user' => null, 'password' => ''];
747 $using_invites = DI::config()->get('system', 'invitation_only');
749 $invite_id = !empty($data['invite_id']) ? Strings::escapeTags(trim($data['invite_id'])) : '';
750 $username = !empty($data['username']) ? Strings::escapeTags(trim($data['username'])) : '';
751 $nickname = !empty($data['nickname']) ? Strings::escapeTags(trim($data['nickname'])) : '';
752 $email = !empty($data['email']) ? Strings::escapeTags(trim($data['email'])) : '';
753 $openid_url = !empty($data['openid_url']) ? Strings::escapeTags(trim($data['openid_url'])) : '';
754 $photo = !empty($data['photo']) ? Strings::escapeTags(trim($data['photo'])) : '';
755 $password = !empty($data['password']) ? trim($data['password']) : '';
756 $password1 = !empty($data['password1']) ? trim($data['password1']) : '';
757 $confirm = !empty($data['confirm']) ? trim($data['confirm']) : '';
758 $blocked = !empty($data['blocked']);
759 $verified = !empty($data['verified']);
760 $language = !empty($data['language']) ? Strings::escapeTags(trim($data['language'])) : 'en';
762 $netpublish = $publish = !empty($data['profile_publish_reg']);
764 if ($password1 != $confirm) {
765 throw new Exception(DI::l10n()->t('Passwords do not match. Password unchanged.'));
766 } elseif ($password1 != '') {
767 $password = $password1;
770 if ($using_invites) {
772 throw new Exception(DI::l10n()->t('An invitation is required.'));
775 if (!Register::existsByHash($invite_id)) {
776 throw new Exception(DI::l10n()->t('Invitation could not be verified.'));
780 /// @todo Check if this part is really needed. We should have fetched all this data in advance
781 if (empty($username) || empty($email) || empty($nickname)) {
783 if (!Network::isUrlValid($openid_url)) {
784 throw new Exception(DI::l10n()->t('Invalid OpenID url'));
786 $_SESSION['register'] = 1;
787 $_SESSION['openid'] = $openid_url;
789 $openid = new LightOpenID(DI::baseUrl()->getHostname());
790 $openid->identity = $openid_url;
791 $openid->returnUrl = DI::baseUrl() . '/openid';
792 $openid->required = ['namePerson/friendly', 'contact/email', 'namePerson'];
793 $openid->optional = ['namePerson/first', 'media/image/aspect11', 'media/image/default'];
795 $authurl = $openid->authUrl();
796 } catch (Exception $e) {
797 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.') . EOL . EOL . DI::l10n()->t('The error message was:') . $e->getMessage(), 0, $e);
799 System::externalRedirect($authurl);
803 throw new Exception(DI::l10n()->t('Please enter the required information.'));
806 if (!Network::isUrlValid($openid_url)) {
810 // collapse multiple spaces in name
811 $username = preg_replace('/ +/', ' ', $username);
813 $username_min_length = max(1, min(64, intval(DI::config()->get('system', 'username_min_length', 3))));
814 $username_max_length = max(1, min(64, intval(DI::config()->get('system', 'username_max_length', 48))));
816 if ($username_min_length > $username_max_length) {
817 Logger::log(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), Logger::WARNING);
818 $tmp = $username_min_length;
819 $username_min_length = $username_max_length;
820 $username_max_length = $tmp;
823 if (mb_strlen($username) < $username_min_length) {
824 throw new Exception(DI::l10n()->tt('Username should be at least %s character.', 'Username should be at least %s characters.', $username_min_length));
827 if (mb_strlen($username) > $username_max_length) {
828 throw new Exception(DI::l10n()->tt('Username should be at most %s character.', 'Username should be at most %s characters.', $username_max_length));
831 // So now we are just looking for a space in the full name.
832 $loose_reg = DI::config()->get('system', 'no_regfullname');
834 $username = mb_convert_case($username, MB_CASE_TITLE, 'UTF-8');
835 if (strpos($username, ' ') === false) {
836 throw new Exception(DI::l10n()->t("That doesn't appear to be your full (First Last) name."));
840 if (!Network::isEmailDomainAllowed($email)) {
841 throw new Exception(DI::l10n()->t('Your email domain is not among those allowed on this site.'));
844 if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !Network::isEmailDomainValid($email)) {
845 throw new Exception(DI::l10n()->t('Not a valid email address.'));
847 if (self::isNicknameBlocked($nickname)) {
848 throw new Exception(DI::l10n()->t('The nickname was blocked from registration by the nodes admin.'));
851 if (DI::config()->get('system', 'block_extended_register', false) && DBA::exists('user', ['email' => $email])) {
852 throw new Exception(DI::l10n()->t('Cannot use that email.'));
855 // Disallow somebody creating an account using openid that uses the admin email address,
856 // since openid bypasses email verification. We'll allow it if there is not yet an admin account.
857 if (DI::config()->get('config', 'admin_email') && strlen($openid_url)) {
858 $adminlist = explode(',', str_replace(' ', '', strtolower(DI::config()->get('config', 'admin_email'))));
859 if (in_array(strtolower($email), $adminlist)) {
860 throw new Exception(DI::l10n()->t('Cannot use that email.'));
864 $nickname = $data['nickname'] = strtolower($nickname);
866 if (!preg_match('/^[a-z0-9][a-z0-9_]*$/', $nickname)) {
867 throw new Exception(DI::l10n()->t('Your nickname can only contain a-z, 0-9 and _.'));
870 // Check existing and deleted accounts for this nickname.
872 DBA::exists('user', ['nickname' => $nickname])
873 || DBA::exists('userd', ['username' => $nickname])
875 throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
878 $new_password = strlen($password) ? $password : User::generateNewPassword();
879 $new_password_encoded = self::hashPassword($new_password);
881 $return['password'] = $new_password;
883 $keys = Crypto::newKeypair(4096);
884 if ($keys === false) {
885 throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
888 $prvkey = $keys['prvkey'];
889 $pubkey = $keys['pubkey'];
891 // Create another keypair for signing/verifying salmon protocol messages.
892 $sres = Crypto::newKeypair(512);
893 $sprvkey = $sres['prvkey'];
894 $spubkey = $sres['pubkey'];
896 $insert_result = DBA::insert('user', [
897 'guid' => System::createUUID(),
898 'username' => $username,
899 'password' => $new_password_encoded,
901 'openid' => $openid_url,
902 'nickname' => $nickname,
905 'spubkey' => $spubkey,
906 'sprvkey' => $sprvkey,
907 'verified' => $verified,
908 'blocked' => $blocked,
909 'language' => $language,
911 'register_date' => DateTimeFormat::utcNow(),
912 'default-location' => ''
915 if ($insert_result) {
916 $uid = DBA::lastInsertId();
917 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
919 throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
923 throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
926 // if somebody clicked submit twice very quickly, they could end up with two accounts
927 // due to race condition. Remove this one.
928 $user_count = DBA::count('user', ['nickname' => $nickname]);
929 if ($user_count > 1) {
930 DBA::delete('user', ['uid' => $uid]);
932 throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
935 $insert_result = DBA::insert('profile', [
938 'photo' => DI::baseUrl() . "/photo/profile/{$uid}.jpg",
939 'thumb' => DI::baseUrl() . "/photo/avatar/{$uid}.jpg",
940 'publish' => $publish,
941 'net-publish' => $netpublish,
943 if (!$insert_result) {
944 DBA::delete('user', ['uid' => $uid]);
946 throw new Exception(DI::l10n()->t('An error occurred creating your default profile. Please try again.'));
949 // Create the self contact
950 if (!Contact::createSelfFromUserId($uid)) {
951 DBA::delete('user', ['uid' => $uid]);
953 throw new Exception(DI::l10n()->t('An error occurred creating your self contact. Please try again.'));
956 // Create a group with no members. This allows somebody to use it
957 // right away as a default group for new contacts.
958 $def_gid = Group::create($uid, DI::l10n()->t('Friends'));
960 DBA::delete('user', ['uid' => $uid]);
962 throw new Exception(DI::l10n()->t('An error occurred creating your default contact group. Please try again.'));
965 $fields = ['def_gid' => $def_gid];
966 if (DI::config()->get('system', 'newuser_private') && $def_gid) {
967 $fields['allow_gid'] = '<' . $def_gid . '>';
970 DBA::update('user', $fields, ['uid' => $uid]);
972 // if we have no OpenID photo try to look up an avatar
973 if (!strlen($photo)) {
974 $photo = Network::lookupAvatarByEmail($email);
977 // unless there is no avatar-addon loaded
978 if (strlen($photo)) {
979 $photo_failure = false;
981 $filename = basename($photo);
982 $curlResult = DI::httpRequest()->get($photo, true);
983 if ($curlResult->isSuccess()) {
984 $img_str = $curlResult->getBody();
985 $type = $curlResult->getContentType();
991 $type = Images::getMimeTypeByData($img_str, $photo, $type);
993 $Image = new Image($img_str, $type);
994 if ($Image->isValid()) {
995 $Image->scaleToSquare(300);
997 $resource_id = Photo::newResource();
999 $r = Photo::store($Image, $uid, 0, $resource_id, $filename, DI::l10n()->t('Profile Photos'), 4);
1002 $photo_failure = true;
1005 $Image->scaleDown(80);
1007 $r = Photo::store($Image, $uid, 0, $resource_id, $filename, DI::l10n()->t('Profile Photos'), 5);
1010 $photo_failure = true;
1013 $Image->scaleDown(48);
1015 $r = Photo::store($Image, $uid, 0, $resource_id, $filename, DI::l10n()->t('Profile Photos'), 6);
1018 $photo_failure = true;
1021 if (!$photo_failure) {
1022 Photo::update(['profile' => 1], ['resource-id' => $resource_id]);
1027 Hook::callAll('register_account', $uid);
1029 $return['user'] = $user;
1034 * Sets block state for a given user
1036 * @param int $uid The user id
1037 * @param bool $block Block state (default is true)
1039 * @return bool True, if successfully blocked
1043 public static function block(int $uid, bool $block = true)
1045 return DBA::update('user', ['blocked' => $block], ['uid' => $uid]);
1049 * Allows a registration based on a hash
1051 * @param string $hash
1053 * @return bool True, if the allow was successful
1055 * @throws HTTPException\InternalServerErrorException
1058 public static function allow(string $hash)
1060 $register = Register::getByHash($hash);
1061 if (!DBA::isResult($register)) {
1065 $user = User::getById($register['uid']);
1066 if (!DBA::isResult($user)) {
1070 Register::deleteByHash($hash);
1072 DBA::update('user', ['blocked' => false, 'verified' => true], ['uid' => $register['uid']]);
1074 $profile = DBA::selectFirst('profile', ['net-publish'], ['uid' => $register['uid']]);
1076 if (DBA::isResult($profile) && $profile['net-publish'] && DI::config()->get('system', 'directory')) {
1077 $url = DI::baseUrl() . '/profile/' . $user['nickname'];
1078 Worker::add(PRIORITY_LOW, "Directory", $url);
1081 $l10n = DI::l10n()->withLang($register['language']);
1083 return User::sendRegisterOpenEmail(
1086 DI::config()->get('config', 'sitename'),
1087 DI::baseUrl()->get(),
1088 ($register['password'] ?? '') ?: 'Sent in a previous email'
1093 * Denys a pending registration
1095 * @param string $hash The hash of the pending user
1097 * This does not have to go through user_remove() and save the nickname
1098 * permanently against re-registration, as the person was not yet
1099 * allowed to have friends on this system
1101 * @return bool True, if the deny was successfull
1104 public static function deny(string $hash)
1106 $register = Register::getByHash($hash);
1107 if (!DBA::isResult($register)) {
1111 $user = User::getById($register['uid']);
1112 if (!DBA::isResult($user)) {
1116 return DBA::delete('user', ['uid' => $register['uid']]) &&
1117 Register::deleteByHash($register['hash']);
1121 * Creates a new user based on a minimal set and sends an email to this user
1123 * @param string $name The user's name
1124 * @param string $email The user's email address
1125 * @param string $nick The user's nick name
1126 * @param string $lang The user's language (default is english)
1128 * @return bool True, if the user was created successfully
1129 * @throws HTTPException\InternalServerErrorException
1130 * @throws ErrorException
1131 * @throws ImagickException
1133 public static function createMinimal(string $name, string $email, string $nick, string $lang = L10n::DEFAULT)
1138 throw new HTTPException\InternalServerErrorException('Invalid arguments.');
1141 $result = self::create([
1142 'username' => $name,
1144 'nickname' => $nick,
1149 $user = $result['user'];
1150 $preamble = Strings::deindent(DI::l10n()->t('
1152 the administrator of %2$s has set up an account for you.'));
1153 $body = Strings::deindent(DI::l10n()->t('
1154 The login details are as follows:
1160 You may change your password from your account "Settings" page after logging
1163 Please take a few moments to review the other account settings on that page.
1165 You may also wish to add some basic information to your default profile
1166 (on the "Profiles" page) so that other people can easily find you.
1168 We recommend setting your full name, adding a profile photo,
1169 adding some profile "keywords" (very useful in making new friends) - and
1170 perhaps what country you live in; if you do not wish to be more specific
1173 We fully respect your right to privacy, and none of these items are necessary.
1174 If you are new and do not know anybody here, they may help
1175 you to make some new and interesting friends.
1177 If you ever want to delete your account, you can do so at %1$s/removeme
1179 Thank you and welcome to %4$s.'));
1181 $preamble = sprintf($preamble, $user['username'], DI::config()->get('config', 'sitename'));
1182 $body = sprintf($body, DI::baseUrl()->get(), $user['nickname'], $result['password'], DI::config()->get('config', 'sitename'));
1184 $email = DI::emailer()
1186 ->withMessage(DI::l10n()->t('Registration details for %s', DI::config()->get('config', 'sitename')), $preamble, $body)
1188 ->withRecipient($user['email'])
1190 return DI::emailer()->send($email);
1194 * Sends pending registration confirmation email
1196 * @param array $user User record array
1197 * @param string $sitename
1198 * @param string $siteurl
1199 * @param string $password Plaintext password
1200 * @return NULL|boolean from notification() and email() inherited
1201 * @throws HTTPException\InternalServerErrorException
1203 public static function sendRegisterPendingEmail($user, $sitename, $siteurl, $password)
1205 $body = Strings::deindent(DI::l10n()->t(
1208 Thank you for registering at %2$s. Your account is pending for approval by the administrator.
1210 Your login details are as follows:
1223 $email = DI::emailer()
1225 ->withMessage(DI::l10n()->t('Registration at %s', $sitename), $body)
1227 ->withRecipient($user['email'])
1229 return DI::emailer()->send($email);
1233 * Sends registration confirmation
1235 * It's here as a function because the mail is sent from different parts
1237 * @param L10n $l10n The used language
1238 * @param array $user User record array
1239 * @param string $sitename
1240 * @param string $siteurl
1241 * @param string $password Plaintext password
1243 * @return NULL|boolean from notification() and email() inherited
1244 * @throws HTTPException\InternalServerErrorException
1246 public static function sendRegisterOpenEmail(L10n $l10n, $user, $sitename, $siteurl, $password)
1248 $preamble = Strings::deindent($l10n->t(
1251 Thank you for registering at %2$s. Your account has been created.
1256 $body = Strings::deindent($l10n->t(
1258 The login details are as follows:
1264 You may change your password from your account "Settings" page after logging
1267 Please take a few moments to review the other account settings on that page.
1269 You may also wish to add some basic information to your default profile
1270 ' . "\x28" . 'on the "Profiles" page' . "\x29" . ' so that other people can easily find you.
1272 We recommend setting your full name, adding a profile photo,
1273 adding some profile "keywords" ' . "\x28" . 'very useful in making new friends' . "\x29" . ' - and
1274 perhaps what country you live in; if you do not wish to be more specific
1277 We fully respect your right to privacy, and none of these items are necessary.
1278 If you are new and do not know anybody here, they may help
1279 you to make some new and interesting friends.
1281 If you ever want to delete your account, you can do so at %3$s/removeme
1283 Thank you and welcome to %2$s.',
1291 $email = DI::emailer()
1293 ->withMessage(DI::l10n()->t('Registration details for %s', $sitename), $preamble, $body)
1295 ->withRecipient($user['email'])
1297 return DI::emailer()->send($email);
1301 * @param int $uid user to remove
1303 * @throws HTTPException\InternalServerErrorException
1305 public static function remove(int $uid)
1311 Logger::log('Removing user: ' . $uid);
1313 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1315 Hook::callAll('remove_user', $user);
1317 // save username (actually the nickname as it is guaranteed
1318 // unique), so it cannot be re-registered in the future.
1319 DBA::insert('userd', ['username' => $user['nickname']]);
1321 // The user and related data will be deleted in Friendica\Worker\ExpireAndRemoveUsers
1322 DBA::update('user', ['account_removed' => true, 'account_expires_on' => DateTimeFormat::utc('now + 7 day')], ['uid' => $uid]);
1323 Worker::add(PRIORITY_HIGH, 'Notifier', Delivery::REMOVAL, $uid);
1325 // Send an update to the directory
1326 $self = DBA::selectFirst('contact', ['url'], ['uid' => $uid, 'self' => true]);
1327 Worker::add(PRIORITY_LOW, 'Directory', $self['url']);
1329 // Remove the user relevant data
1330 Worker::add(PRIORITY_NEGLIGIBLE, 'RemoveUser', $uid);
1336 * Return all identities to a user
1338 * @param int $uid The user id
1339 * @return array All identities for this user
1341 * Example for a return:
1345 * 'username' => 'maxmuster',
1346 * 'nickname' => 'Max Mustermann'
1350 * 'username' => 'johndoe',
1351 * 'nickname' => 'John Doe'
1356 public static function identities($uid)
1360 $user = DBA::selectFirst('user', ['uid', 'nickname', 'username', 'parent-uid'], ['uid' => $uid]);
1361 if (!DBA::isResult($user)) {
1365 if ($user['parent-uid'] == 0) {
1366 // First add our own entry
1368 'uid' => $user['uid'],
1369 'username' => $user['username'],
1370 'nickname' => $user['nickname']
1373 // Then add all the children
1376 ['uid', 'username', 'nickname'],
1377 ['parent-uid' => $user['uid'], 'account_removed' => false]
1379 if (DBA::isResult($r)) {
1380 $identities = array_merge($identities, DBA::toArray($r));
1383 // First entry is our parent
1386 ['uid', 'username', 'nickname'],
1387 ['uid' => $user['parent-uid'], 'account_removed' => false]
1389 if (DBA::isResult($r)) {
1390 $identities = DBA::toArray($r);
1393 // Then add all siblings
1396 ['uid', 'username', 'nickname'],
1397 ['parent-uid' => $user['parent-uid'], 'account_removed' => false]
1399 if (DBA::isResult($r)) {
1400 $identities = array_merge($identities, DBA::toArray($r));
1405 "SELECT `user`.`uid`, `user`.`username`, `user`.`nickname`
1407 INNER JOIN `user` ON `manage`.`mid` = `user`.`uid`
1408 WHERE `user`.`account_removed` = 0 AND `manage`.`uid` = ?",
1411 if (DBA::isResult($r)) {
1412 $identities = array_merge($identities, DBA::toArray($r));
1419 * Returns statistical information about the current users of this node
1425 public static function getStatistics()
1429 'active_users_halfyear' => 0,
1430 'active_users_monthly' => 0,
1431 'active_users_weekly' => 0,
1434 $userStmt = DBA::select('owner-view', ['uid', 'login_date', 'last-item'],
1435 ["`verified` AND `login_date` > ? AND NOT `blocked`
1436 AND NOT `account_removed` AND NOT `account_expired`",
1437 DBA::NULL_DATETIME]);
1438 if (!DBA::isResult($userStmt)) {
1442 $halfyear = time() - (180 * 24 * 60 * 60);
1443 $month = time() - (30 * 24 * 60 * 60);
1444 $week = time() - (7 * 24 * 60 * 60);
1446 while ($user = DBA::fetch($userStmt)) {
1447 $statistics['total_users']++;
1449 if ((strtotime($user['login_date']) > $halfyear) || (strtotime($user['last-item']) > $halfyear)
1451 $statistics['active_users_halfyear']++;
1454 if ((strtotime($user['login_date']) > $month) || (strtotime($user['last-item']) > $month)
1456 $statistics['active_users_monthly']++;
1459 if ((strtotime($user['login_date']) > $week) || (strtotime($user['last-item']) > $week)
1461 $statistics['active_users_weekly']++;
1464 DBA::close($userStmt);
1470 * Get all users of the current node
1472 * @param int $start Start count (Default is 0)
1473 * @param int $count Count of the items per page (Default is @see Pager::ITEMS_PER_PAGE)
1474 * @param string $type The type of users, which should get (all, bocked, removed)
1475 * @param string $order Order of the user list (Default is 'contact.name')
1476 * @param bool $descending Order direction (Default is ascending)
1478 * @return array The list of the users
1481 public static function getList($start = 0, $count = Pager::ITEMS_PER_PAGE, $type = 'all', $order = 'name', bool $descending = false)
1483 $param = ['limit' => [$start, $count], 'order' => [$order => $descending]];
1487 $condition['account_removed'] = false;
1488 $condition['blocked'] = false;
1491 $condition['blocked'] = true;
1494 $condition['account_removed'] = true;
1498 return DBA::selectToArray('owner-view', [], $condition, $param);