misuzu/src/Users/Users.php
flash 383e2ed0e0 Rewrote the user information class.
This one took multiple days and it pretty invasive into the core of Misuzu so issue might (will) arise, there's also some features that have gone temporarily missing in the mean time and some inefficiencies introduced that will be fixed again at a later time.
The old class isn't gone entirely because I still have to figure out what I'm gonna do about validation, but for the most part this knocks out one of the "layers of backwards compatibility", as I've been referring to it, and is moving us closer to a future where Flashii actually gets real updates.
If you run into anything that's broken and you're inhibited from reporting it through the forum, do it through chat or mail me at flashii-issues@flash.moe.
2023-08-02 22:12:47 +00:00

544 lines
19 KiB
PHP

<?php
namespace Misuzu\Users;
use InvalidArgumentException;
use RuntimeException;
use Index\DateTime;
use Index\Colour\Colour;
use Index\Data\DbStatementCache;
use Index\Data\DbTools;
use Index\Data\IDbConnection;
use Index\Net\IPAddress;
use Misuzu\Pagination;
class Users {
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->dbConn = $dbConn;
$this->cache = new DbStatementCache($dbConn);
}
private const PASSWORD_ALGO = PASSWORD_ARGON2ID;
private const PASSWORD_OPTS = [];
public static function passwordHash(string $password): string {
return password_hash($password, self::PASSWORD_ALGO, self::PASSWORD_OPTS);
}
public static function passwordNeedsRehash(string $passwordHash): bool {
return password_needs_rehash($passwordHash, self::PASSWORD_ALGO, self::PASSWORD_OPTS);
}
public function countUsers(
RoleInfo|string|null $roleInfo = null,
UserInfo|string|null $after = null,
?int $lastActiveInMinutes = null,
?int $newerThanDays = null,
?DateTime $birthdate = null,
?bool $deleted = null
): int {
if($roleInfo instanceof RoleInfo)
$roleInfo = $roleInfo->getId();
if($after instanceof UserInfo)
$after = $after->getId();
$hasRoleInfo = $roleInfo !== null;
$hasAfter = $after !== null;
$hasLastActiveInMinutes = $lastActiveInMinutes !== null;
$hasNewerThanDays = $newerThanDays !== null;
$hasBirthdate = $birthdate !== null;
$hasDeleted = $deleted !== null;
$args = 0;
$query = 'SELECT COUNT(*) FROM msz_users';
if($hasRoleInfo) {
++$args;
$query .= ' WHERE user_id IN (SELECT user_id FROM msz_users_roles WHERE role_id = ?)';
}
if($hasAfter)
$query .= sprintf(' %s user_id > ?', ++$args > 1 ? 'AND' : 'WHERE');
if($hasLastActiveInMinutes)
$query .= sprintf(' %s user_active > NOW() - INTERVAL ? MINUTE', ++$args > 1 ? 'AND' : 'WHERE');
if($hasNewerThanDays)
$query .= sprintf(' %s user_created > NOW() - INTERVAL ? DAY', ++$args > 1 ? 'AND' : 'WHERE');
if($hasDeleted)
$query .= sprintf(' %s user_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS');
$args = 0;
$stmt = $this->cache->get($query);
if($hasRoleInfo)
$stmt->addParameter(++$args, $roleInfo);
if($hasAfter)
$stmt->addParameter(++$args, $after);
if($hasLastActiveInMinutes)
$stmt->addParameter(++$args, $lastActiveInMinutes);
if($hasNewerThanDays)
$stmt->addParameter(++$args, $newerThanDays);
$stmt->execute();
$result = $stmt->getResult();
return $result->next() ? $result->getInteger(0) : 0;
}
private const GET_USERS_SORT = [
'id' => ['user_id', false],
'name' => ['username', false],
'country' => ['user_country', false],
'created' => ['user_created', true],
'active' => ['user_active', true],
'random' => ['RAND()', null],
];
public function getUsers(
RoleInfo|string|null $roleInfo = null,
UserInfo|string|null $after = null,
?int $lastActiveInMinutes = null,
?int $newerThanDays = null,
?DateTime $birthdate = null,
?bool $deleted = null,
?string $orderBy = null,
?bool $reverseOrder = null,
?Pagination $pagination = null
): array {
if($roleInfo instanceof RoleInfo)
$roleInfo = $roleInfo->getId();
if($after instanceof UserInfo)
$after = $after->getId();
$hasRoleInfo = $roleInfo !== null;
$hasAfter = $after !== null;
$hasLastActiveInMinutes = $lastActiveInMinutes !== null;
$hasNewerThanDays = $newerThanDays !== null;
$hasBirthdate = $birthdate !== null;
$hasDeleted = $deleted !== null;
$hasOrderBy = $orderBy !== null;
$hasReverseOrder = $reverseOrder !== null;
$hasPagination = $pagination !== null;
if($hasOrderBy) {
if(!array_key_exists($orderBy, self::GET_USERS_SORT))
throw new InvalidArgumentException('Invalid sort specified.');
$orderBy = self::GET_USERS_SORT[$orderBy];
if($hasReverseOrder && $reverseOrder && $orderBy[1] !== null)
$orderBy[1] = !$orderBy[1];
}
$args = 0;
$query = 'SELECT user_id, username, password, email, INET6_NTOA(register_ip), INET6_NTOA(last_ip), user_super, user_country, user_colour, UNIX_TIMESTAMP(user_created), UNIX_TIMESTAMP(user_active), UNIX_TIMESTAMP(user_deleted), display_role, user_totp_key, user_about_content, user_about_parser, user_signature_content, user_signature_parser, user_birthdate, user_background_settings, user_title FROM msz_users';
if($hasRoleInfo) {
++$args;
$query .= ' WHERE user_id IN (SELECT user_id FROM msz_users_roles WHERE role_id = ?)';
}
if($hasAfter)
$query .= sprintf(' %s user_id > ?', ++$args > 1 ? 'AND' : 'WHERE');
if($hasLastActiveInMinutes)
$query .= sprintf(' %s user_active > NOW() - INTERVAL ? MINUTE', ++$args > 1 ? 'AND' : 'WHERE');
if($hasNewerThanDays)
$query .= sprintf(' %s user_created > NOW() - INTERVAL ? DAY', ++$args > 1 ? 'AND' : 'WHERE');
if($hasDeleted)
$query .= sprintf(' %s user_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS');
if($hasBirthdate)
$query .= sprintf(' %s user_birthdate LIKE ?', ++$args > 1 ? 'AND' : 'WHERE');
if($hasOrderBy) {
$query .= sprintf(' ORDER BY %s', $orderBy[0]);
if($orderBy !== null)
$query .= ' ' . ($orderBy[1] ? 'DESC' : 'ASC');
}
if($hasPagination)
$query .= ' LIMIT ? OFFSET ?';
$args = 0;
$stmt = $this->cache->get($query);
if($hasRoleInfo)
$stmt->addParameter(++$args, $roleInfo);
if($hasAfter)
$stmt->addParameter(++$args, $after);
if($hasLastActiveInMinutes)
$stmt->addParameter(++$args, $lastActiveInMinutes);
if($hasNewerThanDays)
$stmt->addParameter(++$args, $newerThanDays);
if($hasBirthdate)
$stmt->addParameter(++$args, $birthdate->format('%-m-d'));
if($hasPagination) {
$stmt->addParameter(++$args, $pagination->getRange());
$stmt->addParameter(++$args, $pagination->getOffset());
}
$stmt->execute();
$users = [];
$result = $stmt->getResult();
while($result->next())
$users[] = new UserInfo($result);
return $users;
}
public const GET_USER_ID = 0x01;
public const GET_USER_NAME = 0x02;
public const GET_USER_MAIL = 0x04;
private const GET_USER_SELECT_ALIASES = [
'id' => self::GET_USER_ID,
'name' => self::GET_USER_NAME,
'email' => self::GET_USER_MAIL,
'profile' => self::GET_USER_ID | self::GET_USER_NAME,
'login' => self::GET_USER_NAME | self::GET_USER_MAIL,
'recovery' => self::GET_USER_MAIL,
];
public function getUser(string $value, int|string $select = self::GET_USER_ID): UserInfo {
if($value === '')
throw new InvalidArgumentException('$value may not be empty.');
if(is_string($select)) {
if(!array_key_exists($select, self::GET_USER_SELECT_ALIASES))
throw new InvalidArgumentException('Invalid $select alias.');
$select = self::GET_USER_SELECT_ALIASES[$select];
} elseif($select === 0)
throw new InvalidArgumentException('$select may not be zero.');
$selectId = ($select & self::GET_USER_ID) > 0;
$selectName = ($select & self::GET_USER_NAME) > 0;
$selectMail = ($select & self::GET_USER_MAIL) > 0;
if(!$selectId && !$selectName && !$selectMail)
throw new InvalidArgumentException('$select flagset is invalid.');
$args = 0;
$query = 'SELECT user_id, username, password, email, INET6_NTOA(register_ip), INET6_NTOA(last_ip), user_super, user_country, user_colour, UNIX_TIMESTAMP(user_created), UNIX_TIMESTAMP(user_active), UNIX_TIMESTAMP(user_deleted), display_role, user_totp_key, user_about_content, user_about_parser, user_signature_content, user_signature_parser, user_birthdate, user_background_settings, user_title FROM msz_users';
if($selectId) {
++$args;
$query .= ' WHERE user_id = ?';
}
if($selectName) // change the collation for both name and email to a case insensitive one
$query .= sprintf(' %s LOWER(username) = LOWER(?)', ++$args > 1 ? 'OR' : 'WHERE');
if($selectMail)
$query .= sprintf(' %s LOWER(email) = LOWER(?)', ++$args > 1 ? 'OR' : 'WHERE');
$args = 0;
$stmt = $this->cache->get($query);
if($selectId)
$stmt->addParameter(++$args, $value);
if($selectName)
$stmt->addParameter(++$args, $value);
if($selectMail)
$stmt->addParameter(++$args, $value);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('User not found.');
return new UserInfo($result);
}
public function createUser(
string $name,
string $password,
string $email,
IPAddress|string $remoteAddr,
string $countryCode,
RoleInfo|string|null $displayRoleInfo = null
): UserInfo {
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
if($displayRoleInfo instanceof RoleInfo)
$displayRoleInfo = $displayRoleInfo->getId();
elseif($displayRoleInfo === null)
$displayRoleInfo = Roles::DEFAULT_ROLE;
$password = self::passwordHash($password);
// todo: validation
$stmt = $this->cache->get('INSERT INTO msz_users (username, password, email, register_ip, last_ip, user_country, display_role) VALUES (?, ?, ?, INET6_ATON(?), INET6_ATON(?), ?, ?)');
$stmt->addParameter(1, $name);
$stmt->addParameter(2, $password);
$stmt->addParameter(3, $email);
$stmt->addParameter(4, $remoteAddr);
$stmt->addParameter(5, $remoteAddr);
$stmt->addParameter(6, $countryCode);
$stmt->addParameter(7, $displayRoleInfo);
$stmt->execute();
return $this->getUser((string)$this->dbConn->getLastInsertId(), self::GET_USER_ID);
}
public function updateUser(
UserInfo|string $userInfo,
?string $name = null,
?string $emailAddr = null,
?string $password = null,
?string $countryCode = null,
?Colour $colour = null,
RoleInfo|string|null $displayRoleInfo = null,
?string $totpKey = null,
?string $aboutContent = null,
?int $aboutParser = null,
?string $signatureContent = null,
?int $signatureParser = null,
?int $birthYear = null,
?int $birthMonth = null,
?int $birthDay = null,
?int $backgroundSettings = null,
?string $title = null
): void {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if($displayRoleInfo instanceof RoleInfo)
$displayRoleInfo = $displayRoleInfo->getId();
// do sanity checks on values at some point lol
$fields = [];
$values = [];
if($name !== null) {
$fields[] = 'username = ?';
$values[] = $name;
}
if($emailAddr !== null) {
$fields[] = 'email = ?';
$values[] = $emailAddr;
}
if($password !== null) {
$fields[] = 'password = ?';
$values[] = $password === '' ? null : self::passwordHash($password);
}
if($countryCode !== null) {
$fields[] = 'user_country = ?';
$values[] = $countryCode;
}
if($colour !== null) {
$fields[] = 'user_colour = ?';
$values[] = $colour->shouldInherit() ? null : Colour::toMisuzu($colour);
}
if($displayRoleInfo !== null) {
$fields[] = 'display_role = ?';
$values[] = $displayRoleInfo;
}
if($totpKey !== null) {
$fields[] = 'user_totp_key = ?';
$values[] = $totpKey === '' ? null : $totpKey;
}
if($aboutContent !== null) {
$fields[] = 'user_about_content = ?';
$values[] = $aboutContent;
}
if($aboutParser !== null) {
$fields[] = 'user_about_parser = ?';
$values[] = $aboutParser;
}
if($signatureContent !== null) {
$fields[] = 'user_signature_content = ?';
$values[] = $signatureContent;
}
if($signatureParser !== null) {
$fields[] = 'user_signature_parser = ?';
$values[] = $signatureParser;
}
if($birthMonth !== null && $birthDay !== null) {
// lowest leap year MariaDB accepts, used a 'no year' value
if($birthYear < 1004)
$birthYear = 1004;
$fields[] = 'user_birthdate = ?';
$values[] = $birthMonth < 1 || $birthDay < 1 ? null : sprintf('%04d-%02d-%02d', $birthYear, $birthMonth, $birthDay);
}
if($backgroundSettings !== null) {
$fields[] = 'user_background_settings = ?';
$values[] = $backgroundSettings;
}
if($title !== null) {
$fields[] = 'user_title = ?';
$values[] = $title;
}
if(empty($fields))
return;
$args = 0;
$stmt = $this->cache->get(sprintf('UPDATE msz_users SET %s WHERE user_id = ?', implode(', ', $fields)));
foreach($values as $value)
$stmt->addParameter(++$args, $value);
$stmt->addParameter(++$args, $userInfo);
$stmt->execute();
}
public function recordUserActivity(
UserInfo|string $userInfo,
IPAddress|string $remoteAddr
): void {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
$stmt = $this->cache->get('UPDATE msz_users SET user_active = NOW(), last_ip = INET6_ATON(?) WHERE user_id = ?');
$stmt->addParameter(1, $remoteAddr);
$stmt->addParameter(2, $userInfo);
$stmt->execute();
}
public function hasRole(
UserInfo|string $userInfo,
RoleInfo|string $roleInfo
): bool {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if($roleInfo instanceof RoleInfo)
$roleInfo = $roleInfo->getId();
return in_array($roleInfo, $this->hasRoles($userInfo, $roleInfo));
}
public function hasRoles(
UserInfo|string $userInfo,
RoleInfo|string|array $roleInfos
): array {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if(!is_array($roleInfos))
$roleInfos = [$roleInfos];
elseif(empty($roleInfos))
return [];
$args = 0;
$stmt = $this->cache->get(sprintf(
'SELECT role_id FROM msz_users_roles WHERE user_id = ? AND role_id IN (%s)',
DbTools::prepareListString($roleInfos)
));
$stmt->addParameter(++$args, $userInfo);
foreach($roleInfos as $roleInfo) {
if($roleInfo instanceof RoleInfo)
$roleInfo = $roleInfo->getId();
elseif(!is_string($roleInfo))
throw new InvalidArgumentException('$roleInfos must be strings of instances of RoleInfo.');
$stmt->addParameter(++$args, $roleInfo);
}
$stmt->execute();
$roleIds = [];
$result = $stmt->getResult();
while($result->next())
$roleIds[] = (string)$result->getInteger(0);
return $roleIds;
}
public function addRoles(
UserInfo|string $userInfo,
RoleInfo|string|array $roleInfos
): void {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if(!is_array($roleInfos))
$roleInfos = [$roleInfos];
elseif(empty($roleInfos))
return;
$stmt = $this->cache->get(sprintf(
'REPLACE INTO msz_users_roles (user_id, role_id) VALUES %s',
DbTools::prepareListString($roleInfos, '(?, ?)')
));
$args = 0;
foreach($roleInfos as $roleInfo) {
if($roleInfo instanceof RoleInfo)
$roleInfo = $roleInfo->getId();
elseif(!is_string($roleInfo))
throw new InvalidArgumentException('$roleInfos must be strings of instances of RoleInfo.');
$stmt->addParameter(++$args, $userInfo);
$stmt->addParameter(++$args, $roleInfo);
}
$stmt->execute();
}
public function removeRoles(
UserInfo|string $userInfo,
RoleInfo|string|array $roleInfos
): void {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if(!is_array($roleInfos))
$roleInfos = [$roleInfos];
elseif(empty($roleInfos))
return;
$args = 0;
$stmt = $this->cache->get(sprintf(
'DELETE FROM msz_users_roles WHERE user_id = ? AND role_id IN (%s)',
DbTools::prepareListString($roleInfos)
));
$stmt->addParameter(++$args, $userInfo);
foreach($roleInfos as $roleInfo) {
if($roleInfo instanceof RoleInfo)
$roleInfo = $roleInfo->getId();
elseif(!is_string($roleInfo))
throw new InvalidArgumentException('$roleInfos must be strings of instances of RoleInfo.');
$stmt->addParameter(++$args, $roleInfo);
}
$stmt->execute();
}
// the below two funcs should probably be moved to a higher level location so caching can be introduced
// without cluttering the data source interface <-- real words i just wrote
public function getUserColour(UserInfo|string $userInfo): Colour {
if($userInfo instanceof UserInfo) {
if($userInfo->hasColour())
return $userInfo->getColour();
$query = '?';
$value = $userInfo->getDisplayRoleId();
} else {
$query = '(SELECT display_role FROM msz_users WHERE user_id = ?)';
$value = $userInfo;
}
$stmt = $this->cache->get(sprintf('SELECT role_colour FROM msz_roles WHERE role_id = %s', $query));
$stmt->addParameter(1, $value);
$stmt->execute();
$result = $stmt->getResult();
return $result->next() ? Colour::fromMisuzu($result->getInteger(0)) : Colour::none();
}
public function getUserRank(UserInfo|string $userInfo): int {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
$stmt = $this->cache->get('SELECT MAX(role_hierarchy) FROM msz_roles WHERE role_id IN (SELECT role_id FROM msz_users_roles WHERE user_id = ?)');
$stmt->addParameter(1, $userInfo);
$stmt->execute();
$result = $stmt->getResult();
return $result->next() ? $result->getInteger(0) : 0;
}
}