]> git.mxchange.org Git - friendica.git/blob - src/Model/User.php
774549d0a0d0a35f476e4f67b62a4972f1d19f3a
[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_UK"
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();
547                 if (in_array($owner['language'], array_keys($languages))) {
548                         $language = $owner['language'];
549                 } else {
550                         $language = DI::config()->get('system', 'language');
551                 }
552                 if ($short) {
553                         return substr($language, 0, 2);
554                 }
555                 return $language;
556         }
557
558         /**
559          * Authenticate a user with a clear text password
560          *
561          * Returns the user id associated with a successful password authentication
562          *
563          * @param mixed  $user_info
564          * @param string $password
565          * @param bool   $third_party
566          * @return int User Id if authentication is successful
567          * @throws HTTPException\ForbiddenException
568          * @throws HTTPException\NotFoundException
569          */
570         public static function getIdFromPasswordAuthentication($user_info, string $password, bool $third_party = false): int
571         {
572                 // Addons registered with the "authenticate" hook may create the user on the
573                 // fly. `getAuthenticationInfo` will fail if the user doesn't exist yet. If
574                 // the user doesn't exist, we should give the addons a chance to create the
575                 // user in our database, if applicable, before re-throwing the exception if
576                 // they fail.
577                 try {
578                         $user = self::getAuthenticationInfo($user_info);
579                 } catch (Exception $e) {
580                         $username = (is_string($user_info) ? $user_info : $user_info['nickname'] ?? '');
581
582                         // Addons can create users, and since this 'catch' branch should only
583                         // execute if getAuthenticationInfo can't find an existing user, that's
584                         // exactly what will happen here. Creating a numeric username would create
585                         // ambiguity with user IDs, possibly opening up an attack vector.
586                         // So let's be very careful about that.
587                         if (empty($username) || is_numeric($username)) {
588                                 throw $e;
589                         }
590
591                         return self::getIdFromAuthenticateHooks($username, $password);
592                 }
593
594                 if ($third_party && DI::pConfig()->get($user['uid'], '2fa', 'verified')) {
595                         // Third-party apps can't verify two-factor authentication, we use app-specific passwords instead
596                         if (AppSpecificPassword::authenticateUser($user['uid'], $password)) {
597                                 return $user['uid'];
598                         }
599                 } elseif (strpos($user['password'], '$') === false) {
600                         //Legacy hash that has not been replaced by a new hash yet
601                         if (self::hashPasswordLegacy($password) === $user['password']) {
602                                 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
603
604                                 return $user['uid'];
605                         }
606                 } elseif (!empty($user['legacy_password'])) {
607                         //Legacy hash that has been double-hashed and not replaced by a new hash yet
608                         //Warning: `legacy_password` is not necessary in sync with the content of `password`
609                         if (password_verify(self::hashPasswordLegacy($password), $user['password'])) {
610                                 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
611
612                                 return $user['uid'];
613                         }
614                 } elseif (password_verify($password, $user['password'])) {
615                         //New password hash
616                         if (password_needs_rehash($user['password'], PASSWORD_DEFAULT)) {
617                                 self::updatePasswordHashed($user['uid'], self::hashPassword($password));
618                         }
619
620                         return $user['uid'];
621                 } else {
622                         return self::getIdFromAuthenticateHooks($user['nickname'], $password); // throws
623                 }
624
625                 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
626         }
627
628         /**
629          * Try to obtain a user ID via "authenticate" hook addons
630          *
631          * Returns the user id associated with a successful password authentication
632          *
633          * @param string $username
634          * @param string $password
635          * @return int User Id if authentication is successful
636          * @throws HTTPException\ForbiddenException
637          */
638         public static function getIdFromAuthenticateHooks(string $username, string $password): int
639         {
640                 $addon_auth = [
641                         'username'      => $username,
642                         'password'      => $password,
643                         'authenticated' => 0,
644                         'user_record'   => null
645                 ];
646
647                 /*
648                  * An addon indicates successful login by setting 'authenticated' to non-zero value and returning a user record
649                  * Addons should never set 'authenticated' except to indicate success - as hooks may be chained
650                  * and later addons should not interfere with an earlier one that succeeded.
651                  */
652                 Hook::callAll('authenticate', $addon_auth);
653
654                 if ($addon_auth['authenticated'] && $addon_auth['user_record']) {
655                         return $addon_auth['user_record']['uid'];
656                 }
657
658                 throw new HTTPException\ForbiddenException(DI::l10n()->t('Login failed'));
659         }
660
661         /**
662          * Returns authentication info from various parameters types
663          *
664          * User info can be any of the following:
665          * - User DB object
666          * - User Id
667          * - User email or username or nickname
668          * - User array with at least the uid and the hashed password
669          *
670          * @param mixed $user_info
671          * @return array|null Null if not found/determined
672          * @throws HTTPException\NotFoundException
673          */
674         public static function getAuthenticationInfo($user_info)
675         {
676                 $user = null;
677
678                 if (is_object($user_info) || is_array($user_info)) {
679                         if (is_object($user_info)) {
680                                 $user = (array) $user_info;
681                         } else {
682                                 $user = $user_info;
683                         }
684
685                         if (
686                                 !isset($user['uid'])
687                                 || !isset($user['password'])
688                                 || !isset($user['legacy_password'])
689                         ) {
690                                 throw new Exception(DI::l10n()->t('Not enough information to authenticate'));
691                         }
692                 } elseif (is_int($user_info) || is_string($user_info)) {
693                         if (is_int($user_info)) {
694                                 $user = DBA::selectFirst(
695                                         'user',
696                                         ['uid', 'nickname', 'password', 'legacy_password'],
697                                         [
698                                                 'uid' => $user_info,
699                                                 'blocked' => 0,
700                                                 'account_expired' => 0,
701                                                 'account_removed' => 0,
702                                                 'verified' => 1
703                                         ]
704                                 );
705                         } else {
706                                 $fields = ['uid', 'nickname', 'password', 'legacy_password'];
707                                 $condition = [
708                                         "(`email` = ? OR `username` = ? OR `nickname` = ?)
709                                         AND NOT `blocked` AND NOT `account_expired` AND NOT `account_removed` AND `verified`",
710                                         $user_info, $user_info, $user_info
711                                 ];
712                                 $user = DBA::selectFirst('user', $fields, $condition);
713                         }
714
715                         if (!DBA::isResult($user)) {
716                                 throw new HTTPException\NotFoundException(DI::l10n()->t('User not found'));
717                         }
718                 }
719
720                 return $user;
721         }
722
723         /**
724          * Update the day of the last activity of the given user
725          *
726          * @param integer $uid
727          * @return void
728          */
729         public static function updateLastActivity(int $uid)
730         {
731                 if (!$uid) {
732                         return;
733                 }
734
735                 $user = User::getById($uid, ['last-activity']);
736                 if (empty($user)) {
737                         return;
738                 }
739
740                 $current_day = DateTimeFormat::utcNow('Y-m-d');
741
742                 if ($user['last-activity'] != $current_day) {
743                         User::update(['last-activity' => $current_day], $uid);
744                         // Set the last activity for all identities of the user
745                         DBA::update('user', ['last-activity' => $current_day], ['parent-uid' => $uid, 'account_removed' => false]);
746                 }
747         }
748
749         /**
750          * Generates a human-readable random password
751          *
752          * @return string
753          * @throws Exception
754          */
755         public static function generateNewPassword(): string
756         {
757                 return ucfirst(Strings::getRandomName(8)) . random_int(1000, 9999);
758         }
759
760         /**
761          * Checks if the provided plaintext password has been exposed or not
762          *
763          * @param string $password
764          * @return bool
765          * @throws Exception
766          */
767         public static function isPasswordExposed(string $password): bool
768         {
769                 $cache = new CacheItemPool();
770                 $cache->changeConfig([
771                         'cacheDirectory' => System::getTempPath() . '/password-exposed-cache/',
772                 ]);
773
774                 try {
775                         $passwordExposedChecker = new PasswordExposed\PasswordExposedChecker(null, $cache);
776
777                         return $passwordExposedChecker->passwordExposed($password) === PasswordExposed\PasswordStatus::EXPOSED;
778                 } catch (Exception $e) {
779                         Logger::error('Password Exposed Exception: ' . $e->getMessage(), [
780                                 'code' => $e->getCode(),
781                                 'file' => $e->getFile(),
782                                 'line' => $e->getLine(),
783                                 'trace' => $e->getTraceAsString()
784                         ]);
785
786                         return false;
787                 }
788         }
789
790         /**
791          * Legacy hashing function, kept for password migration purposes
792          *
793          * @param string $password
794          * @return string
795          */
796         private static function hashPasswordLegacy(string $password): string
797         {
798                 return hash('whirlpool', $password);
799         }
800
801         /**
802          * Global user password hashing function
803          *
804          * @param string $password
805          * @return string
806          * @throws Exception
807          */
808         public static function hashPassword(string $password): string
809         {
810                 if (!trim($password)) {
811                         throw new Exception(DI::l10n()->t('Password can\'t be empty'));
812                 }
813
814                 return password_hash($password, PASSWORD_DEFAULT);
815         }
816
817         /**
818          * Allowed characters are a-z, A-Z, 0-9 and special characters except white spaces and accentuated letters.
819          *
820          * Password length is limited to 72 characters if the current default password hashing algorithm is Blowfish.
821          * From the manual: "Using the PASSWORD_BCRYPT as the algorithm, will result in the password parameter being
822          * truncated to a maximum length of 72 bytes."
823          *
824          * @see https://www.php.net/manual/en/function.password-hash.php#refsect1-function.password-hash-parameters
825          *
826          * @param string|null $delimiter Whether the regular expression is meant to be wrapper in delimiter characters
827          * @return string
828          */
829         public static function getPasswordRegExp(string $delimiter = null): string
830         {
831                 $allowed_characters = ':!"#$%&\'()*+,-./;<=>?@[\]^_`{|}~';
832
833                 if ($delimiter) {
834                         $allowed_characters = preg_quote($allowed_characters, $delimiter);
835                 }
836
837                 return '^[a-zA-Z0-9' . $allowed_characters . ']' . (PASSWORD_DEFAULT === PASSWORD_BCRYPT ? '{1,72}' : '+') . '$';
838         }
839
840         /**
841          * Updates a user row with a new plaintext password
842          *
843          * @param int    $uid
844          * @param string $password
845          * @return bool
846          * @throws Exception
847          */
848         public static function updatePassword(int $uid, string $password): bool
849         {
850                 $password = trim($password);
851
852                 if (empty($password)) {
853                         throw new Exception(DI::l10n()->t('Empty passwords are not allowed.'));
854                 }
855
856                 if (!DI::config()->get('system', 'disable_password_exposed', false) && self::isPasswordExposed($password)) {
857                         throw new Exception(DI::l10n()->t('The new password has been exposed in a public data dump, please choose another.'));
858                 }
859
860                 if (PASSWORD_DEFAULT === PASSWORD_BCRYPT && strlen($password) > 72) {
861                         throw new Exception(DI::l10n()->t('The password length is limited to 72 characters.'));
862                 }
863
864                 if (!preg_match('/' . self::getPasswordRegExp('/') . '/', $password)) {
865                         throw new Exception(DI::l10n()->t("The password can't contain white spaces nor accentuated letters"));
866                 }
867
868                 return self::updatePasswordHashed($uid, self::hashPassword($password));
869         }
870
871         /**
872          * Updates a user row with a new hashed password.
873          * Empties the password reset token field just in case.
874          *
875          * @param int    $uid
876          * @param string $password_hashed
877          * @return bool
878          * @throws Exception
879          */
880         private static function updatePasswordHashed(int $uid, string $password_hashed): bool
881         {
882                 $fields = [
883                         'password' => $password_hashed,
884                         'pwdreset' => null,
885                         'pwdreset_time' => null,
886                         'legacy_password' => false
887                 ];
888                 return DBA::update('user', $fields, ['uid' => $uid]);
889         }
890
891         /**
892          * Returns if the given uid is valid and in the admin list
893          *
894          * @param int $uid
895          *
896          * @return bool
897          * @throws Exception
898          */
899         public static function isSiteAdmin(int $uid): bool
900         {
901                 return DBA::exists('user', [
902                         'uid'   => $uid,
903                         'email' => self::getAdminEmailList()
904                 ]);
905         }
906
907         /**
908          * Returns if the given uid is valid and a moderator
909          *
910          * @param int $uid
911          *
912          * @return bool
913          * @throws Exception
914          */
915         public static function isModerator(int $uid): bool
916         {
917                 // @todo Replace with a moderator check in the future
918                 return self::isSiteAdmin($uid);
919         }
920
921         /**
922          * Checks if a nickname is in the list of the forbidden nicknames
923          *
924          * Check if a nickname is forbidden from registration on the node by the
925          * admin. Forbidden nicknames (e.g. role names) can be configured in the
926          * admin panel.
927          *
928          * @param string $nickname The nickname that should be checked
929          * @return boolean True is the nickname is blocked on the node
930          */
931         public static function isNicknameBlocked(string $nickname): bool
932         {
933                 $forbidden_nicknames = DI::config()->get('system', 'forbidden_nicknames', '');
934                 if (!empty($forbidden_nicknames)) {
935                         $forbidden = explode(',', $forbidden_nicknames);
936                         $forbidden = array_map('trim', $forbidden);
937                 } else {
938                         $forbidden = [];
939                 }
940
941                 // Add the name of the internal actor to the "forbidden" list
942                 $actor_name = self::getActorName();
943                 if (!empty($actor_name)) {
944                         $forbidden[] = $actor_name;
945                 }
946
947                 if (empty($forbidden)) {
948                         return false;
949                 }
950
951                 // check if the nickname is in the list of blocked nicknames
952                 if (in_array(strtolower($nickname), $forbidden)) {
953                         return true;
954                 }
955
956                 // else return false
957                 return false;
958         }
959
960         /**
961          * Get avatar link for given user
962          *
963          * @param array  $user
964          * @param string $size One of the Proxy::SIZE_* constants
965          * @return string avatar link
966          * @throws Exception
967          */
968         public static function getAvatarUrl(array $user, string $size = ''): string
969         {
970                 if (empty($user['nickname'])) {
971                         DI::logger()->warning('Missing user nickname key', ['trace' => System::callstack(20)]);
972                 }
973
974                 $url = DI::baseUrl() . '/photo/';
975
976                 switch ($size) {
977                         case Proxy::SIZE_MICRO:
978                                 $url .= 'micro/';
979                                 $scale = 6;
980                                 break;
981                         case Proxy::SIZE_THUMB:
982                                 $url .= 'avatar/';
983                                 $scale = 5;
984                                 break;
985                         default:
986                                 $url .= 'profile/';
987                                 $scale = 4;
988                                 break;
989                 }
990
991                 $updated  =  '';
992                 $mimetype = '';
993
994                 $photo = Photo::selectFirst(['type', 'created', 'edited', 'updated'], ["scale" => $scale, 'uid' => $user['uid'], 'profile' => true]);
995                 if (!empty($photo)) {
996                         $updated  = max($photo['created'], $photo['edited'], $photo['updated']);
997                         $mimetype = $photo['type'];
998                 }
999
1000                 return $url . $user['nickname'] . Images::getExtensionByMimeType($mimetype) . ($updated ? '?ts=' . strtotime($updated) : '');
1001         }
1002
1003         /**
1004          * Get banner link for given user
1005          *
1006          * @param array  $user
1007          * @return string banner link
1008          * @throws Exception
1009          */
1010         public static function getBannerUrl(array $user): string
1011         {
1012                 if (empty($user['nickname'])) {
1013                         DI::logger()->warning('Missing user nickname key', ['trace' => System::callstack(20)]);
1014                 }
1015
1016                 $url = DI::baseUrl() . '/photo/banner/';
1017
1018                 $updated  = '';
1019                 $mimetype = '';
1020
1021                 $photo = Photo::selectFirst(['type', 'created', 'edited', 'updated'], ["scale" => 3, 'uid' => $user['uid'], 'photo-type' => Photo::USER_BANNER]);
1022                 if (!empty($photo)) {
1023                         $updated  = max($photo['created'], $photo['edited'], $photo['updated']);
1024                         $mimetype = $photo['type'];
1025                 } else {
1026                         // Only for the RC phase: Don't return an image link for the default picture
1027                         return '';
1028                 }
1029
1030                 return $url . $user['nickname'] . Images::getExtensionByMimeType($mimetype) . ($updated ? '?ts=' . strtotime($updated) : '');
1031         }
1032
1033         /**
1034          * Catch-all user creation function
1035          *
1036          * Creates a user from the provided data array, either form fields or OpenID.
1037          * Required: { username, nickname, email } or { openid_url }
1038          *
1039          * Performs the following:
1040          * - Sends to the OpenId auth URL (if relevant)
1041          * - Creates new key pairs for crypto
1042          * - Create self-contact
1043          * - Create profile image
1044          *
1045          * @param  array $data
1046          * @return array
1047          * @throws ErrorException
1048          * @throws HTTPException\InternalServerErrorException
1049          * @throws ImagickException
1050          * @throws Exception
1051          */
1052         public static function create(array $data): array
1053         {
1054                 $return = ['user' => null, 'password' => ''];
1055
1056                 $using_invites = DI::config()->get('system', 'invitation_only');
1057
1058                 $invite_id  = !empty($data['invite_id'])  ? trim($data['invite_id'])  : '';
1059                 $username   = !empty($data['username'])   ? trim($data['username'])   : '';
1060                 $nickname   = !empty($data['nickname'])   ? trim($data['nickname'])   : '';
1061                 $email      = !empty($data['email'])      ? trim($data['email'])      : '';
1062                 $openid_url = !empty($data['openid_url']) ? trim($data['openid_url']) : '';
1063                 $photo      = !empty($data['photo'])      ? trim($data['photo'])      : '';
1064                 $password   = !empty($data['password'])   ? trim($data['password'])   : '';
1065                 $password1  = !empty($data['password1'])  ? trim($data['password1'])  : '';
1066                 $confirm    = !empty($data['confirm'])    ? trim($data['confirm'])    : '';
1067                 $blocked    = !empty($data['blocked']);
1068                 $verified   = !empty($data['verified']);
1069                 $language   = !empty($data['language'])   ? trim($data['language'])   : 'en';
1070
1071                 $netpublish = $publish = !empty($data['profile_publish_reg']);
1072
1073                 if ($password1 != $confirm) {
1074                         throw new Exception(DI::l10n()->t('Passwords do not match. Password unchanged.'));
1075                 } elseif ($password1 != '') {
1076                         $password = $password1;
1077                 }
1078
1079                 if ($using_invites) {
1080                         if (!$invite_id) {
1081                                 throw new Exception(DI::l10n()->t('An invitation is required.'));
1082                         }
1083
1084                         if (!Register::existsByHash($invite_id)) {
1085                                 throw new Exception(DI::l10n()->t('Invitation could not be verified.'));
1086                         }
1087                 }
1088
1089                 /// @todo Check if this part is really needed. We should have fetched all this data in advance
1090                 if (empty($username) || empty($email) || empty($nickname)) {
1091                         if ($openid_url) {
1092                                 if (!Network::isUrlValid($openid_url)) {
1093                                         throw new Exception(DI::l10n()->t('Invalid OpenID url'));
1094                                 }
1095                                 $_SESSION['register'] = 1;
1096                                 $_SESSION['openid'] = $openid_url;
1097
1098                                 $openid = new LightOpenID(DI::baseUrl()->getHost());
1099                                 $openid->identity = $openid_url;
1100                                 $openid->returnUrl = DI::baseUrl() . '/openid';
1101                                 $openid->required = ['namePerson/friendly', 'contact/email', 'namePerson'];
1102                                 $openid->optional = ['namePerson/first', 'media/image/aspect11', 'media/image/default'];
1103                                 try {
1104                                         $authurl = $openid->authUrl();
1105                                 } catch (Exception $e) {
1106                                         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);
1107                                 }
1108                                 System::externalRedirect($authurl);
1109                                 // NOTREACHED
1110                         }
1111
1112                         throw new Exception(DI::l10n()->t('Please enter the required information.'));
1113                 }
1114
1115                 if (!Network::isUrlValid($openid_url)) {
1116                         $openid_url = '';
1117                 }
1118
1119                 // collapse multiple spaces in name
1120                 $username = preg_replace('/ +/', ' ', $username);
1121
1122                 $username_min_length = max(1, min(64, intval(DI::config()->get('system', 'username_min_length', 3))));
1123                 $username_max_length = max(1, min(64, intval(DI::config()->get('system', 'username_max_length', 48))));
1124
1125                 if ($username_min_length > $username_max_length) {
1126                         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));
1127                         $tmp = $username_min_length;
1128                         $username_min_length = $username_max_length;
1129                         $username_max_length = $tmp;
1130                 }
1131
1132                 if (mb_strlen($username) < $username_min_length) {
1133                         throw new Exception(DI::l10n()->tt('Username should be at least %s character.', 'Username should be at least %s characters.', $username_min_length));
1134                 }
1135
1136                 if (mb_strlen($username) > $username_max_length) {
1137                         throw new Exception(DI::l10n()->tt('Username should be at most %s character.', 'Username should be at most %s characters.', $username_max_length));
1138                 }
1139
1140                 // So now we are just looking for a space in the full name.
1141                 $loose_reg = DI::config()->get('system', 'no_regfullname');
1142                 if (!$loose_reg) {
1143                         $username = mb_convert_case($username, MB_CASE_TITLE, 'UTF-8');
1144                         if (strpos($username, ' ') === false) {
1145                                 throw new Exception(DI::l10n()->t("That doesn't appear to be your full (First Last) name."));
1146                         }
1147                 }
1148
1149                 if (!Network::isEmailDomainAllowed($email)) {
1150                         throw new Exception(DI::l10n()->t('Your email domain is not among those allowed on this site.'));
1151                 }
1152
1153                 if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !Network::isEmailDomainValid($email)) {
1154                         throw new Exception(DI::l10n()->t('Not a valid email address.'));
1155                 }
1156                 if (self::isNicknameBlocked($nickname)) {
1157                         throw new Exception(DI::l10n()->t('The nickname was blocked from registration by the nodes admin.'));
1158                 }
1159
1160                 if (DI::config()->get('system', 'block_extended_register', false) && DBA::exists('user', ['email' => $email])) {
1161                         throw new Exception(DI::l10n()->t('Cannot use that email.'));
1162                 }
1163
1164                 // Disallow somebody creating an account using openid that uses the admin email address,
1165                 // since openid bypasses email verification. We'll allow it if there is not yet an admin account.
1166                 if (strlen($openid_url) && in_array(strtolower($email), self::getAdminEmailList())) {
1167                         throw new Exception(DI::l10n()->t('Cannot use that email.'));
1168                 }
1169
1170                 $nickname = $data['nickname'] = strtolower($nickname);
1171
1172                 if (!preg_match('/^[a-z0-9][a-z0-9_]*$/', $nickname)) {
1173                         throw new Exception(DI::l10n()->t('Your nickname can only contain a-z, 0-9 and _.'));
1174                 }
1175
1176                 // Check existing and deleted accounts for this nickname.
1177                 if (
1178                         DBA::exists('user', ['nickname' => $nickname])
1179                         || DBA::exists('userd', ['username' => $nickname])
1180                 ) {
1181                         throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1182                 }
1183
1184                 $new_password = strlen($password) ? $password : User::generateNewPassword();
1185                 $new_password_encoded = self::hashPassword($new_password);
1186
1187                 $return['password'] = $new_password;
1188
1189                 $keys = Crypto::newKeypair(4096);
1190                 if ($keys === false) {
1191                         throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
1192                 }
1193
1194                 $prvkey = $keys['prvkey'];
1195                 $pubkey = $keys['pubkey'];
1196
1197                 // Create another keypair for signing/verifying salmon protocol messages.
1198                 $sres = Crypto::newKeypair(512);
1199                 $sprvkey = $sres['prvkey'];
1200                 $spubkey = $sres['pubkey'];
1201
1202                 $insert_result = DBA::insert('user', [
1203                         'guid'     => System::createUUID(),
1204                         'username' => $username,
1205                         'password' => $new_password_encoded,
1206                         'email'    => $email,
1207                         'openid'   => $openid_url,
1208                         'nickname' => $nickname,
1209                         'pubkey'   => $pubkey,
1210                         'prvkey'   => $prvkey,
1211                         'spubkey'  => $spubkey,
1212                         'sprvkey'  => $sprvkey,
1213                         'verified' => $verified,
1214                         'blocked'  => $blocked,
1215                         'language' => $language,
1216                         'timezone' => 'UTC',
1217                         'register_date' => DateTimeFormat::utcNow(),
1218                         'default-location' => ''
1219                 ]);
1220
1221                 if ($insert_result) {
1222                         $uid = DBA::lastInsertId();
1223                         $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1224                 } else {
1225                         throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1226                 }
1227
1228                 if (!$uid) {
1229                         throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1230                 }
1231
1232                 // if somebody clicked submit twice very quickly, they could end up with two accounts
1233                 // due to race condition. Remove this one.
1234                 $user_count = DBA::count('user', ['nickname' => $nickname]);
1235                 if ($user_count > 1) {
1236                         DBA::delete('user', ['uid' => $uid]);
1237
1238                         throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1239                 }
1240
1241                 $insert_result = DBA::insert('profile', [
1242                         'uid' => $uid,
1243                         'name' => $username,
1244                         'photo' => self::getAvatarUrl($user),
1245                         'thumb' => self::getAvatarUrl($user, Proxy::SIZE_THUMB),
1246                         'publish' => $publish,
1247                         'net-publish' => $netpublish,
1248                 ]);
1249                 if (!$insert_result) {
1250                         DBA::delete('user', ['uid' => $uid]);
1251
1252                         throw new Exception(DI::l10n()->t('An error occurred creating your default profile. Please try again.'));
1253                 }
1254
1255                 // Create the self contact
1256                 if (!Contact::createSelfFromUserId($uid)) {
1257                         DBA::delete('user', ['uid' => $uid]);
1258
1259                         throw new Exception(DI::l10n()->t('An error occurred creating your self contact. Please try again.'));
1260                 }
1261
1262                 // Create a circle with no members. This allows somebody to use it
1263                 // right away as a default circle for new contacts.
1264                 $def_gid = Circle::create($uid, DI::l10n()->t('Friends'));
1265                 if (!$def_gid) {
1266                         DBA::delete('user', ['uid' => $uid]);
1267
1268                         throw new Exception(DI::l10n()->t('An error occurred creating your default contact circle. Please try again.'));
1269                 }
1270
1271                 $fields = ['def_gid' => $def_gid];
1272                 if (DI::config()->get('system', 'newuser_private') && $def_gid) {
1273                         $fields['allow_gid'] = '<' . $def_gid . '>';
1274                 }
1275
1276                 DBA::update('user', $fields, ['uid' => $uid]);
1277
1278                 $def_gid_groups = Circle::create($uid, DI::l10n()->t('Groups'));
1279                 if ($def_gid_groups) {
1280                         DI::pConfig()->set($uid, 'system', 'default-group-gid', $def_gid_groups);
1281                 }
1282
1283                 // if we have no OpenID photo try to look up an avatar
1284                 if (!strlen($photo)) {
1285                         $photo = Network::lookupAvatarByEmail($email);
1286                 }
1287
1288                 // unless there is no avatar-addon loaded
1289                 if (strlen($photo)) {
1290                         $photo_failure = false;
1291
1292                         $filename = basename($photo);
1293                         $curlResult = DI::httpClient()->get($photo, HttpClientAccept::IMAGE);
1294                         if ($curlResult->isSuccess()) {
1295                                 Logger::debug('Got picture', ['Content-Type' => $curlResult->getHeader('Content-Type'), 'url' => $photo]);
1296                                 $img_str = $curlResult->getBody();
1297                                 $type = $curlResult->getContentType();
1298                         } else {
1299                                 $img_str = '';
1300                                 $type = '';
1301                         }
1302
1303                         $type = Images::getMimeTypeByData($img_str, $photo, $type);
1304
1305                         $image = new Image($img_str, $type);
1306                         if ($image->isValid()) {
1307                                 $image->scaleToSquare(300);
1308
1309                                 $resource_id = Photo::newResource();
1310
1311                                 // Not using Photo::PROFILE_PHOTOS here, so that it is discovered as translatable string
1312                                 $profile_album = DI::l10n()->t('Profile Photos');
1313
1314                                 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 4);
1315
1316                                 if ($r === false) {
1317                                         $photo_failure = true;
1318                                 }
1319
1320                                 $image->scaleDown(80);
1321
1322                                 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 5);
1323
1324                                 if ($r === false) {
1325                                         $photo_failure = true;
1326                                 }
1327
1328                                 $image->scaleDown(48);
1329
1330                                 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 6);
1331
1332                                 if ($r === false) {
1333                                         $photo_failure = true;
1334                                 }
1335
1336                                 if (!$photo_failure) {
1337                                         Photo::update(['profile' => true, 'photo-type' => Photo::USER_AVATAR], ['resource-id' => $resource_id]);
1338                                 }
1339                         }
1340
1341                         Contact::updateSelfFromUserID($uid, true);
1342                 }
1343
1344                 Hook::callAll('register_account', $uid);
1345
1346                 self::setRegisterMethodByUserCount();
1347
1348                 $return['user'] = $user;
1349                 return $return;
1350         }
1351
1352         /**
1353          * Update a user entry and distribute the changes if needed
1354          *
1355          * @param array   $fields
1356          * @param integer $uid
1357          * @return boolean
1358          * @throws Exception
1359          */
1360         public static function update(array $fields, int $uid): bool
1361         {
1362                 if (!DBA::update('user', $fields, ['uid' => $uid])) {
1363                         return false;
1364                 }
1365
1366                 if (Contact::updateSelfFromUserID($uid)) {
1367                         Profile::publishUpdate($uid);
1368                 }
1369
1370                 return true;
1371         }
1372
1373         /**
1374          * Sets block state for a given user
1375          *
1376          * @param int  $uid   The user id
1377          * @param bool $block Block state (default is true)
1378          *
1379          * @return bool True, if successfully blocked
1380
1381          * @throws Exception
1382          */
1383         public static function block(int $uid, bool $block = true): bool
1384         {
1385                 return DBA::update('user', ['blocked' => $block], ['uid' => $uid]);
1386         }
1387
1388         /**
1389          * Allows a registration based on a hash
1390          *
1391          * @param string $hash
1392          *
1393          * @return bool True, if the allow was successful
1394          *
1395          * @throws HTTPException\InternalServerErrorException
1396          * @throws Exception
1397          */
1398         public static function allow(string $hash): bool
1399         {
1400                 $register = Register::getByHash($hash);
1401                 if (!DBA::isResult($register)) {
1402                         return false;
1403                 }
1404
1405                 $user = User::getById($register['uid']);
1406                 if (!DBA::isResult($user)) {
1407                         return false;
1408                 }
1409
1410                 Register::deleteByHash($hash);
1411
1412                 DBA::update('user', ['blocked' => false, 'verified' => true], ['uid' => $register['uid']]);
1413
1414                 $profile = DBA::selectFirst('profile', ['net-publish'], ['uid' => $register['uid']]);
1415
1416                 if (DBA::isResult($profile) && $profile['net-publish'] && Search::getGlobalDirectory()) {
1417                         $url = DI::baseUrl() . '/profile/' . $user['nickname'];
1418                         Worker::add(Worker::PRIORITY_LOW, "Directory", $url);
1419                 }
1420
1421                 $l10n = DI::l10n()->withLang($register['language']);
1422
1423                 return User::sendRegisterOpenEmail(
1424                         $l10n,
1425                         $user,
1426                         DI::config()->get('config', 'sitename'),
1427                         DI::baseUrl(),
1428                         ($register['password'] ?? '') ?: 'Sent in a previous email'
1429                 );
1430         }
1431
1432         /**
1433          * Denys a pending registration
1434          *
1435          * @param string $hash The hash of the pending user
1436          *
1437          * This does not have to go through user_remove() and save the nickname
1438          * permanently against re-registration, as the person was not yet
1439          * allowed to have friends on this system
1440          *
1441          * @return bool True, if the deny was successful
1442          * @throws Exception
1443          */
1444         public static function deny(string $hash): bool
1445         {
1446                 $register = Register::getByHash($hash);
1447                 if (!DBA::isResult($register)) {
1448                         return false;
1449                 }
1450
1451                 $user = User::getById($register['uid']);
1452                 if (!DBA::isResult($user)) {
1453                         return false;
1454                 }
1455
1456                 // Delete the avatar
1457                 Photo::delete(['uid' => $register['uid']]);
1458
1459                 return DBA::delete('user', ['uid' => $register['uid']]) &&
1460                        Register::deleteByHash($register['hash']);
1461         }
1462
1463         /**
1464          * Creates a new user based on a minimal set and sends an email to this user
1465          *
1466          * @param string $name  The user's name
1467          * @param string $email The user's email address
1468          * @param string $nick  The user's nick name
1469          * @param string $lang  The user's language (default is english)
1470          * @return bool True, if the user was created successfully
1471          * @throws HTTPException\InternalServerErrorException
1472          * @throws ErrorException
1473          * @throws ImagickException
1474          */
1475         public static function createMinimal(string $name, string $email, string $nick, string $lang = L10n::DEFAULT): bool
1476         {
1477                 if (empty($name) ||
1478                     empty($email) ||
1479                     empty($nick)) {
1480                         throw new HTTPException\InternalServerErrorException('Invalid arguments.');
1481                 }
1482
1483                 $result = self::create([
1484                         'username' => $name,
1485                         'email' => $email,
1486                         'nickname' => $nick,
1487                         'verified' => 1,
1488                         'language' => $lang
1489                 ]);
1490
1491                 $user = $result['user'];
1492                 $preamble = Strings::deindent(DI::l10n()->t('
1493                 Dear %1$s,
1494                         the administrator of %2$s has set up an account for you.'));
1495                 $body = Strings::deindent(DI::l10n()->t('
1496                 The login details are as follows:
1497
1498                 Site Location:  %1$s
1499                 Login Name:             %2$s
1500                 Password:               %3$s
1501
1502                 You may change your password from your account "Settings" page after logging
1503                 in.
1504
1505                 Please take a few moments to review the other account settings on that page.
1506
1507                 You may also wish to add some basic information to your default profile
1508                 (on the "Profiles" page) so that other people can easily find you.
1509
1510                 We recommend setting your full name, adding a profile photo,
1511                 adding some profile "keywords" (very useful in making new friends) - and
1512                 perhaps what country you live in; if you do not wish to be more specific
1513                 than that.
1514
1515                 We fully respect your right to privacy, and none of these items are necessary.
1516                 If you are new and do not know anybody here, they may help
1517                 you to make some new and interesting friends.
1518
1519                 If you ever want to delete your account, you can do so at %1$s/settings/removeme
1520
1521                 Thank you and welcome to %4$s.'));
1522
1523                 $preamble = sprintf($preamble, $user['username'], DI::config()->get('config', 'sitename'));
1524                 $body = sprintf($body, DI::baseUrl(), $user['nickname'], $result['password'], DI::config()->get('config', 'sitename'));
1525
1526                 $email = DI::emailer()
1527                         ->newSystemMail()
1528                         ->withMessage(DI::l10n()->t('Registration details for %s', DI::config()->get('config', 'sitename')), $preamble, $body)
1529                         ->forUser($user)
1530                         ->withRecipient($user['email'])
1531                         ->build();
1532                 return DI::emailer()->send($email);
1533         }
1534
1535         /**
1536          * Sends pending registration confirmation email
1537          *
1538          * @param array  $user     User record array
1539          * @param string $sitename
1540          * @param string $siteurl
1541          * @param string $password Plaintext password
1542          * @return NULL|boolean from notification() and email() inherited
1543          * @throws HTTPException\InternalServerErrorException
1544          */
1545         public static function sendRegisterPendingEmail(array $user, string $sitename, string $siteurl, string $password)
1546         {
1547                 $body = Strings::deindent(DI::l10n()->t(
1548                         '
1549                         Dear %1$s,
1550                                 Thank you for registering at %2$s. Your account is pending for approval by the administrator.
1551
1552                         Your login details are as follows:
1553
1554                         Site Location:  %3$s
1555                         Login Name:             %4$s
1556                         Password:               %5$s
1557                 ',
1558                         $user['username'],
1559                         $sitename,
1560                         $siteurl,
1561                         $user['nickname'],
1562                         $password
1563                 ));
1564
1565                 $email = DI::emailer()
1566                         ->newSystemMail()
1567                         ->withMessage(DI::l10n()->t('Registration at %s', $sitename), $body)
1568                         ->forUser($user)
1569                         ->withRecipient($user['email'])
1570                         ->build();
1571                 return DI::emailer()->send($email);
1572         }
1573
1574         /**
1575          * Sends registration confirmation
1576          *
1577          * It's here as a function because the mail is sent from different parts
1578          *
1579          * @param L10n   $l10n     The used language
1580          * @param array  $user     User record array
1581          * @param string $sitename
1582          * @param string $siteurl
1583          * @param string $password Plaintext password
1584          *
1585          * @return NULL|boolean from notification() and email() inherited
1586          * @throws HTTPException\InternalServerErrorException
1587          */
1588         public static function sendRegisterOpenEmail(L10n $l10n, array $user, string $sitename, string $siteurl, string $password)
1589         {
1590                 $preamble = Strings::deindent($l10n->t(
1591                         '
1592                                 Dear %1$s,
1593                                 Thank you for registering at %2$s. Your account has been created.
1594                         ',
1595                         $user['username'],
1596                         $sitename
1597                 ));
1598                 $body = Strings::deindent($l10n->t(
1599                         '
1600                         The login details are as follows:
1601
1602                         Site Location:  %3$s
1603                         Login Name:             %1$s
1604                         Password:               %5$s
1605
1606                         You may change your password from your account "Settings" page after logging
1607                         in.
1608
1609                         Please take a few moments to review the other account settings on that page.
1610
1611                         You may also wish to add some basic information to your default profile
1612                         ' . "\x28" . 'on the "Profiles" page' . "\x29" . ' so that other people can easily find you.
1613
1614                         We recommend setting your full name, adding a profile photo,
1615                         adding some profile "keywords" ' . "\x28" . 'very useful in making new friends' . "\x29" . ' - and
1616                         perhaps what country you live in; if you do not wish to be more specific
1617                         than that.
1618
1619                         We fully respect your right to privacy, and none of these items are necessary.
1620                         If you are new and do not know anybody here, they may help
1621                         you to make some new and interesting friends.
1622
1623                         If you ever want to delete your account, you can do so at %3$s/settings/removeme
1624
1625                         Thank you and welcome to %2$s.',
1626                         $user['nickname'],
1627                         $sitename,
1628                         $siteurl,
1629                         $user['username'],
1630                         $password
1631                 ));
1632
1633                 $email = DI::emailer()
1634                         ->newSystemMail()
1635                         ->withMessage(DI::l10n()->t('Registration details for %s', $sitename), $preamble, $body)
1636                         ->forUser($user)
1637                         ->withRecipient($user['email'])
1638                         ->build();
1639                 return DI::emailer()->send($email);
1640         }
1641
1642         /**
1643          * @param int $uid user to remove
1644          * @return bool
1645          * @throws HTTPException\InternalServerErrorException
1646          */
1647         public static function remove(int $uid): bool
1648         {
1649                 if (empty($uid)) {
1650                         return false;
1651                 }
1652
1653                 Logger::notice('Removing user', ['user' => $uid]);
1654
1655                 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1656
1657                 Hook::callAll('remove_user', $user);
1658
1659                 // save username (actually the nickname as it is guaranteed
1660                 // unique), so it cannot be re-registered in the future.
1661                 DBA::insert('userd', ['username' => $user['nickname']]);
1662
1663                 // Remove all personal settings, especially connector settings
1664                 DBA::delete('pconfig', ['uid' => $uid]);
1665
1666                 // The user and related data will be deleted in Friendica\Worker\ExpireAndRemoveUsers
1667                 DBA::update('user', ['account_removed' => true, 'account_expires_on' => DateTimeFormat::utc('now + 7 day')], ['uid' => $uid]);
1668                 Worker::add(Worker::PRIORITY_HIGH, 'Notifier', Delivery::REMOVAL, $uid);
1669
1670                 // Send an update to the directory
1671                 $self = DBA::selectFirst('contact', ['url'], ['uid' => $uid, 'self' => true]);
1672                 Worker::add(Worker::PRIORITY_LOW, 'Directory', $self['url']);
1673
1674                 // Remove the user relevant data
1675                 Worker::add(Worker::PRIORITY_NEGLIGIBLE, 'RemoveUser', $uid);
1676
1677                 self::setRegisterMethodByUserCount();
1678                 return true;
1679         }
1680
1681         /**
1682          * Return all identities to a user
1683          *
1684          * @param int $uid The user id
1685          * @return array All identities for this user
1686          *
1687          * Example for a return:
1688          *    [
1689          *        [
1690          *            'uid' => 1,
1691          *            'username' => 'maxmuster',
1692          *            'nickname' => 'Max Mustermann'
1693          *        ],
1694          *        [
1695          *            'uid' => 2,
1696          *            'username' => 'johndoe',
1697          *            'nickname' => 'John Doe'
1698          *        ]
1699          *    ]
1700          * @throws Exception
1701          */
1702         public static function identities(int $uid): array
1703         {
1704                 if (!$uid) {
1705                         return [];
1706                 }
1707
1708                 $identities = [];
1709
1710                 $user = DBA::selectFirst('user', ['uid', 'nickname', 'username', 'parent-uid'], ['uid' => $uid]);
1711                 if (!DBA::isResult($user)) {
1712                         return $identities;
1713                 }
1714
1715                 if (!$user['parent-uid']) {
1716                         // First add our own entry
1717                         $identities = [[
1718                                 'uid' => $user['uid'],
1719                                 'username' => $user['username'],
1720                                 'nickname' => $user['nickname']
1721                         ]];
1722
1723                         // Then add all the children
1724                         $r = DBA::select(
1725                                 'user',
1726                                 ['uid', 'username', 'nickname'],
1727                                 ['parent-uid' => $user['uid'], 'account_removed' => false]
1728                         );
1729                         if (DBA::isResult($r)) {
1730                                 $identities = array_merge($identities, DBA::toArray($r));
1731                         }
1732                 } else {
1733                         // First entry is our parent
1734                         $r = DBA::select(
1735                                 'user',
1736                                 ['uid', 'username', 'nickname'],
1737                                 ['uid' => $user['parent-uid'], 'account_removed' => false]
1738                         );
1739                         if (DBA::isResult($r)) {
1740                                 $identities = DBA::toArray($r);
1741                         }
1742
1743                         // Then add all siblings
1744                         $r = DBA::select(
1745                                 'user',
1746                                 ['uid', 'username', 'nickname'],
1747                                 ['parent-uid' => $user['parent-uid'], 'account_removed' => false]
1748                         );
1749                         if (DBA::isResult($r)) {
1750                                 $identities = array_merge($identities, DBA::toArray($r));
1751                         }
1752                 }
1753
1754                 $r = DBA::p(
1755                         "SELECT `user`.`uid`, `user`.`username`, `user`.`nickname`
1756                         FROM `manage`
1757                         INNER JOIN `user` ON `manage`.`mid` = `user`.`uid`
1758                         WHERE `user`.`account_removed` = 0 AND `manage`.`uid` = ?",
1759                         $user['uid']
1760                 );
1761                 if (DBA::isResult($r)) {
1762                         $identities = array_merge($identities, DBA::toArray($r));
1763                 }
1764
1765                 return $identities;
1766         }
1767
1768         /**
1769          * Check if the given user id has delegations or is delegated
1770          *
1771          * @param int $uid
1772          * @return bool
1773          */
1774         public static function hasIdentities(int $uid): bool
1775         {
1776                 if (!$uid) {
1777                         return false;
1778                 }
1779
1780                 $user = DBA::selectFirst('user', ['parent-uid'], ['uid' => $uid, 'account_removed' => false]);
1781                 if (!DBA::isResult($user)) {
1782                         return false;
1783                 }
1784
1785                 if ($user['parent-uid']) {
1786                         return true;
1787                 }
1788
1789                 if (DBA::exists('user', ['parent-uid' => $uid, 'account_removed' => false])) {
1790                         return true;
1791                 }
1792
1793                 if (DBA::exists('manage', ['uid' => $uid])) {
1794                         return true;
1795                 }
1796
1797                 return false;
1798         }
1799
1800         /**
1801          * Returns statistical information about the current users of this node
1802          *
1803          * @return array
1804          *
1805          * @throws Exception
1806          */
1807         public static function getStatistics(): array
1808         {
1809                 $statistics = [
1810                         'total_users'           => 0,
1811                         'active_users_halfyear' => 0,
1812                         'active_users_monthly'  => 0,
1813                         'active_users_weekly'   => 0,
1814                 ];
1815
1816                 $userStmt = DBA::select('owner-view', ['uid', 'last-activity', 'last-item'],
1817                         ["`verified` AND `last-activity` > ? AND NOT `blocked`
1818                         AND NOT `account_removed` AND NOT `account_expired`",
1819                         DBA::NULL_DATETIME]);
1820                 if (!DBA::isResult($userStmt)) {
1821                         return $statistics;
1822                 }
1823
1824                 $halfyear = time() - (180 * 24 * 60 * 60);
1825                 $month = time() - (30 * 24 * 60 * 60);
1826                 $week = time() - (7 * 24 * 60 * 60);
1827
1828                 while ($user = DBA::fetch($userStmt)) {
1829                         $statistics['total_users']++;
1830
1831                         if ((strtotime($user['last-activity']) > $halfyear) || (strtotime($user['last-item']) > $halfyear)
1832                         ) {
1833                                 $statistics['active_users_halfyear']++;
1834                         }
1835
1836                         if ((strtotime($user['last-activity']) > $month) || (strtotime($user['last-item']) > $month)
1837                         ) {
1838                                 $statistics['active_users_monthly']++;
1839                         }
1840
1841                         if ((strtotime($user['last-activity']) > $week) || (strtotime($user['last-item']) > $week)
1842                         ) {
1843                                 $statistics['active_users_weekly']++;
1844                         }
1845                 }
1846                 DBA::close($userStmt);
1847
1848                 return $statistics;
1849         }
1850
1851         /**
1852          * Get all users of the current node
1853          *
1854          * @param int    $start Start count (Default is 0)
1855          * @param int    $count Count of the items per page (Default is @see Pager::ITEMS_PER_PAGE)
1856          * @param string $type  The type of users, which should get (all, blocked, removed)
1857          * @param string $order Order of the user list (Default is 'contact.name')
1858          * @param bool   $descending Order direction (Default is ascending)
1859          * @return array|bool The list of the users
1860          * @throws Exception
1861          */
1862         public static function getList(int $start = 0, int $count = Pager::ITEMS_PER_PAGE, string $type = 'all', string $order = 'name', bool $descending = false)
1863         {
1864                 $param = ['limit' => [$start, $count], 'order' => [$order => $descending]];
1865                 $condition = [];
1866                 switch ($type) {
1867                         case 'active':
1868                                 $condition['account_removed'] = false;
1869                                 $condition['blocked'] = false;
1870                                 break;
1871
1872                         case 'blocked':
1873                                 $condition['account_removed'] = false;
1874                                 $condition['blocked'] = true;
1875                                 $condition['verified'] = true;
1876                                 break;
1877
1878                         case 'removed':
1879                                 $condition['account_removed'] = true;
1880                                 break;
1881                 }
1882
1883                 return DBA::selectToArray('owner-view', [], $condition, $param);
1884         }
1885
1886         /**
1887          * Returns a list of lowercase admin email addresses from the comma-separated list in the config
1888          *
1889          * @return array
1890          */
1891         public static function getAdminEmailList(): array
1892         {
1893                 $adminEmails = strtolower(str_replace(' ', '', DI::config()->get('config', 'admin_email')));
1894                 if (!$adminEmails) {
1895                         return [];
1896                 }
1897
1898                 return explode(',', $adminEmails);
1899         }
1900
1901         /**
1902          * Returns the complete list of admin user accounts
1903          *
1904          * @param array $fields
1905          * @return array
1906          * @throws Exception
1907          */
1908         public static function getAdminList(array $fields = []): array
1909         {
1910                 $condition = [
1911                         'email'           => self::getAdminEmailList(),
1912                         'parent-uid'      => null,
1913                         'blocked'         => false,
1914                         'verified'        => true,
1915                         'account_removed' => false,
1916                         'account_expired' => false,
1917                 ];
1918
1919                 return DBA::selectToArray('user', $fields, $condition, ['order' => ['uid']]);
1920         }
1921
1922         /**
1923          * Return a list of admin user accounts where each unique email address appears only once.
1924          *
1925          * This method is meant for admin notifications that do not need to be sent multiple times to the same email address.
1926          *
1927          * @param array $fields
1928          * @return array
1929          * @throws Exception
1930          */
1931         public static function getAdminListForEmailing(array $fields = []): array
1932         {
1933                 return array_filter(self::getAdminList($fields), function ($user) {
1934                         static $emails = [];
1935
1936                         if (in_array($user['email'], $emails)) {
1937                                 return false;
1938                         }
1939
1940                         $emails[] = $user['email'];
1941
1942                         return true;
1943                 });
1944         }
1945
1946         public static function setRegisterMethodByUserCount()
1947         {
1948                 $max_registered_users = DI::config()->get('config', 'max_registered_users');
1949                 if ($max_registered_users <= 0) {
1950                         return;
1951                 }
1952
1953                 $register_policy = DI::config()->get('config', 'register_policy');
1954                 if (!in_array($register_policy, [Module\Register::OPEN, Module\Register::CLOSED])) {
1955                         Logger::debug('Unsupported register policy.', ['policy' => $register_policy]);
1956                         return;
1957                 }
1958
1959                 $users = DBA::count('user', ['blocked' => false, 'account_removed' => false, 'account_expired' => false]);
1960                 if (($users >= $max_registered_users) && ($register_policy == Module\Register::OPEN)) {
1961                         DI::config()->set('config', 'register_policy', Module\Register::CLOSED);
1962                         Logger::notice('Max users reached, registration is closed.', ['users' => $users, 'max' => $max_registered_users]);
1963                 } elseif (($users < $max_registered_users) && ($register_policy == Module\Register::CLOSED)) {
1964                         DI::config()->set('config', 'register_policy', Module\Register::OPEN);
1965                         Logger::notice('Below maximum users, registration is opened.', ['users' => $users, 'max' => $max_registered_users]);
1966                 } else {
1967                         Logger::debug('Unchanged register policy', ['policy' => $register_policy, 'users' => $users, 'max' => $max_registered_users]);
1968                 }
1969         }
1970 }