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