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