diff --git a/assets/misuzu.css/main.css b/assets/misuzu.css/main.css index db3e2a2..00dabb6 100644 --- a/assets/misuzu.css/main.css +++ b/assets/misuzu.css/main.css @@ -160,21 +160,6 @@ html { @include home/landingv2.css; @include manage/_manage.css; -@include manage/blacklist.css; -@include manage/changelog-actions-tags.css; -@include manage/emote.css; -@include manage/emotes.css; -@include manage/navigation.css; -@include manage/role-item.css; -@include manage/roles.css; -@include manage/settings.css; -@include manage/statistic.css; -@include manage/statistics.css; -@include manage/tag.css; -@include manage/tags.css; -@include manage/user-item.css; -@include manage/user.css; -@include manage/users.css; @include news/container.css; @include news/feeds.css; diff --git a/assets/misuzu.css/manage/_manage.css b/assets/misuzu.css/manage/_manage.css index f97f464..c2dc510 100644 --- a/assets/misuzu.css/manage/_manage.css +++ b/assets/misuzu.css/manage/_manage.css @@ -23,3 +23,21 @@ width: 100%; } } + +@include manage/blacklist.css; +@include manage/changelog-actions-tags.css; +@include manage/emote.css; +@include manage/emotes.css; +@include manage/navigation.css; +@include manage/note.css; +@include manage/notes.css; +@include manage/role-item.css; +@include manage/roles.css; +@include manage/settings.css; +@include manage/statistic.css; +@include manage/statistics.css; +@include manage/tag.css; +@include manage/tags.css; +@include manage/user-item.css; +@include manage/user.css; +@include manage/users.css; diff --git a/assets/misuzu.css/manage/note.css b/assets/misuzu.css/manage/note.css new file mode 100644 index 0000000..59c3bf3 --- /dev/null +++ b/assets/misuzu.css/manage/note.css @@ -0,0 +1,88 @@ +.manage__note { + margin: 2px; +} + +.manage__note--view .manage__note--editing, +.manage__note--edit .manage__note--viewing { + display: none !important; + visibility: hidden !important; +} + +.manage__note__header { + display: flex; + overflow: hidden; + align-items: center; +} + +.manage__note__title { + flex-grow: 1; + flex-shrink: 1; + font-size: 1.4em; + line-height: 1.3em; +} +.manage__note__title__text { + padding: 2px 5px; +} +.manage__note__title input { + width: 100%; +} +.manage__note__actions { + display: flex; + flex-grow: 0; + flex-shrink: 0; + gap: 1px; + padding: 1px; + margin: 1px; +} +.manage__note__action { + width: 36px; + height: 36px; +} + +.manage__note__attributes { + display: flex; + gap: 12px; + margin: 0 4px; +} + +.manage__note__attribute { + display: flex; + gap: 4px; + align-items: center; +} +.manage__note__created__icon { + font-size: 16px; +} + +.manage__note__author a, +.manage__note__user a { + color: inherit; + text-decoration: none; +} +.manage__note__author__name a, +.manage__note__user__name a { + font-weight: bold; + display: inline-block; + padding-top: 2px; + border-bottom: 2px solid var(--user-colour, #fff); +} + +.manage__note__body { + margin: 2px; +} + +.manage__note__nobody { + text-align: center; + font-size: .9em; + font-style: italic; +} + +.manage__note__editor { + width: 100%; +} +.manage__note__editor textarea { + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 300px; +} diff --git a/assets/misuzu.css/manage/notes.css b/assets/misuzu.css/manage/notes.css new file mode 100644 index 0000000..a839e85 --- /dev/null +++ b/assets/misuzu.css/manage/notes.css @@ -0,0 +1,122 @@ +.manage__notes__pagination { + margin: 2px; +} + +.manage__notes__actions { + display: flex; + gap: 2px; + margin: 2px; +} + +.manage__notes__item { + padding: 2px; + margin: 2px; +} +.manage__notes__item:not(:last-child) { + border-bottom: 1px solid var(--accent-colour); +} + +.manage__notes__item__header { + display: flex; + overflow: hidden; + align-items: center; +} +.manage__notes__item__title { + flex-grow: 1; + flex-shrink: 1; + font-size: 1.4em; + line-height: 1.3em; + padding: 2px 5px; +} +.manage__notes__item__title a { + color: inherit; + text-decoration: none; +} +.manage__notes__item__title a:hover, +.manage__notes__item__title a:focus { + text-decoration: underline; +} +.manage__notes__item__actions { + display: flex; + flex-grow: 0; + flex-shrink: 0; + gap: 1px; + padding: 1px; + margin: 1px; +} +.manage__notes__item__action { + width: 36px; + height: 36px; +} + +.manage__notes__item__attributes { + display: flex; + gap: 12px; + margin: 0 4px; +} + +.manage__notes__item__attribute { + display: flex; + gap: 4px; + align-items: center; +} +.manage__notes__item__created__icon { + font-size: 16px; +} + +.manage__notes__item__author a, +.manage__notes__item__user a { + color: inherit; + text-decoration: none; +} +.manage__notes__item__author__name a, +.manage__notes__item__user__name a { + font-weight: bold; + display: inline-block; + padding-top: 2px; + border-bottom: 2px solid var(--user-colour, #fff); +} +.manage__notes__item__user__filter a { + padding: 2px 4px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.2); + transition: background .2s; +} +.manage__notes__item__user__filter a:hover, +.manage__notes__item__user__filter a:focus { + background: rgba(255, 255, 255, 0.4); +} +.manage__notes__item__user__filter a:active { + background: rgba(255, 255, 255, 0.1); +} + +.manage__notes__item__body { + margin: 2px; +} + +.manage__notes__item__nobody { + text-align: center; + font-size: .9em; + font-style: italic; +} + +.manage__notes__item__continue { + text-align: center; +} +.manage__notes__item__continue a { + display: inline-block; + padding: 2px 5px; + color: inherit; + text-decoration: none; + border-radius: 5px; + background: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.2); + transition: background .2s; +} +.manage__notes__item__continue a:hover, +.manage__notes__item__continue a:focus { + background: rgba(255, 255, 255, 0.4); +} +.manage__notes__item__continue a:active { + background: rgba(255, 255, 255, 0.1); +} diff --git a/database/2023_07_24_201010_add_moderator_notes_table.php b/database/2023_07_24_201010_add_moderator_notes_table.php new file mode 100644 index 0000000..140aaea --- /dev/null +++ b/database/2023_07_24_201010_add_moderator_notes_table.php @@ -0,0 +1,46 @@ +execute(' + CREATE TABLE msz_users_modnotes ( + note_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INT(10) UNSIGNED NOT NULL, + author_id INT(10) UNSIGNED NULL DEFAULT NULL, + note_created TIMESTAMP NOT NULL DEFAULT current_timestamp(), + note_title VARCHAR(255) NOT NULL, + note_body TEXT NOT NULL, + PRIMARY KEY (note_id), + KEY users_modnotes_user_foreign (user_id), + KEY users_modnotes_author_foreign (author_id), + KEY users_modnotes_created_index (note_created), + CONSTRAINT users_modnotes_user_foreign + FOREIGN KEY (user_id) + REFERENCES msz_users (user_id) + ON UPDATE CASCADE + ON DELETE CASCADE, + CONSTRAINT users_modnotes_author_foreign + FOREIGN KEY (author_id) + REFERENCES msz_users (user_id) + ON UPDATE CASCADE + ON DELETE SET NULL + ) ENGINE=InnoDB COLLATE=utf8mb4_bin + '); + + // migrate existing notes + $conn->execute(' + INSERT INTO msz_users_modnotes (user_id, author_id, note_created, note_title, note_body) + SELECT user_id, issuer_id, warning_created, warning_note, COALESCE(warning_note_private, "") + FROM msz_user_warnings + WHERE warning_type = 0 + '); + + // delete notes from the warnings table + $conn->execute('DELETE FROM msz_user_warnings WHERE warning_type = 0'); + + // for good measure update silences to bans since i forgot to do that as a migration + $conn->execute('UPDATE msz_user_warnings SET warning_type = 3 WHERE warning_type = 2'); + } +} diff --git a/public-legacy/manage/users/note.php b/public-legacy/manage/users/note.php new file mode 100644 index 0000000..f3cfa79 --- /dev/null +++ b/public-legacy/manage/users/note.php @@ -0,0 +1,87 @@ +getId(), MSZ_PERM_USER_MANAGE_NOTES)) { + echo render_error(403); + return; +} + +$hasNoteId = filter_has_var(INPUT_GET, 'n'); +$hasUserId = filter_has_var(INPUT_GET, 'u'); + +if((!$hasNoteId && !$hasUserId) || ($hasNoteId && $hasUserId)) { + echo render_error(400); + return; +} + +$modNotes = $msz->getModNotes(); + +if($hasUserId) { + $isNew = true; + + try { + $userInfo = User::byId((int)filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT)); + } catch(RuntimeException $ex) { + echo render_error(404); + return; + } + + $authorInfo = User::getCurrent(); +} elseif($hasNoteId) { + $isNew = false; + + try { + $noteInfo = $modNotes->getNote((string)filter_input(INPUT_GET, 'n', FILTER_SANITIZE_NUMBER_INT)); + } catch(RuntimeException $ex) { + echo render_error(404); + return; + } + + if($_SERVER['REQUEST_METHOD'] === 'GET' && filter_has_var(INPUT_GET, 'delete')) { + if(CSRF::validateRequest()) { + $modNotes->deleteNote($noteInfo); + $msz->createAuditLog('MOD_NOTE_DELETE', [$noteInfo->getId(), $noteInfo->getUserId()]); + url_redirect('manage-users-notes', ['user' => $noteInfo->getUserId()]); + } else render_error(403); + return; + } + + $userInfo = User::byId((int)$noteInfo->getUserId()); + $authorInfo = $noteInfo->hasAuthorId() ? User::byId((int)$noteInfo->getAuthorId()) : null; +} + +while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { + $title = trim((string)filter_input(INPUT_POST, 'mn_title')); + $body = trim((string)filter_input(INPUT_POST, 'mn_body')); + + if($isNew) { + $noteInfo = $modNotes->createNote($userInfo, $title, $body, $authorInfo); + } else { + if($title === $noteInfo->getTitle()) + $title = null; + if($body === $noteInfo->getBody()) + $body = null; + + if($title !== null || $body !== null) + $modNotes->updateNote($noteInfo, $title, $body); + } + + $msz->createAuditLog( + $isNew ? 'MOD_NOTE_CREATE' : 'MOD_NOTE_UPDATE', + [$noteInfo->getId(), $userInfo->getId()] + ); + + // this is easier + url_redirect('manage-users-note', ['note' => $noteInfo->getId()]); + return; +} + +Template::render('manage.users.note', [ + 'note_new' => $isNew, + 'note_info' => $noteInfo ?? null, + 'note_user' => $userInfo, + 'note_author' => $authorInfo, +]); diff --git a/public-legacy/manage/users/notes.php b/public-legacy/manage/users/notes.php new file mode 100644 index 0000000..7279ba8 --- /dev/null +++ b/public-legacy/manage/users/notes.php @@ -0,0 +1,63 @@ +getId(), MSZ_PERM_USER_MANAGE_NOTES)) { + echo render_error(403); + return; +} + +$userInfos = [ + (string)User::getCurrent()->getId() => User::getCurrent(), +]; + +$filterUser = null; +if(filter_has_var(INPUT_GET, 'u')) { + $filterUserId = (int)filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT); + try { + $filterUser = User::byId($filterUserId); + $userInfos[(string)$filterUser->getId()] = $filterUser; + } catch(RuntimeException $ex) { + echo render_error(404); + return; + } +} + +$modNotes = $msz->getModNotes(); +$pagination = new Pagination($modNotes->countNotes(userInfo: $filterUser), 10); + +if(!$pagination->hasValidOffset()) { + echo render_error(404); + return; +} + +$notes = []; +$noteInfos = $modNotes->getNotes(userInfo: $filterUser, pagination: $pagination); + +foreach($noteInfos as $noteInfo) { + if(array_key_exists($noteInfo->getUserId(), $userInfos)) + $userInfo = $userInfos[$noteInfo->getUserId()]; + else + $userInfos[$noteInfo->getUserId()] = $userInfo = User::byId((int)$noteInfo->getUserId()); + + if(!$noteInfo->hasAuthorId()) + $authorInfo = null; + elseif(array_key_exists($noteInfo->getAuthorId(), $userInfos)) + $authorInfo = $userInfos[$noteInfo->getAuthorId()]; + else + $userInfos[$noteInfo->getAuthorId()] = $authorInfo = User::byId((int)$noteInfo->getAuthorId()); + + $notes[] = [ + 'info' => $noteInfo, + 'user' => $userInfo, + 'author' => $authorInfo, + ]; +} + +Template::render('manage.users.notes', [ + 'manage_notes' => $notes, + 'manage_notes_pagination' => $pagination, + 'manage_notes_filter_user' => $filterUser, +]); diff --git a/public-legacy/manage/users/user.php b/public-legacy/manage/users/user.php index 6cef5c2..f5e785b 100644 --- a/public-legacy/manage/users/user.php +++ b/public-legacy/manage/users/user.php @@ -25,6 +25,7 @@ try { $canEdit = $currentUser->hasAuthorityOver($userInfo); $canEditPerms = $canEdit && perms_check_user(MSZ_PERMS_USER, $currentUserId, MSZ_PERM_USER_MANAGE_PERMS); +$canManageNotes = perms_check_user(MSZ_PERMS_USER, $currentUserId, MSZ_PERM_USER_MANAGE_NOTES); $permissions = manage_perms_list(perms_get_user_raw($userId)); if(CSRF::validateRequest() && $canEdit) { @@ -187,5 +188,6 @@ Template::render('manage.users.user', [ 'manage_roles' => UserRole::all(true), 'can_edit_user' => $canEdit, 'can_edit_perms' => $canEdit && $canEditPerms, + 'can_manage_notes' => $canManageNotes, 'permissions' => $permissions ?? [], ]); diff --git a/public-legacy/manage/users/warnings.php b/public-legacy/manage/users/warnings.php index abf4558..57d3eed 100644 --- a/public-legacy/manage/users/warnings.php +++ b/public-legacy/manage/users/warnings.php @@ -152,7 +152,6 @@ Template::render('manage.users.warnings', [ 'user' => $warningsUserInfo, 'durations' => $warningDurations, 'types' => [ - UserWarning::TYPE_NOTE => 'Note', UserWarning::TYPE_WARN => 'Warning', UserWarning::TYPE_BAHN => 'Ban', ], diff --git a/src/AuditLog/AuditLogInfo.php b/src/AuditLog/AuditLogInfo.php index 169f2b6..3151d33 100644 --- a/src/AuditLog/AuditLogInfo.php +++ b/src/AuditLog/AuditLogInfo.php @@ -76,51 +76,55 @@ class AuditLogInfo { 'PERSONAL_SESSION_DESTROY_ALL' => 'Ended all personal sessions.', 'PERSONAL_DATA_DOWNLOAD' => 'Downloaded archive of account data.', - 'PASSWORD_RESET' => 'Successfully used the password reset form to change password.', + 'PASSWORD_RESET' => 'Successfully used the password reset form to change password.', - 'CHANGELOG_ENTRY_CREATE' => 'Created a new changelog entry #%d.', - 'CHANGELOG_ENTRY_EDIT' => 'Edited changelog entry #%d.', - 'CHANGELOG_TAG_ADD' => 'Added tag #%2$d to changelog entry #%1$d.', - 'CHANGELOG_TAG_REMOVE' => 'Removed tag #%2$d from changelog entry #%1$d.', - 'CHANGELOG_TAG_CREATE' => 'Created new changelog tag #%d.', - 'CHANGELOG_TAG_EDIT' => 'Edited changelog tag #%d.', - 'CHANGELOG_TAG_DELETE' => 'Deleted changelog tag #%d.', - 'CHANGELOG_ACTION_CREATE' => 'Created new changelog action #%d.', - 'CHANGELOG_ACTION_EDIT' => 'Edited changelog action #%d.', + 'CHANGELOG_ENTRY_CREATE' => 'Created a new changelog entry #%d.', + 'CHANGELOG_ENTRY_EDIT' => 'Edited changelog entry #%d.', + 'CHANGELOG_TAG_ADD' => 'Added tag #%2$d to changelog entry #%1$d.', + 'CHANGELOG_TAG_REMOVE' => 'Removed tag #%2$d from changelog entry #%1$d.', + 'CHANGELOG_TAG_CREATE' => 'Created new changelog tag #%d.', + 'CHANGELOG_TAG_EDIT' => 'Edited changelog tag #%d.', + 'CHANGELOG_TAG_DELETE' => 'Deleted changelog tag #%d.', + 'CHANGELOG_ACTION_CREATE' => 'Created new changelog action #%d.', + 'CHANGELOG_ACTION_EDIT' => 'Edited changelog action #%d.', - 'COMMENT_ENTRY_DELETE' => 'Deleted comment #%d.', - 'COMMENT_ENTRY_DELETE_MOD' => 'Deleted comment #%d by user #%d %s.', - 'COMMENT_ENTRY_RESTORE' => 'Restored comment #%d by user #%d %s.', + 'COMMENT_ENTRY_DELETE' => 'Deleted comment #%d.', + 'COMMENT_ENTRY_DELETE_MOD' => 'Deleted comment #%d by user #%d %s.', + 'COMMENT_ENTRY_RESTORE' => 'Restored comment #%d by user #%d %s.', - 'NEWS_POST_CREATE' => 'Created news post #%d.', - 'NEWS_POST_EDIT' => 'Edited news post #%d.', - 'NEWS_POST_DELETE' => 'Deleted news post #%d.', - 'NEWS_CATEGORY_CREATE' => 'Created news category #%d.', - 'NEWS_CATEGORY_EDIT' => 'Edited news category #%d.', - 'NEWS_CATEGORY_DELETE' => 'Deleted news category #%d.', + 'NEWS_POST_CREATE' => 'Created news post #%d.', + 'NEWS_POST_EDIT' => 'Edited news post #%d.', + 'NEWS_POST_DELETE' => 'Deleted news post #%d.', + 'NEWS_CATEGORY_CREATE' => 'Created news category #%d.', + 'NEWS_CATEGORY_EDIT' => 'Edited news category #%d.', + 'NEWS_CATEGORY_DELETE' => 'Deleted news category #%d.', - 'FORUM_POST_EDIT' => 'Edited forum post #%d.', - 'FORUM_POST_DELETE' => 'Deleted forum post #%d.', - 'FORUM_POST_RESTORE' => 'Restored forum post #%d.', - 'FORUM_POST_NUKE' => 'Nuked forum post #%d.', + 'FORUM_POST_EDIT' => 'Edited forum post #%d.', + 'FORUM_POST_DELETE' => 'Deleted forum post #%d.', + 'FORUM_POST_RESTORE' => 'Restored forum post #%d.', + 'FORUM_POST_NUKE' => 'Nuked forum post #%d.', - 'FORUM_TOPIC_DELETE' => 'Deleted forum topic #%d.', - 'FORUM_TOPIC_RESTORE' => 'Restored forum topic #%d.', - 'FORUM_TOPIC_NUKE' => 'Nuked forum topic #%d.', - 'FORUM_TOPIC_BUMP' => 'Manually bumped forum topic #%d.', - 'FORUM_TOPIC_LOCK' => 'Locked forum topic #%d.', - 'FORUM_TOPIC_UNLOCK' => 'Unlocked forum topic #%d.', - 'FORUM_TOPIC_REDIR_CREATE' => 'Created redirect for topic #%d.', - 'FORUM_TOPIC_REDIR_REMOVE' => 'Removed redirect for topic #%d.', + 'FORUM_TOPIC_DELETE' => 'Deleted forum topic #%d.', + 'FORUM_TOPIC_RESTORE' => 'Restored forum topic #%d.', + 'FORUM_TOPIC_NUKE' => 'Nuked forum topic #%d.', + 'FORUM_TOPIC_BUMP' => 'Manually bumped forum topic #%d.', + 'FORUM_TOPIC_LOCK' => 'Locked forum topic #%d.', + 'FORUM_TOPIC_UNLOCK' => 'Unlocked forum topic #%d.', + 'FORUM_TOPIC_REDIR_CREATE' => 'Created redirect for topic #%d.', + 'FORUM_TOPIC_REDIR_REMOVE' => 'Removed redirect for topic #%d.', - 'CONFIG_CREATE' => 'Created config value with name "%s".', - 'CONFIG_UPDATE' => 'Updated config value with name "%s".', - 'CONFIG_DELETE' => 'Deleted config value with name "%s".', + 'CONFIG_CREATE' => 'Created config value with name "%s".', + 'CONFIG_UPDATE' => 'Updated config value with name "%s".', + 'CONFIG_DELETE' => 'Deleted config value with name "%s".', - 'EMOTICON_CREATE' => 'Created emoticon #%s.', - 'EMOTICON_EDIT' => 'Edited emoticon #%s.', - 'EMOTICON_DELETE' => 'Deleted emoticon #%s.', - 'EMOTICON_ORDER' => 'Changed order of emoticon #%s.', - 'EMOTICON_ALIAS' => 'Added alias "%2$s" to emoticon #%1$s.', + 'EMOTICON_CREATE' => 'Created emoticon #%s.', + 'EMOTICON_EDIT' => 'Edited emoticon #%s.', + 'EMOTICON_DELETE' => 'Deleted emoticon #%s.', + 'EMOTICON_ORDER' => 'Changed order of emoticon #%s.', + 'EMOTICON_ALIAS' => 'Added alias "%2$s" to emoticon #%1$s.', + + 'MOD_NOTE_CREATE' => 'Added moderator note #%d to user #%d.', + 'MOD_NOTE_UPDATE' => 'Edited moderator note #%d on user #%d.', + 'MOD_NOTE_DELETE' => 'Removed moderator note #%d from user #%d.', ]; } diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index 33f96c2..c41e60d 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -11,6 +11,7 @@ use Misuzu\Config\IConfig; use Misuzu\Emoticons\Emotes; use Misuzu\News\News; use Misuzu\SharpChat\SharpChatRoutes; +use Misuzu\Users\ModNotes; use Misuzu\Users\User; use Index\Data\IDbConnection; use Index\Data\Migration\IDbMigrationRepo; @@ -37,6 +38,7 @@ class MisuzuContext { private Comments $comments; private LoginAttempts $loginAttempts; private RecoveryTokens $recoveryTokens; + private ModNotes $modNotes; public function __construct(IDbConnection $dbConn, IConfig $config) { $this->dbConn = $dbConn; @@ -48,6 +50,7 @@ class MisuzuContext { $this->comments = new Comments($this->dbConn); $this->loginAttempts = new LoginAttempts($this->dbConn); $this->recoveryTokens = new RecoveryTokens($this->dbConn); + $this->modNotes = new ModNotes($this->dbConn); } public function getDbConn(): IDbConnection { @@ -103,6 +106,10 @@ class MisuzuContext { return $this->recoveryTokens; } + public function getModNotes(): ModNotes { + return $this->modNotes; + } + public function createAuditLog(string $action, array $params = [], User|string|null $userInfo = null): void { if($userInfo === null && User::hasCurrent()) $userInfo = User::getCurrent(); diff --git a/src/Users/ModNoteInfo.php b/src/Users/ModNoteInfo.php new file mode 100644 index 0000000..0a17fdf --- /dev/null +++ b/src/Users/ModNoteInfo.php @@ -0,0 +1,68 @@ +noteId = (string)$result->getInteger(0); + $this->userId = (string)$result->getInteger(1); + $this->authorId = $result->isNull(2) ? null : (string)$result->getInteger(2); + $this->created = $result->getInteger(3); + $this->title = $result->getString(4); + $this->body = $result->getString(5); + } + + public function getId(): string { + return $this->noteId; + } + + public function getUserId(): string { + return $this->userId; + } + + public function hasAuthorId(): bool { + return $this->authorId !== null; + } + + public function getAuthorId(): ?string { + return $this->authorId; + } + + public function getCreatedTime(): int { + return $this->created; + } + + public function getCreatedAt(): DateTime { + return DateTime::fromUnixTimeSeconds($this->created); + } + + public function getTitle(): string { + return $this->title; + } + + public function hasBody(): bool { + return trim($this->body) !== ''; + } + + public function getBody(): string { + return $this->body; + } + + public function getFirstParagraph(): string { + $index = mb_strpos($this->body, "\n"); + return $index === false ? $this->body : mb_substr($this->body, 0, $index); + } + + public function hasMoreParagraphs(): bool { + return mb_strpos($this->body, "\n") !== false; + } +} diff --git a/src/Users/ModNotes.php b/src/Users/ModNotes.php new file mode 100644 index 0000000..289487b --- /dev/null +++ b/src/Users/ModNotes.php @@ -0,0 +1,175 @@ +dbConn = $dbConn; + $this->cache = new DbStatementCache($dbConn); + } + + public function countNotes( + User|string|null $userInfo = null, + User|string|null $authorInfo = null + ): int { + if($userInfo instanceof User) + $userInfo = (string)$userInfo->getId(); + if($authorInfo instanceof User) + $authorInfo = (string)$authorInfo->getId(); + + $hasUserInfo = $userInfo !== null; + $hasAuthorInfo = $authorInfo !== null; + + $args = 0; + $query = 'SELECT COUNT(*) FROM msz_users_modnotes'; + if($hasUserInfo) { + ++$args; + $query .= ' WHERE user_id = ?'; + } + if($hasAuthorInfo) + $query .= sprintf(' %s author_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); + + $args = 0; + $stmt = $this->cache->get($query); + if($hasUserInfo) + $stmt->addParameter(++$args, $userInfo); + if($hasAuthorInfo) + $stmt->addParameter(++$args, $authorInfo); + $stmt->execute(); + + $result = $stmt->getResult(); + $count = 0; + + if($result->next()) + $count = $result->getInteger(0); + + return $count; + } + + public function getNotes( + User|string|null $userInfo = null, + User|string|null $authorInfo = null, + ?Pagination $pagination = null + ): array { + if($userInfo instanceof User) + $userInfo = (string)$userInfo->getId(); + if($authorInfo instanceof User) + $authorInfo = (string)$authorInfo->getId(); + + $hasUserInfo = $userInfo !== null; + $hasAuthorInfo = $authorInfo !== null; + $hasPagination = $pagination !== null; + + $args = 0; + $query = 'SELECT note_id, user_id, author_id, UNIX_TIMESTAMP(note_created), note_title, note_body FROM msz_users_modnotes'; + if($hasUserInfo) { + ++$args; + $query .= ' WHERE user_id = ?'; + } + if($hasAuthorInfo) + $query .= sprintf(' %s author_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); + $query .= ' ORDER BY note_created DESC'; + if($hasPagination) + $query .= ' LIMIT ? OFFSET ?'; + + $args = 0; + $stmt = $this->cache->get($query); + if($hasUserInfo) + $stmt->addParameter(++$args, $userInfo); + if($hasAuthorInfo) + $stmt->addParameter(++$args, $authorInfo); + if($hasPagination) { + $stmt->addParameter(++$args, $pagination->getRange()); + $stmt->addParameter(++$args, $pagination->getOffset()); + } + $stmt->execute(); + + $result = $stmt->getResult(); + $notes = []; + + while($result->next()) + $notes[] = new ModNoteInfo($result); + + return $notes; + } + + public function getNote(string $noteId): ModNoteInfo { + $stmt = $this->cache->get('SELECT note_id, user_id, author_id, UNIX_TIMESTAMP(note_created), note_title, note_body FROM msz_users_modnotes WHERE note_id = ?'); + $stmt->addParameter(1, $noteId); + $stmt->execute(); + $result = $stmt->getResult(); + + if(!$result->next()) + throw new RuntimeException('No note with ID $noteId found.'); + + return new ModNoteInfo($result); + } + + public function createNote( + User|string $userInfo, + string $title, + string $body, + User|string|null $authorInfo = null + ): ModNoteInfo { + if($userInfo instanceof User) + $userInfo = (string)$userInfo->getId(); + if($authorInfo instanceof User) + $authorInfo = (string)$authorInfo->getId(); + + $stmt = $this->cache->get('INSERT INTO msz_users_modnotes (user_id, author_id, note_title, note_body) VALUES (?, ?, ?, ?)'); + $stmt->addParameter(1, $userInfo); + $stmt->addParameter(2, $authorInfo); + $stmt->addParameter(3, $title); + $stmt->addParameter(4, $body); + $stmt->execute(); + + return $this->getNote((string)$this->dbConn->getLastInsertId()); + } + + public function deleteNote(ModNoteInfo|string|array $noteInfos): void { + if(!is_array($noteInfos)) + $noteInfos = [$noteInfos]; + + $stmt = $this->cache->get(sprintf( + 'DELETE FROM msz_users_modnotes WHERE note_id IN (%s)', + DbTools::prepareListString($noteInfos) + )); + + $args = 0; + foreach($noteInfos as $noteInfo) { + if($noteInfo instanceof ModNoteInfo) + $noteInfo = $noteInfo->getId(); + elseif(!is_string($noteInfos)) + throw new InvalidArgumentException('$noteInfos must be strings of instances of ModNoteInfo'); + + $stmt->addParameter(++$args, $noteInfo); + } + + $stmt->execute(); + } + + public function updateNote( + ModNoteInfo|string $noteInfo, + ?string $title = null, + ?string $body = null + ): void { + if($noteInfo instanceof ModNoteInfo) + $noteInfo = $noteInfo->getId(); + + $stmt = $this->cache->get('UPDATE msz_users_modnotes SET note_title = COALESCE(?, note_title), note_body = COALESCE(?, note_body) WHERE note_id = ?'); + $stmt->addParameter(1, $title); + $stmt->addParameter(2, $body); + $stmt->addParameter(3, $noteInfo); + $stmt->execute(); + } +} diff --git a/src/Users/UserWarning.php b/src/Users/UserWarning.php index 4965ffa..5b173cf 100644 --- a/src/Users/UserWarning.php +++ b/src/Users/UserWarning.php @@ -7,9 +7,6 @@ use Misuzu\DB; use Misuzu\Pagination; class UserWarning { - // Informational notes on profile, only show up for moderators - public const TYPE_NOTE = 0; - // Warning, only shows up to moderators and the user themselves public const TYPE_WARN = 1; @@ -17,7 +14,7 @@ class UserWarning { // User will still be able to log in and change certain details but can no longer partake in community things public const TYPE_BAHN = 3; - private const TYPES = [self::TYPE_NOTE, self::TYPE_WARN, self::TYPE_BAHN]; + private const TYPES = [self::TYPE_WARN, self::TYPE_BAHN]; private const VISIBLE_TO_STAFF = self::TYPES; private const VISIBLE_TO_USER = [self::TYPE_WARN, self::TYPE_BAHN]; @@ -127,7 +124,6 @@ class UserWarning { } public function getType(): int { return $this->warning_type; } - public function isNote(): bool { return $this->getType() === self::TYPE_NOTE; } public function isWarning(): bool { return $this->getType() === self::TYPE_WARN; } public function isBan(): bool { return $this->getType() === self::TYPE_BAHN; } diff --git a/src/manage.php b/src/manage.php index 35257d1..888bab0 100644 --- a/src/manage.php +++ b/src/manage.php @@ -20,6 +20,8 @@ function manage_get_menu(int $userId): array { $menu['Users & Roles']['Users'] = url('manage-users'); if(perms_check_user(MSZ_PERMS_USER, $userId, MSZ_PERM_USER_MANAGE_ROLES)) $menu['Users & Roles']['Roles'] = url('manage-roles'); + if(perms_check_user(MSZ_PERMS_USER, $userId, MSZ_PERM_USER_MANAGE_NOTES)) + $menu['Users & Roles']['Notes'] = url('manage-users-notes'); if(perms_check_user(MSZ_PERMS_USER, $userId, MSZ_PERM_USER_MANAGE_WARNINGS)) $menu['Users & Roles']['Warnings'] = url('manage-users-warnings'); @@ -194,9 +196,14 @@ function manage_perms_list(array $rawPerms): array { ], [ 'section' => 'manage-warnings', - 'title' => 'Can manage bans, warnings and notes.', + 'title' => 'Can manage bans and warnings.', 'perm' => MSZ_PERM_USER_MANAGE_WARNINGS, ], + [ + 'section' => 'manage-notes', + 'title' => 'Can manage user notes.', + 'perm' => MSZ_PERM_USER_MANAGE_NOTES, + ], ], ], [ diff --git a/src/perms.php b/src/perms.php index af83f10..07c86ae 100644 --- a/src/perms.php +++ b/src/perms.php @@ -21,6 +21,7 @@ define('MSZ_PERM_USER_MANAGE_PERMS', 0x00400000); define('MSZ_PERM_USER_MANAGE_REPORTS', 0x00800000); define('MSZ_PERM_USER_MANAGE_WARNINGS', 0x01000000); //define('MSZ_PERM_USER_MANAGE_BLACKLISTS', 0x02000000); // Replaced with MSZ_PERM_GENERAL_MANAGE_BLACKLIST +define('MSZ_PERM_USER_MANAGE_NOTES', 0x04000000); define('MSZ_PERMS_CHANGELOG', 'changelog'); define('MSZ_PERM_CHANGELOG_MANAGE_CHANGES', 0x00000001); diff --git a/src/url.php b/src/url.php index f544d34..5aabb13 100644 --- a/src/url.php +++ b/src/url.php @@ -126,6 +126,9 @@ define('MSZ_URLS', [ 'manage-user' => ['/manage/users/user.php', ['u' => '']], 'manage-users-warnings' => ['/manage/users/warnings.php', ['u' => '']], 'manage-users-warning-delete' => ['/manage/users/warnings.php', ['w' => '', 'delete' => '1', 'csrf' => '{csrf}']], + 'manage-users-notes' => ['/manage/users/notes.php', ['u' => '']], + 'manage-users-note' => ['/manage/users/note.php', ['n' => '', 'u' => '']], + 'manage-users-note-delete' => ['/manage/users/note.php', ['n' => '', 'delete' => '1', 'csrf' => '{csrf}']], 'manage-roles' => ['/manage/users/roles.php'], 'manage-role' => ['/manage/users/role.php', ['r' => '']], diff --git a/templates/manage/users/note.twig b/templates/manage/users/note.twig new file mode 100644 index 0000000..d7e1ddd --- /dev/null +++ b/templates/manage/users/note.twig @@ -0,0 +1,73 @@ +{% extends 'manage/users/master.twig' %} +{% from 'macros.twig' import pagination, container_title, avatar %} +{% from '_layout/input.twig' import input_hidden, input_csrf, input_text, input_checkbox, input_select %} + +{% block manage_content %} +
+ {{ container_title(' ' ~ (note_new ? ('Adding note to ' ~ note_user.username) : ('Editing note #' ~ note_info.id))) }} + +
+ {{ input_csrf() }} + +
+
+
{{ note_info.title|default() }}
+
{{ input_text('mn_title', '', note_info.title|default(), 'text', '', true, null, 1) }}
+
+
+ + + {% if not note_new %} + + + {% endif %} +
+
+ +
+ {% if note_author is not null %} + + {% endif %} + {% if not note_new %} +
+
+
+ +
+
+ {% endif %} + +
+ + {% if not note_new and note_info.hasBody %} +
+ {{ note_info.body|parse_text(2)|raw }} +
+ {% else %} +
+ This note has no additional content. +
+ {% endif %} + +
+ +
+
+
+{% endblock %} diff --git a/templates/manage/users/notes.twig b/templates/manage/users/notes.twig new file mode 100644 index 0000000..028ef20 --- /dev/null +++ b/templates/manage/users/notes.twig @@ -0,0 +1,99 @@ +{% extends 'manage/users/master.twig' %} +{% from 'macros.twig' import pagination, container_title, avatar %} + +{% set notes_pagination = pagination(manage_notes_pagination, url('manage-users-notes', {'user': manage_notes_filter_user.id|default(0)})) %} +{% set notes_filtering = manage_notes_filter_user is not null %} + +{% block manage_content %} +
+ {{ container_title(' User Notes') }} + +
+ Private moderator notes, can be used for anything you'd like to share internally. + {% if not notes_filtering %}Filter by a user to create a new note.{% endif %} +
+ + {% if notes_pagination|trim|length > 0 %} +
+ {{ notes_pagination }} +
+ {% endif %} + + {% if notes_filtering %} +
+ New Note +
+ {% endif %} + +
+ {% for note in manage_notes %} +
+
+ +
+ + +
+
+
+ {% if note.author is not null %} + + {% endif %} +
+
+
+ +
+
+
+
Regarding
+ + + {% if not notes_filtering %} +
+ Filter +
+ {% endif %} +
+
+ {% if note.info.hasBody %} +
+ {% if notes_filtering %} + {{ note.info.body|parse_text(2)|raw }} + {% else %} + {{ note.info.firstParagraph|parse_text(2)|raw }} + {% endif %} +
+ {% else %} +
+ This note has no additional content. +
+ {% endif %} + {% if not notes_filtering and note.info.hasMoreParagraphs %} + + {% endif %} +
+ {% endfor %} +
+ + {% if notes_pagination|trim|length > 0 %} +
+ {{ notes_pagination }} +
+ {% endif %} +
+{% endblock %} diff --git a/templates/manage/users/user.twig b/templates/manage/users/user.twig index 1551159..39b28dc 100644 --- a/templates/manage/users/user.twig +++ b/templates/manage/users/user.twig @@ -132,6 +132,20 @@ {% endif %} + {% if can_manage_notes %} +
+ {{ container_title('Manage notes') }} + +
+

Can you tell I'm just tacking this on?

+
+ + +
+ {% endif %} + {% if current_user.super %}
{{ container_title('Send test e-mail to ' ~ user_info.username ~ ' (' ~ user_info.id ~ ')') }}