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