2022-09-13 13:14:49 +00:00
|
|
|
<?php
|
|
|
|
namespace Misuzu\Users;
|
|
|
|
|
|
|
|
use DateTime;
|
|
|
|
use DateTimeZone;
|
2023-07-22 15:02:41 +00:00
|
|
|
use InvalidArgumentException;
|
|
|
|
use RuntimeException;
|
2023-07-05 01:33:12 +00:00
|
|
|
use Index\XString;
|
2023-01-02 23:48:04 +00:00
|
|
|
use Index\Colour\Colour;
|
2023-07-12 23:08:35 +00:00
|
|
|
use Misuzu\DateCheck;
|
2022-09-13 13:14:49 +00:00
|
|
|
use Misuzu\DB;
|
|
|
|
use Misuzu\Memoizer;
|
|
|
|
use Misuzu\Pagination;
|
|
|
|
use Misuzu\TOTP;
|
|
|
|
use Misuzu\Parsers\Parser;
|
|
|
|
use Misuzu\Users\Assets\UserAvatarAsset;
|
|
|
|
use Misuzu\Users\Assets\UserBackgroundAsset;
|
|
|
|
|
|
|
|
// Quick note to myself and others about the `display_role` column in the users database and its corresponding methods in this class.
|
|
|
|
// Never ever EVER use it for ANYTHING other than determining display colours, there's a small chance that it might not be accurate.
|
|
|
|
// And even if it were, roles properties are aggregated and thus must all be accounted for.
|
|
|
|
|
|
|
|
// TODO
|
|
|
|
// - Search for comments starting with TODO
|
|
|
|
// - Move background settings and about shit to a separate users_profiles table (should birthdate be profile specific?)
|
|
|
|
// - Create a users_stats table containing static counts for things like followers, followings, topics, posts, etc.
|
|
|
|
|
2023-07-29 17:31:43 +00:00
|
|
|
class User {
|
2022-09-13 13:14:49 +00:00
|
|
|
public const NAME_MIN_LENGTH = 3; // Minimum username length
|
|
|
|
public const NAME_MAX_LENGTH = 16; // Maximum username length, unless your name is Flappyzor(WorldwideOnline2018through2019through2020)
|
|
|
|
public const NAME_REGEX = '[A-Za-z0-9-_]+'; // Username character constraint
|
|
|
|
|
|
|
|
// Minimum amount of unique characters for passwords
|
|
|
|
public const PASSWORD_UNIQUE = 6;
|
|
|
|
|
|
|
|
// Password hashing algorithm
|
|
|
|
public const PASSWORD_ALGO = PASSWORD_ARGON2ID;
|
|
|
|
|
|
|
|
// Maximum length of profile about section
|
|
|
|
public const PROFILE_ABOUT_MAX_LENGTH = 50000;
|
|
|
|
|
|
|
|
// Maximum length of forum signature
|
|
|
|
public const FORUM_SIGNATURE_MAX_LENGTH = 2000;
|
|
|
|
|
|
|
|
// Database fields
|
|
|
|
private $user_id = -1;
|
|
|
|
private $username = '';
|
|
|
|
private $password = '';
|
|
|
|
private $email = '';
|
|
|
|
private $register_ip = '::1';
|
|
|
|
private $last_ip = '::1';
|
|
|
|
private $user_super = 0;
|
|
|
|
private $user_country = 'XX';
|
|
|
|
private $user_colour = null;
|
|
|
|
private $user_created = null;
|
|
|
|
private $user_active = null;
|
|
|
|
private $user_deleted = null;
|
|
|
|
private $display_role = 1;
|
|
|
|
private $user_totp_key = null;
|
|
|
|
private $user_about_content = null;
|
|
|
|
private $user_about_parser = 0;
|
|
|
|
private $user_signature_content = null;
|
|
|
|
private $user_signature_parser = 0;
|
|
|
|
private $user_birthdate = null;
|
|
|
|
private $user_background_settings = 0;
|
|
|
|
private $user_title = null;
|
|
|
|
|
|
|
|
private static $localUser = null;
|
|
|
|
|
|
|
|
private $totp = null;
|
|
|
|
|
2023-07-28 15:07:30 +00:00
|
|
|
private const QUERY_SELECT = 'SELECT %1$s FROM `msz_users`';
|
|
|
|
private const SELECT = '`user_id`, `username`, `password`, `email`, `user_super`, `user_title`'
|
|
|
|
. ', `user_country`, `user_colour`, `display_role`, `user_totp_key`'
|
|
|
|
. ', `user_about_content`, `user_about_parser`'
|
|
|
|
. ', `user_signature_content`, `user_signature_parser`'
|
|
|
|
. ', `user_birthdate`, `user_background_settings`'
|
|
|
|
. ', INET6_NTOA(`register_ip`) AS `register_ip`'
|
|
|
|
. ', INET6_NTOA(`last_ip`) AS `last_ip`'
|
|
|
|
. ', UNIX_TIMESTAMP(`user_created`) AS `user_created`'
|
|
|
|
. ', UNIX_TIMESTAMP(`user_active`) AS `user_active`'
|
|
|
|
. ', UNIX_TIMESTAMP(`user_deleted`) AS `user_deleted`';
|
2022-09-13 13:14:49 +00:00
|
|
|
|
|
|
|
public function getId(): int {
|
|
|
|
return $this->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();
|
2023-07-27 23:26:05 +00:00
|
|
|
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);
|
|
|
|
}
|
2022-09-13 13:14:49 +00:00
|
|
|
}
|
|
|
|
return $this->realColour;
|
|
|
|
}
|
|
|
|
public function setColour(?Colour $colour): self {
|
2023-01-02 23:48:04 +00:00
|
|
|
return $this->setColourRaw($colour === null ? null : Colour::toMisuzu($colour));
|
2022-09-13 13:14:49 +00:00
|
|
|
}
|
|
|
|
public function getUserColour(): Colour { // Only ever gets the user's actual colour
|
|
|
|
if($this->userColour === null)
|
2023-01-02 23:48:04 +00:00
|
|
|
$this->userColour = Colour::fromMisuzu($this->getColourRaw());
|
2022-09-13 13:14:49 +00:00
|
|
|
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`)'
|
2023-07-27 23:26:05 +00:00
|
|
|
. ' FROM `msz_roles`'
|
|
|
|
. ' WHERE `role_id` IN (SELECT `role_id` FROM `msz_users_roles` WHERE `user_id` = :user)'
|
2022-09-13 13:14:49 +00:00
|
|
|
)->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 {
|
2023-07-12 23:08:35 +00:00
|
|
|
// 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;
|
2022-09-13 13:14:49 +00:00
|
|
|
$this->user_birthdate = $month < 1 || $day < 1 ? null : sprintf('%04d-%02d-%02d', $year, $month, $day);
|
|
|
|
return $this;
|
|
|
|
}
|
|
|
|
public function hasAge(): bool {
|
2023-01-01 19:06:01 +00:00
|
|
|
return $this->hasBirthdate() && (int)$this->getBirthdate()->format('Y') > 1900;
|
2022-09-13 13:14:49 +00:00
|
|
|
}
|
|
|
|
public function getAge(): int {
|
|
|
|
if(!$this->hasAge())
|
|
|
|
return -1;
|
2023-01-01 19:06:01 +00:00
|
|
|
return (int)$this->getBirthdate()->diff(new DateTime('now', new DateTimeZone('UTC')))->format('%y');
|
2022-09-13 13:14:49 +00:00
|
|
|
}
|
|
|
|
|
2023-01-05 18:33:03 +00:00
|
|
|
public function bumpActivity(string $lastRemoteAddress): void {
|
2022-09-13 13:14:49 +00:00
|
|
|
$this->user_active = time();
|
2023-01-05 18:33:03 +00:00
|
|
|
$this->last_ip = $lastRemoteAddress;
|
2022-09-13 13:14:49 +00:00
|
|
|
|
|
|
|
DB::prepare(
|
2023-07-28 15:07:30 +00:00
|
|
|
'UPDATE `msz_users`'
|
2022-09-13 13:14:49 +00:00
|
|
|
. ' 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';
|
|
|
|
|
2023-01-01 01:53:29 +00:00
|
|
|
if(str_starts_with(mb_strtolower($name), 'flappyzor'))
|
|
|
|
return 'flapp';
|
|
|
|
|
2022-09-13 13:14:49 +00:00
|
|
|
$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`'
|
2023-07-28 15:07:30 +00:00
|
|
|
. ' FROM `msz_users`'
|
2022-09-13 13:14:49 +00:00
|
|
|
. ' 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!';
|
2023-01-01 01:53:29 +00:00
|
|
|
case 'flapp':
|
|
|
|
return 'Your username may not start with Flappyzor!';
|
2022-09-13 13:14:49 +00:00
|
|
|
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`'
|
2023-07-28 15:07:30 +00:00
|
|
|
. ' FROM `msz_users`'
|
2022-09-13 13:14:49 +00:00
|
|
|
. ' WHERE LOWER(`email`) = LOWER(:email)'
|
|
|
|
) ->bind('email', $address)
|
|
|
|
->fetchColumn();
|
|
|
|
if($userId > 0)
|
|
|
|
return 'in-use';
|
|
|
|
|
|
|
|
return '';
|
|
|
|
}
|
|
|
|
|
|
|
|
public static function validatePassword(string $password): string {
|
2023-07-05 01:33:12 +00:00
|
|
|
if(XString::countUnique($password) < self::PASSWORD_UNIQUE)
|
2022-09-13 13:14:49 +00:00
|
|
|
return 'weak';
|
|
|
|
|
|
|
|
return '';
|
|
|
|
}
|
|
|
|
|
|
|
|
public static function validateBirthdate(int $year, int $month, int $day, int $yearRange = 100): string {
|
2023-07-12 23:08:35 +00:00
|
|
|
if($day !== 0 && $month !== 0) {
|
|
|
|
if($year > 0 && ($year < date('Y') - $yearRange || $year > date('Y')))
|
2022-09-13 13:14:49 +00:00
|
|
|
return 'year';
|
|
|
|
|
2023-07-12 23:08:35 +00:00
|
|
|
if(!DateCheck::isValidDate($year, $month, $day))
|
|
|
|
return 'date';
|
|
|
|
}
|
2022-09-13 13:14:49 +00:00
|
|
|
|
|
|
|
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(
|
2023-07-28 15:07:30 +00:00
|
|
|
'UPDATE `msz_users`'
|
2022-09-13 13:14:49 +00:00
|
|
|
. ' SET `username` = :username, `email` = :email, `password` = :password'
|
|
|
|
. ', `user_super` = :is_super, `user_country` = :country, `user_colour` = :colour, `user_title` = :title'
|
2023-07-27 23:26:05 +00:00
|
|
|
. ', `user_totp_key` = :totp'
|
2022-09-13 13:14:49 +00:00
|
|
|
. ' 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(
|
2023-07-28 15:07:30 +00:00
|
|
|
'UPDATE `msz_users`'
|
2022-09-13 13:14:49 +00:00
|
|
|
. ' SET `user_about_content` = :about_content, `user_about_parser` = :about_parser'
|
|
|
|
. ', `user_signature_content` = :signature_content, `user_signature_parser` = :signature_parser'
|
2023-05-26 18:41:21 +00:00
|
|
|
. ', `user_background_settings` = :background_settings, `user_birthdate` = :birthdate'
|
2022-09-13 13:14:49 +00:00
|
|
|
. ' 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)
|
2023-05-26 18:41:21 +00:00
|
|
|
->bind('birthdate', $this->user_birthdate)
|
2022-09-13 13:14:49 +00:00
|
|
|
->execute();
|
|
|
|
}
|
|
|
|
|
|
|
|
public static function create(
|
|
|
|
string $username,
|
|
|
|
string $password,
|
|
|
|
string $email,
|
2023-07-11 00:25:43 +00:00
|
|
|
string $ipAddress,
|
|
|
|
string $countryCode = 'XX'
|
2022-09-13 13:14:49 +00:00
|
|
|
): self {
|
|
|
|
$createUser = DB::prepare(
|
2023-07-28 15:07:30 +00:00
|
|
|
'INSERT INTO `msz_users` (`username`, `password`, `email`, `register_ip`, `last_ip`, `user_country`, `display_role`)'
|
2022-09-13 13:14:49 +00:00
|
|
|
. ' 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))
|
2023-07-11 00:25:43 +00:00
|
|
|
->bind('user_country', $countryCode)
|
2022-09-13 13:14:49 +00:00
|
|
|
->executeGetId();
|
|
|
|
|
|
|
|
if($createUser < 1)
|
2023-07-22 15:02:41 +00:00
|
|
|
throw new RuntimeException('User creation failed.');
|
2022-09-13 13:14:49 +00:00
|
|
|
|
|
|
|
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 {
|
2023-07-28 15:07:30 +00:00
|
|
|
return sprintf(self::QUERY_SELECT, self::SELECT);
|
2022-09-13 13:14:49 +00:00
|
|
|
}
|
2023-07-18 22:24:23 +00:00
|
|
|
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;
|
|
|
|
|
2022-09-13 13:14:49 +00:00
|
|
|
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)
|
2023-07-22 15:02:41 +00:00
|
|
|
throw new RuntimeException('Failed to fetch user by ID.');
|
2022-09-13 13:14:49 +00:00
|
|
|
return $user;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
public static function byUsername(string $username): ?self {
|
2023-04-29 21:57:21 +00:00
|
|
|
if(empty($username))
|
2023-07-22 15:02:41 +00:00
|
|
|
throw new InvalidArgumentException('$username may not be empty.');
|
2023-04-29 21:57:21 +00:00
|
|
|
|
2022-09-13 13:14:49 +00:00
|
|
|
$username = mb_strtolower($username);
|
2023-01-01 01:53:29 +00:00
|
|
|
|
|
|
|
if(str_starts_with($username, 'flappyzor'))
|
|
|
|
return self::byId(14);
|
|
|
|
|
2022-09-13 13:14:49 +00:00
|
|
|
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)
|
2023-07-22 15:02:41 +00:00
|
|
|
throw new RuntimeException('Failed to find user by ID.');
|
2022-09-13 13:14:49 +00:00
|
|
|
return $user;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
public static function byEMailAddress(string $address): ?self {
|
2023-04-29 21:57:21 +00:00
|
|
|
if(empty($address))
|
2023-07-22 15:02:41 +00:00
|
|
|
throw new InvalidArgumentException('$address may not be empty.');
|
2023-04-29 21:57:21 +00:00
|
|
|
|
2022-09-13 13:14:49 +00:00
|
|
|
$address = mb_strtolower($address);
|
2023-04-29 21:57:21 +00:00
|
|
|
|
2022-09-13 13:14:49 +00:00
|
|
|
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)
|
2023-07-22 15:02:41 +00:00
|
|
|
throw new RuntimeException('Failed to find user by e-mail address.');
|
2022-09-13 13:14:49 +00:00
|
|
|
return $user;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
public static function byUsernameOrEMailAddress(string $usernameOrAddress): self {
|
2023-04-29 21:57:21 +00:00
|
|
|
if(empty($usernameOrAddress))
|
2023-07-22 15:02:41 +00:00
|
|
|
throw new InvalidArgumentException('$usernameOrAddress may not be empty.');
|
2023-04-29 21:57:21 +00:00
|
|
|
|
2022-09-13 13:14:49 +00:00
|
|
|
$usernameOrAddressLower = mb_strtolower($usernameOrAddress);
|
2023-01-01 01:53:29 +00:00
|
|
|
|
|
|
|
if(!str_contains($usernameOrAddressLower, '@') && str_starts_with($usernameOrAddressLower, 'flappyzor'))
|
|
|
|
return self::byId(14);
|
|
|
|
|
2022-09-13 13:14:49 +00:00
|
|
|
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)
|
2023-07-22 15:02:41 +00:00
|
|
|
throw new RuntimeException('Failed to find user by name or e-mail address.');
|
2022-09-13 13:14:49 +00:00
|
|
|
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 {
|
2023-04-29 21:57:21 +00:00
|
|
|
if(empty($userIdOrName))
|
2023-07-22 15:02:41 +00:00
|
|
|
throw new InvalidArgumentException('$userIdOrName may not be empty.');
|
2023-04-29 21:57:21 +00:00
|
|
|
|
2022-09-13 13:14:49 +00:00
|
|
|
$userIdOrNameLower = mb_strtolower($userIdOrName);
|
2023-01-01 01:53:29 +00:00
|
|
|
|
|
|
|
if(str_starts_with($userIdOrNameLower, 'flappyzor'))
|
|
|
|
return self::byId(14);
|
|
|
|
|
2022-09-13 13:14:49 +00:00
|
|
|
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)
|
2023-07-22 15:02:41 +00:00
|
|
|
throw new RuntimeException('Failed to find user by ID or name.');
|
2022-09-13 13:14:49 +00:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|