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