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