Added new moderator notes system.

This commit is contained in:
flash 2023-07-25 14:40:31 +00:00
parent ee304af133
commit 3299d73df2
20 changed files with 918 additions and 61 deletions

View file

@ -160,21 +160,6 @@ html {
@include home/landingv2.css; @include home/landingv2.css;
@include manage/_manage.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/container.css;
@include news/feeds.css; @include news/feeds.css;

View file

@ -23,3 +23,21 @@
width: 100%; 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;

View file

@ -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;
}

View file

@ -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);
}

View file

@ -0,0 +1,46 @@
<?php
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class AddModeratorNotesTable_20230724_201010 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->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');
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
if(!User::hasCurrent() || !perms_check_user(MSZ_PERMS_USER, User::getCurrent()->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,
]);

View file

@ -0,0 +1,63 @@
<?php
namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
if(!User::hasCurrent() || !perms_check_user(MSZ_PERMS_USER, User::getCurrent()->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,
]);

View file

@ -25,6 +25,7 @@ try {
$canEdit = $currentUser->hasAuthorityOver($userInfo); $canEdit = $currentUser->hasAuthorityOver($userInfo);
$canEditPerms = $canEdit && perms_check_user(MSZ_PERMS_USER, $currentUserId, MSZ_PERM_USER_MANAGE_PERMS); $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)); $permissions = manage_perms_list(perms_get_user_raw($userId));
if(CSRF::validateRequest() && $canEdit) { if(CSRF::validateRequest() && $canEdit) {
@ -187,5 +188,6 @@ Template::render('manage.users.user', [
'manage_roles' => UserRole::all(true), 'manage_roles' => UserRole::all(true),
'can_edit_user' => $canEdit, 'can_edit_user' => $canEdit,
'can_edit_perms' => $canEdit && $canEditPerms, 'can_edit_perms' => $canEdit && $canEditPerms,
'can_manage_notes' => $canManageNotes,
'permissions' => $permissions ?? [], 'permissions' => $permissions ?? [],
]); ]);

View file

@ -152,7 +152,6 @@ Template::render('manage.users.warnings', [
'user' => $warningsUserInfo, 'user' => $warningsUserInfo,
'durations' => $warningDurations, 'durations' => $warningDurations,
'types' => [ 'types' => [
UserWarning::TYPE_NOTE => 'Note',
UserWarning::TYPE_WARN => 'Warning', UserWarning::TYPE_WARN => 'Warning',
UserWarning::TYPE_BAHN => 'Ban', UserWarning::TYPE_BAHN => 'Ban',
], ],

View file

@ -76,51 +76,55 @@ class AuditLogInfo {
'PERSONAL_SESSION_DESTROY_ALL' => 'Ended all personal sessions.', 'PERSONAL_SESSION_DESTROY_ALL' => 'Ended all personal sessions.',
'PERSONAL_DATA_DOWNLOAD' => 'Downloaded archive of account data.', '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_CREATE' => 'Created a new changelog entry #%d.',
'CHANGELOG_ENTRY_EDIT' => 'Edited changelog entry #%d.', 'CHANGELOG_ENTRY_EDIT' => 'Edited changelog entry #%d.',
'CHANGELOG_TAG_ADD' => 'Added tag #%2$d to changelog entry #%1$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_REMOVE' => 'Removed tag #%2$d from changelog entry #%1$d.',
'CHANGELOG_TAG_CREATE' => 'Created new changelog tag #%d.', 'CHANGELOG_TAG_CREATE' => 'Created new changelog tag #%d.',
'CHANGELOG_TAG_EDIT' => 'Edited changelog tag #%d.', 'CHANGELOG_TAG_EDIT' => 'Edited changelog tag #%d.',
'CHANGELOG_TAG_DELETE' => 'Deleted changelog tag #%d.', 'CHANGELOG_TAG_DELETE' => 'Deleted changelog tag #%d.',
'CHANGELOG_ACTION_CREATE' => 'Created new changelog action #%d.', 'CHANGELOG_ACTION_CREATE' => 'Created new changelog action #%d.',
'CHANGELOG_ACTION_EDIT' => 'Edited changelog action #%d.', 'CHANGELOG_ACTION_EDIT' => 'Edited changelog action #%d.',
'COMMENT_ENTRY_DELETE' => 'Deleted comment #%d.', 'COMMENT_ENTRY_DELETE' => 'Deleted comment #%d.',
'COMMENT_ENTRY_DELETE_MOD' => 'Deleted comment #%d by user #%d %s.', 'COMMENT_ENTRY_DELETE_MOD' => 'Deleted comment #%d by user #%d %s.',
'COMMENT_ENTRY_RESTORE' => 'Restored 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_CREATE' => 'Created news post #%d.',
'NEWS_POST_EDIT' => 'Edited news post #%d.', 'NEWS_POST_EDIT' => 'Edited news post #%d.',
'NEWS_POST_DELETE' => 'Deleted news post #%d.', 'NEWS_POST_DELETE' => 'Deleted news post #%d.',
'NEWS_CATEGORY_CREATE' => 'Created news category #%d.', 'NEWS_CATEGORY_CREATE' => 'Created news category #%d.',
'NEWS_CATEGORY_EDIT' => 'Edited news category #%d.', 'NEWS_CATEGORY_EDIT' => 'Edited news category #%d.',
'NEWS_CATEGORY_DELETE' => 'Deleted news category #%d.', 'NEWS_CATEGORY_DELETE' => 'Deleted news category #%d.',
'FORUM_POST_EDIT' => 'Edited forum post #%d.', 'FORUM_POST_EDIT' => 'Edited forum post #%d.',
'FORUM_POST_DELETE' => 'Deleted forum post #%d.', 'FORUM_POST_DELETE' => 'Deleted forum post #%d.',
'FORUM_POST_RESTORE' => 'Restored forum post #%d.', 'FORUM_POST_RESTORE' => 'Restored forum post #%d.',
'FORUM_POST_NUKE' => 'Nuked forum post #%d.', 'FORUM_POST_NUKE' => 'Nuked forum post #%d.',
'FORUM_TOPIC_DELETE' => 'Deleted forum topic #%d.', 'FORUM_TOPIC_DELETE' => 'Deleted forum topic #%d.',
'FORUM_TOPIC_RESTORE' => 'Restored forum topic #%d.', 'FORUM_TOPIC_RESTORE' => 'Restored forum topic #%d.',
'FORUM_TOPIC_NUKE' => 'Nuked forum topic #%d.', 'FORUM_TOPIC_NUKE' => 'Nuked forum topic #%d.',
'FORUM_TOPIC_BUMP' => 'Manually bumped forum topic #%d.', 'FORUM_TOPIC_BUMP' => 'Manually bumped forum topic #%d.',
'FORUM_TOPIC_LOCK' => 'Locked forum topic #%d.', 'FORUM_TOPIC_LOCK' => 'Locked forum topic #%d.',
'FORUM_TOPIC_UNLOCK' => 'Unlocked forum topic #%d.', 'FORUM_TOPIC_UNLOCK' => 'Unlocked forum topic #%d.',
'FORUM_TOPIC_REDIR_CREATE' => 'Created redirect for topic #%d.', 'FORUM_TOPIC_REDIR_CREATE' => 'Created redirect for topic #%d.',
'FORUM_TOPIC_REDIR_REMOVE' => 'Removed redirect for topic #%d.', 'FORUM_TOPIC_REDIR_REMOVE' => 'Removed redirect for topic #%d.',
'CONFIG_CREATE' => 'Created config value with name "%s".', 'CONFIG_CREATE' => 'Created config value with name "%s".',
'CONFIG_UPDATE' => 'Updated config value with name "%s".', 'CONFIG_UPDATE' => 'Updated config value with name "%s".',
'CONFIG_DELETE' => 'Deleted config value with name "%s".', 'CONFIG_DELETE' => 'Deleted config value with name "%s".',
'EMOTICON_CREATE' => 'Created emoticon #%s.', 'EMOTICON_CREATE' => 'Created emoticon #%s.',
'EMOTICON_EDIT' => 'Edited emoticon #%s.', 'EMOTICON_EDIT' => 'Edited emoticon #%s.',
'EMOTICON_DELETE' => 'Deleted emoticon #%s.', 'EMOTICON_DELETE' => 'Deleted emoticon #%s.',
'EMOTICON_ORDER' => 'Changed order of emoticon #%s.', 'EMOTICON_ORDER' => 'Changed order of emoticon #%s.',
'EMOTICON_ALIAS' => 'Added alias "%2$s" to emoticon #%1$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.',
]; ];
} }

View file

@ -11,6 +11,7 @@ use Misuzu\Config\IConfig;
use Misuzu\Emoticons\Emotes; use Misuzu\Emoticons\Emotes;
use Misuzu\News\News; use Misuzu\News\News;
use Misuzu\SharpChat\SharpChatRoutes; use Misuzu\SharpChat\SharpChatRoutes;
use Misuzu\Users\ModNotes;
use Misuzu\Users\User; use Misuzu\Users\User;
use Index\Data\IDbConnection; use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigrationRepo; use Index\Data\Migration\IDbMigrationRepo;
@ -37,6 +38,7 @@ class MisuzuContext {
private Comments $comments; private Comments $comments;
private LoginAttempts $loginAttempts; private LoginAttempts $loginAttempts;
private RecoveryTokens $recoveryTokens; private RecoveryTokens $recoveryTokens;
private ModNotes $modNotes;
public function __construct(IDbConnection $dbConn, IConfig $config) { public function __construct(IDbConnection $dbConn, IConfig $config) {
$this->dbConn = $dbConn; $this->dbConn = $dbConn;
@ -48,6 +50,7 @@ class MisuzuContext {
$this->comments = new Comments($this->dbConn); $this->comments = new Comments($this->dbConn);
$this->loginAttempts = new LoginAttempts($this->dbConn); $this->loginAttempts = new LoginAttempts($this->dbConn);
$this->recoveryTokens = new RecoveryTokens($this->dbConn); $this->recoveryTokens = new RecoveryTokens($this->dbConn);
$this->modNotes = new ModNotes($this->dbConn);
} }
public function getDbConn(): IDbConnection { public function getDbConn(): IDbConnection {
@ -103,6 +106,10 @@ class MisuzuContext {
return $this->recoveryTokens; return $this->recoveryTokens;
} }
public function getModNotes(): ModNotes {
return $this->modNotes;
}
public function createAuditLog(string $action, array $params = [], User|string|null $userInfo = null): void { public function createAuditLog(string $action, array $params = [], User|string|null $userInfo = null): void {
if($userInfo === null && User::hasCurrent()) if($userInfo === null && User::hasCurrent())
$userInfo = User::getCurrent(); $userInfo = User::getCurrent();

68
src/Users/ModNoteInfo.php Normal file
View file

@ -0,0 +1,68 @@
<?php
namespace Misuzu\Users;
use Index\DateTime;
use Index\Data\IDbResult;
class ModNoteInfo {
private string $noteId;
private string $userId;
private ?string $authorId;
private int $created;
private string $title;
private string $body;
public function __construct(IDbResult $result) {
$this->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;
}
}

175
src/Users/ModNotes.php Normal file
View file

@ -0,0 +1,175 @@
<?php
namespace Misuzu\Users;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbStatementCache;
use Index\Data\DbTools;
use Index\Data\IDbConnection;
use Misuzu\Pagination;
use Misuzu\Users\User;
class ModNotes {
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->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();
}
}

View file

@ -7,9 +7,6 @@ use Misuzu\DB;
use Misuzu\Pagination; use Misuzu\Pagination;
class UserWarning { 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 // Warning, only shows up to moderators and the user themselves
public const TYPE_WARN = 1; 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 // 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; 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_STAFF = self::TYPES;
private const VISIBLE_TO_USER = [self::TYPE_WARN, self::TYPE_BAHN]; 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 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 isWarning(): bool { return $this->getType() === self::TYPE_WARN; }
public function isBan(): bool { return $this->getType() === self::TYPE_BAHN; } public function isBan(): bool { return $this->getType() === self::TYPE_BAHN; }

View file

@ -20,6 +20,8 @@ function manage_get_menu(int $userId): array {
$menu['Users & Roles']['Users'] = url('manage-users'); $menu['Users & Roles']['Users'] = url('manage-users');
if(perms_check_user(MSZ_PERMS_USER, $userId, MSZ_PERM_USER_MANAGE_ROLES)) if(perms_check_user(MSZ_PERMS_USER, $userId, MSZ_PERM_USER_MANAGE_ROLES))
$menu['Users & Roles']['Roles'] = url('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)) if(perms_check_user(MSZ_PERMS_USER, $userId, MSZ_PERM_USER_MANAGE_WARNINGS))
$menu['Users & Roles']['Warnings'] = url('manage-users-warnings'); $menu['Users & Roles']['Warnings'] = url('manage-users-warnings');
@ -194,9 +196,14 @@ function manage_perms_list(array $rawPerms): array {
], ],
[ [
'section' => 'manage-warnings', 'section' => 'manage-warnings',
'title' => 'Can manage bans, warnings and notes.', 'title' => 'Can manage bans and warnings.',
'perm' => MSZ_PERM_USER_MANAGE_WARNINGS, 'perm' => MSZ_PERM_USER_MANAGE_WARNINGS,
], ],
[
'section' => 'manage-notes',
'title' => 'Can manage user notes.',
'perm' => MSZ_PERM_USER_MANAGE_NOTES,
],
], ],
], ],
[ [

View file

@ -21,6 +21,7 @@ define('MSZ_PERM_USER_MANAGE_PERMS', 0x00400000);
define('MSZ_PERM_USER_MANAGE_REPORTS', 0x00800000); define('MSZ_PERM_USER_MANAGE_REPORTS', 0x00800000);
define('MSZ_PERM_USER_MANAGE_WARNINGS', 0x01000000); 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_BLACKLISTS', 0x02000000); // Replaced with MSZ_PERM_GENERAL_MANAGE_BLACKLIST
define('MSZ_PERM_USER_MANAGE_NOTES', 0x04000000);
define('MSZ_PERMS_CHANGELOG', 'changelog'); define('MSZ_PERMS_CHANGELOG', 'changelog');
define('MSZ_PERM_CHANGELOG_MANAGE_CHANGES', 0x00000001); define('MSZ_PERM_CHANGELOG_MANAGE_CHANGES', 0x00000001);

View file

@ -126,6 +126,9 @@ define('MSZ_URLS', [
'manage-user' => ['/manage/users/user.php', ['u' => '<user>']], 'manage-user' => ['/manage/users/user.php', ['u' => '<user>']],
'manage-users-warnings' => ['/manage/users/warnings.php', ['u' => '<user>']], 'manage-users-warnings' => ['/manage/users/warnings.php', ['u' => '<user>']],
'manage-users-warning-delete' => ['/manage/users/warnings.php', ['w' => '<warning>', 'delete' => '1', 'csrf' => '{csrf}']], 'manage-users-warning-delete' => ['/manage/users/warnings.php', ['w' => '<warning>', 'delete' => '1', 'csrf' => '{csrf}']],
'manage-users-notes' => ['/manage/users/notes.php', ['u' => '<user>']],
'manage-users-note' => ['/manage/users/note.php', ['n' => '<note>', 'u' => '<user>']],
'manage-users-note-delete' => ['/manage/users/note.php', ['n' => '<note>', 'delete' => '1', 'csrf' => '{csrf}']],
'manage-roles' => ['/manage/users/roles.php'], 'manage-roles' => ['/manage/users/roles.php'],
'manage-role' => ['/manage/users/role.php', ['r' => '<role>']], 'manage-role' => ['/manage/users/role.php', ['r' => '<role>']],

View file

@ -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 %}
<div class="container">
{{ container_title('<i class="fas fa-sticky-note fa-fw"></i> ' ~ (note_new ? ('Adding note to ' ~ note_user.username) : ('Editing note #' ~ note_info.id))) }}
<form method="post" enctype="multipart/form-data" action="{{ url('manage-users-note', note_new ? {'user': note_user.id} : {'note': note_info.id}) }}" class="manage__note {{ note_new ? 'manage__note--edit' : 'manage__note--view' }}">
{{ input_csrf() }}
<div class="manage__note__header">
<div class="manage__note__title">
<div class="manage__note__title__text manage__note--viewing">{{ note_info.title|default() }}</div>
<div class="manage__note--editing">{{ input_text('mn_title', '', note_info.title|default(), 'text', '', true, null, 1) }}</div>
</div>
<div class="manage__note__actions">
<a href="javascript:;" onclick="this.closest('.manage__note').classList.remove('manage__note--view'); this.closest('.manage__note').classList.add('manage__note--edit');" title="Edit" class="input__button input__button--autosize manage__notes__item__action manage__note--viewing"><i class="fas fa-pen fa-fw"></i></a>
<button class="input__button input__button--autosize manage__notes__item__action manage__note--editing" title="{{ note_new ? 'Save Note' : 'Save Changes' }}" tabindex="3"><i class="fas fa-save fa-fw"></i></button>
{% if not note_new %}
<button class="input__button input__button--autosize input__button--destroy manage__notes__item__action manage__note--editing" type="reset" title="Discard Changes" tabindex="4"><i class="fas fa-slash fa-fw"></i></button>
<a href="javascript:;" onclick="this.closest('.manage__note').classList.remove('manage__note--edit'); this.closest('.manage__note').classList.add('manage__note--view');" title="View" class="input__button input__button--autosize manage__notes__item__action manage__note--editing" tabindex="5"><i class="fas fa-eye fa-fw"></i></a>
{% endif %}
</div>
</div>
<div class="manage__note__attributes">
{% if note_author is not null %}
<div class="manage__note__attribute manage__notes__item__author" style="--user-colour: {{ note_author.colour }}">
<div class="manage__note__author__prefix">Created by</div>
<div class="manage__note__author__avatar">
<a href="{{ url('user-profile', {'user': note_author.id}) }}">{{ avatar(note_author.id, 20, note_author.username) }}</a>
</div>
<div class="manage__note__author__name">
<a href="{{ url('user-profile', {'user': note_author.id}) }}">{{ note_author.username }}</a>
</div>
</div>
{% endif %}
{% if not note_new %}
<div class="manage__note__attribute manage__note__created">
<div class="manage__note__created__icon"><i class="fas fa-clock"></i></div>
<div class="manage__note__created__time">
<time datetime="{{ note_info.createdTime|date('c') }}" title="{{ note_info.createdTime|date('r') }}">{{ note_info.createdTime|time_format }}</time>
</div>
</div>
{% endif %}
<div class="manage__note__attribute manage__note__user" style="--user-colour: {{ note_user.colour }}">
<div class="manage__note__user__prefix">Regarding</div>
<div class="manage__note__user__avatar">
<a href="{{ url('manage-user', {'user': note_user.id}) }}">{{ avatar(note_user.id, 20, note_user.username) }}</a>
</div>
<div class="manage__note__user__name">
<a href="{{ url('manage-user', {'user': note_user.id}) }}">{{ note_user.username }}</a>
</div>
</div>
</div>
{% if not note_new and note_info.hasBody %}
<div class="manage__note__body markdown manage__note--viewing">
{{ note_info.body|parse_text(2)|raw }}
</div>
{% else %}
<div class="manage__note__nobody manage__note--viewing">
This note has no additional content.
</div>
{% endif %}
<div class="manage__note__editor manage__note--editing">
<textarea name="mn_body" class="input__textarea" tabindex="2">{{ note_info.body|default() }}</textarea>
</div>
</form>
</div>
{% endblock %}

View file

@ -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 %}
<div class="container manage__notes">
{{ container_title('<i class="fas fa-sticky-note fa-fw"></i> User Notes') }}
<div class="manage__description">
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 %}
</div>
{% if notes_pagination|trim|length > 0 %}
<div class="manage__notes__pagination">
{{ notes_pagination }}
</div>
{% endif %}
{% if notes_filtering %}
<div class="manage__notes__actions">
<a href="{{ url('manage-users-note', {'user': manage_notes_filter_user.id}) }}" class="input__button">New Note</a>
</div>
{% endif %}
<div class="manage__notes__list">
{% for note in manage_notes %}
<div class="manage__notes__item">
<div class="manage__notes__item__header">
<div class="manage__notes__item__title"><a href="{{ url('manage-users-note', {'note': note.info.id}) }}">{{ note.info.title }}</a></div>
<div class="manage__notes__item__actions">
<a href="{{ url('manage-users-note', {'note': note.info.id}) }}" title="View/Edit" class="input__button input__button--autosize manage__notes__item__action"><i class="fas fa-pen fa-fw"></i></a>
<a href="{{ url('manage-users-note-delete', {'note': note.info.id}) }}" title="Delete" class="input__button input__button--autosize input__button--destroy manage__notes__item__action" onclick="return confirm('Are you sure?');"><i class="fas fa-times fa-fw"></i></a>
</div>
</div>
<div class="manage__notes__item__attributes">
{% if note.author is not null %}
<div class="manage__notes__item__attribute manage__notes__item__author" style="--user-colour: {{ note.author.colour }}">
<div class="manage__notes__item__author__prefix">Created by</div>
<div class="manage__notes__item__author__avatar">
<a href="{{ url('user-profile', {'user': note.author.id}) }}">{{ avatar(note.author.id, 20, note.author.username) }}</a>
</div>
<div class="manage__notes__item__author__name">
<a href="{{ url('user-profile', {'user': note.author.id}) }}">{{ note.author.username }}</a>
</div>
</div>
{% endif %}
<div class="manage__notes__item__attribute manage__notes__item__created">
<div class="manage__notes__item__created__icon"><i class="fas fa-clock"></i></div>
<div class="manage__notes__item__created__time">
<time datetime="{{ note.info.createdTime|date('c') }}" title="{{ note.info.createdTime|date('r') }}">{{ note.info.createdTime|time_format }}</time>
</div>
</div>
<div class="manage__notes__item__attribute manage__notes__item__user" style="--user-colour: {{ note.user.colour }}">
<div class="manage__notes__item__user__prefix">Regarding</div>
<div class="manage__notes__item__user__avatar">
<a href="{{ url('manage-user', {'user': note.user.id}) }}">{{ avatar(note.user.id, 20, note.user.username) }}</a>
</div>
<div class="manage__notes__item__user__name">
<a href="{{ url('manage-user', {'user': note.user.id}) }}">{{ note.user.username }}</a>
</div>
{% if not notes_filtering %}
<div class="manage__notes__item__user__filter">
<a href="{{ url('manage-users-notes', {'user': note.user.id}) }}">Filter</a>
</div>
{% endif %}
</div>
</div>
{% if note.info.hasBody %}
<div class="manage__notes__item__body markdown">
{% if notes_filtering %}
{{ note.info.body|parse_text(2)|raw }}
{% else %}
{{ note.info.firstParagraph|parse_text(2)|raw }}
{% endif %}
</div>
{% else %}
<div class="manage__notes__item__nobody">
This note has no additional content.
</div>
{% endif %}
{% if not notes_filtering and note.info.hasMoreParagraphs %}
<div class="manage__notes__item__continue">
<a href="{{ url('manage-users-note', {'note': note.info.id}) }}">Continue reading</a>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% if notes_pagination|trim|length > 0 %}
<div class="manage__notes__pagination">
{{ notes_pagination }}
</div>
{% endif %}
</div>
{% endblock %}

View file

@ -132,6 +132,20 @@
{% endif %} {% endif %}
</form> </form>
{% if can_manage_notes %}
<div class="container manage__user__container">
{{ container_title('Manage notes') }}
<div class="container__content">
<p>Can you tell I'm just tacking this on?</p>
</div>
<div class="manage__user__buttons">
<a href="{{ url('manage-users-notes', {'user': user_info.id}) }}" class="input__button manage__user__button">View/Edit Notes</a>
</div>
</div>
{% endif %}
{% if current_user.super %} {% if current_user.super %}
<form method="post" action="{{ url('manage-user', {'user': user_info.id}) }}" class="container manage__user__container"> <form method="post" action="{{ url('manage-user', {'user': user_info.id}) }}" class="container manage__user__container">
{{ container_title('Send test e-mail to ' ~ user_info.username ~ ' (' ~ user_info.id ~ ')') }} {{ container_title('Send test e-mail to ' ~ user_info.username ~ ' (' ~ user_info.id ~ ')') }}