Merge pull request #5987 from WoltLab/acp-dahsboard-box-hight
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / data / user / User.class.php
CommitLineData
11ade432 1<?php
a9229942 2
11ade432 3namespace wcf\data\user;
a9229942
TD
4
5use wcf\data\DatabaseObject;
4132baef 6use wcf\data\IPopoverObject;
a9229942 7use wcf\data\IUserContent;
7a23a706 8use wcf\data\language\Language;
ec1b1daf 9use wcf\data\user\group\UserGroup;
ef37949e 10use wcf\data\user\option\UserOption;
b401cd0d 11use wcf\system\cache\builder\UserOptionCacheBuilder;
0d30adfb 12use wcf\system\language\LanguageFactory;
0602bb11 13use wcf\system\request\IRouteController;
647741fd 14use wcf\system\request\LinkHandler;
4a2e041c 15use wcf\system\user\authentication\password\algorithm\DoubleBcrypt;
6724430f 16use wcf\system\user\authentication\password\PasswordAlgorithmManager;
c96ee721 17use wcf\system\user\storage\UserStorageHandler;
2bc9f31d 18use wcf\system\WCF;
41be0d84 19use wcf\util\JSON;
e904f3dc 20use wcf\util\UserUtil;
11ade432
AE
21
22/**
23 * Represents a user.
a9229942
TD
24 *
25 * @author Alexander Ebert
26 * @copyright 2001-2020 WoltLab GmbH
27 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
a9229942
TD
28 *
29 * @property-read int $userID unique id of the user
30 * @property-read string $username name of the user
31 * @property-read string $email email address of the user
32 * @property-read string $password double salted hash of the user's password
33 * @property-read string $accessToken token used for access authentication, for example used by feed pages
34 * @property-read int $languageID id of the interface language used by the user
35 * @property-read int $registrationDate timestamp at which the user has registered/has been created
36 * @property-read int $styleID id of the style used by the user
37 * @property-read int $banned is `1` if the user is banned, otherwise `0`
38 * @property-read string $banReason reason why the user is banned
39 * @property-read int $banExpires timestamp at which the banned user is automatically unbanned
40 * @property-read int $activationCode flag which determines, whether the user is activated (for legacy reasons an random integer, if the user is *not* activated)
41 * @property-read string $emailConfirmed code sent to the user's email address used for account activation or null if the email is confirmed
42 * @property-read int $lastLostPasswordRequestTime timestamp at which the user has reported that they lost their password or 0 if password has not been reported as lost
43 * @property-read string $lostPasswordKey code used for authenticating setting new password after password loss or empty if password has not been reported as lost
44 * @property-read int $lastUsernameChange timestamp at which the user changed their name the last time or 0 if username has not been changed
45 * @property-read string $newEmail new email address of the user that has to be manually confirmed or empty if no new email address has been set
46 * @property-read string $oldUsername previous name of the user or empty if they have had no previous name
47 * @property-read int $quitStarted timestamp at which the user terminated their account
48 * @property-read int $reactivationCode code used for authenticating setting new email address or empty if no new email address has been set
49 * @property-read string $registrationIpAddress ip address of the user at the time of registration or empty if user has been created manually or if no ip address are logged
50 * @property-read int|null $avatarID id of the user's avatar or null if they have no avatar
51 * @property-read int $disableAvatar is `1` if the user's avatar has been disabled, otherwise `0`
52 * @property-read string $disableAvatarReason reason why the user's avatar is disabled
53 * @property-read int $disableAvatarExpires timestamp at which the user's avatar will automatically be enabled again
a9229942
TD
54 * @property-read string $signature text of the user's signature
55 * @property-read int $signatureEnableHtml is `1` if HTML will rendered in the user's signature, otherwise `0`
56 * @property-read int $disableSignature is `1` if the user's signature has been disabled, otherwise `0`
57 * @property-read string $disableSignatureReason reason why the user's signature is disabled
58 * @property-read int $disableSignatureExpires timestamp at which the user's signature will automatically be enabled again
59 * @property-read int $lastActivityTime timestamp of the user's last activity
60 * @property-read int $profileHits number of times the user's profile has been visited
61 * @property-read int|null $rankID id of the user's rank or null if they have no rank
62 * @property-read string $userTitle custom user title used instead of rank title or empty if user has no custom title
63 * @property-read int|null $userOnlineGroupID id of the user group whose online marking is used when printing the user's formatted name or null if no special marking is used
64 * @property-read int $activityPoints total number of the user's activity points
65 * @property-read string $notificationMailToken token used for authenticating requests by the user to disable notification emails
66 * @property-read string $authData data of the third party used for authentication
67 * @property-read int $likesReceived cumulative result of likes (counting +1) the user's contents have received
68 * @property-read string $coverPhotoHash hash of the user's cover photo
69 * @property-read string $coverPhotoExtension extension of the user's cover photo file
d4cf0997 70 * @property-read int $coverPhotoHasWebP is `1` if a webp variant of the cover photo and its thumbnail exists, otherwise `0`
a9229942
TD
71 * @property-read int $disableCoverPhoto is `1` if the user's cover photo has been disabled, otherwise `0`
72 * @property-read string $disableCoverPhotoReason reason why the user's cover photo is disabled
73 * @property-read int $disableCoverPhotoExpires timestamp at which the user's cover photo will automatically be enabled again
74 * @property-read int $articles number of articles written by the user
75 * @property-read string $blacklistMatches JSON string of an array with all matches in the blacklist, otherwise an empty string
76 * @property-read int $multifactorActive is `1` if the use has enabled a second factor, otherwise `0`
f23c1c22 77 * @property-read int $trophyPoints total number of user's trophies in active categories
11ade432 78 */
a9229942
TD
79final class User extends DatabaseObject implements IPopoverObject, IRouteController, IUserContent
80{
81 /**
82 * list of group ids
83 * @var int[]
84 */
85 protected $groupIDs;
86
87 /**
88 * true, if user has access to the ACP
89 * @var bool
90 */
91 protected $hasAdministrativePermissions;
92
93 /**
94 * list of language ids
95 * @var int[]
96 */
97 protected $languageIDs;
98
99 /**
100 * date time zone object
101 * @var \DateTimeZone
102 */
103 protected $timezoneObj;
104
105 /**
106 * list of user options
107 * @var UserOption[]
108 */
109 protected static $userOptions;
110
111 const REGISTER_ACTIVATION_NONE = 0;
112
113 const REGISTER_ACTIVATION_USER = 1;
114
115 const REGISTER_ACTIVATION_ADMIN = 2;
116
117 const REGISTER_ACTIVATION_USER_AND_ADMIN = self::REGISTER_ACTIVATION_USER | self::REGISTER_ACTIVATION_ADMIN;
118
119 /** @noinspection PhpMissingParentConstructorInspection */
120
121 /**
122 * @inheritDoc
123 */
124 public function __construct($id, $row = null, ?DatabaseObject $object = null)
125 {
126 if ($id !== null) {
127 $sql = "SELECT user_option_value.*, user_table.*
128 FROM wcf" . WCF_N . "_user user_table
129 LEFT JOIN wcf" . WCF_N . "_user_option_value user_option_value
c240c98a 130 ON user_option_value.userID = user_table.userID
a9229942
TD
131 WHERE user_table.userID = ?";
132 $statement = WCF::getDB()->prepareStatement($sql);
133 $statement->execute([$id]);
134 $row = $statement->fetchArray();
135
136 // enforce data type 'array'
137 if ($row === false) {
138 $row = [];
139 }
140 } elseif ($object !== null) {
141 $row = $object->data;
142 }
143
144 $this->handleData($row);
145 }
146
147 /**
148 * Returns true if the given password is the correct password for this user.
149 *
150 * @param string $password
151 * @return bool password correct
152 */
2dcb1b34 153 public function checkPassword(
ef0cb290 154 #[\SensitiveParameter]
2dcb1b34
TD
155 $password
156 ) {
a9229942
TD
157 $isValid = false;
158
159 $manager = PasswordAlgorithmManager::getInstance();
160
161 // Compatibility for WoltLab Suite < 5.4.
162 if (DoubleBcrypt::isLegacyDoubleBcrypt($this->password)) {
163 $algorithmName = 'DoubleBcrypt';
164 $hash = $this->password;
165 } else {
166 [$algorithmName, $hash] = \explode(':', $this->password, 2);
167 }
168
169 $algorithm = $manager->getAlgorithmFromName($algorithmName);
170
171 $isValid = $algorithm->verify($password, $hash);
172
173 if (!$isValid) {
174 return false;
175 }
176
177 $defaultAlgorithm = $manager->getDefaultAlgorithm();
178 if (\get_class($algorithm) !== \get_class($defaultAlgorithm) || $algorithm->needsRehash($hash)) {
179 $userEditor = new UserEditor($this);
180 $userEditor->update([
181 'password' => $password,
182 ]);
183 }
184
185 // $isValid is always true at this point. However we intentionally use a variable
186 // that defaults to false to prevent accidents during refactoring.
187 \assert($isValid);
188
189 return $isValid;
190 }
191
192 /**
193 * @deprecated 5.4 - This method always returns false, as user sessions are long-lived now.
194 */
195 public function checkCookiePassword($passwordHash)
196 {
197 return false;
198 }
199
200 /**
201 * Returns an array with all the groups in which the actual user is a member.
202 *
203 * @param bool $skipCache
204 * @return int[]
205 */
206 public function getGroupIDs($skipCache = false)
207 {
208 if ($this->groupIDs === null || $skipCache) {
209 if (!$this->userID) {
210 // user is a guest, use default guest group
211 $this->groupIDs = UserGroup::getGroupIDsByType([UserGroup::GUESTS, UserGroup::EVERYONE]);
212 } else {
213 // get group ids
214 $data = UserStorageHandler::getInstance()->getField('groupIDs', $this->userID);
215
216 // cache does not exist or is outdated
217 if ($data === null || $skipCache) {
218 $sql = "SELECT groupID
219 FROM wcf" . WCF_N . "_user_to_group
220 WHERE userID = ?";
221 $statement = WCF::getDB()->prepareStatement($sql);
222 $statement->execute([$this->userID]);
223 $this->groupIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
224
225 // update storage data
226 if (!$skipCache) {
227 UserStorageHandler::getInstance()->update(
228 $this->userID,
229 'groupIDs',
230 \serialize($this->groupIDs)
231 );
232 }
233 } else {
234 $this->groupIDs = \unserialize($data);
235 }
236 }
237
238 \sort($this->groupIDs, \SORT_NUMERIC);
239 }
240
241 return $this->groupIDs;
242 }
243
244 /**
245 * Returns a list of language ids for this user.
246 *
247 * @return int[]
248 */
249 public function getLanguageIDs()
250 {
251 if ($this->languageIDs === null) {
252 $this->languageIDs = [];
253
254 if ($this->userID) {
255 // get language ids
256 $data = UserStorageHandler::getInstance()->getField('languageIDs', $this->userID);
257
258 // cache does not exist or is outdated
259 if ($data === null) {
260 $sql = "SELECT languageID
261 FROM wcf" . WCF_N . "_user_to_language
262 WHERE userID = ?";
263 $statement = WCF::getDB()->prepareStatement($sql);
264 $statement->execute([$this->userID]);
265 $this->languageIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
266
267 // update storage data
268 UserStorageHandler::getInstance()->update(
269 $this->userID,
270 'languageIDs',
271 \serialize($this->languageIDs)
272 );
273 } else {
274 $this->languageIDs = \unserialize($data);
275 }
c862f249
MW
276 } else {
277 $this->languageIDs = LanguageFactory::getInstance()->getContentLanguageIDs();
a9229942
TD
278 }
279 }
280
281 return $this->languageIDs;
282 }
283
284 /**
285 * Returns the value of the user option with the given name.
286 *
287 * @param string $name user option name
288 * @param bool $filterDisabled suppress values for disabled options
289 * @return mixed user option value
290 */
291 public function getUserOption($name, $filterDisabled = false)
292 {
293 $optionID = self::getUserOptionID($name);
294 if ($optionID === null) {
295 return;
296 } elseif ($filterDisabled && self::$userOptions[$name]->isDisabled) {
297 return;
298 }
299
dee6b280 300 return $this->data['userOption' . $optionID] ?? null;
a9229942
TD
301 }
302
303 /**
304 * Fetches all user options from cache.
305 */
306 protected static function getUserOptionCache()
307 {
308 self::$userOptions = UserOptionCacheBuilder::getInstance()->getData([], 'options');
309 }
310
311 /**
312 * Returns the id of a user option.
313 *
314 * @param string $name
c0b28aa2 315 * @return int|null
a9229942
TD
316 */
317 public static function getUserOptionID($name)
318 {
319 // get user option cache if necessary
320 if (self::$userOptions === null) {
321 self::getUserOptionCache();
322 }
323
324 if (!isset(self::$userOptions[$name])) {
c0b28aa2 325 return null;
a9229942
TD
326 }
327
328 return self::$userOptions[$name]->optionID;
329 }
330
331 /**
332 * @inheritDoc
333 */
334 public function __get($name)
335 {
53d2a48c 336 return $this->data[$name] ?? $this->getUserOption($name);
a9229942
TD
337 }
338
339 /**
340 * Returns the user with the given username.
341 *
342 * @param string $username
343 * @return User
344 */
345 public static function getUserByUsername($username)
346 {
347 $sql = "SELECT user_option_value.*, user_table.*
348 FROM wcf" . WCF_N . "_user user_table
349 LEFT JOIN wcf" . WCF_N . "_user_option_value user_option_value
c240c98a 350 ON user_option_value.userID = user_table.userID
a9229942
TD
351 WHERE user_table.username = ?";
352 $statement = WCF::getDB()->prepareStatement($sql);
353 $statement->execute([$username]);
354 $row = $statement->fetchArray();
355 if (!$row) {
356 $row = [];
357 }
358
359 return new self(null, $row);
360 }
361
362 /**
363 * Returns the user with the given email.
364 *
365 * @param string $email
366 * @return User
367 */
368 public static function getUserByEmail($email)
369 {
370 $sql = "SELECT user_option_value.*, user_table.*
371 FROM wcf" . WCF_N . "_user user_table
372 LEFT JOIN wcf" . WCF_N . "_user_option_value user_option_value
c240c98a 373 ON user_option_value.userID = user_table.userID
a9229942
TD
374 WHERE user_table.email = ?";
375 $statement = WCF::getDB()->prepareStatement($sql);
376 $statement->execute([$email]);
377 $row = $statement->fetchArray();
378 if (!$row) {
379 $row = [];
380 }
381
382 return new self(null, $row);
383 }
384
385 /**
386 * Returns the user with the given authData.
387 *
388 * @param string $authData
389 * @return User
390 */
391 public static function getUserByAuthData($authData)
392 {
393 $sql = "SELECT user_option_value.*, user_table.*
394 FROM wcf" . WCF_N . "_user user_table
395 LEFT JOIN wcf" . WCF_N . "_user_option_value user_option_value
c240c98a 396 ON user_option_value.userID = user_table.userID
a9229942
TD
397 WHERE user_table.authData = ?";
398 $statement = WCF::getDB()->prepareStatement($sql);
399 $statement->execute([$authData]);
400 $row = $statement->fetchArray();
401 if (!$row) {
402 $row = [];
403 }
404
405 return new self(null, $row);
406 }
407
408 /**
409 * Returns 3rd party auth provider name.
410 *
411 * @return string
412 * @since 5.2
413 */
414 public function getAuthProvider()
415 {
416 if (!$this->authData) {
417 return '';
418 }
419
420 return \mb_substr($this->authData, 0, \mb_strpos($this->authData, ':'));
421 }
422
423 /**
424 * Returns true if this user is marked.
425 *
426 * @return bool
427 */
428 public function isMarked()
429 {
430 $markedUsers = WCF::getSession()->getVar('markedUsers');
431 if ($markedUsers !== null) {
432 if (\in_array($this->userID, $markedUsers)) {
433 return 1;
434 }
435 }
436
437 return 0;
438 }
439
440 /**
441 * Returns true if the email is confirmed.
442 *
443 * @return bool
444 * @since 5.3
445 */
446 public function isEmailConfirmed()
447 {
448 return $this->emailConfirmed === null;
449 }
450
451 /**
452 * Returns the time zone of this user.
453 *
454 * @return \DateTimeZone
455 */
456 public function getTimeZone()
457 {
458 if ($this->timezoneObj === null) {
459 if ($this->timezone) {
460 $this->timezoneObj = new \DateTimeZone($this->timezone);
461 } else {
462 $this->timezoneObj = new \DateTimeZone(TIMEZONE);
463 }
464 }
465
466 return $this->timezoneObj;
467 }
468
38b18b4a
MW
469 /**
470 * Applies the user's timezone to the given timestamp.
471 */
472 public function getLocalDate(int $timestamp): \DateTimeImmutable
473 {
474 $dateTime = (new \DateTimeImmutable('@' . $timestamp));
475 $dateTime = $dateTime->setTimezone($this->getTimeZone());
476
477 return $dateTime;
478 }
479
a9229942
TD
480 /**
481 * Returns a list of users.
482 *
483 * @param array $userIDs
484 * @return User[]
485 */
486 public static function getUsers(array $userIDs)
487 {
488 $userList = new UserList();
489 $userList->setObjectIDs($userIDs);
490 $userList->readObjects();
491
492 return $userList->getObjects();
493 }
494
495 /**
496 * Returns username.
a9229942 497 */
83c324d3 498 public function __toString(): string
a9229942 499 {
eaba5c14 500 return $this->getTitle();
a9229942
TD
501 }
502
503 /**
504 * @inheritDoc
505 */
506 public static function getDatabaseTableAlias()
507 {
508 return 'user_table';
509 }
510
511 /**
512 * @inheritDoc
513 */
a4a634aa 514 public function getTitle(): string
a9229942 515 {
eaba5c14 516 return $this->username ?: '';
a9229942
TD
517 }
518
519 /**
520 * Returns the language of this user.
521 *
522 * @return Language
523 */
524 public function getLanguage()
525 {
526 $language = LanguageFactory::getInstance()->getLanguage($this->languageID);
527 if ($language === null) {
528 $language = LanguageFactory::getInstance()->getLanguage(LanguageFactory::getInstance()->getDefaultLanguageID());
529 }
530
531 return $language;
532 }
533
534 /**
535 * Returns true if the active user can edit this user.
536 *
537 * @return bool
538 */
539 public function canEdit()
540 {
541 return WCF::getSession()->getPermission('admin.user.canEditUser') && UserGroup::isAccessibleGroup($this->getGroupIDs());
542 }
543
544 /**
545 * Returns true, if this user has access to the ACP.
546 *
547 * @return bool
548 */
549 public function hasAdministrativeAccess()
550 {
551 if ($this->hasAdministrativePermissions === null) {
552 $this->hasAdministrativePermissions = false;
553
554 if ($this->userID) {
555 foreach (UserGroup::getGroupsByIDs($this->getGroupIDs()) as $group) {
556 if ($group->isAdminGroup()) {
557 $this->hasAdministrativePermissions = true;
558 break;
559 }
560 }
561 }
562 }
563
564 return $this->hasAdministrativePermissions;
565 }
566
567 /**
568 * Returns true, if this user is a member of the owner group.
569 *
570 * @return bool
571 * @since 5.2
572 */
573 public function hasOwnerAccess()
574 {
52e95d8e
TD
575 foreach (UserGroup::getGroupsByIDs($this->getGroupIDs()) as $group) {
576 if ($group->isOwner()) {
577 return true;
a9229942
TD
578 }
579 }
580
52e95d8e 581 return false;
a9229942
TD
582 }
583
584 /**
585 * @inheritDoc
586 */
587 public function getUserID()
588 {
589 return $this->userID;
590 }
591
592 /**
593 * @inheritDoc
594 */
595 public function getUsername()
596 {
597 return $this->username;
598 }
599
600 /**
601 * @inheritDoc
602 */
603 public function getTime()
604 {
605 return $this->registrationDate;
606 }
607
608 /**
609 * @inheritDoc
610 */
51cb3c5e 611 public function getLink(): string
a9229942
TD
612 {
613 return LinkHandler::getInstance()->getLink('User', [
614 'application' => 'wcf',
615 'object' => $this,
616 'forceFrontend' => true,
617 ]);
618 }
619
a9229942
TD
620 /**
621 * Returns the registration ip address, attempts to convert to IPv4.
622 *
623 * @return string
624 */
625 public function getRegistrationIpAddress()
626 {
627 if ($this->registrationIpAddress) {
628 return UserUtil::convertIPv6To4($this->registrationIpAddress);
629 }
630
631 return '';
632 }
633
634 /**
635 * Returns true, if this user can purchase paid subscriptions.
636 *
637 * @return bool
638 */
639 public function canPurchasePaidSubscriptions()
640 {
641 return WCF::getUser()->userID && !$this->pendingActivation();
642 }
643
644 /**
645 * Returns the list of fields that had matches in the blacklist. An empty list is
646 * returned if the user has been approved, regardless of any known matches.
647 *
648 * @return string[]
649 * @since 5.2
650 */
651 public function getBlacklistMatches()
652 {
653 if ($this->pendingActivation() && $this->blacklistMatches) {
654 $matches = JSON::decode($this->blacklistMatches);
655 if (\is_array($matches)) {
656 return $matches;
657 }
658 }
659
660 return [];
661 }
662
663 /**
664 * Returns a human readable list of fields that have positive matches against the
665 * blacklist. If you require the raw field names, please use `getBlacklistMatches()`
666 * instead.
667 *
668 * @return string[]
669 * @since 5.2
670 */
671 public function getBlacklistMatchesTitle()
672 {
673 return \array_map(static function ($field) {
674 if ($field === 'ip') {
675 $field = 'ipAddress';
676 }
677
678 return WCF::getLanguage()->get('wcf.user.' . $field);
679 }, $this->getBlacklistMatches());
680 }
681
682 /**
683 * Returns true if this user is not activated.
684 *
685 * @return bool
686 * @since 5.3
687 */
688 public function pendingActivation()
689 {
690 return $this->activationCode != 0;
691 }
692
693 /**
694 * Returns true if this user requires activation by the user.
695 *
696 * @return bool
697 * @since 5.3
698 */
699 public function requiresEmailActivation()
700 {
701 return REGISTER_ACTIVATION_METHOD & self::REGISTER_ACTIVATION_USER && $this->pendingActivation() && !$this->isEmailConfirmed();
702 }
703
704 /**
705 * Returns true if this user requires the activation by an admin.
706 *
707 * @return bool
708 * @since 5.3
709 */
710 public function requiresAdminActivation()
711 {
712 return REGISTER_ACTIVATION_METHOD & self::REGISTER_ACTIVATION_ADMIN && $this->pendingActivation();
713 }
714
715 /**
716 * Returns true if this user can confirm the email themself.
717 *
718 * @return bool
719 * @since 5.3
720 */
721 public function canEmailConfirm()
722 {
723 return REGISTER_ACTIVATION_METHOD & self::REGISTER_ACTIVATION_USER && !$this->isEmailConfirmed();
724 }
725
726 /**
727 * Returns true, if the user must confirm his email by themself.
728 *
729 * @return bool
730 * @since 5.3
731 */
732 public function mustSelfEmailConfirm()
733 {
734 return REGISTER_ACTIVATION_METHOD & self::REGISTER_ACTIVATION_USER;
735 }
736
ef052a1d
TD
737 /**
738 * Returns true if the user is a member of a user group that requires
739 * multi-factor authentication to be enabled.
56da9452
MS
740 *
741 * @since 5.4
ef052a1d
TD
742 */
743 public function requiresMultifactor(): bool
744 {
745 foreach (UserGroup::getGroupsByIDs($this->getGroupIDs()) as $group) {
746 if ($group->requireMultifactor) {
747 return true;
748 }
749 }
750
751 return false;
752 }
753
a9229942
TD
754 /**
755 * @inheritDoc
756 */
757 public function getPopoverLinkClass()
758 {
759 return 'userLink';
760 }
11ade432 761}