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