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