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