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