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