user_id < 1 ? -1 : $this->user_id; } public function getUsername(): string { return $this->username; } public function getEmailAddress(): string { return $this->email; } public function getRegisterRemoteAddress(): string { return $this->register_ip ?? '::1'; } public function getLastRemoteAddress(): string { return $this->last_ip ?? '::1'; } public function isSuper(): bool { return boolval($this->user_super); } public function hasCountry(): bool { return $this->user_country !== 'XX'; } public function getCountry(): string { return $this->user_country ?? 'XX'; } public function setCountry(string $country): self { $this->user_country = strtoupper(substr($country, 0, 2)); return $this; } public function getCountryName(): string { return get_country_name($this->getCountry()); } private $userColour = null; private $realColour = null; public function getColour(): Colour { // Swaps role colour in if user has no personal colour if($this->realColour === null) { $this->realColour = $this->getUserColour(); if($this->realColour->shouldInherit()) $this->realColour = $this->getDisplayRole()->getColour(); } return $this->realColour; } public function setColour(?Colour $colour): self { return $this->setColourRaw($colour === null ? null : Colour::toMisuzu($colour)); } public function getUserColour(): Colour { // Only ever gets the user's actual colour if($this->userColour === null) $this->userColour = Colour::fromMisuzu($this->getColourRaw()); return $this->userColour; } public function getColourRaw(): int { return $this->user_colour ?? 0x40000000; } public function setColourRaw(?int $colour): self { $this->user_colour = $colour; $this->userColour = null; $this->realColour = null; return $this; } public function getCreatedTime(): int { return $this->user_created === null ? -1 : $this->user_created; } public function hasBeenActive(): bool { return $this->user_active !== null; } public function getActiveTime(): int { return $this->user_active === null ? -1 : $this->user_active; } private $userRank = null; public function getRank(): int { if($this->userRank === null) $this->userRank = (int)DB::prepare( 'SELECT MAX(`role_hierarchy`)' . ' FROM `' . DB::PREFIX . UserRole::TABLE . '`' . ' WHERE `role_id` IN (SELECT `role_id` FROM `' . DB::PREFIX . UserRoleRelation::TABLE . '` WHERE `user_id` = :user)' )->bind('user', $this->getId())->fetchColumn(); return $this->userRank; } public function hasAuthorityOver(HasRankInterface $other): bool { // Don't even bother checking if we're a super user if($this->isSuper()) return true; if($other instanceof self && $other->getId() === $this->getId()) return true; return $this->getRank() > $other->getRank(); } public function getDisplayRoleId(): int { return $this->display_role < 1 ? -1 : $this->display_role; } public function setDisplayRoleId(int $roleId): self { $this->display_role = $roleId < 1 ? -1 : $roleId; return $this; } public function getDisplayRole(): UserRole { return $this->getRoleRelations()[$this->getDisplayRoleId()]->getRole(); } public function setDisplayRole(UserRole $role): self { if($this->hasRole($role)) $this->setDisplayRoleId($role->getId()); return $this; } public function isDisplayRole(UserRole $role): bool { return $this->getDisplayRoleId() === $role->getId(); } public function hasTOTP(): bool { return !empty($this->user_totp_key); } public function getTOTP(): TOTP { if($this->totp === null) $this->totp = new TOTP($this->user_totp_key); return $this->totp; } public function getTOTPKey(): string { return $this->user_totp_key ?? ''; } public function setTOTPKey(string $key): self { $this->totp = null; $this->user_totp_key = $key; return $this; } public function removeTOTPKey(): self { $this->totp = null; $this->user_totp_key = null; return $this; } public function getValidTOTPTokens(): array { if(!$this->hasTOTP()) return []; $totp = $this->getTOTP(); return [ $totp->generate(time()), $totp->generate(time() - 30), $totp->generate(time() + 30), ]; } public function hasProfileAbout(): bool { return !empty($this->user_about_content); } public function getProfileAboutText(): string { return $this->user_about_content ?? ''; } public function setProfileAboutText(string $text): self { $this->user_about_content = empty($text) ? null : $text; return $this; } public function getProfileAboutParser(): int { return $this->hasProfileAbout() ? $this->user_about_parser : Parser::BBCODE; } public function setProfileAboutParser(int $parser): self { $this->user_about_parser = $parser; return $this; } public function getProfileAboutParsed(): string { if(!$this->hasProfileAbout()) return ''; return Parser::instance($this->getProfileAboutParser()) ->parseText(htmlspecialchars($this->getProfileAboutText())); } public function hasForumSignature(): bool { return !empty($this->user_signature_content); } public function getForumSignatureText(): string { return $this->user_signature_content ?? ''; } public function setForumSignatureText(string $text): self { $this->user_signature_content = empty($text) ? null : $text; return $this; } public function getForumSignatureParser(): int { return $this->hasForumSignature() ? $this->user_signature_parser : Parser::BBCODE; } public function setForumSignatureParser(int $parser): self { $this->user_signature_parser = $parser; return $this; } public function getForumSignatureParsed(): string { if(!$this->hasForumSignature()) return ''; return Parser::instance($this->getForumSignatureParser()) ->parseText(htmlspecialchars($this->getForumSignatureText())); } // Address these through getBackgroundInfo() public function getBackgroundSettings(): int { return $this->user_background_settings; } public function setBackgroundSettings(int $settings): self { $this->user_background_settings = $settings; return $this; } public function hasTitle(): bool { return !empty($this->user_title); } public function getTitle(): string { return $this->user_title ?? ''; } public function setTitle(string $title): self { $this->user_title = empty($title) ? null : $title; return $this; } public function hasBirthdate(): bool { return $this->user_birthdate !== null; } public function getBirthdate(): DateTime { return new DateTime($this->user_birthdate ?? '0000-01-01', new DateTimeZone('UTC')); } public function setBirthdate(int $year, int $month, int $day): self { // lowest leap year mariadb supports lol // should probably split the date field and year field in the db but i'm afraid of internal dependencies rn if($year < 1004) $year = 1004; $this->user_birthdate = $month < 1 || $day < 1 ? null : sprintf('%04d-%02d-%02d', $year, $month, $day); return $this; } public function hasAge(): bool { return $this->hasBirthdate() && (int)$this->getBirthdate()->format('Y') > 1900; } public function getAge(): int { if(!$this->hasAge()) return -1; return (int)$this->getBirthdate()->diff(new DateTime('now', new DateTimeZone('UTC')))->format('%y'); } public function bumpActivity(string $lastRemoteAddress): void { $this->user_active = time(); $this->last_ip = $lastRemoteAddress; DB::prepare( 'UPDATE `' . DB::PREFIX . self::TABLE . '`' . ' SET `user_active` = FROM_UNIXTIME(:active), `last_ip` = INET6_ATON(:address)' . ' WHERE `user_id` = :user' ) ->bind('user', $this->user_id) ->bind('active', $this->user_active) ->bind('address', $this->last_ip) ->execute(); } // TODO: Is this the proper location/implementation for this? (no) private $commentPermsArray = null; public function commentPerms(): array { if($this->commentPermsArray === null) $this->commentPermsArray = perms_check_user_bulk(MSZ_PERMS_COMMENTS, $this->getId(), [ 'can_comment' => MSZ_PERM_COMMENTS_CREATE, 'can_delete' => MSZ_PERM_COMMENTS_DELETE_OWN | MSZ_PERM_COMMENTS_DELETE_ANY, 'can_delete_any' => MSZ_PERM_COMMENTS_DELETE_ANY, 'can_pin' => MSZ_PERM_COMMENTS_PIN, 'can_lock' => MSZ_PERM_COMMENTS_LOCK, 'can_vote' => MSZ_PERM_COMMENTS_VOTE, ]); return $this->commentPermsArray; } private $legacyPerms = null; public function getLegacyPerms(): array { if($this->legacyPerms === null) $this->legacyPerms = perms_get_user($this->getId()); return $this->legacyPerms; } /************ * PASSWORD * ************/ public static function hashPassword(string $password): string { return password_hash($password, self::PASSWORD_ALGO); } public function hasPassword(): bool { return !empty($this->password); } public function checkPassword(string $password): bool { return $this->hasPassword() && password_verify($password, $this->password); } public function passwordNeedsRehash(): bool { return password_needs_rehash($this->password, self::PASSWORD_ALGO); } public function removePassword(): self { $this->password = null; return $this; } public function setPassword(string $password): self { $this->password = self::hashPassword($password); return $this; } /************ * DELETING * ************/ public function getDeletedTime(): int { return $this->user_deleted === null ? -1 : $this->user_deleted; } public function isDeleted(): bool { return $this->getDeletedTime() >= 0; } /********** * ASSETS * **********/ private $avatarAsset = null; public function getAvatarInfo(): UserAvatarAsset { if($this->avatarAsset === null) $this->avatarAsset = new UserAvatarAsset($this); return $this->avatarAsset; } public function hasAvatar(): bool { return $this->getAvatarInfo()->isPresent(); } private $backgroundAsset = null; public function getBackgroundInfo(): UserBackgroundAsset { if($this->backgroundAsset === null) $this->backgroundAsset = new UserBackgroundAsset($this); return $this->backgroundAsset; } public function hasBackground(): bool { return $this->getBackgroundInfo()->isPresent(); } /********* * ROLES * *********/ private $roleRelations = null; public function addRole(UserRole $role, bool $display = false): void { if(!$this->hasRole($role)) $this->roleRelations[$role->getId()] = UserRoleRelation::create($this, $role); if($display && $this->isDisplayRole($role)) $this->setDisplayRole($role); } public function removeRole(UserRole $role): void { if(!$this->hasRole($role)) return; UserRoleRelation::destroy($this, $role); unset($this->roleRelations[$role->getId()]); if($this->isDisplayRole($role)) $this->setDisplayRoleId(UserRole::DEFAULT); } public function getRoleRelations(): array { if($this->roleRelations === null) { $this->roleRelations = []; foreach(UserRoleRelation::byUser($this) as $rel) $this->roleRelations[$rel->getRoleId()] = $rel; } return $this->roleRelations; } public function getRoles(): array { $roles = []; foreach($this->getRoleRelations() as $rel) $roles[$rel->getRoleId()] = $rel->getRole(); return $roles; } public function hasRole(UserRole $role): bool { return array_key_exists($role->getId(), $this->getRoleRelations()); } /*************** * FORUM STATS * ***************/ private $forumTopicCount = -1; private $forumPostCount = -1; public function getForumTopicCount(): int { if($this->forumTopicCount < 0) $this->forumTopicCount = (int)DB::prepare('SELECT COUNT(*) FROM `msz_forum_topics` WHERE `user_id` = :user AND `topic_deleted` IS NULL') ->bind('user', $this->getId()) ->fetchColumn(); return $this->forumTopicCount; } public function getForumPostCount(): int { if($this->forumPostCount < 0) $this->forumPostCount = (int)DB::prepare('SELECT COUNT(*) FROM `msz_forum_posts` WHERE `user_id` = :user AND `post_deleted` IS NULL') ->bind('user', $this->getId()) ->fetchColumn(); return $this->forumPostCount; } /************ * WARNINGS * ************/ private $activeWarning = -1; public function getActiveWarning(): ?UserWarning { if($this->activeWarning === -1) $this->activeWarning = UserWarning::byUserActive($this); return $this->activeWarning; } public function hasActiveWarning(): bool { return $this->getActiveWarning() !== null && !$this->getActiveWarning()->hasExpired(); } public function isSilenced(): bool { return $this->hasActiveWarning() && $this->getActiveWarning()->isSilence(); } public function isBanned(): bool { return $this->hasActiveWarning() && $this->getActiveWarning()->isBan(); } public function getActiveWarningExpiration(): int { return !$this->hasActiveWarning() ? 0 : $this->getActiveWarning()->getExpirationTime(); } public function isActiveWarningPermanent(): bool { return $this->hasActiveWarning() && $this->getActiveWarning()->isPermanent(); } public function getProfileWarnings(?self $viewer): array { return UserWarning::byProfile($this, $viewer); } /************** * LOCAL USER * **************/ public function setCurrent(): void { self::$localUser = $this; } public static function unsetCurrent(): void { self::$localUser = null; } public static function getCurrent(): ?self { return self::$localUser; } public static function hasCurrent(): bool { return self::$localUser !== null; } /************** * VALIDATION * **************/ public static function validateUsername(string $name): string { if($name !== trim($name)) return 'trim'; if(str_starts_with(mb_strtolower($name), 'flappyzor')) return 'flapp'; $length = mb_strlen($name); if($length < self::NAME_MIN_LENGTH) return 'short'; if($length > self::NAME_MAX_LENGTH) return 'long'; if(!preg_match('#^' . self::NAME_REGEX . '$#u', $name)) return 'invalid'; $userId = (int)DB::prepare( 'SELECT `user_id`' . ' FROM `' . DB::PREFIX . self::TABLE . '`' . ' WHERE LOWER(`username`) = LOWER(:username)' ) ->bind('username', $name) ->fetchColumn(); if($userId > 0) return 'in-use'; return ''; } public static function usernameValidationErrorString(string $error): string { switch($error) { case 'trim': return 'Your username may not start or end with spaces!'; case 'short': return sprintf('Your username is too short, it has to be at least %d characters!', self::NAME_MIN_LENGTH); case 'long': return sprintf("Your username is too long, it can't be longer than %d characters!", self::NAME_MAX_LENGTH); case 'invalid': return 'Your username contains invalid characters.'; case 'in-use': return 'This username is already taken!'; case 'flapp': return 'Your username may not start with Flappyzor!'; case '': return 'This username is correctly formatted!'; default: return 'This username is incorrectly formatted.'; } } public static function validateEMailAddress(string $address): string { if(filter_var($address, FILTER_VALIDATE_EMAIL) === false) return 'format'; if(!checkdnsrr(mb_substr(mb_strstr($address, '@'), 1), 'MX')) return 'dns'; $userId = (int)DB::prepare( 'SELECT `user_id`' . ' FROM `' . DB::PREFIX . self::TABLE . '`' . ' WHERE LOWER(`email`) = LOWER(:email)' ) ->bind('email', $address) ->fetchColumn(); if($userId > 0) return 'in-use'; return ''; } public static function validatePassword(string $password): string { if(XString::countUnique($password) < self::PASSWORD_UNIQUE) return 'weak'; return ''; } public static function validateBirthdate(int $year, int $month, int $day, int $yearRange = 100): string { if($day !== 0 && $month !== 0) { if($year > 0 && ($year < date('Y') - $yearRange || $year > date('Y'))) return 'year'; if(!DateCheck::isValidDate($year, $month, $day)) return 'date'; } return ''; } public static function validateProfileAbout(int $parser, string $text): string { if(!Parser::isValid($parser)) return 'parser'; $length = strlen($text); if($length > self::PROFILE_ABOUT_MAX_LENGTH) return 'long'; return ''; } public static function validateForumSignature(int $parser, string $text): string { if(!Parser::isValid($parser)) return 'parser'; $length = strlen($text); if($length > self::FORUM_SIGNATURE_MAX_LENGTH) return 'long'; return ''; } /********************* * CREATION + SAVING * *********************/ public function save(): void { $save = DB::prepare( 'UPDATE `' . DB::PREFIX . self::TABLE . '`' . ' SET `username` = :username, `email` = :email, `password` = :password' . ', `user_super` = :is_super, `user_country` = :country, `user_colour` = :colour, `user_title` = :title' . ', `display_role` = :display_role, `user_totp_key` = :totp' . ' WHERE `user_id` = :user' ) ->bind('user', $this->user_id) ->bind('username', $this->username) ->bind('email', $this->email) ->bind('password', $this->password) ->bind('is_super', $this->user_super) ->bind('country', $this->user_country) ->bind('colour', $this->user_colour) ->bind('display_role', $this->display_role) ->bind('totp', $this->user_totp_key) ->bind('title', $this->user_title) ->execute(); } public function saveProfile(): void { $save = DB::prepare( 'UPDATE `' . DB::PREFIX . self::TABLE . '`' . ' SET `user_about_content` = :about_content, `user_about_parser` = :about_parser' . ', `user_signature_content` = :signature_content, `user_signature_parser` = :signature_parser' . ', `user_background_settings` = :background_settings, `user_birthdate` = :birthdate' . ' WHERE `user_id` = :user' ) ->bind('user', $this->user_id) ->bind('about_content', $this->user_about_content) ->bind('about_parser', $this->user_about_parser) ->bind('signature_content', $this->user_signature_content) ->bind('signature_parser', $this->user_signature_parser) ->bind('background_settings', $this->user_background_settings) ->bind('birthdate', $this->user_birthdate) ->execute(); } public static function create( string $username, string $password, string $email, string $ipAddress, string $countryCode = 'XX' ): self { $createUser = DB::prepare( 'INSERT INTO `' . DB::PREFIX . self::TABLE . '` (`username`, `password`, `email`, `register_ip`, `last_ip`, `user_country`, `display_role`)' . ' VALUES (:username, :password, LOWER(:email), INET6_ATON(:register_ip), INET6_ATON(:last_ip), :user_country, 1)' ) ->bind('username', $username) ->bind('email', $email) ->bind('register_ip', $ipAddress) ->bind('last_ip', $ipAddress) ->bind('password', self::hashPassword($password)) ->bind('user_country', $countryCode) ->executeGetId(); if($createUser < 1) throw new UserCreationFailedException; return self::byId($createUser); } /************ * FETCHING * ************/ private static function countQueryBase(): string { return sprintf(self::QUERY_SELECT, 'COUNT(*)'); } public static function countAll(bool $showDeleted = false): int { return (int)DB::prepare( self::countQueryBase() . ($showDeleted ? '' : ' WHERE `user_deleted` IS NULL') )->fetchColumn(); } private static function memoizer() { static $memoizer = null; if($memoizer === null) $memoizer = new Memoizer; return $memoizer; } private static function byQueryBase(): string { return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE)); } public static function byId(string|int $userId): ?self { // newer classes all treat ids as if they're strings // php plays nice with it but may as well be sure since phpstan screams about it if(is_string($userId)) $userId = (int)$userId; return self::memoizer()->find($userId, function() use ($userId) { $user = DB::prepare(self::byQueryBase() . ' WHERE `user_id` = :user_id') ->bind('user_id', $userId) ->fetchObject(self::class); if(!$user) throw new UserNotFoundException; return $user; }); } public static function byUsername(string $username): ?self { if(empty($username)) throw new UserNotFoundException; $username = mb_strtolower($username); if(str_starts_with($username, 'flappyzor')) return self::byId(14); return self::memoizer()->find(function($user) use ($username) { return mb_strtolower($user->getUsername()) === $username; }, function() use ($username) { $user = DB::prepare(self::byQueryBase() . ' WHERE LOWER(`username`) = :username') ->bind('username', $username) ->fetchObject(self::class); if(!$user) throw new UserNotFoundException; return $user; }); } public static function byEMailAddress(string $address): ?self { if(empty($address)) throw new UserNotFoundException; $address = mb_strtolower($address); return self::memoizer()->find(function($user) use ($address) { return mb_strtolower($user->getEmailAddress()) === $address; }, function() use ($address) { $user = DB::prepare(self::byQueryBase() . ' WHERE LOWER(`email`) = :email') ->bind('email', $address) ->fetchObject(self::class); if(!$user) throw new UserNotFoundException; return $user; }); } public static function byUsernameOrEMailAddress(string $usernameOrAddress): self { if(empty($usernameOrAddress)) throw new UserNotFoundException; $usernameOrAddressLower = mb_strtolower($usernameOrAddress); if(!str_contains($usernameOrAddressLower, '@') && str_starts_with($usernameOrAddressLower, 'flappyzor')) return self::byId(14); return self::memoizer()->find(function($user) use ($usernameOrAddressLower) { return mb_strtolower($user->getUsername()) === $usernameOrAddressLower || mb_strtolower($user->getEmailAddress()) === $usernameOrAddressLower; }, function() use ($usernameOrAddressLower) { $user = DB::prepare(self::byQueryBase() . ' WHERE LOWER(`email`) = :email OR LOWER(`username`) = :username') ->bind('email', $usernameOrAddressLower) ->bind('username', $usernameOrAddressLower) ->fetchObject(self::class); if(!$user) throw new UserNotFoundException; return $user; }); } public static function byLatest(): ?self { return DB::prepare(self::byQueryBase() . ' WHERE `user_deleted` IS NULL ORDER BY `user_id` DESC LIMIT 1') ->fetchObject(self::class); } public static function findForProfile($userIdOrName): ?self { if(empty($userIdOrName)) throw new UserNotFoundException; $userIdOrNameLower = mb_strtolower($userIdOrName); if(str_starts_with($userIdOrNameLower, 'flappyzor')) return self::byId(14); return self::memoizer()->find(function($user) use ($userIdOrNameLower) { return $user->getId() == $userIdOrNameLower || mb_strtolower($user->getUsername()) === $userIdOrNameLower; }, function() use ($userIdOrName) { $user = DB::prepare(self::byQueryBase() . ' WHERE `user_id` = :user_id OR LOWER(`username`) = LOWER(:username)') ->bind('user_id', (int)$userIdOrName) ->bind('username', (string)$userIdOrName) ->fetchObject(self::class); if(!$user) throw new UserNotFoundException; return $user; }); } public static function byBirthdate(?DateTime $date = null): array { $date = $date === null ? new DateTime('now', new DateTimeZone('UTC')) : (clone $date)->setTimezone(new DateTimeZone('UTC')); return DB::prepare(self::byQueryBase() . ' WHERE `user_deleted` IS NULL AND `user_birthdate` LIKE :date') ->bind('date', $date->format('%-m-d')) ->fetchObjects(self::class); } public static function all(bool $showDeleted = false, ?Pagination $pagination = null): array { $query = self::byQueryBase(); if(!$showDeleted) $query .= ' WHERE `user_deleted` IS NULL'; $query .= ' ORDER BY `user_id` ASC'; if($pagination !== null) $query .= ' LIMIT :range OFFSET :offset'; $getObjects = DB::prepare($query); if($pagination !== null) $getObjects->bind('range', $pagination->getRange()) ->bind('offset', $pagination->getOffset()); return $getObjects->fetchObjects(self::class); } }