3 * @copyright Copyright (C) 2010-2022, 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\System;
34 use Friendica\Core\Worker;
35 use Friendica\Database\DBA;
37 use Friendica\Network\HTTPClient\Client\HttpClientAccept;
38 use Friendica\Security\TwoFactor\Model\AppSpecificPassword;
39 use Friendica\Network\HTTPException;
40 use Friendica\Object\Image;
41 use Friendica\Util\Crypto;
42 use Friendica\Util\DateTimeFormat;
43 use Friendica\Util\Images;
44 use Friendica\Util\Network;
45 use Friendica\Util\Proxy;
46 use Friendica\Util\Strings;
47 use Friendica\Worker\Delivery;
52 * This class handles User related functions
59 * PAGE_FLAGS_NORMAL is a typical personal profile account
60 * PAGE_FLAGS_SOAPBOX automatically approves all friend requests as Contact::SHARING, (readonly)
61 * PAGE_FLAGS_COMMUNITY automatically approves all friend requests as Contact::SHARING, but with
62 * write access to wall and comments (no email and not included in page owner's ACL lists)
63 * PAGE_FLAGS_FREELOVE automatically approves all friend requests as full friends (Contact::FRIEND).
67 const PAGE_FLAGS_NORMAL = 0;
68 const PAGE_FLAGS_SOAPBOX = 1;
69 const PAGE_FLAGS_COMMUNITY = 2;
70 const PAGE_FLAGS_FREELOVE = 3;
71 const PAGE_FLAGS_BLOG = 4;
72 const PAGE_FLAGS_PRVGROUP = 5;
80 * ACCOUNT_TYPE_PERSON - the account belongs to a person
81 * Associated page types: PAGE_FLAGS_NORMAL, PAGE_FLAGS_SOAPBOX, PAGE_FLAGS_FREELOVE
83 * ACCOUNT_TYPE_ORGANISATION - the account belongs to an organisation
84 * Associated page type: PAGE_FLAGS_SOAPBOX
86 * ACCOUNT_TYPE_NEWS - the account is a news reflector
87 * Associated page type: PAGE_FLAGS_SOAPBOX
89 * ACCOUNT_TYPE_COMMUNITY - the account is community forum
90 * Associated page types: PAGE_COMMUNITY, PAGE_FLAGS_PRVGROUP
92 * ACCOUNT_TYPE_RELAY - the account is a relay
93 * This will only be assigned to contacts, not to user accounts
96 const ACCOUNT_TYPE_PERSON = 0;
97 const ACCOUNT_TYPE_ORGANISATION = 1;
98 const ACCOUNT_TYPE_NEWS = 2;
99 const ACCOUNT_TYPE_COMMUNITY = 3;
100 const ACCOUNT_TYPE_RELAY = 4;
101 const ACCOUNT_TYPE_DELETED = 127;
106 private static $owner;
109 * Returns the numeric account type by their string
111 * @param string $accounttype as string constant
112 * @return int|null Numeric account type - or null when not set
114 public static function getAccountTypeByString(string $accounttype)
116 switch ($accounttype) {
118 return User::ACCOUNT_TYPE_PERSON;
120 return User::ACCOUNT_TYPE_ORGANISATION;
122 return User::ACCOUNT_TYPE_NEWS;
124 return User::ACCOUNT_TYPE_COMMUNITY;
132 * Fetch the system account
134 * @return array system account
136 public static function getSystemAccount()
138 $system = Contact::selectFirst([], ['self' => true, 'uid' => 0]);
139 if (!DBA::isResult($system)) {
140 self::createSystemAccount();
141 $system = Contact::selectFirst([], ['self' => true, 'uid' => 0]);
142 if (!DBA::isResult($system)) {
147 $system['sprvkey'] = $system['uprvkey'] = $system['prvkey'];
148 $system['spubkey'] = $system['upubkey'] = $system['pubkey'];
149 $system['nickname'] = $system['nick'];
150 $system['page-flags'] = User::PAGE_FLAGS_SOAPBOX;
151 $system['account-type'] = $system['contact-type'];
152 $system['guid'] = '';
153 $system['picdate'] = '';
154 $system['theme'] = '';
155 $system['publish'] = false;
156 $system['net-publish'] = false;
157 $system['hide-friends'] = true;
158 $system['prv_keywords'] = '';
159 $system['pub_keywords'] = '';
160 $system['address'] = '';
161 $system['locality'] = '';
162 $system['region'] = '';
163 $system['postal-code'] = '';
164 $system['country-name'] = '';
165 $system['homepage'] = DI::baseUrl()->get();
166 $system['dob'] = '0000-00-00';
168 // Ensure that the user contains data
169 $user = DBA::selectFirst('user', ['prvkey', 'guid'], ['uid' => 0]);
170 if (empty($user['prvkey']) || empty($user['guid'])) {
172 'username' => $system['name'],
173 'nickname' => $system['nick'],
174 'register_date' => $system['created'],
175 'pubkey' => $system['pubkey'],
176 'prvkey' => $system['prvkey'],
177 'spubkey' => $system['spubkey'],
178 'sprvkey' => $system['sprvkey'],
179 'guid' => System::createUUID(),
181 'page-flags' => User::PAGE_FLAGS_SOAPBOX,
182 'account-type' => User::ACCOUNT_TYPE_RELAY,
185 DBA::update('user', $fields, ['uid' => 0]);
187 $system['guid'] = $fields['guid'];
189 $system['guid'] = $user['guid'];
196 * Create the system account
200 private static function createSystemAccount()
202 $system_actor_name = self::getActorName();
203 if (empty($system_actor_name)) {
207 $keys = Crypto::newKeypair(4096);
208 if ($keys === false) {
209 throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
214 $system['created'] = DateTimeFormat::utcNow();
215 $system['self'] = true;
216 $system['network'] = Protocol::ACTIVITYPUB;
217 $system['name'] = 'System Account';
218 $system['addr'] = $system_actor_name . '@' . DI::baseUrl()->getHostname();
219 $system['nick'] = $system_actor_name;
220 $system['url'] = DI::baseUrl() . '/friendica';
222 $system['avatar'] = $system['photo'] = Contact::getDefaultAvatar($system, Proxy::SIZE_SMALL);
223 $system['thumb'] = Contact::getDefaultAvatar($system, Proxy::SIZE_THUMB);
224 $system['micro'] = Contact::getDefaultAvatar($system, Proxy::SIZE_MICRO);
226 $system['nurl'] = Strings::normaliseLink($system['url']);
227 $system['pubkey'] = $keys['pubkey'];
228 $system['prvkey'] = $keys['prvkey'];
229 $system['blocked'] = 0;
230 $system['pending'] = 0;
231 $system['contact-type'] = Contact::TYPE_RELAY; // In AP this is translated to 'Application'
232 $system['name-date'] = DateTimeFormat::utcNow();
233 $system['uri-date'] = DateTimeFormat::utcNow();
234 $system['avatar-date'] = DateTimeFormat::utcNow();
235 $system['closeness'] = 0;
236 $system['baseurl'] = DI::baseUrl();
237 $system['gsid'] = GServer::getID($system['baseurl']);
238 Contact::insert($system);
242 * Detect a usable actor name
244 * @return string actor account name
246 public static function getActorName()
248 $system_actor_name = DI::config()->get('system', 'actor_name');
249 if (!empty($system_actor_name)) {
250 $self = Contact::selectFirst(['nick'], ['uid' => 0, 'self' => true]);
251 if (!empty($self['nick'])) {
252 if ($self['nick'] != $system_actor_name) {
253 // Reset the actor name to the already used name
254 DI::config()->set('system', 'actor_name', $self['nick']);
255 $system_actor_name = $self['nick'];
258 return $system_actor_name;
261 // List of possible actor names
262 $possible_accounts = ['friendica', 'actor', 'system', 'internal'];
263 foreach ($possible_accounts as $name) {
264 if (!DBA::exists('user', ['nickname' => $name, 'account_removed' => false, 'expire' => false]) &&
265 !DBA::exists('userd', ['username' => $name])) {
266 DI::config()->set('system', 'actor_name', $name);
274 * Returns true if a user record exists with the provided id
276 * @param integer $uid
280 public static function exists($uid)
282 return DBA::exists('user', ['uid' => $uid]);
286 * @param integer $uid
287 * @param array $fields
288 * @return array|boolean User record if it exists, false otherwise
291 public static function getById($uid, array $fields = [])
293 return !empty($uid) ? DBA::selectFirst('user', $fields, ['uid' => $uid]) : [];
297 * Returns a user record based on it's GUID
299 * @param string $guid The guid of the user
300 * @param array $fields The fields to retrieve
301 * @param bool $active True, if only active records are searched
303 * @return array|boolean User record if it exists, false otherwise
306 public static function getByGuid(string $guid, array $fields = [], bool $active = true)
309 $cond = ['guid' => $guid, 'account_expired' => false, 'account_removed' => false];
311 $cond = ['guid' => $guid];
314 return DBA::selectFirst('user', $fields, $cond);
318 * @param string $nickname
319 * @param array $fields
320 * @return array|boolean User record if it exists, false otherwise
323 public static function getByNickname($nickname, array $fields = [])
325 return DBA::selectFirst('user', $fields, ['nickname' => $nickname]);
329 * Returns the user id of a given profile URL
333 * @return integer user id
336 public static function getIdForURL(string $url)
338 // Avoid database queries when the local node hostname isn't even part of the url.
339 if (!Contact::isLocal($url)) {
343 $self = Contact::selectFirst(['uid'], ['self' => true, 'nurl' => Strings::normaliseLink($url)]);
344 if (!empty($self['uid'])) {
348 $self = Contact::selectFirst(['uid'], ['self' => true, 'addr' => $url]);
349 if (!empty($self['uid'])) {
353 $self = Contact::selectFirst(['uid'], ['self' => true, 'alias' => [$url, Strings::normaliseLink($url)]]);
354 if (!empty($self['uid'])) {
362 * Get a user based on its email
364 * @param string $email
365 * @param array $fields
367 * @return array|boolean User record if it exists, false otherwise
371 public static function getByEmail($email, array $fields = [])
373 return DBA::selectFirst('user', $fields, ['email' => $email]);
377 * Fetch the user array of the administrator. The first one if there are several.
379 * @param array $fields
382 public static function getFirstAdmin(array $fields = [])
384 if (!empty(DI::config()->get('config', 'admin_nickname'))) {
385 return self::getByNickname(DI::config()->get('config', 'admin_nickname'), $fields);
386 } elseif (!empty(DI::config()->get('config', 'admin_email'))) {
387 $adminList = explode(',', str_replace(' ', '', DI::config()->get('config', 'admin_email')));
388 return self::getByEmail($adminList[0], $fields);
395 * Get owner data by user id
398 * @param boolean $repairMissing Repair the owner data if it's missing
399 * @return boolean|array
402 public static function getOwnerDataById(int $uid, bool $repairMissing = true)
405 return self::getSystemAccount();
408 if (!empty(self::$owner[$uid])) {
409 return self::$owner[$uid];
412 $owner = DBA::selectFirst('owner-view', [], ['uid' => $uid]);
413 if (!DBA::isResult($owner)) {
414 if (!DBA::exists('user', ['uid' => $uid]) || !$repairMissing) {
417 if (!DBA::exists('profile', ['uid' => $uid])) {
418 DBA::insert('profile', ['uid' => $uid]);
420 if (!DBA::exists('contact', ['uid' => $uid, 'self' => true])) {
421 Contact::createSelfFromUserId($uid);
423 $owner = self::getOwnerDataById($uid, false);
426 if (empty($owner['nickname'])) {
430 if (!$repairMissing || $owner['account_expired']) {
434 // Check if the returned data is valid, otherwise fix it. See issue #6122
436 // Check for correct url and normalised nurl
437 $url = DI::baseUrl() . '/profile/' . $owner['nickname'];
438 $repair = empty($owner['network']) || ($owner['url'] != $url) || ($owner['nurl'] != Strings::normaliseLink($owner['url']));
441 // Check if "addr" is present and correct
442 $addr = $owner['nickname'] . '@' . substr(DI::baseUrl(), strpos(DI::baseUrl(), '://') + 3);
443 $repair = ($addr != $owner['addr']) || empty($owner['prvkey']) || empty($owner['pubkey']);
447 // Check if the avatar field is filled and the photo directs to the correct path
448 $avatar = Photo::selectFirst(['resource-id'], ['uid' => $uid, 'profile' => true]);
449 if (DBA::isResult($avatar)) {
450 $repair = empty($owner['avatar']) || !strpos($owner['photo'], $avatar['resource-id']);
455 Contact::updateSelfFromUserID($uid);
456 // Return the corrected data and avoid a loop
457 $owner = self::getOwnerDataById($uid, false);
460 self::$owner[$uid] = $owner;
465 * Get owner data by nick name
468 * @return boolean|array
471 public static function getOwnerDataByNick($nick)
473 $user = DBA::selectFirst('user', ['uid'], ['nickname' => $nick]);
475 if (!DBA::isResult($user)) {
479 return self::getOwnerDataById($user['uid']);
483 * Returns the default group for a given user and network
485 * @param int $uid User id
487 * @return int group id
490 public static function getDefaultGroup($uid)
492 $user = DBA::selectFirst('user', ['def_gid'], ['uid' => $uid]);
493 if (DBA::isResult($user)) {
494 $default_group = $user["def_gid"];
499 return $default_group;
503 * Authenticate a user with a clear text password
505 * Returns the user id associated with a successful password authentication
507 * @param mixed $user_info
508 * @param string $password
509 * @param bool $third_party
510 * @return int User Id if authentication is successful
511 * @throws HTTPException\ForbiddenException
512 * @throws HTTPException\NotFoundException
514 public static function getIdFromPasswordAuthentication($user_info, $password, $third_party = false)
516 // Addons registered with the "authenticate" hook may create the user on the
517 // fly. `getAuthenticationInfo` will fail if the user doesn't exist yet. If
518 // the user doesn't exist, we should give the addons a chance to create the
519 // user in our database, if applicable, before re-throwing the exception if
522 $user = self::getAuthenticationInfo($user_info);
523 } catch (Exception $e) {
524 $username = (is_string($user_info) ? $user_info : $user_info['nickname'] ?? '');
526 // Addons can create users, and since this 'catch' branch should only
527 // execute if getAuthenticationInfo can't find an existing user, that's
528 // exactly what will happen here. Creating a numeric username would create
529 // abiguity with user IDs, possibly opening up an attack vector.
530 // So let's be very careful about that.
531 if (empty($username) || is_numeric($username)) {
535 return self::getIdFromAuthenticateHooks($username, $password);
538 if ($third_party && DI::pConfig()->get($user['uid'], '2fa', 'verified')) {
539 // Third-party apps can't verify two-factor authentication, we use app-specific passwords instead
540 if (AppSpecificPassword::authenticateUser($user['uid'], $password)) {
543 } elseif (strpos($user['password'], '$') === false) {
544 //Legacy hash that has not been replaced by a new hash yet
545 if (self::hashPasswordLegacy($password) === $user['password']) {
546 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
550 } elseif (!empty($user['legacy_password'])) {
551 //Legacy hash that has been double-hashed and not replaced by a new hash yet
552 //Warning: `legacy_password` is not necessary in sync with the content of `password`
553 if (password_verify(self::hashPasswordLegacy($password), $user['password'])) {
554 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
558 } elseif (password_verify($password, $user['password'])) {
560 if (password_needs_rehash($user['password'], PASSWORD_DEFAULT)) {
561 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
566 return self::getIdFromAuthenticateHooks($user['nickname'], $password); // throws
569 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
573 * Try to obtain a user ID via "authenticate" hook addons
575 * Returns the user id associated with a successful password authentication
577 * @param string $username
578 * @param string $password
579 * @return int User Id if authentication is successful
580 * @throws HTTPException\ForbiddenException
582 public static function getIdFromAuthenticateHooks($username, $password)
585 'username' => $username,
586 'password' => $password,
587 'authenticated' => 0,
588 'user_record' => null
592 * An addon indicates successful login by setting 'authenticated' to non-zero value and returning a user record
593 * Addons should never set 'authenticated' except to indicate success - as hooks may be chained
594 * and later addons should not interfere with an earlier one that succeeded.
596 Hook::callAll('authenticate', $addon_auth);
598 if ($addon_auth['authenticated'] && $addon_auth['user_record']) {
599 return $addon_auth['user_record']['uid'];
602 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
606 * Returns authentication info from various parameters types
608 * User info can be any of the following:
611 * - User email or username or nickname
612 * - User array with at least the uid and the hashed password
614 * @param mixed $user_info
616 * @throws HTTPException\NotFoundException
618 public static function getAuthenticationInfo($user_info)
622 if (is_object($user_info) || is_array($user_info)) {
623 if (is_object($user_info)) {
624 $user = (array) $user_info;
631 || !isset($user['password'])
632 || !isset($user['legacy_password'])
634 throw new Exception(DI::l10n()->t('Not enough information to authenticate'));
636 } elseif (is_int($user_info) || is_string($user_info)) {
637 if (is_int($user_info)) {
638 $user = DBA::selectFirst(
640 ['uid', 'nickname', 'password', 'legacy_password'],
644 'account_expired' => 0,
645 'account_removed' => 0,
650 $fields = ['uid', 'nickname', 'password', 'legacy_password'];
652 "(`email` = ? OR `username` = ? OR `nickname` = ?)
653 AND NOT `blocked` AND NOT `account_expired` AND NOT `account_removed` AND `verified`",
654 $user_info, $user_info, $user_info
656 $user = DBA::selectFirst('user', $fields, $condition);
659 if (!DBA::isResult($user)) {
660 throw new HTTPException\NotFoundException(DI::l10n()->t('User not found'));
668 * Generates a human-readable random password
673 public static function generateNewPassword()
675 return ucfirst(Strings::getRandomName(8)) . random_int(1000, 9999);
679 * Checks if the provided plaintext password has been exposed or not
681 * @param string $password
685 public static function isPasswordExposed($password)
687 $cache = new CacheItemPool();
688 $cache->changeConfig([
689 'cacheDirectory' => System::getTempPath() . '/password-exposed-cache/',
693 $passwordExposedChecker = new PasswordExposed\PasswordExposedChecker(null, $cache);
695 return $passwordExposedChecker->passwordExposed($password) === PasswordExposed\PasswordStatus::EXPOSED;
696 } catch (Exception $e) {
697 Logger::error('Password Exposed Exception: ' . $e->getMessage(), [
698 'code' => $e->getCode(),
699 'file' => $e->getFile(),
700 'line' => $e->getLine(),
701 'trace' => $e->getTraceAsString()
709 * Legacy hashing function, kept for password migration purposes
711 * @param string $password
714 private static function hashPasswordLegacy($password)
716 return hash('whirlpool', $password);
720 * Global user password hashing function
722 * @param string $password
726 public static function hashPassword($password)
728 if (!trim($password)) {
729 throw new Exception(DI::l10n()->t('Password can\'t be empty'));
732 return password_hash($password, PASSWORD_DEFAULT);
736 * Updates a user row with a new plaintext password
739 * @param string $password
743 public static function updatePassword($uid, $password)
745 $password = trim($password);
747 if (empty($password)) {
748 throw new Exception(DI::l10n()->t('Empty passwords are not allowed.'));
751 if (!DI::config()->get('system', 'disable_password_exposed', false) && self::isPasswordExposed($password)) {
752 throw new Exception(DI::l10n()->t('The new password has been exposed in a public data dump, please choose another.'));
755 $allowed_characters = '!"#$%&\'()*+,-./;<=>?@[\]^_`{|}~';
757 if (!preg_match('/^[a-z0-9' . preg_quote($allowed_characters, '/') . ']+$/i', $password)) {
758 throw new Exception(DI::l10n()->t('The password can\'t contain accentuated letters, white spaces or colons (:)'));
761 return self::updatePasswordHashed($uid, self::hashPassword($password));
765 * Updates a user row with a new hashed password.
766 * Empties the password reset token field just in case.
769 * @param string $pasword_hashed
773 private static function updatePasswordHashed($uid, $pasword_hashed)
776 'password' => $pasword_hashed,
778 'pwdreset_time' => null,
779 'legacy_password' => false
781 return DBA::update('user', $fields, ['uid' => $uid]);
785 * Checks if a nickname is in the list of the forbidden nicknames
787 * Check if a nickname is forbidden from registration on the node by the
788 * admin. Forbidden nicknames (e.g. role namess) can be configured in the
791 * @param string $nickname The nickname that should be checked
792 * @return boolean True is the nickname is blocked on the node
794 public static function isNicknameBlocked($nickname)
796 $forbidden_nicknames = DI::config()->get('system', 'forbidden_nicknames', '');
797 if (!empty($forbidden_nicknames)) {
798 $forbidden = explode(',', $forbidden_nicknames);
799 $forbidden = array_map('trim', $forbidden);
804 // Add the name of the internal actor to the "forbidden" list
805 $actor_name = self::getActorName();
806 if (!empty($actor_name)) {
807 $forbidden[] = $actor_name;
810 if (empty($forbidden)) {
814 // check if the nickname is in the list of blocked nicknames
815 if (in_array(strtolower($nickname), $forbidden)) {
824 * Get avatar link for given user
827 * @param string $size One of the Proxy::SIZE_* constants
828 * @return string avatar link
831 public static function getAvatarUrl(array $user, string $size = ''):string
833 if (empty($user['nickname'])) {
834 DI::logger()->warning('Missing user nickname key', ['trace' => System::callstack(20)]);
837 $url = DI::baseUrl() . '/photo/';
840 case Proxy::SIZE_MICRO:
844 case Proxy::SIZE_THUMB:
857 $photo = Photo::selectFirst(['type', 'created', 'edited', 'updated'], ["scale" => $scale, 'uid' => $user['uid'], 'profile' => true]);
858 if (!empty($photo)) {
859 $updated = max($photo['created'], $photo['edited'], $photo['updated']);
860 $mimetype = $photo['type'];
863 return $url . $user['nickname'] . Images::getExtensionByMimeType($mimetype) . ($updated ? '?ts=' . strtotime($updated) : '');
867 * Get banner link for given user
870 * @return string banner link
873 public static function getBannerUrl(array $user):string
875 if (empty($user['nickname'])) {
876 DI::logger()->warning('Missing user nickname key', ['trace' => System::callstack(20)]);
879 $url = DI::baseUrl() . '/photo/banner/';
884 $photo = Photo::selectFirst(['type', 'created', 'edited', 'updated'], ["scale" => 3, 'uid' => $user['uid'], 'photo-type' => Photo::USER_BANNER]);
885 if (!empty($photo)) {
886 $updated = max($photo['created'], $photo['edited'], $photo['updated']);
887 $mimetype = $photo['type'];
889 // Only for the RC phase: Don't return an image link for the default picture
893 return $url . $user['nickname'] . Images::getExtensionByMimeType($mimetype) . ($updated ? '?ts=' . strtotime($updated) : '');
897 * Catch-all user creation function
899 * Creates a user from the provided data array, either form fields or OpenID.
900 * Required: { username, nickname, email } or { openid_url }
902 * Performs the following:
903 * - Sends to the OpenId auth URL (if relevant)
904 * - Creates new key pairs for crypto
905 * - Create self-contact
906 * - Create profile image
910 * @throws ErrorException
911 * @throws HTTPException\InternalServerErrorException
912 * @throws ImagickException
915 public static function create(array $data)
917 $return = ['user' => null, 'password' => ''];
919 $using_invites = DI::config()->get('system', 'invitation_only');
921 $invite_id = !empty($data['invite_id']) ? trim($data['invite_id']) : '';
922 $username = !empty($data['username']) ? trim($data['username']) : '';
923 $nickname = !empty($data['nickname']) ? trim($data['nickname']) : '';
924 $email = !empty($data['email']) ? trim($data['email']) : '';
925 $openid_url = !empty($data['openid_url']) ? trim($data['openid_url']) : '';
926 $photo = !empty($data['photo']) ? trim($data['photo']) : '';
927 $password = !empty($data['password']) ? trim($data['password']) : '';
928 $password1 = !empty($data['password1']) ? trim($data['password1']) : '';
929 $confirm = !empty($data['confirm']) ? trim($data['confirm']) : '';
930 $blocked = !empty($data['blocked']);
931 $verified = !empty($data['verified']);
932 $language = !empty($data['language']) ? trim($data['language']) : 'en';
934 $netpublish = $publish = !empty($data['profile_publish_reg']);
936 if ($password1 != $confirm) {
937 throw new Exception(DI::l10n()->t('Passwords do not match. Password unchanged.'));
938 } elseif ($password1 != '') {
939 $password = $password1;
942 if ($using_invites) {
944 throw new Exception(DI::l10n()->t('An invitation is required.'));
947 if (!Register::existsByHash($invite_id)) {
948 throw new Exception(DI::l10n()->t('Invitation could not be verified.'));
952 /// @todo Check if this part is really needed. We should have fetched all this data in advance
953 if (empty($username) || empty($email) || empty($nickname)) {
955 if (!Network::isUrlValid($openid_url)) {
956 throw new Exception(DI::l10n()->t('Invalid OpenID url'));
958 $_SESSION['register'] = 1;
959 $_SESSION['openid'] = $openid_url;
961 $openid = new LightOpenID(DI::baseUrl()->getHostname());
962 $openid->identity = $openid_url;
963 $openid->returnUrl = DI::baseUrl() . '/openid';
964 $openid->required = ['namePerson/friendly', 'contact/email', 'namePerson'];
965 $openid->optional = ['namePerson/first', 'media/image/aspect11', 'media/image/default'];
967 $authurl = $openid->authUrl();
968 } catch (Exception $e) {
969 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);
971 System::externalRedirect($authurl);
975 throw new Exception(DI::l10n()->t('Please enter the required information.'));
978 if (!Network::isUrlValid($openid_url)) {
982 // collapse multiple spaces in name
983 $username = preg_replace('/ +/', ' ', $username);
985 $username_min_length = max(1, min(64, intval(DI::config()->get('system', 'username_min_length', 3))));
986 $username_max_length = max(1, min(64, intval(DI::config()->get('system', 'username_max_length', 48))));
988 if ($username_min_length > $username_max_length) {
989 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));
990 $tmp = $username_min_length;
991 $username_min_length = $username_max_length;
992 $username_max_length = $tmp;
995 if (mb_strlen($username) < $username_min_length) {
996 throw new Exception(DI::l10n()->tt('Username should be at least %s character.', 'Username should be at least %s characters.', $username_min_length));
999 if (mb_strlen($username) > $username_max_length) {
1000 throw new Exception(DI::l10n()->tt('Username should be at most %s character.', 'Username should be at most %s characters.', $username_max_length));
1003 // So now we are just looking for a space in the full name.
1004 $loose_reg = DI::config()->get('system', 'no_regfullname');
1006 $username = mb_convert_case($username, MB_CASE_TITLE, 'UTF-8');
1007 if (strpos($username, ' ') === false) {
1008 throw new Exception(DI::l10n()->t("That doesn't appear to be your full (First Last) name."));
1012 if (!Network::isEmailDomainAllowed($email)) {
1013 throw new Exception(DI::l10n()->t('Your email domain is not among those allowed on this site.'));
1016 if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !Network::isEmailDomainValid($email)) {
1017 throw new Exception(DI::l10n()->t('Not a valid email address.'));
1019 if (self::isNicknameBlocked($nickname)) {
1020 throw new Exception(DI::l10n()->t('The nickname was blocked from registration by the nodes admin.'));
1023 if (DI::config()->get('system', 'block_extended_register', false) && DBA::exists('user', ['email' => $email])) {
1024 throw new Exception(DI::l10n()->t('Cannot use that email.'));
1027 // Disallow somebody creating an account using openid that uses the admin email address,
1028 // since openid bypasses email verification. We'll allow it if there is not yet an admin account.
1029 if (DI::config()->get('config', 'admin_email') && strlen($openid_url)) {
1030 $adminlist = explode(',', str_replace(' ', '', strtolower(DI::config()->get('config', 'admin_email'))));
1031 if (in_array(strtolower($email), $adminlist)) {
1032 throw new Exception(DI::l10n()->t('Cannot use that email.'));
1036 $nickname = $data['nickname'] = strtolower($nickname);
1038 if (!preg_match('/^[a-z0-9][a-z0-9_]*$/', $nickname)) {
1039 throw new Exception(DI::l10n()->t('Your nickname can only contain a-z, 0-9 and _.'));
1042 // Check existing and deleted accounts for this nickname.
1044 DBA::exists('user', ['nickname' => $nickname])
1045 || DBA::exists('userd', ['username' => $nickname])
1047 throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1050 $new_password = strlen($password) ? $password : User::generateNewPassword();
1051 $new_password_encoded = self::hashPassword($new_password);
1053 $return['password'] = $new_password;
1055 $keys = Crypto::newKeypair(4096);
1056 if ($keys === false) {
1057 throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
1060 $prvkey = $keys['prvkey'];
1061 $pubkey = $keys['pubkey'];
1063 // Create another keypair for signing/verifying salmon protocol messages.
1064 $sres = Crypto::newKeypair(512);
1065 $sprvkey = $sres['prvkey'];
1066 $spubkey = $sres['pubkey'];
1068 $insert_result = DBA::insert('user', [
1069 'guid' => System::createUUID(),
1070 'username' => $username,
1071 'password' => $new_password_encoded,
1073 'openid' => $openid_url,
1074 'nickname' => $nickname,
1075 'pubkey' => $pubkey,
1076 'prvkey' => $prvkey,
1077 'spubkey' => $spubkey,
1078 'sprvkey' => $sprvkey,
1079 'verified' => $verified,
1080 'blocked' => $blocked,
1081 'language' => $language,
1082 'timezone' => 'UTC',
1083 'register_date' => DateTimeFormat::utcNow(),
1084 'default-location' => ''
1087 if ($insert_result) {
1088 $uid = DBA::lastInsertId();
1089 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1091 throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1095 throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1098 // if somebody clicked submit twice very quickly, they could end up with two accounts
1099 // due to race condition. Remove this one.
1100 $user_count = DBA::count('user', ['nickname' => $nickname]);
1101 if ($user_count > 1) {
1102 DBA::delete('user', ['uid' => $uid]);
1104 throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1107 $insert_result = DBA::insert('profile', [
1109 'name' => $username,
1110 'photo' => self::getAvatarUrl($user),
1111 'thumb' => self::getAvatarUrl($user, Proxy::SIZE_THUMB),
1112 'publish' => $publish,
1113 'net-publish' => $netpublish,
1115 if (!$insert_result) {
1116 DBA::delete('user', ['uid' => $uid]);
1118 throw new Exception(DI::l10n()->t('An error occurred creating your default profile. Please try again.'));
1121 // Create the self contact
1122 if (!Contact::createSelfFromUserId($uid)) {
1123 DBA::delete('user', ['uid' => $uid]);
1125 throw new Exception(DI::l10n()->t('An error occurred creating your self contact. Please try again.'));
1128 // Create a group with no members. This allows somebody to use it
1129 // right away as a default group for new contacts.
1130 $def_gid = Group::create($uid, DI::l10n()->t('Friends'));
1132 DBA::delete('user', ['uid' => $uid]);
1134 throw new Exception(DI::l10n()->t('An error occurred creating your default contact group. Please try again.'));
1137 $fields = ['def_gid' => $def_gid];
1138 if (DI::config()->get('system', 'newuser_private') && $def_gid) {
1139 $fields['allow_gid'] = '<' . $def_gid . '>';
1142 DBA::update('user', $fields, ['uid' => $uid]);
1144 // if we have no OpenID photo try to look up an avatar
1145 if (!strlen($photo)) {
1146 $photo = Network::lookupAvatarByEmail($email);
1149 // unless there is no avatar-addon loaded
1150 if (strlen($photo)) {
1151 $photo_failure = false;
1153 $filename = basename($photo);
1154 $curlResult = DI::httpClient()->get($photo, HttpClientAccept::IMAGE);
1155 if ($curlResult->isSuccess()) {
1156 Logger::debug('Got picture', ['Content-Type' => $curlResult->getHeader('Content-Type'), 'url' => $photo]);
1157 $img_str = $curlResult->getBody();
1158 $type = $curlResult->getContentType();
1164 $type = Images::getMimeTypeByData($img_str, $photo, $type);
1166 $Image = new Image($img_str, $type);
1167 if ($Image->isValid()) {
1168 $Image->scaleToSquare(300);
1170 $resource_id = Photo::newResource();
1172 // Not using Photo::PROFILE_PHOTOS here, so that it is discovered as translateble string
1173 $profile_album = DI::l10n()->t('Profile Photos');
1175 $r = Photo::store($Image, $uid, 0, $resource_id, $filename, $profile_album, 4);
1178 $photo_failure = true;
1181 $Image->scaleDown(80);
1183 $r = Photo::store($Image, $uid, 0, $resource_id, $filename, $profile_album, 5);
1186 $photo_failure = true;
1189 $Image->scaleDown(48);
1191 $r = Photo::store($Image, $uid, 0, $resource_id, $filename, $profile_album, 6);
1194 $photo_failure = true;
1197 if (!$photo_failure) {
1198 Photo::update(['profile' => true, 'photo-type' => Photo::USER_AVATAR], ['resource-id' => $resource_id]);
1202 Contact::updateSelfFromUserID($uid, true);
1205 Hook::callAll('register_account', $uid);
1207 $return['user'] = $user;
1212 * Update a user entry and distribute the changes if needed
1214 * @param array $fields
1215 * @param integer $uid
1218 public static function update(array $fields, int $uid): bool
1220 $old_owner = self::getOwnerDataById($uid);
1221 if (empty($old_owner)) {
1225 if (!DBA::update('user', $fields, ['uid' => $uid])) {
1229 $update = Contact::updateSelfFromUserID($uid);
1231 $owner = self::getOwnerDataById($uid);
1232 if (empty($owner)) {
1236 if ($old_owner['name'] != $owner['name']) {
1237 Profile::update(['name' => $owner['name']], $uid);
1241 Profile::publishUpdate($uid);
1248 * Sets block state for a given user
1250 * @param int $uid The user id
1251 * @param bool $block Block state (default is true)
1253 * @return bool True, if successfully blocked
1257 public static function block(int $uid, bool $block = true)
1259 return DBA::update('user', ['blocked' => $block], ['uid' => $uid]);
1263 * Allows a registration based on a hash
1265 * @param string $hash
1267 * @return bool True, if the allow was successful
1269 * @throws HTTPException\InternalServerErrorException
1272 public static function allow(string $hash)
1274 $register = Register::getByHash($hash);
1275 if (!DBA::isResult($register)) {
1279 $user = User::getById($register['uid']);
1280 if (!DBA::isResult($user)) {
1284 Register::deleteByHash($hash);
1286 DBA::update('user', ['blocked' => false, 'verified' => true], ['uid' => $register['uid']]);
1288 $profile = DBA::selectFirst('profile', ['net-publish'], ['uid' => $register['uid']]);
1290 if (DBA::isResult($profile) && $profile['net-publish'] && DI::config()->get('system', 'directory')) {
1291 $url = DI::baseUrl() . '/profile/' . $user['nickname'];
1292 Worker::add(PRIORITY_LOW, "Directory", $url);
1295 $l10n = DI::l10n()->withLang($register['language']);
1297 return User::sendRegisterOpenEmail(
1300 DI::config()->get('config', 'sitename'),
1301 DI::baseUrl()->get(),
1302 ($register['password'] ?? '') ?: 'Sent in a previous email'
1307 * Denys a pending registration
1309 * @param string $hash The hash of the pending user
1311 * This does not have to go through user_remove() and save the nickname
1312 * permanently against re-registration, as the person was not yet
1313 * allowed to have friends on this system
1315 * @return bool True, if the deny was successfull
1318 public static function deny(string $hash)
1320 $register = Register::getByHash($hash);
1321 if (!DBA::isResult($register)) {
1325 $user = User::getById($register['uid']);
1326 if (!DBA::isResult($user)) {
1330 // Delete the avatar
1331 Photo::delete(['uid' => $register['uid']]);
1333 return DBA::delete('user', ['uid' => $register['uid']]) &&
1334 Register::deleteByHash($register['hash']);
1338 * Creates a new user based on a minimal set and sends an email to this user
1340 * @param string $name The user's name
1341 * @param string $email The user's email address
1342 * @param string $nick The user's nick name
1343 * @param string $lang The user's language (default is english)
1345 * @return bool True, if the user was created successfully
1346 * @throws HTTPException\InternalServerErrorException
1347 * @throws ErrorException
1348 * @throws ImagickException
1350 public static function createMinimal(string $name, string $email, string $nick, string $lang = L10n::DEFAULT)
1355 throw new HTTPException\InternalServerErrorException('Invalid arguments.');
1358 $result = self::create([
1359 'username' => $name,
1361 'nickname' => $nick,
1366 $user = $result['user'];
1367 $preamble = Strings::deindent(DI::l10n()->t('
1369 the administrator of %2$s has set up an account for you.'));
1370 $body = Strings::deindent(DI::l10n()->t('
1371 The login details are as follows:
1377 You may change your password from your account "Settings" page after logging
1380 Please take a few moments to review the other account settings on that page.
1382 You may also wish to add some basic information to your default profile
1383 (on the "Profiles" page) so that other people can easily find you.
1385 We recommend setting your full name, adding a profile photo,
1386 adding some profile "keywords" (very useful in making new friends) - and
1387 perhaps what country you live in; if you do not wish to be more specific
1390 We fully respect your right to privacy, and none of these items are necessary.
1391 If you are new and do not know anybody here, they may help
1392 you to make some new and interesting friends.
1394 If you ever want to delete your account, you can do so at %1$s/removeme
1396 Thank you and welcome to %4$s.'));
1398 $preamble = sprintf($preamble, $user['username'], DI::config()->get('config', 'sitename'));
1399 $body = sprintf($body, DI::baseUrl()->get(), $user['nickname'], $result['password'], DI::config()->get('config', 'sitename'));
1401 $email = DI::emailer()
1403 ->withMessage(DI::l10n()->t('Registration details for %s', DI::config()->get('config', 'sitename')), $preamble, $body)
1405 ->withRecipient($user['email'])
1407 return DI::emailer()->send($email);
1411 * Sends pending registration confirmation email
1413 * @param array $user User record array
1414 * @param string $sitename
1415 * @param string $siteurl
1416 * @param string $password Plaintext password
1417 * @return NULL|boolean from notification() and email() inherited
1418 * @throws HTTPException\InternalServerErrorException
1420 public static function sendRegisterPendingEmail($user, $sitename, $siteurl, $password)
1422 $body = Strings::deindent(DI::l10n()->t(
1425 Thank you for registering at %2$s. Your account is pending for approval by the administrator.
1427 Your login details are as follows:
1440 $email = DI::emailer()
1442 ->withMessage(DI::l10n()->t('Registration at %s', $sitename), $body)
1444 ->withRecipient($user['email'])
1446 return DI::emailer()->send($email);
1450 * Sends registration confirmation
1452 * It's here as a function because the mail is sent from different parts
1454 * @param L10n $l10n The used language
1455 * @param array $user User record array
1456 * @param string $sitename
1457 * @param string $siteurl
1458 * @param string $password Plaintext password
1460 * @return NULL|boolean from notification() and email() inherited
1461 * @throws HTTPException\InternalServerErrorException
1463 public static function sendRegisterOpenEmail(L10n $l10n, $user, $sitename, $siteurl, $password)
1465 $preamble = Strings::deindent($l10n->t(
1468 Thank you for registering at %2$s. Your account has been created.
1473 $body = Strings::deindent($l10n->t(
1475 The login details are as follows:
1481 You may change your password from your account "Settings" page after logging
1484 Please take a few moments to review the other account settings on that page.
1486 You may also wish to add some basic information to your default profile
1487 ' . "\x28" . 'on the "Profiles" page' . "\x29" . ' so that other people can easily find you.
1489 We recommend setting your full name, adding a profile photo,
1490 adding some profile "keywords" ' . "\x28" . 'very useful in making new friends' . "\x29" . ' - and
1491 perhaps what country you live in; if you do not wish to be more specific
1494 We fully respect your right to privacy, and none of these items are necessary.
1495 If you are new and do not know anybody here, they may help
1496 you to make some new and interesting friends.
1498 If you ever want to delete your account, you can do so at %3$s/removeme
1500 Thank you and welcome to %2$s.',
1508 $email = DI::emailer()
1510 ->withMessage(DI::l10n()->t('Registration details for %s', $sitename), $preamble, $body)
1512 ->withRecipient($user['email'])
1514 return DI::emailer()->send($email);
1518 * @param int $uid user to remove
1520 * @throws HTTPException\InternalServerErrorException
1522 public static function remove(int $uid)
1528 Logger::notice('Removing user', ['user' => $uid]);
1530 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1532 Hook::callAll('remove_user', $user);
1534 // save username (actually the nickname as it is guaranteed
1535 // unique), so it cannot be re-registered in the future.
1536 DBA::insert('userd', ['username' => $user['nickname']]);
1538 // Remove all personal settings, especially connector settings
1539 DBA::delete('pconfig', ['uid' => $uid]);
1541 // The user and related data will be deleted in Friendica\Worker\ExpireAndRemoveUsers
1542 DBA::update('user', ['account_removed' => true, 'account_expires_on' => DateTimeFormat::utc('now + 7 day')], ['uid' => $uid]);
1543 Worker::add(PRIORITY_HIGH, 'Notifier', Delivery::REMOVAL, $uid);
1545 // Send an update to the directory
1546 $self = DBA::selectFirst('contact', ['url'], ['uid' => $uid, 'self' => true]);
1547 Worker::add(PRIORITY_LOW, 'Directory', $self['url']);
1549 // Remove the user relevant data
1550 Worker::add(PRIORITY_NEGLIGIBLE, 'RemoveUser', $uid);
1556 * Return all identities to a user
1558 * @param int $uid The user id
1559 * @return array All identities for this user
1561 * Example for a return:
1565 * 'username' => 'maxmuster',
1566 * 'nickname' => 'Max Mustermann'
1570 * 'username' => 'johndoe',
1571 * 'nickname' => 'John Doe'
1576 public static function identities($uid)
1584 $user = DBA::selectFirst('user', ['uid', 'nickname', 'username', 'parent-uid'], ['uid' => $uid]);
1585 if (!DBA::isResult($user)) {
1589 if ($user['parent-uid'] == 0) {
1590 // First add our own entry
1592 'uid' => $user['uid'],
1593 'username' => $user['username'],
1594 'nickname' => $user['nickname']
1597 // Then add all the children
1600 ['uid', 'username', 'nickname'],
1601 ['parent-uid' => $user['uid'], 'account_removed' => false]
1603 if (DBA::isResult($r)) {
1604 $identities = array_merge($identities, DBA::toArray($r));
1607 // First entry is our parent
1610 ['uid', 'username', 'nickname'],
1611 ['uid' => $user['parent-uid'], 'account_removed' => false]
1613 if (DBA::isResult($r)) {
1614 $identities = DBA::toArray($r);
1617 // Then add all siblings
1620 ['uid', 'username', 'nickname'],
1621 ['parent-uid' => $user['parent-uid'], 'account_removed' => false]
1623 if (DBA::isResult($r)) {
1624 $identities = array_merge($identities, DBA::toArray($r));
1629 "SELECT `user`.`uid`, `user`.`username`, `user`.`nickname`
1631 INNER JOIN `user` ON `manage`.`mid` = `user`.`uid`
1632 WHERE `user`.`account_removed` = 0 AND `manage`.`uid` = ?",
1635 if (DBA::isResult($r)) {
1636 $identities = array_merge($identities, DBA::toArray($r));
1643 * Check if the given user id has delegations or is delegated
1648 public static function hasIdentities(int $uid):bool
1654 $user = DBA::selectFirst('user', ['parent-uid'], ['uid' => $uid, 'account_removed' => false]);
1655 if (!DBA::isResult($user)) {
1659 if ($user['parent-uid'] != 0) {
1663 if (DBA::exists('user', ['parent-uid' => $uid, 'account_removed' => false])) {
1667 if (DBA::exists('manage', ['uid' => $uid])) {
1675 * Returns statistical information about the current users of this node
1681 public static function getStatistics()
1685 'active_users_halfyear' => 0,
1686 'active_users_monthly' => 0,
1687 'active_users_weekly' => 0,
1690 $userStmt = DBA::select('owner-view', ['uid', 'login_date', 'last-item'],
1691 ["`verified` AND `login_date` > ? AND NOT `blocked`
1692 AND NOT `account_removed` AND NOT `account_expired`",
1693 DBA::NULL_DATETIME]);
1694 if (!DBA::isResult($userStmt)) {
1698 $halfyear = time() - (180 * 24 * 60 * 60);
1699 $month = time() - (30 * 24 * 60 * 60);
1700 $week = time() - (7 * 24 * 60 * 60);
1702 while ($user = DBA::fetch($userStmt)) {
1703 $statistics['total_users']++;
1705 if ((strtotime($user['login_date']) > $halfyear) || (strtotime($user['last-item']) > $halfyear)
1707 $statistics['active_users_halfyear']++;
1710 if ((strtotime($user['login_date']) > $month) || (strtotime($user['last-item']) > $month)
1712 $statistics['active_users_monthly']++;
1715 if ((strtotime($user['login_date']) > $week) || (strtotime($user['last-item']) > $week)
1717 $statistics['active_users_weekly']++;
1720 DBA::close($userStmt);
1726 * Get all users of the current node
1728 * @param int $start Start count (Default is 0)
1729 * @param int $count Count of the items per page (Default is @see Pager::ITEMS_PER_PAGE)
1730 * @param string $type The type of users, which should get (all, bocked, removed)
1731 * @param string $order Order of the user list (Default is 'contact.name')
1732 * @param bool $descending Order direction (Default is ascending)
1734 * @return array The list of the users
1737 public static function getList($start = 0, $count = Pager::ITEMS_PER_PAGE, $type = 'all', $order = 'name', bool $descending = false)
1739 $param = ['limit' => [$start, $count], 'order' => [$order => $descending]];
1743 $condition['account_removed'] = false;
1744 $condition['blocked'] = false;
1747 $condition['account_removed'] = false;
1748 $condition['blocked'] = true;
1749 $condition['verified'] = true;
1752 $condition['account_removed'] = true;
1756 return DBA::selectToArray('owner-view', [], $condition, $param);