]> git.mxchange.org Git - friendica.git/blob - src/Model/User.php
Merge pull request #13331 from annando/paging-search
[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          * Checks if a nickname is in the list of the forbidden nicknames
885          *
886          * Check if a nickname is forbidden from registration on the node by the
887          * admin. Forbidden nicknames (e.g. role names) can be configured in the
888          * admin panel.
889          *
890          * @param string $nickname The nickname that should be checked
891          * @return boolean True is the nickname is blocked on the node
892          */
893         public static function isNicknameBlocked(string $nickname): bool
894         {
895                 $forbidden_nicknames = DI::config()->get('system', 'forbidden_nicknames', '');
896                 if (!empty($forbidden_nicknames)) {
897                         $forbidden = explode(',', $forbidden_nicknames);
898                         $forbidden = array_map('trim', $forbidden);
899                 } else {
900                         $forbidden = [];
901                 }
902
903                 // Add the name of the internal actor to the "forbidden" list
904                 $actor_name = self::getActorName();
905                 if (!empty($actor_name)) {
906                         $forbidden[] = $actor_name;
907                 }
908
909                 if (empty($forbidden)) {
910                         return false;
911                 }
912
913                 // check if the nickname is in the list of blocked nicknames
914                 if (in_array(strtolower($nickname), $forbidden)) {
915                         return true;
916                 }
917
918                 // else return false
919                 return false;
920         }
921
922         /**
923          * Get avatar link for given user
924          *
925          * @param array  $user
926          * @param string $size One of the Proxy::SIZE_* constants
927          * @return string avatar link
928          * @throws Exception
929          */
930         public static function getAvatarUrl(array $user, string $size = ''): string
931         {
932                 if (empty($user['nickname'])) {
933                         DI::logger()->warning('Missing user nickname key', ['trace' => System::callstack(20)]);
934                 }
935
936                 $url = DI::baseUrl() . '/photo/';
937
938                 switch ($size) {
939                         case Proxy::SIZE_MICRO:
940                                 $url .= 'micro/';
941                                 $scale = 6;
942                                 break;
943                         case Proxy::SIZE_THUMB:
944                                 $url .= 'avatar/';
945                                 $scale = 5;
946                                 break;
947                         default:
948                                 $url .= 'profile/';
949                                 $scale = 4;
950                                 break;
951                 }
952
953                 $updated  =  '';
954                 $mimetype = '';
955
956                 $photo = Photo::selectFirst(['type', 'created', 'edited', 'updated'], ["scale" => $scale, 'uid' => $user['uid'], 'profile' => true]);
957                 if (!empty($photo)) {
958                         $updated  = max($photo['created'], $photo['edited'], $photo['updated']);
959                         $mimetype = $photo['type'];
960                 }
961
962                 return $url . $user['nickname'] . Images::getExtensionByMimeType($mimetype) . ($updated ? '?ts=' . strtotime($updated) : '');
963         }
964
965         /**
966          * Get banner link for given user
967          *
968          * @param array  $user
969          * @return string banner link
970          * @throws Exception
971          */
972         public static function getBannerUrl(array $user): string
973         {
974                 if (empty($user['nickname'])) {
975                         DI::logger()->warning('Missing user nickname key', ['trace' => System::callstack(20)]);
976                 }
977
978                 $url = DI::baseUrl() . '/photo/banner/';
979
980                 $updated  = '';
981                 $mimetype = '';
982
983                 $photo = Photo::selectFirst(['type', 'created', 'edited', 'updated'], ["scale" => 3, 'uid' => $user['uid'], 'photo-type' => Photo::USER_BANNER]);
984                 if (!empty($photo)) {
985                         $updated  = max($photo['created'], $photo['edited'], $photo['updated']);
986                         $mimetype = $photo['type'];
987                 } else {
988                         // Only for the RC phase: Don't return an image link for the default picture
989                         return '';
990                 }
991
992                 return $url . $user['nickname'] . Images::getExtensionByMimeType($mimetype) . ($updated ? '?ts=' . strtotime($updated) : '');
993         }
994
995         /**
996          * Catch-all user creation function
997          *
998          * Creates a user from the provided data array, either form fields or OpenID.
999          * Required: { username, nickname, email } or { openid_url }
1000          *
1001          * Performs the following:
1002          * - Sends to the OpenId auth URL (if relevant)
1003          * - Creates new key pairs for crypto
1004          * - Create self-contact
1005          * - Create profile image
1006          *
1007          * @param  array $data
1008          * @return array
1009          * @throws ErrorException
1010          * @throws HTTPException\InternalServerErrorException
1011          * @throws ImagickException
1012          * @throws Exception
1013          */
1014         public static function create(array $data): array
1015         {
1016                 $return = ['user' => null, 'password' => ''];
1017
1018                 $using_invites = DI::config()->get('system', 'invitation_only');
1019
1020                 $invite_id  = !empty($data['invite_id'])  ? trim($data['invite_id'])  : '';
1021                 $username   = !empty($data['username'])   ? trim($data['username'])   : '';
1022                 $nickname   = !empty($data['nickname'])   ? trim($data['nickname'])   : '';
1023                 $email      = !empty($data['email'])      ? trim($data['email'])      : '';
1024                 $openid_url = !empty($data['openid_url']) ? trim($data['openid_url']) : '';
1025                 $photo      = !empty($data['photo'])      ? trim($data['photo'])      : '';
1026                 $password   = !empty($data['password'])   ? trim($data['password'])   : '';
1027                 $password1  = !empty($data['password1'])  ? trim($data['password1'])  : '';
1028                 $confirm    = !empty($data['confirm'])    ? trim($data['confirm'])    : '';
1029                 $blocked    = !empty($data['blocked']);
1030                 $verified   = !empty($data['verified']);
1031                 $language   = !empty($data['language'])   ? trim($data['language'])   : 'en';
1032
1033                 $netpublish = $publish = !empty($data['profile_publish_reg']);
1034
1035                 if ($password1 != $confirm) {
1036                         throw new Exception(DI::l10n()->t('Passwords do not match. Password unchanged.'));
1037                 } elseif ($password1 != '') {
1038                         $password = $password1;
1039                 }
1040
1041                 if ($using_invites) {
1042                         if (!$invite_id) {
1043                                 throw new Exception(DI::l10n()->t('An invitation is required.'));
1044                         }
1045
1046                         if (!Register::existsByHash($invite_id)) {
1047                                 throw new Exception(DI::l10n()->t('Invitation could not be verified.'));
1048                         }
1049                 }
1050
1051                 /// @todo Check if this part is really needed. We should have fetched all this data in advance
1052                 if (empty($username) || empty($email) || empty($nickname)) {
1053                         if ($openid_url) {
1054                                 if (!Network::isUrlValid($openid_url)) {
1055                                         throw new Exception(DI::l10n()->t('Invalid OpenID url'));
1056                                 }
1057                                 $_SESSION['register'] = 1;
1058                                 $_SESSION['openid'] = $openid_url;
1059
1060                                 $openid = new LightOpenID(DI::baseUrl()->getHost());
1061                                 $openid->identity = $openid_url;
1062                                 $openid->returnUrl = DI::baseUrl() . '/openid';
1063                                 $openid->required = ['namePerson/friendly', 'contact/email', 'namePerson'];
1064                                 $openid->optional = ['namePerson/first', 'media/image/aspect11', 'media/image/default'];
1065                                 try {
1066                                         $authurl = $openid->authUrl();
1067                                 } catch (Exception $e) {
1068                                         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);
1069                                 }
1070                                 System::externalRedirect($authurl);
1071                                 // NOTREACHED
1072                         }
1073
1074                         throw new Exception(DI::l10n()->t('Please enter the required information.'));
1075                 }
1076
1077                 if (!Network::isUrlValid($openid_url)) {
1078                         $openid_url = '';
1079                 }
1080
1081                 // collapse multiple spaces in name
1082                 $username = preg_replace('/ +/', ' ', $username);
1083
1084                 $username_min_length = max(1, min(64, intval(DI::config()->get('system', 'username_min_length', 3))));
1085                 $username_max_length = max(1, min(64, intval(DI::config()->get('system', 'username_max_length', 48))));
1086
1087                 if ($username_min_length > $username_max_length) {
1088                         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));
1089                         $tmp = $username_min_length;
1090                         $username_min_length = $username_max_length;
1091                         $username_max_length = $tmp;
1092                 }
1093
1094                 if (mb_strlen($username) < $username_min_length) {
1095                         throw new Exception(DI::l10n()->tt('Username should be at least %s character.', 'Username should be at least %s characters.', $username_min_length));
1096                 }
1097
1098                 if (mb_strlen($username) > $username_max_length) {
1099                         throw new Exception(DI::l10n()->tt('Username should be at most %s character.', 'Username should be at most %s characters.', $username_max_length));
1100                 }
1101
1102                 // So now we are just looking for a space in the full name.
1103                 $loose_reg = DI::config()->get('system', 'no_regfullname');
1104                 if (!$loose_reg) {
1105                         $username = mb_convert_case($username, MB_CASE_TITLE, 'UTF-8');
1106                         if (strpos($username, ' ') === false) {
1107                                 throw new Exception(DI::l10n()->t("That doesn't appear to be your full (First Last) name."));
1108                         }
1109                 }
1110
1111                 if (!Network::isEmailDomainAllowed($email)) {
1112                         throw new Exception(DI::l10n()->t('Your email domain is not among those allowed on this site.'));
1113                 }
1114
1115                 if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !Network::isEmailDomainValid($email)) {
1116                         throw new Exception(DI::l10n()->t('Not a valid email address.'));
1117                 }
1118                 if (self::isNicknameBlocked($nickname)) {
1119                         throw new Exception(DI::l10n()->t('The nickname was blocked from registration by the nodes admin.'));
1120                 }
1121
1122                 if (DI::config()->get('system', 'block_extended_register', false) && DBA::exists('user', ['email' => $email])) {
1123                         throw new Exception(DI::l10n()->t('Cannot use that email.'));
1124                 }
1125
1126                 // Disallow somebody creating an account using openid that uses the admin email address,
1127                 // since openid bypasses email verification. We'll allow it if there is not yet an admin account.
1128                 if (strlen($openid_url) && in_array(strtolower($email), self::getAdminEmailList())) {
1129                         throw new Exception(DI::l10n()->t('Cannot use that email.'));
1130                 }
1131
1132                 $nickname = $data['nickname'] = strtolower($nickname);
1133
1134                 if (!preg_match('/^[a-z0-9][a-z0-9_]*$/', $nickname)) {
1135                         throw new Exception(DI::l10n()->t('Your nickname can only contain a-z, 0-9 and _.'));
1136                 }
1137
1138                 // Check existing and deleted accounts for this nickname.
1139                 if (
1140                         DBA::exists('user', ['nickname' => $nickname])
1141                         || DBA::exists('userd', ['username' => $nickname])
1142                 ) {
1143                         throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1144                 }
1145
1146                 $new_password = strlen($password) ? $password : User::generateNewPassword();
1147                 $new_password_encoded = self::hashPassword($new_password);
1148
1149                 $return['password'] = $new_password;
1150
1151                 $keys = Crypto::newKeypair(4096);
1152                 if ($keys === false) {
1153                         throw new Exception(DI::l10n()->t('SERIOUS ERROR: Generation of security keys failed.'));
1154                 }
1155
1156                 $prvkey = $keys['prvkey'];
1157                 $pubkey = $keys['pubkey'];
1158
1159                 // Create another keypair for signing/verifying salmon protocol messages.
1160                 $sres = Crypto::newKeypair(512);
1161                 $sprvkey = $sres['prvkey'];
1162                 $spubkey = $sres['pubkey'];
1163
1164                 $insert_result = DBA::insert('user', [
1165                         'guid'     => System::createUUID(),
1166                         'username' => $username,
1167                         'password' => $new_password_encoded,
1168                         'email'    => $email,
1169                         'openid'   => $openid_url,
1170                         'nickname' => $nickname,
1171                         'pubkey'   => $pubkey,
1172                         'prvkey'   => $prvkey,
1173                         'spubkey'  => $spubkey,
1174                         'sprvkey'  => $sprvkey,
1175                         'verified' => $verified,
1176                         'blocked'  => $blocked,
1177                         'language' => $language,
1178                         'timezone' => 'UTC',
1179                         'register_date' => DateTimeFormat::utcNow(),
1180                         'default-location' => ''
1181                 ]);
1182
1183                 if ($insert_result) {
1184                         $uid = DBA::lastInsertId();
1185                         $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1186                 } else {
1187                         throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1188                 }
1189
1190                 if (!$uid) {
1191                         throw new Exception(DI::l10n()->t('An error occurred during registration. Please try again.'));
1192                 }
1193
1194                 // if somebody clicked submit twice very quickly, they could end up with two accounts
1195                 // due to race condition. Remove this one.
1196                 $user_count = DBA::count('user', ['nickname' => $nickname]);
1197                 if ($user_count > 1) {
1198                         DBA::delete('user', ['uid' => $uid]);
1199
1200                         throw new Exception(DI::l10n()->t('Nickname is already registered. Please choose another.'));
1201                 }
1202
1203                 $insert_result = DBA::insert('profile', [
1204                         'uid' => $uid,
1205                         'name' => $username,
1206                         'photo' => self::getAvatarUrl($user),
1207                         'thumb' => self::getAvatarUrl($user, Proxy::SIZE_THUMB),
1208                         'publish' => $publish,
1209                         'net-publish' => $netpublish,
1210                 ]);
1211                 if (!$insert_result) {
1212                         DBA::delete('user', ['uid' => $uid]);
1213
1214                         throw new Exception(DI::l10n()->t('An error occurred creating your default profile. Please try again.'));
1215                 }
1216
1217                 // Create the self contact
1218                 if (!Contact::createSelfFromUserId($uid)) {
1219                         DBA::delete('user', ['uid' => $uid]);
1220
1221                         throw new Exception(DI::l10n()->t('An error occurred creating your self contact. Please try again.'));
1222                 }
1223
1224                 // Create a circle with no members. This allows somebody to use it
1225                 // right away as a default circle for new contacts.
1226                 $def_gid = Circle::create($uid, DI::l10n()->t('Friends'));
1227                 if (!$def_gid) {
1228                         DBA::delete('user', ['uid' => $uid]);
1229
1230                         throw new Exception(DI::l10n()->t('An error occurred creating your default contact circle. Please try again.'));
1231                 }
1232
1233                 $fields = ['def_gid' => $def_gid];
1234                 if (DI::config()->get('system', 'newuser_private') && $def_gid) {
1235                         $fields['allow_gid'] = '<' . $def_gid . '>';
1236                 }
1237
1238                 DBA::update('user', $fields, ['uid' => $uid]);
1239
1240                 $def_gid_groups = Circle::create($uid, DI::l10n()->t('Groups'));
1241                 if ($def_gid_groups) {
1242                         DI::pConfig()->set($uid, 'system', 'default-group-gid', $def_gid_groups);
1243                 }
1244
1245                 // if we have no OpenID photo try to look up an avatar
1246                 if (!strlen($photo)) {
1247                         $photo = Network::lookupAvatarByEmail($email);
1248                 }
1249
1250                 // unless there is no avatar-addon loaded
1251                 if (strlen($photo)) {
1252                         $photo_failure = false;
1253
1254                         $filename = basename($photo);
1255                         $curlResult = DI::httpClient()->get($photo, HttpClientAccept::IMAGE);
1256                         if ($curlResult->isSuccess()) {
1257                                 Logger::debug('Got picture', ['Content-Type' => $curlResult->getHeader('Content-Type'), 'url' => $photo]);
1258                                 $img_str = $curlResult->getBody();
1259                                 $type = $curlResult->getContentType();
1260                         } else {
1261                                 $img_str = '';
1262                                 $type = '';
1263                         }
1264
1265                         $type = Images::getMimeTypeByData($img_str, $photo, $type);
1266
1267                         $image = new Image($img_str, $type);
1268                         if ($image->isValid()) {
1269                                 $image->scaleToSquare(300);
1270
1271                                 $resource_id = Photo::newResource();
1272
1273                                 // Not using Photo::PROFILE_PHOTOS here, so that it is discovered as translatable string
1274                                 $profile_album = DI::l10n()->t('Profile Photos');
1275
1276                                 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 4);
1277
1278                                 if ($r === false) {
1279                                         $photo_failure = true;
1280                                 }
1281
1282                                 $image->scaleDown(80);
1283
1284                                 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 5);
1285
1286                                 if ($r === false) {
1287                                         $photo_failure = true;
1288                                 }
1289
1290                                 $image->scaleDown(48);
1291
1292                                 $r = Photo::store($image, $uid, 0, $resource_id, $filename, $profile_album, 6);
1293
1294                                 if ($r === false) {
1295                                         $photo_failure = true;
1296                                 }
1297
1298                                 if (!$photo_failure) {
1299                                         Photo::update(['profile' => true, 'photo-type' => Photo::USER_AVATAR], ['resource-id' => $resource_id]);
1300                                 }
1301                         }
1302
1303                         Contact::updateSelfFromUserID($uid, true);
1304                 }
1305
1306                 Hook::callAll('register_account', $uid);
1307
1308                 self::setRegisterMethodByUserCount();
1309
1310                 $return['user'] = $user;
1311                 return $return;
1312         }
1313
1314         /**
1315          * Update a user entry and distribute the changes if needed
1316          *
1317          * @param array $fields
1318          * @param integer $uid
1319          * @return boolean
1320          */
1321         public static function update(array $fields, int $uid): bool
1322         {
1323                 $old_owner = self::getOwnerDataById($uid);
1324                 if (empty($old_owner)) {
1325                         return false;
1326                 }
1327
1328                 if (!DBA::update('user', $fields, ['uid' => $uid])) {
1329                         return false;
1330                 }
1331
1332                 $update = Contact::updateSelfFromUserID($uid);
1333
1334                 $owner = self::getOwnerDataById($uid);
1335                 if (empty($owner)) {
1336                         return false;
1337                 }
1338
1339                 if ($old_owner['name'] != $owner['name']) {
1340                         Profile::update(['name' => $owner['name']], $uid);
1341                 }
1342
1343                 if ($update) {
1344                         Profile::publishUpdate($uid);
1345                 }
1346
1347                 return true;
1348         }
1349
1350         /**
1351          * Sets block state for a given user
1352          *
1353          * @param int  $uid   The user id
1354          * @param bool $block Block state (default is true)
1355          *
1356          * @return bool True, if successfully blocked
1357
1358          * @throws Exception
1359          */
1360         public static function block(int $uid, bool $block = true): bool
1361         {
1362                 return DBA::update('user', ['blocked' => $block], ['uid' => $uid]);
1363         }
1364
1365         /**
1366          * Allows a registration based on a hash
1367          *
1368          * @param string $hash
1369          *
1370          * @return bool True, if the allow was successful
1371          *
1372          * @throws HTTPException\InternalServerErrorException
1373          * @throws Exception
1374          */
1375         public static function allow(string $hash): bool
1376         {
1377                 $register = Register::getByHash($hash);
1378                 if (!DBA::isResult($register)) {
1379                         return false;
1380                 }
1381
1382                 $user = User::getById($register['uid']);
1383                 if (!DBA::isResult($user)) {
1384                         return false;
1385                 }
1386
1387                 Register::deleteByHash($hash);
1388
1389                 DBA::update('user', ['blocked' => false, 'verified' => true], ['uid' => $register['uid']]);
1390
1391                 $profile = DBA::selectFirst('profile', ['net-publish'], ['uid' => $register['uid']]);
1392
1393                 if (DBA::isResult($profile) && $profile['net-publish'] && Search::getGlobalDirectory()) {
1394                         $url = DI::baseUrl() . '/profile/' . $user['nickname'];
1395                         Worker::add(Worker::PRIORITY_LOW, "Directory", $url);
1396                 }
1397
1398                 $l10n = DI::l10n()->withLang($register['language']);
1399
1400                 return User::sendRegisterOpenEmail(
1401                         $l10n,
1402                         $user,
1403                         DI::config()->get('config', 'sitename'),
1404                         DI::baseUrl(),
1405                         ($register['password'] ?? '') ?: 'Sent in a previous email'
1406                 );
1407         }
1408
1409         /**
1410          * Denys a pending registration
1411          *
1412          * @param string $hash The hash of the pending user
1413          *
1414          * This does not have to go through user_remove() and save the nickname
1415          * permanently against re-registration, as the person was not yet
1416          * allowed to have friends on this system
1417          *
1418          * @return bool True, if the deny was successful
1419          * @throws Exception
1420          */
1421         public static function deny(string $hash): bool
1422         {
1423                 $register = Register::getByHash($hash);
1424                 if (!DBA::isResult($register)) {
1425                         return false;
1426                 }
1427
1428                 $user = User::getById($register['uid']);
1429                 if (!DBA::isResult($user)) {
1430                         return false;
1431                 }
1432
1433                 // Delete the avatar
1434                 Photo::delete(['uid' => $register['uid']]);
1435
1436                 return DBA::delete('user', ['uid' => $register['uid']]) &&
1437                        Register::deleteByHash($register['hash']);
1438         }
1439
1440         /**
1441          * Creates a new user based on a minimal set and sends an email to this user
1442          *
1443          * @param string $name  The user's name
1444          * @param string $email The user's email address
1445          * @param string $nick  The user's nick name
1446          * @param string $lang  The user's language (default is english)
1447          * @return bool True, if the user was created successfully
1448          * @throws HTTPException\InternalServerErrorException
1449          * @throws ErrorException
1450          * @throws ImagickException
1451          */
1452         public static function createMinimal(string $name, string $email, string $nick, string $lang = L10n::DEFAULT): bool
1453         {
1454                 if (empty($name) ||
1455                     empty($email) ||
1456                     empty($nick)) {
1457                         throw new HTTPException\InternalServerErrorException('Invalid arguments.');
1458                 }
1459
1460                 $result = self::create([
1461                         'username' => $name,
1462                         'email' => $email,
1463                         'nickname' => $nick,
1464                         'verified' => 1,
1465                         'language' => $lang
1466                 ]);
1467
1468                 $user = $result['user'];
1469                 $preamble = Strings::deindent(DI::l10n()->t('
1470                 Dear %1$s,
1471                         the administrator of %2$s has set up an account for you.'));
1472                 $body = Strings::deindent(DI::l10n()->t('
1473                 The login details are as follows:
1474
1475                 Site Location:  %1$s
1476                 Login Name:             %2$s
1477                 Password:               %3$s
1478
1479                 You may change your password from your account "Settings" page after logging
1480                 in.
1481
1482                 Please take a few moments to review the other account settings on that page.
1483
1484                 You may also wish to add some basic information to your default profile
1485                 (on the "Profiles" page) so that other people can easily find you.
1486
1487                 We recommend setting your full name, adding a profile photo,
1488                 adding some profile "keywords" (very useful in making new friends) - and
1489                 perhaps what country you live in; if you do not wish to be more specific
1490                 than that.
1491
1492                 We fully respect your right to privacy, and none of these items are necessary.
1493                 If you are new and do not know anybody here, they may help
1494                 you to make some new and interesting friends.
1495
1496                 If you ever want to delete your account, you can do so at %1$s/settings/removeme
1497
1498                 Thank you and welcome to %4$s.'));
1499
1500                 $preamble = sprintf($preamble, $user['username'], DI::config()->get('config', 'sitename'));
1501                 $body = sprintf($body, DI::baseUrl(), $user['nickname'], $result['password'], DI::config()->get('config', 'sitename'));
1502
1503                 $email = DI::emailer()
1504                         ->newSystemMail()
1505                         ->withMessage(DI::l10n()->t('Registration details for %s', DI::config()->get('config', 'sitename')), $preamble, $body)
1506                         ->forUser($user)
1507                         ->withRecipient($user['email'])
1508                         ->build();
1509                 return DI::emailer()->send($email);
1510         }
1511
1512         /**
1513          * Sends pending registration confirmation email
1514          *
1515          * @param array  $user     User record array
1516          * @param string $sitename
1517          * @param string $siteurl
1518          * @param string $password Plaintext password
1519          * @return NULL|boolean from notification() and email() inherited
1520          * @throws HTTPException\InternalServerErrorException
1521          */
1522         public static function sendRegisterPendingEmail(array $user, string $sitename, string $siteurl, string $password)
1523         {
1524                 $body = Strings::deindent(DI::l10n()->t(
1525                         '
1526                         Dear %1$s,
1527                                 Thank you for registering at %2$s. Your account is pending for approval by the administrator.
1528
1529                         Your login details are as follows:
1530
1531                         Site Location:  %3$s
1532                         Login Name:             %4$s
1533                         Password:               %5$s
1534                 ',
1535                         $user['username'],
1536                         $sitename,
1537                         $siteurl,
1538                         $user['nickname'],
1539                         $password
1540                 ));
1541
1542                 $email = DI::emailer()
1543                         ->newSystemMail()
1544                         ->withMessage(DI::l10n()->t('Registration at %s', $sitename), $body)
1545                         ->forUser($user)
1546                         ->withRecipient($user['email'])
1547                         ->build();
1548                 return DI::emailer()->send($email);
1549         }
1550
1551         /**
1552          * Sends registration confirmation
1553          *
1554          * It's here as a function because the mail is sent from different parts
1555          *
1556          * @param L10n   $l10n     The used language
1557          * @param array  $user     User record array
1558          * @param string $sitename
1559          * @param string $siteurl
1560          * @param string $password Plaintext password
1561          *
1562          * @return NULL|boolean from notification() and email() inherited
1563          * @throws HTTPException\InternalServerErrorException
1564          */
1565         public static function sendRegisterOpenEmail(L10n $l10n, array $user, string $sitename, string $siteurl, string $password)
1566         {
1567                 $preamble = Strings::deindent($l10n->t(
1568                         '
1569                                 Dear %1$s,
1570                                 Thank you for registering at %2$s. Your account has been created.
1571                         ',
1572                         $user['username'],
1573                         $sitename
1574                 ));
1575                 $body = Strings::deindent($l10n->t(
1576                         '
1577                         The login details are as follows:
1578
1579                         Site Location:  %3$s
1580                         Login Name:             %1$s
1581                         Password:               %5$s
1582
1583                         You may change your password from your account "Settings" page after logging
1584                         in.
1585
1586                         Please take a few moments to review the other account settings on that page.
1587
1588                         You may also wish to add some basic information to your default profile
1589                         ' . "\x28" . 'on the "Profiles" page' . "\x29" . ' so that other people can easily find you.
1590
1591                         We recommend setting your full name, adding a profile photo,
1592                         adding some profile "keywords" ' . "\x28" . 'very useful in making new friends' . "\x29" . ' - and
1593                         perhaps what country you live in; if you do not wish to be more specific
1594                         than that.
1595
1596                         We fully respect your right to privacy, and none of these items are necessary.
1597                         If you are new and do not know anybody here, they may help
1598                         you to make some new and interesting friends.
1599
1600                         If you ever want to delete your account, you can do so at %3$s/settings/removeme
1601
1602                         Thank you and welcome to %2$s.',
1603                         $user['nickname'],
1604                         $sitename,
1605                         $siteurl,
1606                         $user['username'],
1607                         $password
1608                 ));
1609
1610                 $email = DI::emailer()
1611                         ->newSystemMail()
1612                         ->withMessage(DI::l10n()->t('Registration details for %s', $sitename), $preamble, $body)
1613                         ->forUser($user)
1614                         ->withRecipient($user['email'])
1615                         ->build();
1616                 return DI::emailer()->send($email);
1617         }
1618
1619         /**
1620          * @param int $uid user to remove
1621          * @return bool
1622          * @throws HTTPException\InternalServerErrorException
1623          */
1624         public static function remove(int $uid): bool
1625         {
1626                 if (empty($uid)) {
1627                         return false;
1628                 }
1629
1630                 Logger::notice('Removing user', ['user' => $uid]);
1631
1632                 $user = DBA::selectFirst('user', [], ['uid' => $uid]);
1633
1634                 Hook::callAll('remove_user', $user);
1635
1636                 // save username (actually the nickname as it is guaranteed
1637                 // unique), so it cannot be re-registered in the future.
1638                 DBA::insert('userd', ['username' => $user['nickname']]);
1639
1640                 // Remove all personal settings, especially connector settings
1641                 DBA::delete('pconfig', ['uid' => $uid]);
1642
1643                 // The user and related data will be deleted in Friendica\Worker\ExpireAndRemoveUsers
1644                 DBA::update('user', ['account_removed' => true, 'account_expires_on' => DateTimeFormat::utc('now + 7 day')], ['uid' => $uid]);
1645                 Worker::add(Worker::PRIORITY_HIGH, 'Notifier', Delivery::REMOVAL, $uid);
1646
1647                 // Send an update to the directory
1648                 $self = DBA::selectFirst('contact', ['url'], ['uid' => $uid, 'self' => true]);
1649                 Worker::add(Worker::PRIORITY_LOW, 'Directory', $self['url']);
1650
1651                 // Remove the user relevant data
1652                 Worker::add(Worker::PRIORITY_NEGLIGIBLE, 'RemoveUser', $uid);
1653
1654                 self::setRegisterMethodByUserCount();
1655                 return true;
1656         }
1657
1658         /**
1659          * Return all identities to a user
1660          *
1661          * @param int $uid The user id
1662          * @return array All identities for this user
1663          *
1664          * Example for a return:
1665          *    [
1666          *        [
1667          *            'uid' => 1,
1668          *            'username' => 'maxmuster',
1669          *            'nickname' => 'Max Mustermann'
1670          *        ],
1671          *        [
1672          *            'uid' => 2,
1673          *            'username' => 'johndoe',
1674          *            'nickname' => 'John Doe'
1675          *        ]
1676          *    ]
1677          * @throws Exception
1678          */
1679         public static function identities(int $uid): array
1680         {
1681                 if (!$uid) {
1682                         return [];
1683                 }
1684
1685                 $identities = [];
1686
1687                 $user = DBA::selectFirst('user', ['uid', 'nickname', 'username', 'parent-uid'], ['uid' => $uid]);
1688                 if (!DBA::isResult($user)) {
1689                         return $identities;
1690                 }
1691
1692                 if (!$user['parent-uid']) {
1693                         // First add our own entry
1694                         $identities = [[
1695                                 'uid' => $user['uid'],
1696                                 'username' => $user['username'],
1697                                 'nickname' => $user['nickname']
1698                         ]];
1699
1700                         // Then add all the children
1701                         $r = DBA::select(
1702                                 'user',
1703                                 ['uid', 'username', 'nickname'],
1704                                 ['parent-uid' => $user['uid'], 'account_removed' => false]
1705                         );
1706                         if (DBA::isResult($r)) {
1707                                 $identities = array_merge($identities, DBA::toArray($r));
1708                         }
1709                 } else {
1710                         // First entry is our parent
1711                         $r = DBA::select(
1712                                 'user',
1713                                 ['uid', 'username', 'nickname'],
1714                                 ['uid' => $user['parent-uid'], 'account_removed' => false]
1715                         );
1716                         if (DBA::isResult($r)) {
1717                                 $identities = DBA::toArray($r);
1718                         }
1719
1720                         // Then add all siblings
1721                         $r = DBA::select(
1722                                 'user',
1723                                 ['uid', 'username', 'nickname'],
1724                                 ['parent-uid' => $user['parent-uid'], 'account_removed' => false]
1725                         );
1726                         if (DBA::isResult($r)) {
1727                                 $identities = array_merge($identities, DBA::toArray($r));
1728                         }
1729                 }
1730
1731                 $r = DBA::p(
1732                         "SELECT `user`.`uid`, `user`.`username`, `user`.`nickname`
1733                         FROM `manage`
1734                         INNER JOIN `user` ON `manage`.`mid` = `user`.`uid`
1735                         WHERE `user`.`account_removed` = 0 AND `manage`.`uid` = ?",
1736                         $user['uid']
1737                 );
1738                 if (DBA::isResult($r)) {
1739                         $identities = array_merge($identities, DBA::toArray($r));
1740                 }
1741
1742                 return $identities;
1743         }
1744
1745         /**
1746          * Check if the given user id has delegations or is delegated
1747          *
1748          * @param int $uid
1749          * @return bool
1750          */
1751         public static function hasIdentities(int $uid): bool
1752         {
1753                 if (!$uid) {
1754                         return false;
1755                 }
1756
1757                 $user = DBA::selectFirst('user', ['parent-uid'], ['uid' => $uid, 'account_removed' => false]);
1758                 if (!DBA::isResult($user)) {
1759                         return false;
1760                 }
1761
1762                 if ($user['parent-uid']) {
1763                         return true;
1764                 }
1765
1766                 if (DBA::exists('user', ['parent-uid' => $uid, 'account_removed' => false])) {
1767                         return true;
1768                 }
1769
1770                 if (DBA::exists('manage', ['uid' => $uid])) {
1771                         return true;
1772                 }
1773
1774                 return false;
1775         }
1776
1777         /**
1778          * Returns statistical information about the current users of this node
1779          *
1780          * @return array
1781          *
1782          * @throws Exception
1783          */
1784         public static function getStatistics(): array
1785         {
1786                 $statistics = [
1787                         'total_users'           => 0,
1788                         'active_users_halfyear' => 0,
1789                         'active_users_monthly'  => 0,
1790                         'active_users_weekly'   => 0,
1791                 ];
1792
1793                 $userStmt = DBA::select('owner-view', ['uid', 'last-activity', 'last-item'],
1794                         ["`verified` AND `last-activity` > ? AND NOT `blocked`
1795                         AND NOT `account_removed` AND NOT `account_expired`",
1796                         DBA::NULL_DATETIME]);
1797                 if (!DBA::isResult($userStmt)) {
1798                         return $statistics;
1799                 }
1800
1801                 $halfyear = time() - (180 * 24 * 60 * 60);
1802                 $month = time() - (30 * 24 * 60 * 60);
1803                 $week = time() - (7 * 24 * 60 * 60);
1804
1805                 while ($user = DBA::fetch($userStmt)) {
1806                         $statistics['total_users']++;
1807
1808                         if ((strtotime($user['last-activity']) > $halfyear) || (strtotime($user['last-item']) > $halfyear)
1809                         ) {
1810                                 $statistics['active_users_halfyear']++;
1811                         }
1812
1813                         if ((strtotime($user['last-activity']) > $month) || (strtotime($user['last-item']) > $month)
1814                         ) {
1815                                 $statistics['active_users_monthly']++;
1816                         }
1817
1818                         if ((strtotime($user['last-activity']) > $week) || (strtotime($user['last-item']) > $week)
1819                         ) {
1820                                 $statistics['active_users_weekly']++;
1821                         }
1822                 }
1823                 DBA::close($userStmt);
1824
1825                 return $statistics;
1826         }
1827
1828         /**
1829          * Get all users of the current node
1830          *
1831          * @param int    $start Start count (Default is 0)
1832          * @param int    $count Count of the items per page (Default is @see Pager::ITEMS_PER_PAGE)
1833          * @param string $type  The type of users, which should get (all, blocked, removed)
1834          * @param string $order Order of the user list (Default is 'contact.name')
1835          * @param bool   $descending Order direction (Default is ascending)
1836          * @return array|bool The list of the users
1837          * @throws Exception
1838          */
1839         public static function getList(int $start = 0, int $count = Pager::ITEMS_PER_PAGE, string $type = 'all', string $order = 'name', bool $descending = false)
1840         {
1841                 $param = ['limit' => [$start, $count], 'order' => [$order => $descending]];
1842                 $condition = [];
1843                 switch ($type) {
1844                         case 'active':
1845                                 $condition['account_removed'] = false;
1846                                 $condition['blocked'] = false;
1847                                 break;
1848
1849                         case 'blocked':
1850                                 $condition['account_removed'] = false;
1851                                 $condition['blocked'] = true;
1852                                 $condition['verified'] = true;
1853                                 break;
1854
1855                         case 'removed':
1856                                 $condition['account_removed'] = true;
1857                                 break;
1858                 }
1859
1860                 return DBA::selectToArray('owner-view', [], $condition, $param);
1861         }
1862
1863         /**
1864          * Returns a list of lowercase admin email addresses from the comma-separated list in the config
1865          *
1866          * @return array
1867          */
1868         public static function getAdminEmailList(): array
1869         {
1870                 $adminEmails = strtolower(str_replace(' ', '', DI::config()->get('config', 'admin_email')));
1871                 if (!$adminEmails) {
1872                         return [];
1873                 }
1874
1875                 return explode(',', $adminEmails);
1876         }
1877
1878         /**
1879          * Returns the complete list of admin user accounts
1880          *
1881          * @param array $fields
1882          * @return array
1883          * @throws Exception
1884          */
1885         public static function getAdminList(array $fields = []): array
1886         {
1887                 $condition = [
1888                         'email'           => self::getAdminEmailList(),
1889                         'parent-uid'      => null,
1890                         'blocked'         => false,
1891                         'verified'        => true,
1892                         'account_removed' => false,
1893                         'account_expired' => false,
1894                 ];
1895
1896                 return DBA::selectToArray('user', $fields, $condition, ['order' => ['uid']]);
1897         }
1898
1899         /**
1900          * Return a list of admin user accounts where each unique email address appears only once.
1901          *
1902          * This method is meant for admin notifications that do not need to be sent multiple times to the same email address.
1903          *
1904          * @param array $fields
1905          * @return array
1906          * @throws Exception
1907          */
1908         public static function getAdminListForEmailing(array $fields = []): array
1909         {
1910                 return array_filter(self::getAdminList($fields), function ($user) {
1911                         static $emails = [];
1912
1913                         if (in_array($user['email'], $emails)) {
1914                                 return false;
1915                         }
1916
1917                         $emails[] = $user['email'];
1918
1919                         return true;
1920                 });
1921         }
1922
1923         public static function setRegisterMethodByUserCount()
1924         {
1925                 $max_registered_users = DI::config()->get('config', 'max_registered_users');
1926                 if ($max_registered_users <= 0) {
1927                         return;
1928                 }
1929
1930                 $register_policy = DI::config()->get('config', 'register_policy');
1931                 if (!in_array($register_policy, [Module\Register::OPEN, Module\Register::CLOSED])) {
1932                         Logger::debug('Unsupported register policy.', ['policy' => $register_policy]);
1933                         return;
1934                 }
1935
1936                 $users = DBA::count('user', ['blocked' => false, 'account_removed' => false, 'account_expired' => false]);
1937                 if (($users >= $max_registered_users) && ($register_policy == Module\Register::OPEN)) {
1938                         DI::config()->set('config', 'register_policy', Module\Register::CLOSED);
1939                         Logger::notice('Max users reached, registration is closed.', ['users' => $users, 'max' => $max_registered_users]);
1940                 } elseif (($users < $max_registered_users) && ($register_policy == Module\Register::CLOSED)) {
1941                         DI::config()->set('config', 'register_policy', Module\Register::OPEN);
1942                         Logger::notice('Below maximum users, registration is opened.', ['users' => $users, 'max' => $max_registered_users]);
1943                 } else {
1944                         Logger::debug('Unchanged register policy', ['policy' => $register_policy, 'users' => $users, 'max' => $max_registered_users]);
1945                 }
1946         }
1947 }