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