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()) { $stmt = DB::prepare('SELECT role_colour FROM msz_roles WHERE role_id = (SELECT display_role FROM msz_users WHERE user_id = :user)'); $stmt->bind('user', $this->user_id); $rawColour = $stmt->fetchColumn(); $this->realColour = $rawColour === null ? Colour::none() : Colour::fromMisuzu($rawColour); } } 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 `msz_roles`' . ' WHERE `role_id` IN (SELECT `role_id` FROM `msz_users_roles` WHERE `user_id` = :user)' )->bind('user', $this->getId())->fetchColumn(); return $this->userRank; } public function getDisplayRoleId(): int { return $this->display_role < 1 ? -1 : $this->display_role; } 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 `msz_users`' . ' 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(); } /************ * 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(); } /*************** * 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; } /************** * 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 `msz_users`' . ' 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 `msz_users`' . ' 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 `msz_users`' . ' SET `username` = :username, `email` = :email, `password` = :password' . ', `user_super` = :is_super, `user_country` = :country, `user_colour` = :colour, `user_title` = :title' . ', `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('totp', $this->user_totp_key) ->bind('title', $this->user_title) ->execute(); } public function saveProfile(): void { $save = DB::prepare( 'UPDATE `msz_users`' . ' 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 `msz_users` (`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 RuntimeException('User creation failed.'); 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, self::SELECT); } 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 RuntimeException('Failed to fetch user by ID.'); return $user; }); } public static function byUsername(string $username): ?self { if(empty($username)) throw new InvalidArgumentException('$username may not be empty.'); $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 RuntimeException('Failed to find user by ID.'); return $user; }); } public static function byEMailAddress(string $address): ?self { if(empty($address)) throw new InvalidArgumentException('$address may not be empty.'); $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 RuntimeException('Failed to find user by e-mail address.'); return $user; }); } public static function byUsernameOrEMailAddress(string $usernameOrAddress): self { if(empty($usernameOrAddress)) throw new InvalidArgumentException('$usernameOrAddress may not be empty.'); $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 RuntimeException('Failed to find user by name or e-mail address.'); 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 InvalidArgumentException('$userIdOrName may not be empty.'); $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 RuntimeException('Failed to find user by ID or name.'); 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); } }