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