diff --git a/VERSION b/VERSION index f9d9d8c4..11e801e0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -20250208 +20250209 diff --git a/database/2025_02_09_005714_created_user_name_history_table.php b/database/2025_02_09_005714_created_user_name_history_table.php new file mode 100644 index 00000000..b7f6a922 --- /dev/null +++ b/database/2025_02_09_005714_created_user_name_history_table.php @@ -0,0 +1,28 @@ +<?php +use Index\Db\DbConnection; +use Index\Db\Migration\DbMigration; + +final class CreatedUserNameHistoryTable_20250209_005714 implements DbMigration { + public function migrate(DbConnection $conn): void { + $conn->execute(<<<SQL + CREATE TABLE msz_users_names_history ( + history_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INT(10) UNSIGNED NULL DEFAULT NULL, + history_name_before VARCHAR(255) NOT NULL COLLATE 'utf8mb4_unicode_520_ci', + history_name_after VARCHAR(255) NOT NULL COLLATE 'utf8mb4_unicode_520_ci', + history_private TINYINT(3) UNSIGNED NOT NULL DEFAULT '0', + history_created TIMESTAMP NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (history_id), + INDEX users_names_history_users_foreign (user_id), + INDEX users_names_history_name_before_index (history_name_before), + INDEX users_names_history_created_index (history_created), + INDEX users_names_history_private_index (history_private), + CONSTRAINT users_names_history_users_foreign + FOREIGN KEY (user_id) + REFERENCES msz_users (user_id) + ON UPDATE CASCADE + ON DELETE SET NULL + ) COLLATE='utf8mb4_bin'; + SQL); + } +} diff --git a/public-legacy/profile.php b/public-legacy/profile.php index 9a940d1d..2d5ee719 100644 --- a/public-legacy/profile.php +++ b/public-legacy/profile.php @@ -26,6 +26,12 @@ $viewerId = $viewingAsGuest ? '0' : $viewerInfo->id; try { $userInfo = $msz->usersCtx->getUserInfo($userId, 'profile'); } catch(RuntimeException $ex) { + $userId = $msz->usersCtx->namesHistory->resolvePastUserName($userId); + if($userId !== null) { + header(sprintf('Location: %s', $msz->urls->format('user-profile', ['user' => $userId]))); + return; + } + http_response_code(404); Template::render('profile.index', [ 'profile_is_guest' => $viewingAsGuest, diff --git a/src/Users/UserNameHistoryInfo.php b/src/Users/UserNameHistoryInfo.php new file mode 100644 index 00000000..4fea1c75 --- /dev/null +++ b/src/Users/UserNameHistoryInfo.php @@ -0,0 +1,31 @@ +<?php +namespace Misuzu\Users; + +use Carbon\CarbonImmutable; +use Index\Db\DbResult; + +class UserNameHistoryInfo { + public function __construct( + public private(set) string $id, + public private(set) string $userId, + public private(set) string $nameBefore, + public private(set) string $nameAfter, + public private(set) bool $private, + public private(set) int $createdTime + ) {} + + public static function fromResult(DbResult $result): UserPasswordInfo { + return new UserPasswordInfo( + id: $result->getString(0), + userId: $result->getString(1), + nameBefore: $result->getString(2), + nameAfter: $result->getString(3), + private: $result->getBoolean(4), + createdTime: $result->getInteger(5), + ); + } + + public CarbonImmutable $createdAt { + get => CarbonImmutable::createFromTimestampUTC($this->createdTime); + } +} diff --git a/src/Users/UserNamesHistoryData.php b/src/Users/UserNamesHistoryData.php new file mode 100644 index 00000000..04c3b8db --- /dev/null +++ b/src/Users/UserNamesHistoryData.php @@ -0,0 +1,139 @@ +<?php +namespace Misuzu\Users; + +use RuntimeException; +use Index\Db\{DbConnection,DbStatementCache}; + +class UserNamesHistoryData { + private DbStatementCache $cache; + + public function __construct(DbConnection $dbConn) { + $this->cache = new DbStatementCache($dbConn); + } + + /** @return iterable<UserNameHistoryInfo> */ + public function getUserNamesHistory( + UserInfo|string|null $userInfo = null, + ?bool $private = null + ): iterable { + $hasUserInfo = $userInfo !== null; + + $args = 0; + $query = <<<SQL + SELECT history_id, user_id, history_name_before, history_name_after, history_private, UNIX_TIMESTAMP(history_created) + FROM msz_users_names_history + SQL; + if($hasUserInfo) { + ++$args; + $query .= ' WHERE user_id = ?'; + } + if($private !== null) + $query .= sprintf(' %s history_private %s 0', ++$args > 1 ? 'AND' : 'WHERE', $private ? '<>' : '='); + + $stmt = $this->cache->get($query); + if($hasUserInfo) + $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); + $stmt->execute(); + + return $stmt->getResultIterator(UserNameHistoryInfo::fromResult(...)); + } + + public function getUserNameHistory(string $historyId): UserNameHistoryInfo { + $stmt = $this->cache->get(<<<SQL + SELECT history_id, user_id, history_name_before, history_name_after, history_private, UNIX_TIMESTAMP(history_created) + FROM msz_users_names_history + WHERE history_id = ? + SQL); + $stmt->nextParameter($historyId); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('could not find that name history entry'); + + return UserNameHistoryInfo::fromResult($result); + } + + public function deleteUserNameHistory(UserNameHistoryInfo|string $historyInfo): void { + $stmt = $this->cache->get(<<<SQL + DELETE FROM msz_users_names_history + WHERE history_id = ? + SQL); + $stmt->nextParameter($historyInfo instanceof UserNameHistoryInfo ? $historyInfo->id : $historyInfo); + $stmt->execute(); + } + + public function deleteUserNamesHistory(UserInfo|string $userInfo): void { + $stmt = $this->cache->get(<<<SQL + DELETE FROM msz_users_names_history + WHERE user_id = ? + SQL); + $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); + $stmt->execute(); + } + + public function setUserNameHistoryVisibility( + UserNameHistoryInfo|string $historyInfo, + bool $private + ): void { + $stmt = $this->cache->get(<<<SQL + UPDATE msz_users_names_history + SET history_private = ? + WHERE history_id = ? + SQL); + $stmt->nextParameter($private ? 1 : 0); + $stmt->nextParameter($historyInfo instanceof UserNameHistoryInfo ? $historyInfo->id : $historyInfo); + $stmt->execute(); + } + + // this should probably let you specify a time period + public function setUserNamesHistoryVisibility( + UserInfo|string $userInfo, + bool $private + ): void { + $stmt = $this->cache->get(<<<SQL + UPDATE msz_users_names_history + SET history_private = ? + WHERE user_id = ? + SQL); + $stmt->nextParameter($private ? 1 : 0); + $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); + $stmt->execute(); + } + + public function recordUserNameChange( + UserInfo|string $userInfo, + string $nameBefore, + string $nameAfter, + bool $private = false + ): UserNameHistoryInfo { + $stmt = $this->cache->get(<<<SQL + INSERT INTO msz_users_names_history ( + user_id, history_name_before, history_name_after, history_private + ) VALUES (?, ?, ?, ?) + SQL); + $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo); + $stmt->nextParameter($nameBefore); + $stmt->nextParameter($nameAfter); + $stmt->nextParameter($private ? 1 : 0); + $stmt->execute(); + + return $this->getUserNameHistory($stmt->lastInsertId); + } + + public function resolvePastUserName(string $pastName): ?string { + $stmt = $this->cache->get(<<<SQL + SELECT IF(history_private, NULL, user_id) + FROM msz_users_names_history + WHERE user_id IS NOT NULL + AND history_name_before = ? + ORDER BY history_created DESC + LIMIT 1 + SQL); + $stmt->nextParameter($pastName); + $stmt->execute(); + + $result = $stmt->getResult(); + return $result->next() ? $result->getStringOrNull(0) : null; + } +} diff --git a/src/Users/UsersContext.php b/src/Users/UsersContext.php index a0cb4071..82ffdeb4 100644 --- a/src/Users/UsersContext.php +++ b/src/Users/UsersContext.php @@ -13,6 +13,7 @@ class UsersContext { public private(set) UserPasswordsData $passwords; public private(set) UserTotpsData $totps; public private(set) UserBirthdatesData $birthdates; + public private(set) UserNamesHistoryData $namesHistory; /** @var array<string, UserInfo> */ private array $userInfos = []; @@ -35,6 +36,7 @@ class UsersContext { $this->passwords = new UserPasswordsData($dbConn); $this->totps = new UserTotpsData($dbConn); $this->birthdates = new UserBirthdatesData($dbConn); + $this->namesHistory = new UserNamesHistoryData($dbConn); } public function getUserInfo(string $value, int|string|null $select = null): UserInfo {