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