Rewrote user warnings backend.

This commit is contained in:
flash 2023-07-26 22:43:50 +00:00
parent 86432616c6
commit 2231cd8124
24 changed files with 702 additions and 666 deletions

View File

@ -176,7 +176,7 @@ html {
@include profile/header.css;
@include profile/profile.css;
@include profile/signature.css;
@include profile/warning.css;
@include profile/warnings.css;
@include search/anchor.css;
@include search/categories.css;

View File

@ -43,3 +43,5 @@
@include manage/user-item.css;
@include manage/user.css;
@include manage/users.css;
@include manage/warning.css;
@include manage/warnings.css;

View File

@ -1,7 +1,3 @@
.manage__ban {
/**/
}
.manage__ban__field {
margin: 2px;
margin-bottom: 8px;

View File

@ -0,0 +1,37 @@
.manage__warning__field {
margin: 2px;
margin-bottom: 8px;
}
.manage__warning__title {
font-size: 1.4em;
line-height: 1.5em;
padding: 0 4px;
}
.manage__warning__desc {
font-size: .9em;
line-height: 1.5em;
font-style: italic;
border-bottom: 1px solid var(--accent-colour);
padding: 2px 4px;
margin-bottom: 1px;
}
.manage__warning__body {
padding: 2px;
width: 100%;
}
.manage__warning__body textarea {
min-width: 100%;
max-width: 100%;
width: 100%;
min-height: 100px;
}
.manage__warning__actions {
display: flex;
justify-content: center;
padding: 10px;
padding-top: 0;
}

View File

@ -0,0 +1,91 @@
.manage__warnings__pagination {
margin: 2px;
}
.manage__warnings__actions {
display: flex;
gap: 2px;
margin: 2px;
}
.manage__warnings__item {
padding: 0 2px;
margin: 2px;
border-top: 1px solid var(--accent-colour);
}
.manage__warnings__item:not(:last-child) {
border-bottom: 1px solid var(--accent-colour);
}
.manage__warnings__item__header {
display: flex;
overflow: hidden;
align-items: center;
}
.manage__warnings__item__attributes {
flex-grow: 1;
flex-shrink: 1;
display: flex;
gap: 12px;
margin: 0 4px;
flex-wrap: wrap;
}
.manage__warnings__item__attribute {
display: flex;
gap: 4px;
align-items: center;
}
.manage__warnings__item__created__icon {
font-size: 16px;
}
.manage__warnings__item__actions {
display: flex;
flex-grow: 0;
flex-shrink: 0;
gap: 1px;
padding: 1px;
margin: 1px;
}
.manage__warnings__item__action {
width: 36px;
height: 36px;
}
.manage__warnings__item__author a,
.manage__warnings__item__user a {
color: inherit;
text-decoration: none;
}
.manage__warnings__item__author__name a,
.manage__warnings__item__user__name a {
font-weight: bold;
display: inline-block;
padding-top: 2px;
border-bottom: 2px solid var(--user-colour, #fff);
}
.manage__warnings__item__user__filter a {
padding: 2px 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.2);
transition: background .2s;
}
.manage__warnings__item__user__filter a:hover,
.manage__warnings__item__user__filter a:focus {
background: rgba(255, 255, 255, 0.4);
}
.manage__warnings__item__user__filter a:active {
background: rgba(255, 255, 255, 0.1);
}
.manage__warnings__item__reason {
margin: 1px 4px;
padding: 2px 4px;
border-top: 1px solid var(--accent-colour);
}
.manage__warnings__item__reason p {
padding-left: 4px;
border-left: 2px solid var(--accent-colour);
}

View File

@ -1,135 +0,0 @@
.profile__warning {
margin: 2px;
border-radius: 2px;
border: 1px solid var(--accent-colour);
}
.profile__warning__container {
margin: 2px 0;
}
.profile__warning--warning {
--accent-colour: #666;
}
.profile__warning--ban {
--accent-colour: #c33;
}
.profile__warning--extendo {
margin: 4px;
}
.profile__warning__background {
background-color: var(--accent-colour);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.profile__warning__content {
background-color: var(--background-colour-translucent-9);
display: flex;
padding: 1px;
}
.profile__warning__type,
.profile__warning__created,
.profile__warning__duration {
display: inline-flex;
align-items: center;
justify-content: center;
}
.profile__warning__type {
min-width: 80px;
background-color: var(--accent-colour);
border-radius: 1px;
padding: 0 4px;
}
.profile__warning__created,
.profile__warning__duration {
min-width: 100px;
padding: 0 4px;
}
.profile__warning__note {
padding: 1px 4px;
flex: 1 1 auto;
}
.profile__warning__private {
border-top: 1px solid var(--accent-colour);
margin-top: 1px;
width: 100%;
opacity: .5;
transition: opacity .2s;
}
.profile__warning__private:hover,
.profile__warning__private:active,
.profile__warning__private:focus {
opacity: 1;
}
.profile__warning__tools {
display: flex;
padding-bottom: 1px;
}
.profile__warning__options {
flex: 1 1 auto;
display: flex;
justify-content: flex-end;
align-items: center;
}
.profile__warning__option {
padding: 2px 5px;
color: inherit;
text-decoration: none;
}
.profile__warning__user {
display: flex;
padding: 2px;
min-width: 300px;
}
.profile__warning__user__avatar {
width: 20px;
height: 20px;
}
.profile__warning__user__username {
padding: 0 5px;
min-width: 60px;
color: inherit;
text-decoration: none;
}
.profile__warning__user__username:hover,
.profile__warning__user__username:focus,
.profile__warning__user__username:active {
text-decoration: underline;
}
.profile__warning__user__ip {
display: inline-flex;
padding: 0 5px;
}
.profile__warning__user__ip:before { content: "("; }
.profile__warning__user__ip:after { content: ")"; }
@media (max-width: 800px) {
.profile__warning__content {
flex-wrap: wrap;
}
.profile__warning__tools {
flex-direction: column;
}
.profile__warning__options {
justify-content: flex-start;
}
}

View File

@ -0,0 +1,26 @@
.profile__warnings {
display: flex;
flex-direction: column;
padding: 2px 5px;
}
.profile__warnings__item {
padding-bottom: 5px;
}
.profile__warnings__item:not(:last-child) {
border-bottom: 1px solid #222;
}
.profile__warnings__datetime {
font-size: .9em;
line-height: 1.5em;
font-style: italic;
padding-top: 2px;
}
.profile__warnings__body {
padding: 0 5px;
}
.profile__warnings__body p {
line-height: 1.4em;
}

View File

@ -0,0 +1,43 @@
<?php
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class RedoWarningsTable_20230726_210150 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_users_warnings (
warn_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT(10) UNSIGNED NOT NULL,
mod_id INT(10) UNSIGNED NULL DEFAULT NULL,
warn_body TEXT NOT NULL,
warn_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (warn_id),
KEY users_warnings_user_foreign (user_id),
KEY users_warnings_mod_foreign (mod_id),
KEY users_warnings_created_index (warn_created),
CONSTRAINT users_warnings_user_foreign
FOREIGN KEY (user_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT users_warnings_mod_foreign
FOREIGN KEY (mod_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE SET NULL
) ENGINE=InnoDB COLLATE=utf8mb4_bin
');
// migrate existing warnings, public and private note have been merged but that's fine in prod
// still specifying type = 1 as well even though that should be the only type remaining
$conn->execute('
INSERT INTO msz_users_warnings (user_id, mod_id, warn_body, warn_created)
SELECT user_id, issuer_id, TRIM(CONCAT(COALESCE(warning_note, ""), "\n", COALESCE(warning_note_private, ""))), warning_created
FROM msz_user_warnings
WHERE warning_type = 1
');
// drop the old table with non-plural "user"
$conn->execute('DROP TABLE msz_user_warnings');
}
}

View File

@ -100,6 +100,5 @@ $durations = array_flip([
Template::render('manage.users.ban', [
'ban_user' => $userInfo,
'ban_mod' => $modInfo,
'ban_durations' => $durations,
]);

View File

@ -0,0 +1,54 @@
<?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_WARNINGS)) {
echo render_error(403);
return;
}
$warns = $msz->getWarnings();
if($_SERVER['REQUEST_METHOD'] === 'GET' && filter_has_var(INPUT_GET, 'delete')) {
if(CSRF::validateRequest()) {
try {
$warnInfo = $warns->getWarning((string)filter_input(INPUT_GET, 'w'));
} catch(RuntimeException $ex) {
echo render_error(404);
return;
}
$warns->deleteWarnings($warnInfo);
$msz->createAuditLog('WARN_DELETE', [$warnInfo->getId(), $warnInfo->getUserId()]);
url_redirect('manage-users-warnings', ['user' => $warnInfo->getUserId()]);
} else render_error(403);
return;
}
try {
$userInfo = User::byId((int)filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT));
} catch(RuntimeException $ex) {
echo render_error(404);
return;
}
$modInfo = User::getCurrent();
while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
$body = trim((string)filter_input(INPUT_POST, 'uw_body'));
Template::set('warn_value_body', $body);
$warnInfo = $warns->createWarning(
$userInfo, $body, modInfo: $modInfo
);
$msz->createAuditLog('WARN_CREATE', [$warnInfo->getId(), $userInfo->getId()]);
url_redirect('manage-users-warnings', ['user' => $userInfo->getId()]);
return;
}
Template::render('manage.users.warning', [
'warn_user' => $userInfo,
]);

View File

@ -2,115 +2,62 @@
namespace Misuzu;
use RuntimeException;
use InvalidArgumentException;
use Misuzu\Users\User;
use Misuzu\Users\UserWarning;
if(!User::hasCurrent() || !perms_check_user(MSZ_PERMS_USER, User::getCurrent()->getId(), MSZ_PERM_USER_MANAGE_WARNINGS)) {
echo render_error(403);
return;
}
$notices = [];
$currentUser = User::getCurrent();
$currentUserId = $currentUser->getId();
$userInfos = [
(string)User::getCurrent()->getId() => User::getCurrent(),
];
if(!empty($_POST['lookup']) && is_string($_POST['lookup'])) {
$filterUser = null;
if(filter_has_var(INPUT_GET, 'u')) {
$filterUserId = (int)filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT);
try {
$userId = User::byUsername((string)filter_input(INPUT_POST, 'lookup'))->getId();
$filterUser = User::byId($filterUserId);
$userInfos[(string)$filterUser->getId()] = $filterUser;
} catch(RuntimeException $ex) {
$userId = 0;
}
url_redirect('manage-users-warnings', ['user' => $userId]);
return;
}
// instead of just kinda taking $_GET['w'] this should really fetch the info from the database
// and make sure that the user has authority
if(!empty($_GET['delete'])) {
try {
UserWarning::byId((int)filter_input(INPUT_GET, 'w', FILTER_SANITIZE_NUMBER_INT))->delete();
} catch(RuntimeException $ex) {}
redirect($_SERVER['HTTP_REFERER'] ?? url('manage-users-warnings'));
return;
}
if(!empty($_POST['warning']) && is_array($_POST['warning'])) {
try {
$warningsUserInfo = User::byId((int)($_POST['warning']['user'] ?? 0));
$warningsUser = $warningsUserInfo->getId();
if(!$currentUser->hasAuthorityOver($warningsUserInfo))
$notices[] = 'You do not have authority over this user.';
} catch(RuntimeException $ex) {
$notices[] = 'This user doesn\'t exist.';
}
if(empty($notices) && !empty($warningsUserInfo)) {
try {
$warningInfo = UserWarning::create(
$warningsUserInfo,
$currentUser,
$_POST['warning']['note'],
$_POST['warning']['private']
);
} catch(InvalidArgumentException $ex) {
$notices[] = $ex->getMessage();
} catch(RuntimeException $ex) {
$notices[] = 'Warning creation failed.';
}
echo render_error(404);
return;
}
}
if(empty($warningsUser))
$warningsUser = max(0, (int)($_GET['u'] ?? 0));
$warns = $msz->getWarnings();
$pagination = new Pagination($warns->countWarnings(userInfo: $filterUser), 10);
if(empty($warningsUserInfo))
try {
$warningsUserInfo = User::byId($warningsUser);
} catch(RuntimeException $ex) {
$warningsUserInfo = null;
}
$warningsPagination = new Pagination(UserWarning::countAll($warningsUserInfo), 10);
if(!$warningsPagination->hasValidOffset()) {
if(!$pagination->hasValidOffset()) {
echo render_error(404);
return;
}
// calling array_flip since the input_select macro wants value => display, but this looks cuter
$warningDurations = array_flip([
'Pick a duration...' => 0,
'5 Minutes' => 60 * 5,
'15 Minutes' => 60 * 15,
'30 Minutes' => 60 * 30,
'45 Minutes' => 60 * 45,
'1 Hour' => 60 * 60,
'2 Hours' => 60 * 60 * 2,
'3 Hours' => 60 * 60 * 3,
'6 Hours' => 60 * 60 * 6,
'12 Hours' => 60 * 60 * 12,
'1 Day' => 60 * 60 * 24,
'2 Days' => 60 * 60 * 24 * 2,
'1 Week' => 60 * 60 * 24 * 7,
'2 Weeks' => 60 * 60 * 24 * 7 * 2,
'1 Month' => 60 * 60 * 24 * 365 / 12,
'3 Months' => 60 * 60 * 24 * 365 / 12 * 3,
'6 Months' => 60 * 60 * 24 * 365 / 12 * 6,
'9 Months' => 60 * 60 * 24 * 365 / 12 * 9,
'1 Year' => 60 * 60 * 24 * 365,
'Permanent' => -1,
'Until (YYYY-MM-DD) ->' => -100,
'Until (Seconds) ->' => -200,
'Until (strtotime) ->' => -300,
]);
$warnList = [];
$warnInfos = $warns->getWarnings(userInfo: $filterUser, pagination: $pagination);
foreach($warnInfos as $warnInfo) {
if(array_key_exists($warnInfo->getUserId(), $userInfos))
$userInfo = $userInfos[$warnInfo->getUserId()];
else
$userInfos[$warnInfo->getUserId()] = $userInfo = User::byId((int)$warnInfo->getUserId());
if(!$warnInfo->hasModId())
$modInfo = null;
elseif(array_key_exists($warnInfo->getModId(), $userInfos))
$modInfo = $userInfos[$warnInfo->getModId()];
else
$userInfos[$warnInfo->getModId()] = $modInfo = User::byId((int)$warnInfo->getModId());
$warnList[] = [
'info' => $warnInfo,
'user' => $userInfo,
'mod' => $modInfo,
];
}
Template::render('manage.users.warnings', [
'warnings' => [
'notices' => $notices,
'pagination' => $warningsPagination,
'list' => UserWarning::all($warningsUserInfo, $warningsPagination),
'user' => $warningsUserInfo,
],
'manage_warns' => $warnList,
'manage_warns_pagination' => $pagination,
'manage_warns_filter_user' => $filterUser,
]);

View File

@ -8,7 +8,6 @@ use Misuzu\Parsers\Parser;
use Misuzu\Profile\ProfileFields;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
use Misuzu\Users\UserWarning;
use Misuzu\Users\Assets\UserBackgroundAsset;
$userId = !empty($_GET['u']) && is_string($_GET['u']) ? trim($_GET['u']) : 0;
@ -376,65 +375,62 @@ switch($profileMode) {
case '':
$template = 'profile.index';
$warnings = UserWarning::byProfile($profileUser, $currentUser);
Template::set([
'profile_warnings' => $warnings,
'profile_warnings_view_private' => $canManageWarnings,
'profile_warnings_can_manage' => $canManageWarnings,
]);
if(!$viewingAsGuest) {
Template::set('profile_warnings', $msz->getWarnings()->getWarningsWithDefaultBacklog($profileUser));
if(!$viewingAsGuest && (!$isBanned || $canEdit)) {
$activeCategoryStats = forum_get_user_most_active_category_info($profileUser->getId());
$activeCategoryInfo = empty($activeCategoryStats->forum_id) ? null : forum_get($activeCategoryStats->forum_id);
if((!$isBanned || $canEdit)) {
$activeCategoryStats = forum_get_user_most_active_category_info($profileUser->getId());
$activeCategoryInfo = empty($activeCategoryStats->forum_id) ? null : forum_get($activeCategoryStats->forum_id);
$activeTopicStats = forum_get_user_most_active_topic_info($profileUser->getId());
$activeTopicInfo = empty($activeTopicStats->topic_id) ? null : forum_topic_get($activeTopicStats->topic_id);
$activeTopicStats = forum_get_user_most_active_topic_info($profileUser->getId());
$activeTopicInfo = empty($activeTopicStats->topic_id) ? null : forum_topic_get($activeTopicStats->topic_id);
$profileFieldValues = $profileFields->getFieldValues($profileUser);
$profileFieldInfos = $profileFieldInfos ?? $profileFields->getFields(fieldValueInfos: $isEditing ? null : $profileFieldValues);
$profileFieldFormats = $profileFields->getFieldFormats(fieldValueInfos: $profileFieldValues);
$profileFieldValues = $profileFields->getFieldValues($profileUser);
$profileFieldInfos = $profileFieldInfos ?? $profileFields->getFields(fieldValueInfos: $isEditing ? null : $profileFieldValues);
$profileFieldFormats = $profileFields->getFieldFormats(fieldValueInfos: $profileFieldValues);
$profileFieldRawValues = [];
$profileFieldLinkValues = [];
$profileFieldDisplayValues = [];
$profileFieldRawValues = [];
$profileFieldLinkValues = [];
$profileFieldDisplayValues = [];
// using field infos as the basis for now, uses the correct ordering
foreach($profileFieldInfos as $fieldInfo) {
unset($fieldValue);
// using field infos as the basis for now, uses the correct ordering
foreach($profileFieldInfos as $fieldInfo) {
unset($fieldValue);
foreach($profileFieldValues as $fieldValueTest)
if($fieldValueTest->getFieldId() === $fieldInfo->getId()) {
$fieldValue = $fieldValueTest;
break;
}
$fieldName = $fieldInfo->getName();
if(isset($fieldValue)) {
foreach($profileFieldFormats as $fieldFormatTest)
if($fieldFormatTest->getId() === $fieldValue->getFormatId()) {
$fieldFormat = $fieldFormatTest;
foreach($profileFieldValues as $fieldValueTest)
if($fieldValueTest->getFieldId() === $fieldInfo->getId()) {
$fieldValue = $fieldValueTest;
break;
}
$profileFieldRawValues[$fieldName] = $fieldValue->getValue();
$profileFieldDisplayValues[$fieldName] = $fieldFormat->formatDisplay($fieldValue->getValue());
if($fieldFormat->hasLinkFormat())
$profileFieldLinkValues[$fieldName] = $fieldFormat->formatLink($fieldValue->getValue());
}
}
$fieldName = $fieldInfo->getName();
Template::set([
'profile_active_category_stats' => $activeCategoryStats,
'profile_active_category_info' => $activeCategoryInfo,
'profile_active_topic_stats' => $activeTopicStats,
'profile_active_topic_info' => $activeTopicInfo,
'profile_fields_infos' => $profileFieldInfos,
'profile_fields_raw_values' => $profileFieldRawValues,
'profile_fields_display_values' => $profileFieldDisplayValues,
'profile_fields_link_values' => $profileFieldLinkValues,
]);
if(isset($fieldValue)) {
foreach($profileFieldFormats as $fieldFormatTest)
if($fieldFormatTest->getId() === $fieldValue->getFormatId()) {
$fieldFormat = $fieldFormatTest;
break;
}
$profileFieldRawValues[$fieldName] = $fieldValue->getValue();
$profileFieldDisplayValues[$fieldName] = $fieldFormat->formatDisplay($fieldValue->getValue());
if($fieldFormat->hasLinkFormat())
$profileFieldLinkValues[$fieldName] = $fieldFormat->formatLink($fieldValue->getValue());
}
}
Template::set([
'profile_active_category_stats' => $activeCategoryStats,
'profile_active_category_info' => $activeCategoryInfo,
'profile_active_topic_stats' => $activeTopicStats,
'profile_active_topic_info' => $activeTopicInfo,
'profile_fields_infos' => $profileFieldInfos,
'profile_fields_raw_values' => $profileFieldRawValues,
'profile_fields_display_values' => $profileFieldDisplayValues,
'profile_fields_link_values' => $profileFieldLinkValues,
]);
}
}
break;
}

View File

@ -129,5 +129,8 @@ class AuditLogInfo {
'BAN_CREATE' => 'Added ban #%d to user #%d.',
'BAN_DELETE' => 'Removed ban #%d from user #%d.',
'WARN_CREATE' => 'Added warning #%d to user #%d.',
'WARN_DELETE' => 'Removed warning #%d from user #%d.',
];
}

View File

@ -15,6 +15,7 @@ use Misuzu\Users\Bans;
use Misuzu\Users\BanInfo;
use Misuzu\Users\ModNotes;
use Misuzu\Users\User;
use Misuzu\Users\Warnings;
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigrationRepo;
use Index\Data\Migration\DbMigrationManager;
@ -42,6 +43,7 @@ class MisuzuContext {
private RecoveryTokens $recoveryTokens;
private ModNotes $modNotes;
private Bans $bans;
private Warnings $warnings;
public function __construct(IDbConnection $dbConn, IConfig $config) {
$this->dbConn = $dbConn;
@ -55,6 +57,7 @@ class MisuzuContext {
$this->recoveryTokens = new RecoveryTokens($this->dbConn);
$this->modNotes = new ModNotes($this->dbConn);
$this->bans = new Bans($this->dbConn);
$this->warnings = new Warnings($this->dbConn);
}
public function getDbConn(): IDbConnection {
@ -118,6 +121,10 @@ class MisuzuContext {
return $this->bans;
}
public function getWarnings(): Warnings {
return $this->warnings;
}
private array $activeBansCache = [];
public function tryGetActiveBan(User|string|null $userInfo = null): ?BanInfo {

View File

@ -1,176 +0,0 @@
<?php
namespace Misuzu\Users;
use InvalidArgumentException;
use RuntimeException;
use Misuzu\DB;
use Misuzu\Pagination;
class UserWarning {
private const PROFILE_BACKLOG = 90;
// Database fields
private $warning_id = -1;
private $user_id = -1;
private $user_ip = '::1';
private $issuer_id = -1;
private $issuer_ip = '::1';
private $warning_created = null;
private $warning_duration = null;
private $warning_type = 0;
private $warning_note = '';
private $warning_note_private = '';
private $user = null;
private $issuer = null;
public const TABLE = 'user_warnings';
private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
private const SELECT = '%1$s.`warning_id`, %1$s.`user_id`, %1$s.`issuer_id`, %1$s.`warning_type`, %1$s.`warning_note`, %1$s.`warning_note_private`'
. ', UNIX_TIMESTAMP(%1$s.`warning_created`) AS `warning_created`'
. ', UNIX_TIMESTAMP(%1$s.`warning_duration`) AS `warning_duration`'
. ', INET6_NTOA(%1$s.`user_ip`) AS `user_ip`'
. ', INET6_NTOA(%1$s.`issuer_ip`) AS `issuer_ip`';
public function getId(): int {
return $this->warning_id;
}
public function getUserId(): int {
return $this->user_id;
}
public function getUser(): User {
if($this->user === null)
$this->user = User::byId($this->getUserId());
return $this->user;
}
public function getUserRemoteAddress(): string {
return $this->user_ip;
}
public function getIssuerId(): int {
return $this->issuer_id;
}
public function getIssuer(): User {
if($this->issuer === null)
$this->issuer = User::byId($this->getIssuerId());
return $this->issuer;
}
public function getIssuerRemoteAddress(): string {
return $this->issuer_ip;
}
public function getCreatedTime(): int {
return $this->warning_created === null ? -1 : $this->warning_created;
}
public function getType(): int {
return $this->warning_type;
}
public function getPublicNote(): string {
return $this->warning_note;
}
public function getPrivateNote(): string {
return $this->warning_note_private ?? '';
}
public function hasPrivateNote(): bool {
return !empty($this->warning_note_private);
}
public function delete(): void {
DB::prepare('DELETE FROM `' . DB::PREFIX . self::TABLE . '` WHERE `warning_id` = :warning')
->bind('warning', $this->warning_id)
->execute();
}
public static function create(
User $user,
User $issuer,
string $publicNote,
?string $privateNote = null,
?string $targetAddr = null,
?string $issuerAddr = null
): self {
$targetAddr ??= $user->getLastRemoteAddress();
$issuerAddr ??= $issuer->getLastRemoteAddress();
$warningId = DB::prepare(
'INSERT INTO `' . DB::PREFIX . self::TABLE . '` (`user_id`, `user_ip`, `issuer_id`, `issuer_ip`, `warning_created`, `warning_duration`, `warning_type`, `warning_note`, `warning_note_private`)'
. ' VALUES (:user, INET6_ATON(:user_addr), :issuer, INET6_ATON(:issuer_addr), NOW(), NULL, 1, :public_note, :private_note)'
) ->bind('user', $user->getId())
->bind('user_addr', $targetAddr)
->bind('issuer', $issuer->getId())
->bind('issuer_addr', $issuerAddr)
->bind('public_note', $publicNote)
->bind('private_note', $privateNote)
->executeGetId();
if($warningId < 1)
throw new RuntimeException('Failed to create new warning.');
return self::byId($warningId);
}
private static function countQueryBase(): string {
return sprintf(self::QUERY_SELECT, 'COUNT(*)');
}
public static function countAll(?User $user = null): int {
$getCount = DB::prepare(self::countQueryBase() . ($user === null ? '' : ' WHERE `user_id` = :user'));
if($user !== null)
$getCount->bind('user', $user->getId());
return (int)$getCount->fetchColumn();
}
private static function byQueryBase(): string {
return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
}
public static function byId(int $warningId): self {
$object = DB::prepare(
self::byQueryBase() . ' WHERE `warning_id` = :warning'
) ->bind('warning', $warningId)
->fetchObject(self::class);
if(!$object)
throw new RuntimeException('Not warning with that ID could be found.');
return $object;
}
public static function byProfile(User $user, ?User $viewer = null): array {
if($viewer === null
|| !($user->getId() === $viewer->getId()
|| perms_check_user(MSZ_PERMS_USER, $viewer->getId(), MSZ_PERM_USER_MANAGE_WARNINGS))
) return [];
$getObjects = DB::prepare(
self::byQueryBase()
. ' WHERE `user_id` = :user'
. ' AND `warning_created` >= NOW() - INTERVAL ' . self::PROFILE_BACKLOG . ' DAY'
. ' ORDER BY `warning_created` DESC'
);
$getObjects->bind('user', $user->getId());
return $getObjects->fetchObjects(self::class);
}
public static function all(?User $user = null, ?Pagination $pagination = null): array {
$query = self::byQueryBase()
. ($user === null ? '' : ' WHERE `user_id` = :user')
. ' ORDER BY `warning_created` DESC';
if($pagination !== null)
$query .= ' LIMIT :range OFFSET :offset';
$getObjects = DB::prepare($query);
if($user !== null)
$getObjects->bind('user', $user->getId());
if($pagination !== null)
$getObjects->bind('range', $pagination->getRange())
->bind('offset', $pagination->getOffset());
return $getObjects->fetchObjects(self::class);
}
}

57
src/Users/WarningInfo.php Normal file
View File

@ -0,0 +1,57 @@
<?php
namespace Misuzu\Users;
use Index\DateTime;
use Index\Data\IDbResult;
class WarningInfo {
private string $id;
private string $userId;
private ?string $modId;
private string $body;
private int $created;
public function __construct(IDbResult $result) {
$this->id = (string)$result->getInteger(0);
$this->userId = (string)$result->getInteger(1);
$this->modId = $result->isNull(2) ? null : (string)$result->getInteger(2);
$this->body = $result->getString(3);
$this->created = $result->getInteger(4);
}
public function getId(): string {
return $this->id;
}
public function getUserId(): string {
return $this->userId;
}
public function hasModId(): bool {
return $this->modId !== null;
}
public function getModId(): ?string {
return $this->modId;
}
public function hasBody(): bool {
return $this->body !== '';
}
public function getBody(): string {
return $this->body;
}
public function getBodyLines(): array {
return explode("\n", $this->body);
}
public function getCreatedTime(): int {
return $this->created;
}
public function getCreatedAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->created);
}
}

177
src/Users/Warnings.php Normal file
View File

@ -0,0 +1,177 @@
<?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;
// this system is currently kinda useless because it only silently shows up on profiles
// planning a notification system anyway so that should probably hook into
class Warnings {
public const VISIBLE_BACKLOG = 90 * 24 * 60 * 60;
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->dbConn = $dbConn;
$this->cache = new DbStatementCache($dbConn);
}
public function countWarnings(
User|string|null $userInfo = null,
?int $backlog = null
): int {
if($userInfo instanceof User)
$userInfo = (string)$userInfo->getId();
$hasUserInfo = $userInfo !== null;
$hasBacklog = $backlog !== null;
$args = 0;
$query = 'SELECT COUNT(*) FROM msz_users_warnings';
if($hasUserInfo) {
++$args;
$query .= ' WHERE user_id = ?';
}
if($hasBacklog) {
if($backlog < 1)
throw new InvalidArgumentException('$backlog must be either null to disable it or be greater than 0.');
$query .= sprintf(' %s warn_created > NOW() - INTERVAL ? SECOND', ++$args > 1 ? 'AND' : 'WHERE');
}
$args = 0;
$stmt = $this->cache->get($query);
if($hasUserInfo)
$stmt->addParameter(++$args, $userInfo);
if($hasBacklog)
$stmt->addParameter(++$args, $backlog);
$stmt->execute();
$result = $stmt->getResult();
$count = 0;
if($result->next())
$count = $result->getInteger(0);
return $count;
}
public function getWarningsWithDefaultBacklog(
User|string|null $userInfo = null,
?Pagination $pagination = null
): array {
return $this->getWarnings(
$userInfo,
self::VISIBLE_BACKLOG,
$pagination
);
}
public function getWarnings(
User|string|null $userInfo = null,
?int $backlog = null,
?Pagination $pagination = null
): array {
if($userInfo instanceof User)
$userInfo = (string)$userInfo->getId();
$hasUserInfo = $userInfo !== null;
$hasBacklog = $backlog !== null;
$hasPagination = $pagination !== null;
$args = 0;
$query = 'SELECT warn_id, user_id, mod_id, warn_body, UNIX_TIMESTAMP(warn_created) FROM msz_users_warnings';
if($hasUserInfo) {
++$args;
$query .= ' WHERE user_id = ?';
}
if($hasBacklog) {
if($backlog < 1)
throw new InvalidArgumentException('$backlog must be either null to disable it or be greater than 0.');
$query .= sprintf(' %s warn_created > NOW() - INTERVAL ? SECOND', ++$args > 1 ? 'AND' : 'WHERE');
}
$query .= ' ORDER BY warn_created DESC';
if($hasPagination)
$query .= ' LIMIT ? OFFSET ?';
$args = 0;
$stmt = $this->cache->get($query);
if($hasUserInfo)
$stmt->addParameter(++$args, $userInfo);
if($hasBacklog)
$stmt->addParameter(++$args, $backlog);
if($hasPagination) {
$stmt->addParameter(++$args, $pagination->getRange());
$stmt->addParameter(++$args, $pagination->getOffset());
}
$stmt->execute();
$result = $stmt->getResult();
$warns = [];
while($result->next())
$warns[] = new WarningInfo($result);
return $warns;
}
public function getWarning(string $warnId): WarningInfo {
$stmt = $this->cache->get('SELECT warn_id, user_id, mod_id, warn_body, UNIX_TIMESTAMP(warn_created) FROM msz_users_warnings WHERE warn_id = ?');
$stmt->addParameter(1, $warnId);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('Could not find warning info for ID $warnId.');
return new WarningInfo($result);
}
public function createWarning(
User|string $userInfo,
string $body,
User|string|null $modInfo
): WarningInfo {
if($userInfo instanceof User)
$userInfo = (string)$userInfo->getId();
if($modInfo instanceof User)
$modInfo = (string)$modInfo->getId();
$stmt = $this->cache->get('INSERT INTO msz_users_warnings (user_id, mod_id, warn_body) VALUES (?, ?, ?)');
$stmt->addParameter(1, $userInfo);
$stmt->addParameter(2, $modInfo);
$stmt->addParameter(3, $body);
$stmt->execute();
return $this->getWarning((string)$this->dbConn->getLastInsertId());
}
public function deleteWarnings(WarningInfo|string|array $warnInfos): void {
if(!is_array($warnInfos))
$warnInfos = [$warnInfos];
$stmt = $this->cache->get(sprintf(
'DELETE FROM msz_users_warnings WHERE warn_id IN (%s)',
DbTools::prepareListString($warnInfos)
));
$args = 0;
foreach($warnInfos as $warnInfo) {
if($warnInfo instanceof WarningInfo)
$warnInfo = $warnInfo->getId();
elseif(!is_string($warnInfo))
throw new InvalidArgumentException('$warnInfos must be strings of instances of WarningInfo.');
$stmt->addParameter(++$args, $warnInfo);
}
$stmt->execute();
}
}

View File

@ -125,7 +125,8 @@ define('MSZ_URLS', [
'manage-users' => ['/manage/users'],
'manage-user' => ['/manage/users/user.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' => ['/manage/users/warning.php', ['u' => '<user>']],
'manage-users-warning-delete' => ['/manage/users/warning.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}']],

View File

@ -4,7 +4,7 @@
{% 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))) }}
{{ container_title('<i class="fas fa-sticky-note fa-fw"></i> ' ~ (note_new ? ('Adding mod note to ' ~ note_user.username) : ('Editing mod 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() }}

View File

@ -6,7 +6,7 @@
{% block manage_content %}
<div class="container manage__notes">
{{ container_title('<i class="fas fa-sticky-note fa-fw"></i> User Notes') }}
{{ container_title('<i class="fas fa-sticky-note fa-fw"></i> Moderator Notes') }}
<div class="manage__description">
Private moderator notes, can be used for anything you'd like to share internally.

View File

@ -1,8 +1,25 @@
{% extends 'manage/users/master.twig' %}
{% from 'macros.twig' import pagination, container_title %}
{% from 'user/macros.twig' import user_profile_warning %}
{% from '_layout/input.twig' import input_text, input_csrf, input_select, input_hidden %}
{% 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-exclamation-circle fa-fw"></i> Issuing a warning to user #' ~ warn_user.id ~ ' ' ~ warn_user.username) }}
<form method="post" enctype="multipart/form-data" action="{{ url('manage-users-warning', {'user': warn_user.id}) }}" class="manage__warning">
{{ input_csrf() }}
<div class="manage__warning__field">
<div class="manage__warning__title">Warning Body</div>
<div class="manage__warning__desc">A concise explanation of what the user did to deserve this warning and what they should do to prevent further incidents. Please keep in mind that warnings remain publicly visible for 90 days both to the person receiving the warning and other logging in people viewing their profile.</div>
<div class="manage__warning__body">
<textarea name="uw_body" class="input__textarea" tabindex="1">{{ warn_value_body|default() }}</textarea>
</div>
</div>
<div class="manage__warning__actions">
<button class="input__button" tabindex="2">Issue warning</button>
</div>
</form>
</div>
{% endblock %}

View File

@ -1,92 +1,84 @@
{% extends 'manage/users/master.twig' %}
{% from 'macros.twig' import pagination, container_title %}
{% from 'user/macros.twig' import user_profile_warning %}
{% from '_layout/input.twig' import input_text, input_csrf, input_select, input_hidden %}
{% from 'macros.twig' import pagination, container_title, avatar %}
{% set warns_pagination = pagination(manage_warns_pagination, url('manage-users-warnings', {'user': manage_warns_filter_user.id|default(0)})) %}
{% set warns_filtering = manage_warns_filter_user is not null %}
{% block manage_content %}
<form class="container container--lazy" action="{{ url('manage-users-warnings') }}" method="post">
{{ container_title('<i class="fas fa-users fa-fw"></i> Filters') }}
{{ input_text('lookup', null, warnings.user.username|default(''), 'text', 'Enter a username') }}
<button class="input__button">Filter</button>
</form>
{% if warnings.notices|length > 0 %}
<div class="warning">
<div class="warning__content">
{% for notice in warnings.notices %}
{{ notice }}
{% endfor %}
</div>
</div>
{% endif %}
{% if warnings.user is not null %}
<form class="container container--lazy" method="post" action="">
{{ container_title('<i class="fas fa-user-shield fa-fw"></i> Warn ' ~ warnings.user.username) }}
{{ input_csrf() }}
{{ input_hidden('warning[user]', warnings.user.id) }}
{{ input_text('warning[note]', '', '', 'text', 'Public note') }}
<button class="input__button">Add</button><br>
<textarea class="input__textarea" name="warning[private]" placeholder="Private note"></textarea>
</form>
{% endif %}
<div class="container container--lazy">
<div class="container manage__warnings">
{{ container_title('<i class="fas fa-exclamation-circle fa-fw"></i> Warnings') }}
{% set warnpag = pagination(warnings.pagination, url('manage-users-warnings', {'user': warnings.user.id|default(0)})) %}
{{ warnpag }}
<div class="profile__warnings__container">
<div class="profile__warning profile__warning--extendo">
<div class="profile__warning__background"></div>
<div class="profile__warning__tools">
<div class="profile__warning__user">
<div class="profile__warning__user__username">
User
</div>
<div class="profile__warning__user__ip">
User IP
</div>
</div>
<div class="profile__warning__user">
<div class="profile__warning__user__username">
Issuer
</div>
<div class="profile__warning__user__ip">
Issuer IP
</div>
</div>
</div>
<div class="profile__warning__content">
<div class="profile__warning__type">
Type
</div>
<div class="profile__warning__created">
Created
</div>
<div class="profile__warning__duration">
Duration
</div>
<div class="profile__warning__note">
Note
</div>
</div>
</div>
{% for warning in warnings.list %}
{{ user_profile_warning(warning, true, true, csrf_token()) }}
{% endfor %}
<div class="manage__description">
List of user warnings.
{% if not warns_filtering %}Filter by a user to issue a new warning.{% endif %}
</div>
{{ warnpag }}
{% if warns_pagination|trim|length > 0 %}
<div class="manage__warnings__pagination">
{{ warns_pagination }}
</div>
{% endif %}
{% if warns_filtering %}
<div class="manage__warnings__actions">
<a href="{{ url('manage-users-warning', {'user': manage_warns_filter_user.id}) }}" class="input__button">Issue new Warning</a>
</div>
{% endif %}
<div class="manage__warnings__list">
{% for warn in manage_warns %}
<div class="manage__warnings__item">
<div class="manage__warnings__item__header">
<div class="manage__warnings__item__attributes">
{% if warn.mod is not null %}
<div class="manage__warnings__item__attribute manage__warnings__item__author" style="--user-colour: {{ warn.mod.colour }}">
<div class="manage__warnings__item__author__prefix">Issued by</div>
<div class="manage__warnings__item__author__avatar">
<a href="{{ url('user-profile', {'user': warn.mod.id}) }}">{{ avatar(warn.mod.id, 20, warn.mod.username) }}</a>
</div>
<div class="manage__warnings__item__author__name">
<a href="{{ url('user-profile', {'user': warn.mod.id}) }}">{{ warn.mod.username }}</a>
</div>
</div>
{% endif %}
<div class="manage__warnings__item__attribute manage__warnings__item__created">
<div class="manage__warnings__item__created__icon"><i class="fas fa-clock"></i></div>
<div class="manage__warnings__item__created__time">
<time datetime="{{ warn.info.createdTime|date('c') }}" title="{{ warn.info.createdTime|date('r') }}">{{ warn.info.createdTime|time_format }}</time>
</div>
</div>
<div class="manage__warnings__item__attribute manage__warnings__item__user" style="--user-colour: {{ warn.user.colour }}">
<div class="manage__warnings__item__user__prefix">Subject</div>
<div class="manage__warnings__item__user__avatar">
<a href="{{ url('manage-user', {'user': warn.user.id}) }}">{{ avatar(warn.user.id, 20, warn.user.username) }}</a>
</div>
<div class="manage__warnings__item__user__name">
<a href="{{ url('manage-user', {'user': warn.user.id}) }}">{{ warn.user.username }}</a>
</div>
{% if not warns_filtering %}
<div class="manage__warnings__item__user__filter">
<a href="{{ url('manage-users-warnings', {'user': warn.user.id}) }}">Filter</a>
</div>
{% endif %}
</div>
</div>
<div class="manage__warnings__item__actions">
<a href="{{ url('manage-users-warning-delete', {'warn': warn.info.id}) }}" title="Remove" class="input__button input__button--autosize input__button--destroy manage__warnings__item__action" onclick="return confirm('Are you sure?');"><i class="fas fa-times fa-fw"></i></a>
</div>
</div>
<div class="manage__warnings__item__reason">
{% for line in warn.info.bodyLines %}
<p>{{ line }}</p>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% if warns_pagination|trim|length > 0 %}
<div class="manage__warnings__pagination">
{{ warns_pagination }}
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -1,6 +1,5 @@
{% extends 'profile/master.twig' %}
{% from 'macros.twig' import container_title %}
{% from 'user/macros.twig' import user_profile_warning %}
{% from '_layout/input.twig' import input_hidden, input_csrf, input_text, input_checkbox, input_file, input_file_raw, input_select %}
{% if profile_user is defined %}
@ -81,7 +80,8 @@
{% set show_background_settings = profile_is_editing and perms.edit_background %}
{% set show_birthdate = profile_is_editing and perms.edit_birthdate %}
{% set show_active_forum_info = not profile_is_editing and (profile_active_category_info.forum_id|default(0) > 0 or profile_active_topic_info.topic_id|default(0) > 0) %}
{% set show_sidebar = (not profile_is_banned or profile_can_edit) and profile_is_guest or show_profile_fields or show_background_settings or show_birthdate or show_active_forum_info %}
{% set show_warnings = profile_warnings is defined and profile_warnings|length > 0 %}
{% set show_sidebar = (not profile_is_banned or profile_can_edit) and (profile_is_guest or show_profile_fields or show_background_settings or show_birthdate or show_active_forum_info or show_warnings) %}
{% if show_sidebar %}
<div class="profile__content__side">
@ -265,6 +265,26 @@
</div>
</div>
{% endif %}
{% if show_warnings %}
<div class="container profile__container" style="--accent-colour: #c84;">
{{ container_title('Warnings') }}
<div class="profile__warnings">
{% for warning in profile_warnings %}
<div class="profile__warnings__item">
<div class="profile__warnings__datetime">
<time datetime="{{ warning.createdTime|date('c') }}" title="{{ warning.createdTime|date('r') }}">{{ warning.createdTime|time_format }}</time>
</div>
<div class="profile__warnings__body">
{% for line in warning.bodyLines %}
<p>{{ line }}</p>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
@ -303,57 +323,6 @@
{% endif %}
</div>
{% endif %}
{% if profile_warnings|length > 0 or profile_warnings_can_manage %}
<div class="container profile__container profile__warning__container" id="account-standing">
{{ container_title('Account Standing', false, profile_warnings_can_manage ? url('manage-users-warnings', {'user': profile_user.id}) : '') }}
<div class="profile__warning">
<div class="profile__warning__background"></div>
{% if profile_warnings_can_manage %}
<div class="profile__warning__tools">
<div class="profile__warning__user">
<div class="profile__warning__user__ip">
User IP
</div>
</div>
<div class="profile__warning__user">
<div class="profile__warning__user__username">
Issuer
</div>
<div class="profile__warning__user__ip">
Issuer IP
</div>
</div>
</div>
{% endif %}
<div class="profile__warning__content">
<div class="profile__warning__type">
Type
</div>
<div class="profile__warning__created">
Created
</div>
<div class="profile__warning__duration">
Duration
</div>
<div class="profile__warning__note">
Note
</div>
</div>
</div>
{% for warning in profile_warnings %}
{{ user_profile_warning(warning, profile_warnings_view_private, profile_warnings_can_manage, profile_warnings_can_manage ? csrf_token() : '') }}
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>
</div>

View File

@ -248,70 +248,3 @@
</div>
</div>
{% endmacro %}
{% macro user_profile_warning(warning, show_private_note, show_user_info, delete_csrf) %}
{% from 'macros.twig' import avatar %}
<div class="profile__warning profile__warning--warning{% if show_user_info or delete_csrf %} profile__warning--extendo{% endif %}">
<div class="profile__warning__background"></div>
{% if show_user_info or delete_csrf %}
<div class="profile__warning__tools">
{% if show_user_info %}
<div class="profile__warning__user">
<div class="profile__warning__user__avatar">
{{ avatar(warning.user.id, 20, warning.user.username) }}
</div>
<a class="profile__warning__user__username" href="{{ url('user-profile', {'user': warning.user.id}) }}">
{{ warning.user.username }}
</a>
<div class="profile__warning__user__ip">
{{ warning.userRemoteAddress }}
</div>
</div>
<div class="profile__warning__user">
<div class="profile__warning__user__avatar">
{{ avatar(warning.issuer.id, 20, warning.issuer.username) }}
</div>
<a class="profile__warning__user__username" href="{{ url('user-profile', {'user': warning.issuer.id}) }}">
{{ warning.issuer.username }}
</a>
<div class="profile__warning__user__ip">
{{ warning.issuerRemoteAddress }}
</div>
</div>
{% endif %}
{% if delete_csrf %}
<div class="profile__warning__options">
<a href="{{ url('manage-users-warning-delete', {'warning': warning.id}) }}" class="profile__warning__option"><i class="far fa-trash-alt"></i> Delete</a>
</div>
{% endif %}
</div>
{% endif %}
<div class="profile__warning__content">
<div class="profile__warning__type">
Warning
</div>
<time datetime="{{ warning.createdTime|date('c') }}" title="{{ warning.createdTime|date('r') }}" class="profile__warning__created">
{{ warning.createdTime|time_format }}
</time>
<div class="profile__warning__duration"></div>
<div class="profile__warning__note">
{{ warning.publicNote }}
{% if show_private_note and warning.hasPrivateNote %}
<div class="profile__warning__private">
{{ warning.privateNote|nl2br }}
</div>
{% endif %}
</div>
</div>
</div>
{% endmacro %}