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