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