]> git.mxchange.org Git - friendica.git/blob - src/Model/User.php
Merge pull request #13659 from annando/network-channel
[friendica.git] / src / Model / User.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2010-2023, the Friendica project
4  *
5  * @license GNU AGPL version 3 or any later version
6  *
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.
11  *
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.
16  *
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/>.
19  *
20  */
21
22 namespace Friendica\Model;
23
24 use DivineOmega\DOFileCachePSR6\CacheItemPool;
25 use DivineOmega\PasswordExposed;
26 use ErrorException;
27 use Exception;
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;
37 use Friendica\DI;
38 use Friendica\Module;
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;
50 use ImagickException;
51 use LightOpenID;
52
53 /**
54  * This class handles User related functions
55  */
56 class User
57 {
58         /**
59          * Page/profile types
60          *
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).
66          *
67          * @{
68          */
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;
75         /**
76          * @}
77          */
78
79         /**
80          * Account types
81          *
82          * ACCOUNT_TYPE_PERSON - the account belongs to a person
83          *      Associated page types: PAGE_FLAGS_NORMAL, PAGE_FLAGS_SOAPBOX, PAGE_FLAGS_FREELOVE
84          *
85          * ACCOUNT_TYPE_ORGANISATION - the account belongs to an organisation
86          *      Associated page type: PAGE_FLAGS_SOAPBOX
87          *
88          * ACCOUNT_TYPE_NEWS - the account is a news reflector
89          *      Associated page type: PAGE_FLAGS_SOAPBOX
90          *
91          * ACCOUNT_TYPE_COMMUNITY - the account is community group
92          *      Associated page types: PAGE_COMMUNITY, PAGE_FLAGS_PRVGROUP
93          *
94          * ACCOUNT_TYPE_RELAY - the account is a relay
95          *      This will only be assigned to contacts, not to user accounts
96          * @{
97          */
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;
104         /**
105          * @}
106          */
107
108         private static $owner;
109
110         /**
111          * Returns the numeric account type by their string
112          *
113          * @param string $accounttype as string constant
114          * @return int|null Numeric account type - or null when not set
115          */
116         public static function getAccountTypeByString(string $accounttype)
117         {
118                 switch ($accounttype) {
119                         case 'person':
120                                 return User::ACCOUNT_TYPE_PERSON;
121
122                         case 'organisation':
123                                 return User::ACCOUNT_TYPE_ORGANISATION;
124
125                         case 'news':
126                                 return User::ACCOUNT_TYPE_NEWS;
127
128                         case 'community':
129                                 return User::ACCOUNT_TYPE_COMMUNITY;
130                 }
131                 return null;
132         }
133
134         /**
135          * Get the Uri-Id of the system account
136          *
137          * @return integer
138          */
139         public static function getSystemUriId(): int
140         {
141                 $system = self::getSystemAccount();
142                 return $system['uri-id'] ?? 0;
143         }
144
145         /**
146          * Fetch the system account
147          *
148          * @return array system account
149          */
150         public static function getSystemAccount(): array
151         {
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)) {
157                                 return [];
158                         }
159                 }
160
161                 $system['sprvkey'] = $system['uprvkey'] = $system['prvkey'];
162                 $system['spubkey'] = $system['upubkey'] = $system['pubkey'];
163                 $system['nickname'] = $system['nick'];
164                 $system['page-flags'] = User::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';
182
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'])) {
186                         $fields = [
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(),
195                                 'verified' => true,
196                                 'page-flags' => User::PAGE_FLAGS_SOAPBOX,
197                                 'account-type' => User::ACCOUNT_TYPE_RELAY,
198                         ];
199
200                         DBA::update('user', $fields, ['uid' => 0]);
201
202                         $system['guid'] = $fields['guid'];
203                 } else {
204                         $system['guid']     = $user['guid'];
205                         $system['language'] = $user['language'];
206                 }
207
208                 return $system;
209         }
210
211         /**
212          * Create the system account
213          *
214          * @return void
215          */
216         private static function createSystemAccount()
217         {
218                 $system_actor_name = self::getActorName();
219                 if (empty($system_actor_name)) {
220                         return;
221                 }
222
223                 $keys = Crypto::newKeypair(4096);
224                 if ($keys === false) {
225                         throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
226                 }
227
228                 $system = [
229                         'uid'          => 0,
230                         'created'      => DateTimeFormat::utcNow(),
231                         'self'         => true,
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'],
239                         'blocked'      => 0,
240                         'pending'      => 0,
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(),
245                         'closeness'    => 0,
246                         'baseurl'      => DI::baseUrl(),
247                 ];
248
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']);
254
255                 Contact::insert($system);
256         }
257
258         /**
259          * Detect a usable actor name
260          *
261          * @return string actor account name
262          */
263         public static function getActorName(): string
264         {
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'];
273                                 }
274                         }
275                         return $system_actor_name;
276                 }
277
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);
283                                 return $name;
284                         }
285                 }
286                 return '';
287         }
288
289         /**
290          * Returns true if a user record exists with the provided id
291          *
292          * @param  int $uid
293          *
294          * @return boolean
295          * @throws Exception
296          */
297         public static function exists(int $uid): bool
298         {
299                 return DBA::exists('user', ['uid' => $uid]);
300         }
301
302         /**
303          * @param  integer       $uid
304          * @param array          $fields
305          * @return array|boolean User record if it exists, false otherwise
306          * @throws Exception
307          */
308         public static function getById(int $uid, array $fields = [])
309         {
310                 return !empty($uid) ? DBA::selectFirst('user', $fields, ['uid' => $uid]) : [];
311         }
312
313         /**
314          * Returns a user record based on it's GUID
315          *
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
319          *
320          * @return array|boolean User record if it exists, false otherwise
321          * @throws Exception
322          */
323         public static function getByGuid(string $guid, array $fields = [], bool $active = true)
324         {
325                 if ($active) {
326                         $cond = ['guid' => $guid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false];
327                 } else {
328                         $cond = ['guid' => $guid];
329                 }
330
331                 return DBA::selectFirst('user', $fields, $cond);
332         }
333
334         /**
335          * @param  string        $nickname
336          * @param array          $fields
337          * @return array|boolean User record if it exists, false otherwise
338          * @throws Exception
339          */
340         public static function getByNickname(string $nickname, array $fields = [])
341         {
342                 return DBA::selectFirst('user', $fields, ['nickname' => $nickname]);
343         }
344
345         /**
346          * Set static settings for community user accounts
347          *
348          * @param integer $uid
349          * @return void
350          */
351         public static function setCommunityUserSettings(int $uid)
352         {
353                 $user = self::getById($uid, ['account-type', 'page-flags']);
354                 if ($user['account-type'] != User::ACCOUNT_TYPE_COMMUNITY) {
355                         return;
356                 }
357
358                 DI::pConfig()->set($uid, 'system', 'unlisted', true);
359
360                 $fields = [
361                         'allow_cid'  => '',
362                         'allow_gid'  => $user['page-flags'] == User::PAGE_FLAGS_PRVGROUP ? '<' . Circle::FOLLOWERS . '>' : '',
363                         'deny_cid'   => '',
364                         'deny_gid'   => '',
365                         'blockwall'  => true,
366                         'blocktags'  => true,
367                 ];
368
369                 User::update($fields, $uid);
370
371                 Profile::update(['hide-friends' => true], $uid);
372         }
373
374         /**
375          * Returns the user id of a given profile URL
376          *
377          * @param string $url
378          *
379          * @return integer user id
380          * @throws Exception
381          */
382         public static function getIdForURL(string $url): int
383         {
384                 // Avoid database queries when the local node hostname isn't even part of the url.
385                 if (!Contact::isLocal($url)) {
386                         return 0;
387                 }
388
389                 $self = Contact::selectFirst(['uid'], ['self' => true, 'nurl' => Strings::normaliseLink($url)]);
390                 if (!empty($self['uid'])) {
391                         return $self['uid'];
392                 }
393
394                 $self = Contact::selectFirst(['uid'], ['self' => true, 'addr' => $url]);
395                 if (!empty($self['uid'])) {
396                         return $self['uid'];
397                 }
398
399                 $self = Contact::selectFirst(['uid'], ['self' => true, 'alias' => [$url, Strings::normaliseLink($url)]]);
400                 if (!empty($self['uid'])) {
401                         return $self['uid'];
402                 }
403
404                 return 0;
405         }
406
407         /**
408          * Get a user based on its email
409          *
410          * @param string $email
411          * @param array  $fields
412          * @return array|boolean User record if it exists, false otherwise
413          * @throws Exception
414          */
415         public static function getByEmail(string $email, array $fields = [])
416         {
417                 return DBA::selectFirst('user', $fields, ['email' => $email]);
418         }
419
420         /**
421          * Fetch the user array of the administrator. The first one if there are several.
422          *
423          * @param array $fields
424          * @return array user
425          * @throws Exception
426          */
427         public static function getFirstAdmin(array $fields = []): array
428         {
429                 if (!empty(DI::config()->get('config', 'admin_nickname'))) {
430                         return self::getByNickname(DI::config()->get('config', 'admin_nickname'), $fields);
431                 }
432
433                 return self::getAdminList()[0] ?? [];
434         }
435
436         /**
437          * Get owner data by user id
438          *
439          * @param int     $uid
440          * @param boolean $repairMissing Repair the owner data if it's missing
441          * @return boolean|array
442          * @throws Exception
443          */
444         public static function getOwnerDataById(int $uid, bool $repairMissing = true)
445         {
446                 if ($uid == 0) {
447                         return self::getSystemAccount();
448                 }
449
450                 if (!empty(self::$owner[$uid])) {
451                         return self::$owner[$uid];
452                 }
453
454                 $owner = DBA::selectFirst('owner-view', [], ['uid' => $uid]);
455                 if (!DBA::isResult($owner)) {
456                         if (!self::exists($uid) || !$repairMissing) {
457                                 return false;
458                         }
459                         if (!DBA::exists('profile', ['uid' => $uid])) {
460                                 DBA::insert('profile', ['uid' => $uid]);
461                         }
462                         if (!DBA::exists('contact', ['uid' => $uid, 'self' => true])) {
463                                 Contact::createSelfFromUserId($uid);
464                         }
465                         $owner = self::getOwnerDataById($uid, false);
466                 }
467
468                 if (empty($owner['nickname'])) {
469                         return false;
470                 }
471
472                 if (!$repairMissing || $owner['account_expired']) {
473                         return $owner;
474                 }
475
476                 // Check if the returned data is valid, otherwise fix it. See issue #6122
477
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']));
481
482                 if (!$repair) {
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']);
486                 }
487
488                 if (!$repair) {
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']);
493                         }
494                 }
495
496                 if ($repair) {
497                         Contact::updateSelfFromUserID($uid);
498                         // Return the corrected data and avoid a loop
499                         $owner = self::getOwnerDataById($uid, false);
500                 }
501
502                 self::$owner[$uid] = $owner;
503                 return $owner;
504         }
505
506         /**
507          * Get owner data by nick name
508          *
509          * @param int $nick
510          * @return boolean|array
511          * @throws Exception
512          */
513         public static function getOwnerDataByNick(string $nick)
514         {
515                 $user = DBA::selectFirst('user', ['uid'], ['nickname' => $nick]);
516
517                 if (!DBA::isResult($user)) {
518                         return false;
519                 }
520
521                 return self::getOwnerDataById($user['uid']);
522         }
523
524         /**
525          * Returns the default circle for a given user
526          *
527          * @param int $uid User id
528          *
529          * @return int circle id
530          * @throws Exception
531          */
532         public static function getDefaultCircle(int $uid): int
533         {
534                 $user = DBA::selectFirst('user', ['def_gid'], ['uid' => $uid]);
535                 if (DBA::isResult($user)) {
536                         $default_circle = $user['def_gid'];
537                 } else {
538                         $default_circle = 0;
539                 }
540
541                 return $default_circle;
542         }
543
544         /**
545          * Returns the default circle for groups for a given user
546          *
547          * @param int $uid User id
548          *
549          * @return int circle id
550          * @throws Exception
551          */
552         public static function getDefaultGroupCircle(int $uid): int
553         {
554                 $default_circle = DI::pConfig()->get($uid, 'system', 'default-group-gid');
555                 if (empty($default_circle)) {
556                         $default_circle = self::getDefaultCircle($uid);
557                 }
558
559                 return $default_circle;
560         }
561
562         /**
563          * Fetch the language code from the given user. If the code is invalid, return the system language
564          *
565          * @param integer $uid User-Id
566          * @return string
567          */
568         public static function getLanguageCode(int $uid): string
569         {
570                 $owner    = self::getOwnerDataById($uid);
571                 $language = DI::l10n()->toISO6391($owner['language']);
572                 if (in_array($language, array_keys(DI::l10n()->getLanguageCodes()))) {
573                         return $language;
574                 }
575                 return DI::l10n()->toISO6391(DI::config()->get('system', 'language'));
576         }
577
578         /**
579          * Get a list of all languages that are used by the users
580          *
581          * @return array
582          */
583         public static function getLanguages(): array
584         {
585                 $cachekey  = 'user:getLanguages';
586                 $languages = DI::cache()->get($cachekey);
587                 if (!is_null($languages)) {
588                         return $languages;
589                 }
590
591                 $supported = array_keys(DI::l10n()->getLanguageCodes());
592                 $languages = [];
593                 $uids      = [];
594
595                 $condition = ["`verified` AND NOT `blocked` AND NOT `account_removed` AND NOT `account_expired` AND `uid` > ?", 0];
596
597                 $abandon_days = intval(DI::config()->get('system', 'account_abandon_days'));
598                 if (!empty($abandon_days)) {
599                         $condition = DBA::mergeConditions($condition, ["`last-activity` > ?", DateTimeFormat::utc('now - ' . $abandon_days . ' days')]);
600                 }
601
602                 $users = DBA::select('user', ['uid', 'language'], $condition);
603                 while ($user = DBA::fetch($users)) {
604                         $uids[] = $user['uid'];
605                         $code = DI::l10n()->toISO6391($user['language']);
606                         if (!in_array($code, $supported)) {
607                                 continue;
608                         }
609                         $languages[$code] = $code;
610                 }
611                 DBA::close($users);
612
613                 $channels = DBA::select('pconfig', ['uid', 'v'], ["`cat` = ? AND `k` = ? AND `v` != ?", 'channel', 'languages', '']);
614                 while ($channel = DBA::fetch($channels)) {
615                         if (!in_array($channel['uid'], $uids)) {
616                                 continue;
617                         }
618                         $values = unserialize($channel['v']);
619                         if (!empty($values) && is_array($values)) {
620                                 foreach ($values as $language) {
621                                         $language = DI::l10n()->toISO6391($language);
622                                         $languages[$language] = $language;
623                                 }
624                         }
625                 }
626                 DBA::close($channels);
627
628                 ksort($languages);
629                 $languages = array_keys($languages);
630                 DI::cache()->set($cachekey, $languages);
631
632                 return $languages;
633         }
634
635         /**
636          * Authenticate a user with a clear text password
637          *
638          * Returns the user id associated with a successful password authentication
639          *
640          * @param mixed  $user_info
641          * @param string $password
642          * @param bool   $third_party
643          * @return int User Id if authentication is successful
644          * @throws HTTPException\ForbiddenException
645          * @throws HTTPException\NotFoundException
646          */
647         public static function getIdFromPasswordAuthentication($user_info, string $password, bool $third_party = false): int
648         {
649                 // Addons registered with the "authenticate" hook may create the user on the
650                 // fly. `getAuthenticationInfo` will fail if the user doesn't exist yet. If
651                 // the user doesn't exist, we should give the addons a chance to create the
652                 // user in our database, if applicable, before re-throwing the exception if
653                 // they fail.
654                 try {
655                         $user = self::getAuthenticationInfo($user_info);
656                 } catch (Exception $e) {
657                         $username = (is_string($user_info) ? $user_info : $user_info['nickname'] ?? '');
658
659                         // Addons can create users, and since this 'catch' branch should only
660                         // execute if getAuthenticationInfo can't find an existing user, that's
661                         // exactly what will happen here. Creating a numeric username would create
662                         // ambiguity with user IDs, possibly opening up an attack vector.
663                         // So let's be very careful about that.
664                         if (empty($username) || is_numeric($username)) {
665                                 throw $e;
666                         }
667
668                         return self::getIdFromAuthenticateHooks($username, $password);
669                 }
670
671                 if ($third_party && DI::pConfig()->get($user['uid'], '2fa', 'verified')) {
672                         // Third-party apps can't verify two-factor authentication, we use app-specific passwords instead
673                         if (AppSpecificPassword::authenticateUser($user['uid'], $password)) {
674                                 return $user['uid'];
675                         }
676                 } elseif (strpos($user['password'], '$') === false) {
677                         //Legacy hash that has not been replaced by a new hash yet
678                         if (self::hashPasswordLegacy($password) === $user['password']) {
679                                 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
680
681                                 return $user['uid'];
682                         }
683                 } elseif (!empty($user['legacy_password'])) {
684                         //Legacy hash that has been double-hashed and not replaced by a new hash yet
685                         //Warning: `legacy_password` is not necessary in sync with the content of `password`
686                         if (password_verify(self::hashPasswordLegacy($password), $user['password'])) {
687                                 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
688
689                                 return $user['uid'];
690                         }
691                 } elseif (password_verify($password, $user['password'])) {
692                         //New password hash
693                         if (password_needs_rehash($user['password'], PASSWORD_DEFAULT)) {
694                                 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
695                         }
696
697                         return $user['uid'];
698                 } else {
699                         return self::getIdFromAuthenticateHooks($user['nickname'], $password); // throws
700                 }
701
702                 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
703         }
704
705         /**
706          * Try to obtain a user ID via "authenticate" hook addons
707          *
708          * Returns the user id associated with a successful password authentication
709          *
710          * @param string $username
711          * @param string $password
712          * @return int User Id if authentication is successful
713          * @throws HTTPException\ForbiddenException
714          */
715         public static function getIdFromAuthenticateHooks(string $username, string $password): int
716         {
717                 $addon_auth = [
718                         'username'      => $username,
719                         'password'      => $password,
720                         'authenticated' => 0,
721                         'user_record'   => null
722                 ];
723
724                 /*
725                  * An addon indicates successful login by setting 'authenticated' to non-zero value and returning a user record
726                  * Addons should never set 'authenticated' except to indicate success - as hooks may be chained
727                  * and later addons should not interfere with an earlier one that succeeded.
728                  */
729                 Hook::callAll('authenticate', $addon_auth);
730
731                 if ($addon_auth['authenticated'] && $addon_auth['user_record']) {
732                         return $addon_auth['user_record']['uid'];
733                 }
734
735                 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
736         }
737
738         /**
739          * Returns authentication info from various parameters types
740          *
741          * User info can be any of the following:
742          * - User DB object
743          * - User Id
744          * - User email or username or nickname
745          * - User array with at least the uid and the hashed password
746          *
747          * @param mixed $user_info
748          * @return array|null Null if not found/determined
749          * @throws HTTPException\NotFoundException
750          */
751         public static function getAuthenticationInfo($user_info)
752         {
753                 $user = null;
754
755                 if (is_object($user_info) || is_array($user_info)) {
756                         if (is_object($user_info)) {
757                                 $user = (array) $user_info;
758                         } else {
759                                 $user = $user_info;
760                         }
761
762                         if (
763                                 !isset($user['uid'])
764                                 || !isset($user['password'])
765                                 || !isset($user['legacy_password'])
766                         ) {
767                                 throw new Exception(DI::l10n()->t('Not enough information to authenticate'));
768                         }
769                 } elseif (is_int($user_info) || is_string($user_info)) {
770                         if (is_int($user_info)) {
771                                 $user = DBA::selectFirst(
772                                         'user',
773                                         ['uid', 'nickname', 'password', 'legacy_password'],
774                                         [
775                                                 'uid' => $user_info,
776                                                 'blocked' => 0,
777                                                 'account_expired' => 0,
778                                                 'account_removed' => 0,
779                                                 'verified' => 1
780                                         ]
781                                 );
782                         } else {
783                                 $fields = ['uid', 'nickname', 'password', 'legacy_password'];
784                                 $condition = [
785                                         "(`email` = ? OR `username` = ? OR `nickname` = ?)
786                                         AND `verified` AND NOT `blocked` AND NOT `account_removed` AND NOT `account_expired`",
787                                         $user_info, $user_info, $user_info
788                                 ];
789                                 $user = DBA::selectFirst('user', $fields, $condition);
790                         }
791
792                         if (!DBA::isResult($user)) {
793                                 throw new HTTPException\NotFoundException(DI::l10n()->t('User not found'));
794                         }
795                 }
796
797                 return $user;
798         }
799
800         /**
801          * Update the day of the last activity of the given user
802          *
803          * @param integer $uid
804          * @return void
805          */
806         public static function updateLastActivity(int $uid)
807         {
808                 if (!$uid) {
809                         return;
810                 }
811
812                 $user = User::getById($uid, ['last-activity']);
813                 if (empty($user)) {
814                         return;
815                 }
816
817                 $current_day = DateTimeFormat::utcNow('Y-m-d');
818
819                 if ($user['last-activity'] != $current_day) {
820                         User::update(['last-activity' => $current_day], $uid);
821                         // Set the last activity for all identities of the user
822                         DBA::update('user', ['last-activity' => $current_day], ['parent-uid' => $uid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]);
823                 }
824         }
825
826         /**
827          * Generates a human-readable random password
828          *
829          * @return string
830          * @throws Exception
831          */
832         public static function generateNewPassword(): string
833         {
834                 return ucfirst(Strings::getRandomName(8)) . random_int(1000, 9999);
835         }
836
837         /**
838          * Checks if the provided plaintext password has been exposed or not
839          *
840          * @param string $password
841          * @return bool
842          * @throws Exception
843          */
844         public static function isPasswordExposed(string $password): bool
845         {
846                 $cache = new CacheItemPool();
847                 $cache->changeConfig([
848                         'cacheDirectory' => System::getTempPath() . '/password-exposed-cache/',
849                 ]);
850
851                 try {
852                         $passwordExposedChecker = new PasswordExposed\PasswordExposedChecker(null, $cache);
853
854                         return $passwordExposedChecker->passwordExposed($password) === PasswordExposed\PasswordStatus::EXPOSED;
855                 } catch (Exception $e) {
856                         Logger::error('Password Exposed Exception: ' . $e->getMessage(), [
857                                 'code' => $e->getCode(),
858                                 'file' => $e->getFile(),
859                                 'line' => $e->getLine(),
860                                 'trace' => $e->getTraceAsString()
861                         ]);
862
863                         return false;
864                 }
865         }
866
867         /**
868          * Legacy hashing function, kept for password migration purposes
869          *
870          * @param string $password
871          * @return string
872          */
873         private static function hashPasswordLegacy(string $password): string
874         {
875                 return hash('whirlpool', $password);
876         }
877
878         /**
879          * Global user password hashing function
880          *
881          * @param string $password
882          * @return string
883          * @throws Exception
884          */
885         public static function hashPassword(string $password): string
886         {
887                 if (!trim($password)) {
888                         throw new Exception(DI::l10n()->t('Password can\'t be empty'));
889                 }
890
891                 return password_hash($password, PASSWORD_DEFAULT);
892         }
893
894         /**
895          * Allowed characters are a-z, A-Z, 0-9 and special characters except white spaces and accentuated letters.
896          *
897          * Password length is limited to 72 characters if the current default password hashing algorithm is Blowfish.
898          * From the manual: "Using the PASSWORD_BCRYPT as the algorithm, will result in the password parameter being
899          * truncated to a maximum length of 72 bytes."
900          *
901          * @see https://www.php.net/manual/en/function.password-hash.php#refsect1-function.password-hash-parameters
902          *
903          * @param string|null $delimiter Whether the regular expression is meant to be wrapper in delimiter characters
904          * @return string
905          */
906         public static function getPasswordRegExp(string $delimiter = null): string
907         {
908                 $allowed_characters = ':!"#$%&\'()*+,-./;<=>?@[\]^_`{|}~';
909
910                 if ($delimiter) {
911                         $allowed_characters = preg_quote($allowed_characters, $delimiter);
912                 }
913
914                 return '^[a-zA-Z0-9' . $allowed_characters . ']' . (PASSWORD_DEFAULT === PASSWORD_BCRYPT ? '{1,72}' : '+') . '$';
915         }
916
917         /**
918          * Updates a user row with a new plaintext password
919          *
920          * @param int    $uid
921          * @param string $password
922          * @return bool
923          * @throws Exception
924          */
925         public static function updatePassword(int $uid, string $password): bool
926         {
927                 $password = trim($password);
928
929                 if (empty($password)) {
930                         throw new Exception(DI::l10n()->t('Empty passwords are not allowed.'));
931                 }
932
933                 if (!DI::config()->get('system', 'disable_password_exposed', false) && self::isPasswordExposed($password)) {
934                         throw new Exception(DI::l10n()->t('The new password has been exposed in a public data dump, please choose another.'));
935                 }
936
937                 if (PASSWORD_DEFAULT === PASSWORD_BCRYPT && strlen($password) > 72) {
938                         throw new Exception(DI::l10n()->t('The password length is limited to 72 characters.'));
939                 }
940
941                 if (!preg_match('/' . self::getPasswordRegExp('/') . '/', $password)) {
942                         throw new Exception(DI::l10n()->t("The password can't contain white spaces nor accentuated letters"));
943                 }
944
945                 return self::updatePasswordHashed($uid, self::hashPassword($password));
946         }
947
948         /**
949          * Updates a user row with a new hashed password.
950          * Empties the password reset token field just in case.
951          *
952          * @param int    $uid
953          * @param string $password_hashed
954          * @return bool
955          * @throws Exception
956          */
957         private static function updatePasswordHashed(int $uid, string $password_hashed): bool
958         {
959                 $fields = [
960                         'password' => $password_hashed,
961                         'pwdreset' => null,
962                         'pwdreset_time' => null,
963                         'legacy_password' => false
964                 ];
965                 return DBA::update('user', $fields, ['uid' => $uid]);
966         }
967
968         /**
969          * Returns if the given uid is valid and in the admin list
970          *
971          * @param int $uid
972          *
973          * @return bool
974          * @throws Exception
975          */
976         public static function isSiteAdmin(int $uid): bool
977         {
978                 return DBA::exists('user', [
979                         'uid'   => $uid,
980                         'email' => self::getAdminEmailList()
981                 ]);
982         }
983
984         /**
985          * Returns if the given uid is valid and a moderator
986          *
987          * @param int $uid
988          *
989          * @return bool
990          * @throws Exception
991          */
992         public static function isModerator(int $uid): bool
993         {
994                 // @todo Replace with a moderator check in the future
995                 return self::isSiteAdmin($uid);
996         }
997
998         /**
999          * Checks if a nickname is in the list of the forbidden nicknames
1000          *
1001          * Check if a nickname is forbidden from registration on the node by the
1002          * admin. Forbidden nicknames (e.g. role names) can be configured in the
1003          * admin panel.
1004          *
1005          * @param string $nickname The nickname that should be checked
1006          * @return boolean True is the nickname is blocked on the node
1007          */
1008         public static function isNicknameBlocked(string $nickname): bool
1009         {
1010                 $forbidden_nicknames = DI::config()->get('system', 'forbidden_nicknames', '');
1011                 if (!empty($forbidden_nicknames)) {
1012                         $forbidden = explode(',', $forbidden_nicknames);
1013                         $forbidden = array_map('trim', $forbidden);
1014                 } else {
1015                         $forbidden = [];
1016                 }
1017
1018                 // Add the name of the internal actor to the "forbidden" list
1019                 $actor_name = self::getActorName();
1020                 if (!empty($actor_name)) {
1021                         $forbidden[] = $actor_name;
1022                 }
1023
1024                 if (empty($forbidden)) {
1025                         return false;
1026                 }
1027
1028                 // check if the nickname is in the list of blocked nicknames
1029                 if (in_array(strtolower($nickname), $forbidden)) {
1030                         return true;
1031                 }
1032
1033                 // else return false
1034                 return false;
1035         }
1036
1037         /**
1038          * Get avatar link for given user
1039          *
1040          * @param array  $user
1041          * @param string $size One of the Proxy::SIZE_* constants
1042          * @return string avatar link
1043          * @throws Exception
1044          */
1045         public static function getAvatarUrl(array $user, string $size = ''): string
1046         {
1047                 if (empty($user['nickname'])) {
1048                         DI::logger()->warning('Missing user nickname key');
1049                 }
1050
1051                 $url = DI::baseUrl() . '/photo/';
1052
1053                 switch ($size) {
1054                         case Proxy::SIZE_MICRO:
1055                                 $url .= 'micro/';
1056                                 $scale = 6;
1057                                 break;
1058                         case Proxy::SIZE_THUMB:
1059                                 $url .= 'avatar/';
1060                                 $scale = 5;
1061                                 break;
1062                         default:
1063                                 $url .= 'profile/';
1064                                 $scale = 4;
1065                                 break;
1066                 }
1067
1068                 $updated  =  '';
1069                 $mimetype = '';
1070
1071                 $photo = Photo::selectFirst(['type', 'created', 'edited', 'updated'], ["scale" => $scale, 'uid' => $user['uid'], 'profile' => true]);
1072                 if (!empty($photo)) {
1073                         $updated  = max($photo['created'], $photo['edited'], $photo['updated']);
1074                         $mimetype = $photo['type'];
1075                 }
1076
1077                 return $url . $user['nickname'] . Images::getExtensionByMimeType($mimetype) . ($updated ? '?ts=' . strtotime($updated) : '');
1078         }
1079
1080         /**
1081          * Get banner link for given user
1082          *
1083          * @param array  $user
1084          * @return string banner link
1085          * @throws Exception
1086          */
1087         public static function getBannerUrl(array $user): string
1088         {
1089                 if (empty($user['nickname'])) {
1090                         DI::logger()->warning('Missing user nickname key');
1091                 }
1092
1093                 $url = DI::baseUrl() . '/photo/banner/';
1094
1095                 $updated  = '';
1096                 $mimetype = '';
1097
1098                 $photo = Photo::selectFirst(['type', 'created', 'edited', 'updated'], ["scale" => 3, 'uid' => $user['uid'], 'photo-type' => Photo::USER_BANNER]);
1099                 if (!empty($photo)) {
1100                         $updated  = max($photo['created'], $photo['edited'], $photo['updated']);
1101                         $mimetype = $photo['type'];
1102                 } else {
1103                         // Only for the RC phase: Don't return an image link for the default picture
1104                         return '';
1105                 }
1106
1107                 return $url . $user['nickname'] . Images::getExtensionByMimeType($mimetype) . ($updated ? '?ts=' . strtotime($updated) : '');
1108         }
1109
1110         /**
1111          * Catch-all user creation function
1112          *
1113          * Creates a user from the provided data array, either form fields or OpenID.
1114          * Required: { username, nickname, email } or { openid_url }
1115          *
1116          * Performs the following:
1117          * - Sends to the OpenId auth URL (if relevant)
1118          * - Creates new key pairs for crypto
1119          * - Create self-contact
1120          * - Create profile image
1121          *
1122          * @param  array $data
1123          * @return array
1124          * @throws ErrorException
1125          * @throws HTTPException\InternalServerErrorException
1126          * @throws ImagickException
1127          * @throws Exception
1128          */
1129         public static function create(array $data): array
1130         {
1131                 $return = ['user' => null, 'password' => ''];
1132
1133                 $using_invites = DI::config()->get('system', 'invitation_only');
1134
1135                 $invite_id  = !empty($data['invite_id'])  ? trim($data['invite_id'])  : '';
1136                 $username   = !empty($data['username'])   ? trim($data['username'])   : '';
1137                 $nickname   = !empty($data['nickname'])   ? trim($data['nickname'])   : '';
1138                 $email      = !empty($data['email'])      ? trim($data['email'])      : '';
1139                 $openid_url = !empty($data['openid_url']) ? trim($data['openid_url']) : '';
1140                 $photo      = !empty($data['photo'])      ? trim($data['photo'])      : '';
1141                 $password   = !empty($data['password'])   ? trim($data['password'])   : '';
1142                 $password1  = !empty($data['password1'])  ? trim($data['password1'])  : '';
1143                 $confirm    = !empty($data['confirm'])    ? trim($data['confirm'])    : '';
1144                 $blocked    = !empty($data['blocked']);
1145                 $verified   = !empty($data['verified']);
1146                 $language   = !empty($data['language'])   ? trim($data['language'])   : 'en';
1147
1148                 $netpublish = $publish = !empty($data['profile_publish_reg']);
1149
1150                 if ($password1 != $confirm) {
1151                         throw new Exception(DI::l10n()->t('Passwords do not match. Password unchanged.'));
1152                 } elseif ($password1 != '') {
1153                         $password = $password1;
1154                 }
1155
1156                 if ($using_invites) {
1157                         if (!$invite_id) {
1158                                 throw new Exception(DI::l10n()->t('An invitation is required.'));
1159                         }
1160
1161                         if (!Register::existsByHash($invite_id)) {
1162                                 throw new Exception(DI::l10n()->t('Invitation could not be verified.'));
1163                         }
1164                 }
1165
1166                 /// @todo Check if this part is really needed. We should have fetched all this data in advance
1167                 if (empty($username) || empty($email) || empty($nickname)) {
1168                         if ($openid_url) {
1169                                 if (!Network::isUrlValid($openid_url)) {
1170                                         throw new Exception(DI::l10n()->t('Invalid OpenID url'));
1171                                 }
1172                                 $_SESSION['register'] = 1;
1173                                 $_SESSION['openid'] = $openid_url;
1174
1175                                 $openid = new LightOpenID(DI::baseUrl()->getHost());
1176                                 $openid->identity = $openid_url;
1177                                 $openid->returnUrl = DI::baseUrl() . '/openid';
1178                                 $openid->required = ['namePerson/friendly', 'contact/email', 'namePerson'];
1179                                 $openid->optional = ['namePerson/first', 'media/image/aspect11', 'media/image/default'];
1180                                 try {
1181                                         $authurl = $openid->authUrl();
1182                                 } catch (Exception $e) {
1183                                         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);
1184                                 }
1185                                 System::externalRedirect($authurl);
1186                                 // NOTREACHED
1187                         }
1188
1189                         throw new Exception(DI::l10n()->t('Please enter the required information.'));
1190                 }
1191
1192                 if (!Network::isUrlValid($openid_url)) {
1193                         $openid_url = '';
1194                 }
1195
1196                 // collapse multiple spaces in name
1197                 $username = preg_replace('/ +/', ' ', $username);
1198
1199                 $username_min_length = max(1, min(64, intval(DI::config()->get('system', 'username_min_length', 3))));
1200                 $username_max_length = max(1, min(64, intval(DI::config()->get('system', 'username_max_length', 48))));
1201
1202                 if ($username_min_length > $username_max_length) {
1203                         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));
1204                         $tmp = $username_min_length;
1205                         $username_min_length = $username_max_length;
1206                         $username_max_length = $tmp;
1207                 }
1208
1209                 if (mb_strlen($username) < $username_min_length) {
1210                         throw new Exception(DI::l10n()->tt('Username should be at least %s character.', 'Username should be at least %s characters.', $username_min_length));
1211                 }
1212
1213                 if (mb_strlen($username) > $username_max_length) {
1214                         throw new Exception(DI::l10n()->tt('Username should be at most %s character.', 'Username should be at most %s characters.', $username_max_length));
1215                 }
1216
1217                 // So now we are just looking for a space in the display name.
1218                 $loose_reg = DI::config()->get('system', 'no_regfullname');
1219                 if (!$loose_reg) {
1220                         $username = mb_convert_case($username, MB_CASE_TITLE, 'UTF-8');
1221                         if (strpos($username, ' ') === false) {
1222                                 throw new Exception(DI::l10n()->t("That doesn't appear to be your full (First Last) name."));
1223                         }
1224                 }
1225
1226                 if (!Network::isEmailDomainAllowed($email)) {
1227                         throw new Exception(DI::l10n()->t('Your email domain is not among those allowed on this site.'));
1228                 }
1229
1230                 if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !Network::isEmailDomainValid($email)) {
1231                         throw new Exception(DI::l10n()->t('Not a valid email address.'));
1232                 }
1233                 if (self::isNicknameBlocked($nickname)) {
1234                         throw new Exception(DI::l10n()->t('The nickname was blocked from registration by the nodes admin.'));
1235                 }
1236
1237                 if (DI::config()->get('system', 'block_extended_register', false) && DBA::exists('user', ['email' => $email])) {
1238                         throw new Exception(DI::l10n()->t('Cannot use that email.'));
1239                 }
1240
1241                 // Disallow somebody creating an account using openid that uses the admin email address,
1242                 // since openid bypasses email verification. We'll allow it if there is not yet an admin account.
1243                 if (strlen($openid_url) && in_array(strtolower($email), self::getAdminEmailList())) {
1244                         throw new Exception(DI::l10n()->t('Cannot use that email.'));
1245                 }
1246
1247                 $nickname = $data['nickname'] = strtolower($nickname);
1248
1249                 if (!preg_match('/^[a-z0-9][a-z0-9_]*$/', $nickname)) {
1250                         throw new Exception(DI::l10n()->t('Your nickname can only contain a-z, 0-9 and _.'));
1251                 }
1252
1253                 // Check existing and deleted accounts for this nickname.
1254                 if (
1255                         DBA::exists('user', ['nickname' => $nickname])
1256                         || DBA::exists('userd', ['username' => $nickname])
1257                 ) {
1258                         throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1259                 }
1260
1261                 $new_password = strlen($password) ? $password : User::generateNewPassword();
1262                 $new_password_encoded = self::hashPassword($new_password);
1263
1264                 $return['password'] = $new_password;
1265
1266                 $keys = Crypto::newKeypair(4096);
1267                 if ($keys === false) {
1268                         throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
1269                 }
1270
1271                 $prvkey = $keys['prvkey'];
1272                 $pubkey = $keys['pubkey'];
1273
1274                 // Create another keypair for signing/verifying salmon protocol messages.
1275                 $sres = Crypto::newKeypair(512);
1276                 $sprvkey = $sres['prvkey'];
1277                 $spubkey = $sres['pubkey'];
1278
1279                 $insert_result = DBA::insert('user', [
1280                         'guid'     => System::createUUID(),
1281                         'username' => $username,
1282                         'password' => $new_password_encoded,
1283                         'email'    => $email,
1284                         'openid'   => $openid_url,
1285                         'nickname' => $nickname,
1286                         'pubkey'   => $pubkey,
1287                         'prvkey'   => $prvkey,
1288                         'spubkey'  => $spubkey,
1289                         'sprvkey'  => $sprvkey,
1290                         'verified' => $verified,
1291                         'blocked'  => $blocked,
1292                         'language' => $language,
1293                         'timezone' => 'UTC',
1294                         'register_date' => DateTimeFormat::utcNow(),
1295                         'default-location' => ''
1296                 ]);
1297
1298                 if ($insert_result) {
1299                         $uid = DBA::lastInsertId();
1300                         $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1301                 } else {
1302                         throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1303                 }
1304
1305                 if (!$uid) {
1306                         throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1307                 }
1308
1309                 // if somebody clicked submit twice very quickly, they could end up with two accounts
1310                 // due to race condition. Remove this one.
1311                 $user_count = DBA::count('user', ['nickname' => $nickname]);
1312                 if ($user_count > 1) {
1313                         DBA::delete('user', ['uid' => $uid]);
1314
1315                         throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1316                 }
1317
1318                 $insert_result = DBA::insert('profile', [
1319                         'uid' => $uid,
1320                         'name' => $username,
1321                         'photo' => self::getAvatarUrl($user),
1322                         'thumb' => self::getAvatarUrl($user, Proxy::SIZE_THUMB),
1323                         'publish' => $publish,
1324                         'net-publish' => $netpublish,
1325                 ]);
1326                 if (!$insert_result) {
1327                         DBA::delete('user', ['uid' => $uid]);
1328
1329                         throw new Exception(DI::l10n()->t('An error occurred creating your default profile. Please try again.'));
1330                 }
1331
1332                 // Create the self contact
1333                 if (!Contact::createSelfFromUserId($uid)) {
1334                         DBA::delete('user', ['uid' => $uid]);
1335
1336                         throw new Exception(DI::l10n()->t('An error occurred creating your self contact. Please try again.'));
1337                 }
1338
1339                 // Create a circle with no members. This allows somebody to use it
1340                 // right away as a default circle for new contacts.
1341                 $def_gid = Circle::create($uid, DI::l10n()->t('Friends'));
1342                 if (!$def_gid) {
1343                         DBA::delete('user', ['uid' => $uid]);
1344
1345                         throw new Exception(DI::l10n()->t('An error occurred creating your default contact circle. Please try again.'));
1346                 }
1347
1348                 $fields = ['def_gid' => $def_gid];
1349                 if (DI::config()->get('system', 'newuser_private') && $def_gid) {
1350                         $fields['allow_gid'] = '<' . $def_gid . '>';
1351                 }
1352
1353                 DBA::update('user', $fields, ['uid' => $uid]);
1354
1355                 $def_gid_groups = Circle::create($uid, DI::l10n()->t('Groups'));
1356                 if ($def_gid_groups) {
1357                         DI::pConfig()->set($uid, 'system', 'default-group-gid', $def_gid_groups);
1358                 }
1359
1360                 // if we have no OpenID photo try to look up an avatar
1361                 if (!strlen($photo)) {
1362                         $photo = Network::lookupAvatarByEmail($email);
1363                 }
1364
1365                 // unless there is no avatar-addon loaded
1366                 if (strlen($photo)) {
1367                         $photo_failure = false;
1368
1369                         $filename = basename($photo);
1370                         $curlResult = DI::httpClient()->get($photo, HttpClientAccept::IMAGE);
1371                         if ($curlResult->isSuccess()) {
1372                                 Logger::debug('Got picture', ['Content-Type' => $curlResult->getHeader('Content-Type'), 'url' => $photo]);
1373                                 $img_str = $curlResult->getBody();
1374                                 $type = $curlResult->getContentType();
1375                         } else {
1376                                 $img_str = '';
1377                                 $type = '';
1378                         }
1379
1380                         $type = Images::getMimeTypeByData($img_str, $photo, $type);
1381
1382                         $image = new Image($img_str, $type);
1383                         if ($image->isValid()) {
1384                                 $image->scaleToSquare(300);
1385
1386                                 $resource_id = Photo::newResource();
1387
1388                                 // Not using Photo::PROFILE_PHOTOS here, so that it is discovered as translatable string
1389                                 $profile_album = DI::l10n()->t('Profile Photos');
1390
1391                                 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 4);
1392
1393                                 if ($r === false) {
1394                                         $photo_failure = true;
1395                                 }
1396
1397                                 $image->scaleDown(80);
1398
1399                                 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 5);
1400
1401                                 if ($r === false) {
1402                                         $photo_failure = true;
1403                                 }
1404
1405                                 $image->scaleDown(48);
1406
1407                                 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 6);
1408
1409                                 if ($r === false) {
1410                                         $photo_failure = true;
1411                                 }
1412
1413                                 if (!$photo_failure) {
1414                                         Photo::update(['profile' => true, 'photo-type' => Photo::USER_AVATAR], ['resource-id' => $resource_id]);
1415                                 }
1416                         }
1417
1418                         Contact::updateSelfFromUserID($uid, true);
1419                 }
1420
1421                 Hook::callAll('register_account', $uid);
1422
1423                 self::setRegisterMethodByUserCount();
1424
1425                 $return['user'] = $user;
1426                 return $return;
1427         }
1428
1429         /**
1430          * Update a user entry and distribute the changes if needed
1431          *
1432          * @param array   $fields
1433          * @param integer $uid
1434          * @return boolean
1435          * @throws Exception
1436          */
1437         public static function update(array $fields, int $uid): bool
1438         {
1439                 if (!DBA::update('user', $fields, ['uid' => $uid])) {
1440                         return false;
1441                 }
1442
1443                 if (Contact::updateSelfFromUserID($uid)) {
1444                         Profile::publishUpdate($uid);
1445                 }
1446
1447                 return true;
1448         }
1449
1450         /**
1451          * Sets block state for a given user
1452          *
1453          * @param int  $uid   The user id
1454          * @param bool $block Block state (default is true)
1455          *
1456          * @return bool True, if successfully blocked
1457
1458          * @throws Exception
1459          */
1460         public static function block(int $uid, bool $block = true): bool
1461         {
1462                 return DBA::update('user', ['blocked' => $block], ['uid' => $uid]);
1463         }
1464
1465         /**
1466          * Allows a registration based on a hash
1467          *
1468          * @param string $hash
1469          *
1470          * @return bool True, if the allow was successful
1471          *
1472          * @throws HTTPException\InternalServerErrorException
1473          * @throws Exception
1474          */
1475         public static function allow(string $hash): bool
1476         {
1477                 $register = Register::getByHash($hash);
1478                 if (!DBA::isResult($register)) {
1479                         return false;
1480                 }
1481
1482                 $user = User::getById($register['uid']);
1483                 if (!DBA::isResult($user)) {
1484                         return false;
1485                 }
1486
1487                 Register::deleteByHash($hash);
1488
1489                 DBA::update('user', ['blocked' => false, 'verified' => true], ['uid' => $register['uid']]);
1490
1491                 $profile = DBA::selectFirst('profile', ['net-publish'], ['uid' => $register['uid']]);
1492
1493                 if (DBA::isResult($profile) && $profile['net-publish'] && Search::getGlobalDirectory()) {
1494                         $url = DI::baseUrl() . '/profile/' . $user['nickname'];
1495                         Worker::add(Worker::PRIORITY_LOW, "Directory", $url);
1496                 }
1497
1498                 $l10n = DI::l10n()->withLang($register['language']);
1499
1500                 return User::sendRegisterOpenEmail(
1501                         $l10n,
1502                         $user,
1503                         DI::config()->get('config', 'sitename'),
1504                         DI::baseUrl(),
1505                         ($register['password'] ?? '') ?: 'Sent in a previous email'
1506                 );
1507         }
1508
1509         /**
1510          * Denys a pending registration
1511          *
1512          * @param string $hash The hash of the pending user
1513          *
1514          * This does not have to go through user_remove() and save the nickname
1515          * permanently against re-registration, as the person was not yet
1516          * allowed to have friends on this system
1517          *
1518          * @return bool True, if the deny was successful
1519          * @throws Exception
1520          */
1521         public static function deny(string $hash): bool
1522         {
1523                 $register = Register::getByHash($hash);
1524                 if (!DBA::isResult($register)) {
1525                         return false;
1526                 }
1527
1528                 $user = User::getById($register['uid']);
1529                 if (!DBA::isResult($user)) {
1530                         return false;
1531                 }
1532
1533                 // Delete the avatar
1534                 Photo::delete(['uid' => $register['uid']]);
1535
1536                 return DBA::delete('user', ['uid' => $register['uid']]) &&
1537                         Register::deleteByHash($register['hash']);
1538         }
1539
1540         /**
1541          * Creates a new user based on a minimal set and sends an email to this user
1542          *
1543          * @param string $name  The user's name
1544          * @param string $email The user's email address
1545          * @param string $nick  The user's nick name
1546          * @param string $lang  The user's language (default is english)
1547          * @return bool True, if the user was created successfully
1548          * @throws HTTPException\InternalServerErrorException
1549          * @throws ErrorException
1550          * @throws ImagickException
1551          */
1552         public static function createMinimal(string $name, string $email, string $nick, string $lang = L10n::DEFAULT): bool
1553         {
1554                 if (empty($name) ||
1555                     empty($email) ||
1556                     empty($nick)) {
1557                         throw new HTTPException\InternalServerErrorException('Invalid arguments.');
1558                 }
1559
1560                 $result = self::create([
1561                         'username' => $name,
1562                         'email' => $email,
1563                         'nickname' => $nick,
1564                         'verified' => 1,
1565                         'language' => $lang
1566                 ]);
1567
1568                 $user = $result['user'];
1569                 $preamble = Strings::deindent(DI::l10n()->t('
1570                 Dear %1$s,
1571                         the administrator of %2$s has set up an account for you.'));
1572                 $body = Strings::deindent(DI::l10n()->t('
1573                 The login details are as follows:
1574
1575                 Site Location:  %1$s
1576                 Login Name:             %2$s
1577                 Password:               %3$s
1578
1579                 You may change your password from your account "Settings" page after logging
1580                 in.
1581
1582                 Please take a few moments to review the other account settings on that page.
1583
1584                 You may also wish to add some basic information to your default profile
1585                 (on the "Profiles" page) so that other people can easily find you.
1586
1587                 We recommend adding a profile photo, adding some profile "keywords" 
1588                 (very useful in making new friends) - and perhaps what country you live in; 
1589                 if you do not wish to be more specific than that.
1590
1591                 We fully respect your right to privacy, and none of these items are necessary.
1592                 If you are new and do not know anybody here, they may help
1593                 you to make some new and interesting friends.
1594
1595                 If you ever want to delete your account, you can do so at %1$s/settings/removeme
1596
1597                 Thank you and welcome to %4$s.'));
1598
1599                 $preamble = sprintf($preamble, $user['username'], DI::config()->get('config', 'sitename'));
1600                 $body = sprintf($body, DI::baseUrl(), $user['nickname'], $result['password'], DI::config()->get('config', 'sitename'));
1601
1602                 $email = DI::emailer()
1603                         ->newSystemMail()
1604                         ->withMessage(DI::l10n()->t('Registration details for %s', DI::config()->get('config', 'sitename')), $preamble, $body)
1605                         ->forUser($user)
1606                         ->withRecipient($user['email'])
1607                         ->build();
1608                 return DI::emailer()->send($email);
1609         }
1610
1611         /**
1612          * Sends pending registration confirmation email
1613          *
1614          * @param array  $user     User record array
1615          * @param string $sitename
1616          * @param string $siteurl
1617          * @param string $password Plaintext password
1618          * @return NULL|boolean from notification() and email() inherited
1619          * @throws HTTPException\InternalServerErrorException
1620          */
1621         public static function sendRegisterPendingEmail(array $user, string $sitename, string $siteurl, string $password)
1622         {
1623                 $body = Strings::deindent(DI::l10n()->t(
1624                         '
1625                         Dear %1$s,
1626                                 Thank you for registering at %2$s. Your account is pending for approval by the administrator.
1627
1628                         Your login details are as follows:
1629
1630                         Site Location:  %3$s
1631                         Login Name:             %4$s
1632                         Password:               %5$s
1633                 ',
1634                         $user['username'],
1635                         $sitename,
1636                         $siteurl,
1637                         $user['nickname'],
1638                         $password
1639                 ));
1640
1641                 $email = DI::emailer()
1642                         ->newSystemMail()
1643                         ->withMessage(DI::l10n()->t('Registration at %s', $sitename), $body)
1644                         ->forUser($user)
1645                         ->withRecipient($user['email'])
1646                         ->build();
1647                 return DI::emailer()->send($email);
1648         }
1649
1650         /**
1651          * Sends registration confirmation
1652          *
1653          * It's here as a function because the mail is sent from different parts
1654          *
1655          * @param L10n   $l10n     The used language
1656          * @param array  $user     User record array
1657          * @param string $sitename
1658          * @param string $siteurl
1659          * @param string $password Plaintext password
1660          *
1661          * @return NULL|boolean from notification() and email() inherited
1662          * @throws HTTPException\InternalServerErrorException
1663          */
1664         public static function sendRegisterOpenEmail(L10n $l10n, array $user, string $sitename, string $siteurl, string $password)
1665         {
1666                 $preamble = Strings::deindent($l10n->t(
1667                         '
1668                                 Dear %1$s,
1669                                 Thank you for registering at %2$s. Your account has been created.
1670                         ',
1671                         $user['username'],
1672                         $sitename
1673                 ));
1674                 $body = Strings::deindent($l10n->t(
1675                         '
1676                         The login details are as follows:
1677
1678                         Site Location:  %3$s
1679                         Login Name:             %1$s
1680                         Password:               %5$s
1681
1682                         You may change your password from your account "Settings" page after logging
1683                         in.
1684
1685                         Please take a few moments to review the other account settings on that page.
1686
1687                         You may also wish to add some basic information to your default profile
1688                         ' . "\x28" . 'on the "Profiles" page' . "\x29" . ' so that other people can easily find you.
1689
1690                         We recommend adding a profile photo, adding some profile "keywords" ' . "\x28" . 'very useful
1691                         in making new friends' . "\x29" . ' - and perhaps what country you live in; if you do not wish
1692                         to be more specific than that.
1693
1694                         We fully respect your right to privacy, and none of these items are necessary.
1695                         If you are new and do not know anybody here, they may help
1696                         you to make some new and interesting friends.
1697
1698                         If you ever want to delete your account, you can do so at %3$s/settings/removeme
1699
1700                         Thank you and welcome to %2$s.',
1701                         $user['nickname'],
1702                         $sitename,
1703                         $siteurl,
1704                         $user['username'],
1705                         $password
1706                 ));
1707
1708                 $email = DI::emailer()
1709                         ->newSystemMail()
1710                         ->withMessage(DI::l10n()->t('Registration details for %s', $sitename), $preamble, $body)
1711                         ->forUser($user)
1712                         ->withRecipient($user['email'])
1713                         ->build();
1714                 return DI::emailer()->send($email);
1715         }
1716
1717         /**
1718          * @param int $uid user to remove
1719          * @return bool
1720          * @throws HTTPException\InternalServerErrorException
1721          * @throws HTTPException\NotFoundException
1722          */
1723         public static function remove(int $uid): bool
1724         {
1725                 if (empty($uid)) {
1726                         throw new \InvalidArgumentException('uid needs to be greater than 0');
1727                 }
1728
1729                 Logger::notice('Removing user', ['user' => $uid]);
1730
1731                 $user = self::getById($uid);
1732                 if (!$user) {
1733                         throw new HTTPException\NotFoundException('User not found with uid: ' . $uid);
1734                 }
1735
1736                 if (DBA::exists('user', ['parent-uid' => $uid])) {
1737                         throw new \RuntimeException(DI::l10n()->t("User with delegates can't be removed, please remove delegate users first"));
1738                 }
1739
1740                 Hook::callAll('remove_user', $user);
1741
1742                 // save username (actually the nickname as it is guaranteed
1743                 // unique), so it cannot be re-registered in the future.
1744                 DBA::insert('userd', ['username' => $user['nickname']]);
1745
1746                 // Remove all personal settings, especially connector settings
1747                 DBA::delete('pconfig', ['uid' => $uid]);
1748
1749                 // The user and related data will be deleted in Friendica\Worker\ExpireAndRemoveUsers
1750                 DBA::update('user', ['account_removed' => true, 'account_expires_on' => DateTimeFormat::utc('now + 7 day')], ['uid' => $uid]);
1751                 Worker::add(Worker::PRIORITY_HIGH, 'Notifier', Delivery::REMOVAL, $uid);
1752
1753                 // Send an update to the directory
1754                 $self = DBA::selectFirst('contact', ['url'], ['uid' => $uid, 'self' => true]);
1755                 Worker::add(Worker::PRIORITY_LOW, 'Directory', $self['url']);
1756
1757                 // Remove the user relevant data
1758                 Worker::add(Worker::PRIORITY_NEGLIGIBLE, 'RemoveUser', $uid);
1759
1760                 self::setRegisterMethodByUserCount();
1761                 return true;
1762         }
1763
1764         /**
1765          * Return all identities to a user
1766          *
1767          * @param int $uid The user id
1768          * @return array All identities for this user
1769          *
1770          * Example for a return:
1771          *    [
1772          *        [
1773          *            'uid' => 1,
1774          *            'username' => 'maxmuster',
1775          *            'nickname' => 'Max Mustermann'
1776          *        ],
1777          *        [
1778          *            'uid' => 2,
1779          *            'username' => 'johndoe',
1780          *            'nickname' => 'John Doe'
1781          *        ]
1782          *    ]
1783          * @throws Exception
1784          */
1785         public static function identities(int $uid): array
1786         {
1787                 if (!$uid) {
1788                         return [];
1789                 }
1790
1791                 $identities = [];
1792
1793                 $user = DBA::selectFirst('user', ['uid', 'nickname', 'username', 'parent-uid'], ['uid' => $uid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]);
1794                 if (!DBA::isResult($user)) {
1795                         return $identities;
1796                 }
1797
1798                 if (!$user['parent-uid']) {
1799                         // First add our own entry
1800                         $identities = [[
1801                                 'uid' => $user['uid'],
1802                                 'username' => $user['username'],
1803                                 'nickname' => $user['nickname']
1804                         ]];
1805
1806                         // Then add all the children
1807                         $r = DBA::select(
1808                                 'user',
1809                                 ['uid', 'username', 'nickname'],
1810                                 ['parent-uid' => $user['uid'], 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]
1811                         );
1812                         if (DBA::isResult($r)) {
1813                                 $identities = array_merge($identities, DBA::toArray($r));
1814                         }
1815                 } else {
1816                         // First entry is our parent
1817                         $r = DBA::select(
1818                                 'user',
1819                                 ['uid', 'username', 'nickname'],
1820                                 ['uid' => $user['parent-uid'], 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]
1821                         );
1822                         if (DBA::isResult($r)) {
1823                                 $identities = DBA::toArray($r);
1824                         }
1825
1826                         // Then add all siblings
1827                         $r = DBA::select(
1828                                 'user',
1829                                 ['uid', 'username', 'nickname'],
1830                                 ['parent-uid' => $user['parent-uid'], 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]
1831                         );
1832                         if (DBA::isResult($r)) {
1833                                 $identities = array_merge($identities, DBA::toArray($r));
1834                         }
1835                 }
1836
1837                 $r = DBA::p(
1838                         "SELECT `user`.`uid`, `user`.`username`, `user`.`nickname`
1839                         FROM `manage`
1840                         INNER JOIN `user` ON `manage`.`mid` = `user`.`uid`
1841                         WHERE NOT `user`.`account_removed` AND `manage`.`uid` = ?",
1842                         $user['uid']
1843                 );
1844                 if (DBA::isResult($r)) {
1845                         $identities = array_merge($identities, DBA::toArray($r));
1846                 }
1847
1848                 return $identities;
1849         }
1850
1851         /**
1852          * Check if the given user id has delegations or is delegated
1853          *
1854          * @param int $uid
1855          * @return bool
1856          */
1857         public static function hasIdentities(int $uid): bool
1858         {
1859                 if (!$uid) {
1860                         return false;
1861                 }
1862
1863                 $user = DBA::selectFirst('user', ['parent-uid'], ['uid' => $uid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false]);
1864                 if (!DBA::isResult($user)) {
1865                         return false;
1866                 }
1867
1868                 if ($user['parent-uid']) {
1869                         return true;
1870                 }
1871
1872                 if (DBA::exists('user', ['parent-uid' => $uid, 'verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false])) {
1873                         return true;
1874                 }
1875
1876                 if (DBA::exists('manage', ['uid' => $uid])) {
1877                         return true;
1878                 }
1879
1880                 return false;
1881         }
1882
1883         /**
1884          * Returns statistical information about the current users of this node
1885          *
1886          * @return array
1887          *
1888          * @throws Exception
1889          */
1890         public static function getStatistics(): array
1891         {
1892                 $statistics = [
1893                         'total_users'           => 0,
1894                         'active_users_halfyear' => 0,
1895                         'active_users_monthly'  => 0,
1896                         'active_users_weekly'   => 0,
1897                 ];
1898
1899                 $userStmt = DBA::select('owner-view', ['uid', 'last-activity', 'last-item'],
1900                         ["`verified` AND `last-activity` > ? AND NOT `blocked`
1901                         AND NOT `account_removed` AND NOT `account_expired`",
1902                         DBA::NULL_DATETIME]);
1903                 if (!DBA::isResult($userStmt)) {
1904                         return $statistics;
1905                 }
1906
1907                 $halfyear = time() - (180 * 24 * 60 * 60);
1908                 $month = time() - (30 * 24 * 60 * 60);
1909                 $week = time() - (7 * 24 * 60 * 60);
1910
1911                 while ($user = DBA::fetch($userStmt)) {
1912                         $statistics['total_users']++;
1913
1914                         if ((strtotime($user['last-activity']) > $halfyear) || (strtotime($user['last-item']) > $halfyear)
1915                         ) {
1916                                 $statistics['active_users_halfyear']++;
1917                         }
1918
1919                         if ((strtotime($user['last-activity']) > $month) || (strtotime($user['last-item']) > $month)
1920                         ) {
1921                                 $statistics['active_users_monthly']++;
1922                         }
1923
1924                         if ((strtotime($user['last-activity']) > $week) || (strtotime($user['last-item']) > $week)
1925                         ) {
1926                                 $statistics['active_users_weekly']++;
1927                         }
1928                 }
1929                 DBA::close($userStmt);
1930
1931                 return $statistics;
1932         }
1933
1934         /**
1935          * Get all users of the current node
1936          *
1937          * @param int    $start Start count (Default is 0)
1938          * @param int    $count Count of the items per page (Default is @see Pager::ITEMS_PER_PAGE)
1939          * @param string $type  The type of users, which should get (all, blocked, removed)
1940          * @param string $order Order of the user list (Default is 'contact.name')
1941          * @param bool   $descending Order direction (Default is ascending)
1942          * @return array|bool The list of the users
1943          * @throws Exception
1944          */
1945         public static function getList(int $start = 0, int $count = Pager::ITEMS_PER_PAGE, string $type = 'all', string $order = 'name', bool $descending = false)
1946         {
1947                 $param = ['limit' => [$start, $count], 'order' => [$order => $descending]];
1948                 $condition = [];
1949                 switch ($type) {
1950                         case 'active':
1951                                 $condition['account_removed'] = false;
1952                                 $condition['blocked'] = false;
1953                                 break;
1954
1955                         case 'blocked':
1956                                 $condition['account_removed'] = false;
1957                                 $condition['blocked'] = true;
1958                                 $condition['verified'] = true;
1959                                 break;
1960
1961                         case 'removed':
1962                                 $condition['account_removed'] = true;
1963                                 break;
1964                 }
1965
1966                 return DBA::selectToArray('owner-view', [], $condition, $param);
1967         }
1968
1969         /**
1970          * Returns a list of lowercase admin email addresses from the comma-separated list in the config
1971          *
1972          * @return array
1973          */
1974         public static function getAdminEmailList(): array
1975         {
1976                 $adminEmails = strtolower(str_replace(' ', '', DI::config()->get('config', 'admin_email')));
1977                 if (!$adminEmails) {
1978                         return [];
1979                 }
1980
1981                 return explode(',', $adminEmails);
1982         }
1983
1984         /**
1985          * Returns the complete list of admin user accounts
1986          *
1987          * @param array $fields
1988          * @return array
1989          * @throws Exception
1990          */
1991         public static function getAdminList(array $fields = []): array
1992         {
1993                 $condition = [
1994                         'email'           => self::getAdminEmailList(),
1995                         'parent-uid'      => null,
1996                         'blocked'         => false,
1997                         'verified'        => true,
1998                         'account_removed' => false,
1999                         'account_expired' => false,
2000                 ];
2001
2002                 return DBA::selectToArray('user', $fields, $condition, ['order' => ['uid']]);
2003         }
2004
2005         /**
2006          * Return a list of admin user accounts where each unique email address appears only once.
2007          *
2008          * This method is meant for admin notifications that do not need to be sent multiple times to the same email address.
2009          *
2010          * @param array $fields
2011          * @return array
2012          * @throws Exception
2013          */
2014         public static function getAdminListForEmailing(array $fields = []): array
2015         {
2016                 return array_filter(self::getAdminList($fields), function ($user) {
2017                         static $emails = [];
2018
2019                         if (in_array($user['email'], $emails)) {
2020                                 return false;
2021                         }
2022
2023                         $emails[] = $user['email'];
2024
2025                         return true;
2026                 });
2027         }
2028
2029         public static function setRegisterMethodByUserCount()
2030         {
2031                 $max_registered_users = DI::config()->get('config', 'max_registered_users');
2032                 if ($max_registered_users <= 0) {
2033                         return;
2034                 }
2035
2036                 $register_policy = DI::config()->get('config', 'register_policy');
2037                 if (!in_array($register_policy, [Module\Register::OPEN, Module\Register::CLOSED])) {
2038                         Logger::debug('Unsupported register policy.', ['policy' => $register_policy]);
2039                         return;
2040                 }
2041
2042                 $users = DBA::count('user', ['blocked' => false, 'account_removed' => false, 'account_expired' => false]);
2043                 if (($users >= $max_registered_users) && ($register_policy == Module\Register::OPEN)) {
2044                         DI::config()->set('config', 'register_policy', Module\Register::CLOSED);
2045                         Logger::notice('Max users reached, registration is closed.', ['users' => $users, 'max' => $max_registered_users]);
2046                 } elseif (($users < $max_registered_users) && ($register_policy == Module\Register::CLOSED)) {
2047                         DI::config()->set('config', 'register_policy', Module\Register::OPEN);
2048                         Logger::notice('Below maximum users, registration is opened.', ['users' => $users, 'max' => $max_registered_users]);
2049                 } else {
2050                         Logger::debug('Unchanged register policy', ['policy' => $register_policy, 'users' => $users, 'max' => $max_registered_users]);
2051                 }
2052         }
2053 }