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