Added new banning system.

it actually works and isn't confusing this time around!
This commit is contained in:
flash 2023-07-26 18:19:46 +00:00
parent 057551edb3
commit 1d552e907b
38 changed files with 1132 additions and 358 deletions

View File

@ -24,6 +24,8 @@
}
}
@include manage/ban.css;
@include manage/bans.css;
@include manage/blacklist.css;
@include manage/changelog-actions-tags.css;
@include manage/emote.css;

View File

@ -0,0 +1,77 @@
.manage__ban {
/**/
}
.manage__ban__field {
margin: 2px;
margin-bottom: 8px;
}
.manage__ban__title {
font-size: 1.4em;
line-height: 1.5em;
padding: 0 4px;
}
.manage__ban__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__ban__duration {
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
gap: 5px;
}
.manage__ban__duration__value__custom--hidden {
display: none;
visibility: hidden;
}
.manage__ban__severity {
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
gap: 5px;
}
.manage__ban__severity__slider {
max-width: 200px;
width: 100%;
}
.manage__ban__severity__slider input {
width: 100%;
margin-top: 2px;
}
.manage__ban__severity__display {
max-width: 80px;
width: 100%;
}
.manage__ban__severity__display input {
width: 100%;
margin-bottom: 2px;
}
.manage__ban__reason {
padding: 2px;
width: 100%;
}
.manage__ban__reason textarea {
min-width: 100%;
max-width: 100%;
width: 100%;
min-height: 100px;
}
.manage__ban__actions {
display: flex;
justify-content: center;
padding: 10px;
padding-top: 0;
}

View File

@ -0,0 +1,122 @@
.manage__bans__pagination {
margin: 2px;
}
.manage__bans__actions {
display: flex;
gap: 2px;
margin: 2px;
}
.manage__bans__item {
padding: 0 2px;
margin: 2px;
border-top: 1px solid var(--accent-colour);
}
.manage__bans__item:not(:last-child) {
border-bottom: 1px solid var(--accent-colour);
}
.manage__bans__item__header {
display: flex;
overflow: hidden;
align-items: center;
}
.manage__bans__item__attributes {
flex-grow: 1;
flex-shrink: 1;
display: flex;
gap: 12px;
margin: 0 4px;
flex-wrap: wrap;
}
.manage__bans__item__attribute {
display: flex;
gap: 4px;
align-items: center;
}
.manage__bans__item__created__icon,
.manage__bans__item__expires__icon,
.manage__bans__item__permanent__icon {
font-size: 16px;
}
.manage__bans__item__expires__status span {
padding: 2px 4px;
border-radius: 2px;
}
.manage__bans__item__expires__status--active span {
background: rgba(255, 100, 100, 0.2);
font-weight: 700;
}
.manage__bans__item__expires__status--expired span {
background: rgba(100, 255, 100, 0.2);
}
.manage__bans__item__permanent {
background: rgba(255, 200, 100, 0.2);
border-radius: 2px;
padding: 0 4px;
}
.manage__bans__item__permanent__time {
font-weight: 700;
}
.manage__bans__item__actions {
display: flex;
flex-grow: 0;
flex-shrink: 0;
gap: 1px;
padding: 1px;
margin: 1px;
}
.manage__bans__item__action {
width: 36px;
height: 36px;
}
.manage__bans__item__author a,
.manage__bans__item__user a {
color: inherit;
text-decoration: none;
}
.manage__bans__item__author__name a,
.manage__bans__item__user__name a {
font-weight: bold;
display: inline-block;
padding-top: 2px;
border-bottom: 2px solid var(--user-colour, #fff);
}
.manage__bans__item__user__filter a {
padding: 2px 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.2);
transition: background .2s;
}
.manage__bans__item__user__filter a:hover,
.manage__bans__item__user__filter a:focus {
background: rgba(255, 255, 255, 0.4);
}
.manage__bans__item__user__filter a:active {
background: rgba(255, 255, 255, 0.1);
}
.manage__bans__item__reason {
margin: 1px 4px;
padding: 2px 4px;
border-top: 1px solid var(--accent-colour);
}
.manage__bans__item__reason__title {
font-size: .9em;
line-height: 1.5em;
font-style: italic;
}
.manage__bans__item__reason__body {
padding-left: 4px;
border-left: 2px solid var(--accent-colour);
}
.manage__bans__item__noreason {
font-size: .9em;
font-style: italic;
}

View File

@ -9,10 +9,21 @@
color: #fff;
text-align: center;
}
.warning--red {
--start-colour: #ff3d3d;
--end-colour: #f00;
}
.warning--bigger {
font-size: 1.4em;
line-height: 1.5em;
}
.warning__content {
background-color: rgba(17, 17, 17, .9);
padding: 2px 5px;
}
.warning--bigger .warning__content {
padding: 8px 20px;
}
.warning__link {
color: inherit;
text-decoration: underline dotted;

View File

@ -0,0 +1,45 @@
<?php
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class AddNewBansTable_20230726_175936 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_users_bans (
ban_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT(10) UNSIGNED NOT NULL,
mod_id INT(10) UNSIGNED NULL DEFAULT NULL,
ban_severity TINYINT(4) NOT NULL,
ban_reason_public TEXT NOT NULL,
ban_reason_private TEXT NOT NULL,
ban_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
ban_expires TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (ban_id),
KEY users_bans_user_foreign (user_id),
KEY users_bans_mod_foreign (mod_id),
KEY users_bans_created_index (ban_created),
KEY users_bans_expires_index (ban_expires),
KEY users_bans_severity_index (ban_severity),
CONSTRAINT users_bans_users_foreign
FOREIGN KEY (user_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT users_bans_mod_foreign
FOREIGN KEY (mod_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE SET NULL
) ENGINE=InnoDB COLLATE=utf8mb4_bin
');
$conn->execute('
INSERT INTO msz_users_bans (user_id, mod_id, ban_severity, ban_reason_public, ban_reason_private, ban_created, ban_expires)
SELECT user_id, issuer_id, 0, warning_note, COALESCE(warning_note_private, ""), warning_created, warning_duration
FROM msz_user_warnings
WHERE warning_type = 3
');
$conn->execute('DELETE FROM msz_user_warnings WHERE warning_type = 3');
}
}

View File

@ -5,7 +5,6 @@ use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\UserRole;
use Misuzu\Users\UserSession;
use Misuzu\Users\UserWarning;
if(UserSession::hasCurrent()) {
url_redirect('index');
@ -16,7 +15,17 @@ $register = !empty($_POST['register']) && is_array($_POST['register']) ? $_POST[
$notices = [];
$ipAddress = $_SERVER['REMOTE_ADDR'];
$countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
$restricted = UserWarning::countByRemoteAddress($ipAddress) > 0 ? 'ban' : '';
// there is currently no ip banning system.
// because people can have a wide variety of ip address
// it doesn't make sense to include a single row for it
// in the user bans table
// add better ip tracking and reintroduce the blacklist
// was thinking of having both a storage table and an expanded table
// with the storage table contains range syntaxes and whatnot
// and the expanded table just having seas of raw ips in it with a primary key
// for fast matching
$restricted = '';
$loginAttempts = $msz->getLoginAttempts();
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);

View File

@ -24,7 +24,7 @@ if($currentUserInfo === null) {
return;
}
if($currentUserInfo->isBanned()) {
if($msz->hasActiveBan()) {
echo render_info('You have been banned, check your profile for more information.', 403);
return;
}

View File

@ -27,7 +27,7 @@ if(!perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM)) {
return;
}
if(isset($forumUser) && $forumUser->hasActiveWarning())
if(isset($forumUser) && $msz->hasActiveBan($forumUser))
$perms &= ~MSZ_FORUM_PERM_SET_WRITE;
Template::set('forum_perms', $perms);

View File

@ -18,7 +18,7 @@ if(!empty($postMode) && !UserSession::hasCurrent()) {
$currentUser = User::getCurrent();
$currentUserId = $currentUser === null ? 0 : $currentUser->getId();
if(isset($currentUser) && $currentUser->isBanned()) {
if($postMode !== '' && $msz->hasActiveBan()) {
echo render_info('You have been banned, check your profile for more information.', 403);
return;
}
@ -183,29 +183,9 @@ switch($postMode) {
break;
default: // function as an alt for topic.php?p= by default
$canDeleteAny = perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST);
if(!empty($postInfo['post_deleted']) && !$canDeleteAny) {
echo render_error(404);
break;
}
$postFind = forum_post_find($postInfo['post_id'], $currentUserId);
if(empty($postFind)) {
echo render_error(404);
break;
}
if($canDeleteAny) {
$postInfo['preceeding_post_count'] += $postInfo['preceeding_post_deleted_count'];
}
unset($postInfo['preceeding_post_deleted_count']);
url_redirect('forum-topic', [
'topic' => $postFind['topic_id'],
'page' => floor($postFind['preceeding_post_count'] / MSZ_FORUM_POSTS_PER_PAGE) + 1,
url_redirect('forum-post', [
'post' => $postInfo['post_id'],
'post_fragment' => 'p' . $postInfo['post_id'],
]);
break;
}

View File

@ -12,8 +12,7 @@ if($currentUser === null) {
}
$currentUserId = $currentUser->getId();
if($currentUser->hasActiveWarning()) {
if($msz->hasActiveBan()) {
echo render_error(403);
return;
}

View File

@ -25,7 +25,7 @@ $perms = $topic
? forum_perms_get_user($topic['forum_id'], $topicUserId)[MSZ_FORUM_PERMS_GENERAL]
: 0;
if(isset($topicUser) && $topicUser->hasActiveWarning())
if(isset($topicUser) && $msz->hasActiveBan($topicUser))
$perms &= ~MSZ_FORUM_PERM_SET_WRITE;
$topicIsNuked = empty($topic['topic_id']);
@ -83,7 +83,7 @@ if(in_array($moderationMode, $validModerationModes, true)) {
return;
}
if($topicUser->isBanned()) {
if($msz->hasActiveBan()) {
echo render_info('You have been banned, check your profile for more information.', 403);
return;
}

View File

@ -0,0 +1,105 @@
<?php
namespace Misuzu;
use DateTimeInterface;
use RuntimeException;
use Index\DateTime;
use Misuzu\Users\User;
if(!User::hasCurrent() || !perms_check_user(MSZ_PERMS_USER, User::getCurrent()->getId(), MSZ_PERM_USER_MANAGE_BANS)) {
echo render_error(403);
return;
}
$bans = $msz->getBans();
if($_SERVER['REQUEST_METHOD'] === 'GET' && filter_has_var(INPUT_GET, 'delete')) {
if(CSRF::validateRequest()) {
try {
$banInfo = $bans->getBan((string)filter_input(INPUT_GET, 'b'));
} catch(RuntimeException $ex) {
echo render_error(404);
return;
}
$bans->deleteBans($banInfo);
$msz->createAuditLog('BAN_DELETE', [$banInfo->getId(), $banInfo->getUserId()]);
url_redirect('manage-users-bans', ['user' => $banInfo->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()) {
$expires = (int)filter_input(INPUT_POST, 'ub_expires', FILTER_SANITIZE_NUMBER_INT);
$expiresCustom = (string)filter_input(INPUT_POST, 'ub_expires_custom');
$publicReason = trim((string)filter_input(INPUT_POST, 'ub_reason_pub'));
$privateReason = trim((string)filter_input(INPUT_POST, 'ub_reason_priv'));
$severity = (int)filter_input(INPUT_POST, 'ub_severity', FILTER_SANITIZE_NUMBER_INT);
Template::set([
'ban_value_expires' => $expires,
'ban_value_expires_custom' => $expiresCustom,
'ban_value_reason_pub' => $publicReason,
'ban_value_reason_priv' => $privateReason,
'ban_value_severity' => $severity,
]);
if($expires < 1) {
if($expires === -1) {
$expires = null;
} elseif($expires === -2) {
$expires = DateTime::createFromFormat(DateTimeInterface::ATOM, $expiresCustom . ':00Z');
} else {
echo 'Invalid duration specified.';
break;
}
} else
$expires = time() + $expires;
$banInfo = $bans->createBan(
$userInfo, $expires, $publicReason, $privateReason,
severity: $severity, modInfo: $modInfo
);
$msz->createAuditLog('BAN_CREATE', [$banInfo->getId(), $userInfo->getId()]);
url_redirect('manage-users-bans', ['user' => $userInfo->getId()]);
return;
}
// calling array_flip since the input_select macro wants value => display, but this looks cuter
$durations = array_flip([
'Pick a duration...' => 0,
'15 Minutes' => 60 * 15,
'30 Minutes' => 60 * 30,
'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,
'Custom →' => -2,
]);
Template::render('manage.users.ban', [
'ban_user' => $userInfo,
'ban_mod' => $modInfo,
'ban_durations' => $durations,
]);

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_BANS)) {
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;
}
}
$bans = $msz->getBans();
$pagination = new Pagination($bans->countBans(userInfo: $filterUser), 10);
if(!$pagination->hasValidOffset()) {
echo render_error(404);
return;
}
$banList = [];
$banInfos = $bans->getBans(userInfo: $filterUser, activeFirst: true, pagination: $pagination);
foreach($banInfos as $banInfo) {
if(array_key_exists($banInfo->getUserId(), $userInfos))
$userInfo = $userInfos[$banInfo->getUserId()];
else
$userInfos[$banInfo->getUserId()] = $userInfo = User::byId((int)$banInfo->getUserId());
if(!$banInfo->hasModId())
$modInfo = null;
elseif(array_key_exists($banInfo->getModId(), $userInfos))
$modInfo = $userInfos[$banInfo->getModId()];
else
$userInfos[$banInfo->getModId()] = $modInfo = User::byId((int)$banInfo->getModId());
$banList[] = [
'info' => $banInfo,
'user' => $userInfo,
'mod' => $modInfo,
];
}
Template::render('manage.users.bans', [
'manage_bans' => $banList,
'manage_bans_pagination' => $pagination,
'manage_bans_filter_user' => $filterUser,
]);

View File

@ -17,8 +17,11 @@ $currentUserId = $currentUser->getId();
$canManageUsers = perms_check_user(MSZ_PERMS_USER, $currentUserId, MSZ_PERM_USER_MANAGE_USERS);
$canManagePerms = 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);
$canManageWarnings = perms_check_user(MSZ_PERMS_USER, $currentUserId, MSZ_PERM_USER_MANAGE_WARNINGS);
$canManageBans = perms_check_user(MSZ_PERMS_USER, $currentUserId, MSZ_PERM_USER_MANAGE_BANS);
$hasAccess = $canManageUsers || $canManageNotes || $canManageWarnings || $canManageBans;
if(!$canManageUsers && !$canManageNotes) {
if(!$hasAccess) {
echo render_error(403);
return;
}
@ -198,5 +201,7 @@ Template::render('manage.users.user', [
'can_edit_user' => $canEdit,
'can_edit_perms' => $canEdit && $canEditPerms,
'can_manage_notes' => $canManageNotes,
'can_manage_warnings' => $canManageWarnings,
'can_manage_bans' => $canManageBans,
'permissions' => $permissions ?? [],
]);

View File

@ -36,41 +36,6 @@ if(!empty($_GET['delete'])) {
}
if(!empty($_POST['warning']) && is_array($_POST['warning'])) {
$warningType = (int)($_POST['warning']['type'] ?? 0);
$warningDuration = 0;
$warningDuration = (int)($_POST['warning']['duration'] ?? 0);
if($warningDuration < -1) {
$customDuration = $_POST['warning']['duration_custom'] ?? '';
if(!empty($customDuration)) {
switch($warningDuration) {
case -100: // YYYY-MM-DD
$splitDate = explode('-', $customDuration, 3);
if(count($splitDate) !== 3)
die('Invalid date specified.');
$wYear = (int)$splitDate[0];
$wMonth = (int)$splitDate[1];
$wDay = (int)$splitDate[2];
if(checkdate($wMonth, $wDay, $wYear))
$warningDuration = mktime(0, 0, 0, $wMonth, $wDay, $wYear) - time();
else
die('Invalid date specified.');
break;
case -200: // Raw seconds
$warningDuration = (int)$customDuration;
break;
case -300: // strtotime
$warningDuration = strtotime($customDuration) - time();
break;
}
}
}
try {
$warningsUserInfo = User::byId((int)($_POST['warning']['user'] ?? 0));
$warningsUser = $warningsUserInfo->getId();
@ -81,14 +46,11 @@ if(!empty($_POST['warning']) && is_array($_POST['warning'])) {
$notices[] = 'This user doesn\'t exist.';
}
if(empty($notices) && !empty($warningsUserInfo)) {
try {
$warningInfo = UserWarning::create(
$warningsUserInfo,
$currentUser,
$warningType,
$warningDuration,
$_POST['warning']['note'],
$_POST['warning']['private']
);
@ -150,10 +112,5 @@ Template::render('manage.users.warnings', [
'pagination' => $warningsPagination,
'list' => UserWarning::all($warningsUserInfo, $warningsPagination),
'user' => $warningsUserInfo,
'durations' => $warningDurations,
'types' => [
UserWarning::TYPE_WARN => 'Warning',
UserWarning::TYPE_BAHN => 'Ban',
],
],
]);

View File

@ -8,6 +8,7 @@ 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;
@ -40,17 +41,16 @@ if($profileUser->isDeleted()) {
$notices = [];
$activeBanInfo = $msz->tryGetActiveBan($profileUser);
$isBanned = $activeBanInfo !== null;
$profileFields = new ProfileFields($db);
$viewingOwnProfile = $currentUserId === $profileUser->getId();
$isBanned = $profileUser->hasActiveWarning();
$userPerms = perms_get_user($currentUserId)[MSZ_PERMS_USER];
$canManageWarnings = perms_check($userPerms, MSZ_PERM_USER_MANAGE_WARNINGS);
$canEdit = !$isBanned
&& UserSession::hasCurrent()
&& ($viewingOwnProfile || $currentUser->isSuper() || (
perms_check($userPerms, MSZ_PERM_USER_MANAGE_USERS)
&& $currentUser->hasAuthorityOver($profileUser)
));
$canEdit = !$viewingAsGuest && ((!$isBanned && $viewingOwnProfile) || $currentUser->isSuper() || (
perms_check($userPerms, MSZ_PERM_USER_MANAGE_USERS)
&& $currentUser->hasAuthorityOver($profileUser)
));
if($isEditing) {
if(!$canEdit) {
@ -376,7 +376,7 @@ switch($profileMode) {
case '':
$template = 'profile.index';
$warnings = $profileUser->getProfileWarnings($currentUser);
$warnings = UserWarning::byProfile($profileUser, $currentUser);
Template::set([
'profile_warnings' => $warnings,
@ -384,7 +384,7 @@ switch($profileMode) {
'profile_warnings_can_manage' => $canManageWarnings,
]);
if(!$viewingAsGuest) {
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);
@ -451,5 +451,6 @@ if(!empty($template)) {
'profile_is_banned' => $isBanned,
'profile_is_guest' => $viewingAsGuest,
'profile_is_deleted' => false,
'profile_ban_info' => $activeBanInfo,
]);
}

View File

@ -16,7 +16,7 @@ if(!UserSession::hasCurrent()) {
$errors = [];
$currentUser = User::getCurrent();
$currentUserId = $currentUser->getId();
$isRestricted = $currentUser->hasActiveWarning();
$isRestricted = $msz->hasActiveBan();
$isVerifiedRequest = CSRF::validateRequest();
if(!$isRestricted && $isVerifiedRequest && !empty($_POST['role'])) {

View File

@ -143,14 +143,16 @@ CSRF::init(
(UserSession::hasCurrent() ? UserSession::getCurrent()->getToken() : ($_SERVER['REMOTE_ADDR'] ?? '::1'))
);
if(!empty($userInfo))
if(!empty($userInfo)) {
Template::set('current_user', $userInfo);
Template::set('current_user_ban_info', $msz->tryGetActiveBan());
}
if(!empty($userInfoReal))
Template::set('current_user_real', $userInfoReal);
$inManageMode = str_starts_with($_SERVER['REQUEST_URI'], '/manage');
$hasManageAccess = User::hasCurrent()
&& !User::getCurrent()->hasActiveWarning()
&& !$msz->hasActiveBan()
&& perms_check_user(MSZ_PERMS_GENERAL, User::getCurrent()->getId(), MSZ_PERM_GENERAL_CAN_MANAGE);
Template::set('has_manage_access', $hasManageAccess);

View File

@ -126,5 +126,8 @@ class AuditLogInfo {
'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.',
'BAN_CREATE' => 'Added ban #%d to user #%d.',
'BAN_DELETE' => 'Removed ban #%d from user #%d.',
];
}

View File

@ -10,7 +10,7 @@ use Misuzu\Users\Assets\UserAssetScalableInterface;
final class AssetsHandler extends Handler {
private function canViewAsset($request, User $assetUser): bool {
return !$assetUser->isBanned() || (
return !$this->context->hasActiveBan($assetUser) || (
User::hasCurrent()
&& parse_url($request->getHeaderFirstLine('Referer'), PHP_URL_PATH) === url('user-profile')
&& perms_check_user(MSZ_PERMS_USER, User::getCurrent()->getId(), MSZ_PERM_USER_MANAGE_USERS)

View File

@ -11,6 +11,8 @@ use Misuzu\Config\IConfig;
use Misuzu\Emoticons\Emotes;
use Misuzu\News\News;
use Misuzu\SharpChat\SharpChatRoutes;
use Misuzu\Users\Bans;
use Misuzu\Users\BanInfo;
use Misuzu\Users\ModNotes;
use Misuzu\Users\User;
use Index\Data\IDbConnection;
@ -39,6 +41,7 @@ class MisuzuContext {
private LoginAttempts $loginAttempts;
private RecoveryTokens $recoveryTokens;
private ModNotes $modNotes;
private Bans $bans;
public function __construct(IDbConnection $dbConn, IConfig $config) {
$this->dbConn = $dbConn;
@ -51,6 +54,7 @@ class MisuzuContext {
$this->loginAttempts = new LoginAttempts($this->dbConn);
$this->recoveryTokens = new RecoveryTokens($this->dbConn);
$this->modNotes = new ModNotes($this->dbConn);
$this->bans = new Bans($this->dbConn);
}
public function getDbConn(): IDbConnection {
@ -110,6 +114,30 @@ class MisuzuContext {
return $this->modNotes;
}
public function getBans(): Bans {
return $this->bans;
}
private array $activeBansCache = [];
public function tryGetActiveBan(User|string|null $userInfo = null): ?BanInfo {
if($userInfo === null) {
if(User::hasCurrent())
$userInfo = User::getCurrent();
else return null;
}
$userId = (string)$userInfo->getId();
if(array_key_exists($userId, $this->activeBansCache))
return $this->activeBansCache[$userId];
return $this->activeBansCache[$userId] = $this->bans->tryGetActiveBan($userId);
}
public function hasActiveBan(User|string|null $userInfo = null): bool {
return $this->tryGetActiveBan($userInfo) !== null;
}
public function createAuditLog(string $action, array $params = [], User|string|null $userInfo = null): void {
if($userInfo === null && User::hasCurrent())
$userInfo = User::getCurrent();
@ -185,7 +213,7 @@ class MisuzuContext {
$this->router->get('/forum/mark-as-read', $mszCompatHandler('Forum', 'markAsReadGET'));
$this->router->post('/forum/mark-as-read', $mszCompatHandler('Forum', 'markAsReadPOST'));
new SharpChatRoutes($this->router, $this->config->scopeTo('sockChat'), $this->emotes);
new SharpChatRoutes($this->router, $this->config->scopeTo('sockChat'), $this->bans, $this->emotes);
}
private function registerLegacyRedirects(): void {

View File

@ -8,19 +8,21 @@ use Index\Http\HttpFx;
use Misuzu\AuthToken;
use Misuzu\Config\IConfig;
use Misuzu\Emoticons\Emotes;
use Misuzu\Users\Bans;
// Replace
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
use Misuzu\Users\UserWarning;
final class SharpChatRoutes {
private IConfig $config;
private Bans $bans;
private Emotes $emotes;
private string $hashKey;
public function __construct(IRouter $router, IConfig $config, Emotes $emotes) {
public function __construct(IRouter $router, IConfig $config, Bans $bans, Emotes $emotes) {
$this->config = $config;
$this->bans = $bans;
$this->emotes = $emotes;
$this->hashKey = $this->config->getString('hashKey', 'woomy');
@ -249,27 +251,30 @@ final class SharpChatRoutes {
if(!hash_equals($realHash, $userHash) || $userTime < time() - 60)
return 403;
$warnings = UserWarning::byActive();
$bans = [];
$list = [];
$bans = $this->bans->getBans(activeOnly: true);
$userInfos = [];
foreach($warnings as $warning) {
if(!$warning->isBan() || $warning->hasExpired())
continue;
foreach($bans as $banInfo) {
$userId = $banInfo->getUserId();
if(array_key_exists($userId, $userInfos))
$userInfo = $userInfos[$userId];
else
$userInfos[$userId] = $userInfo = User::byId((int)$userId);
$isPerma = $warning->isPermanent();
$userInfo = $warning->getUser();
$bans[] = [
$isPerma = $banInfo->isPermanent();
$list[] = [
'is_ban' => true,
'user_id' => (string)$userInfo->getId(),
'user_id' => $userId,
'user_name' => $userInfo->getUsername(),
'user_colour' => Colour::toMisuzu($userInfo->getColour()),
'ip_addr' => $warning->getUserRemoteAddress(),
'ip_addr' => '::',
'is_perma' => $isPerma,
'expires' => date('c', $isPerma ? 0xFFFFFFFF : $warning->getExpirationTime()),
'expires' => date('c', $isPerma ? 0x7FFFFFFF : $banInfo->getExpiresTime())
];
}
return $bans;
return $list;
}
public function getBanCheck($response, $request) {
@ -286,32 +291,26 @@ final class SharpChatRoutes {
if(!hash_equals($realHash, $userHash) || $userTime < time() - 60)
return 403;
if(!empty($ipAddress))
$warning = UserWarning::byRemoteAddressActive($ipAddress);
if($userIdIsName)
try {
$userInfo = User::byUsername($userId);
$userId = (string)$userInfo->getId();
} catch(RuntimeException $ex) {
$userId = '';
}
if(empty($warning) && !empty($userId)) {
if($userIdIsName)
try {
$userInfo = User::byUsername($userId);
$userId = $userInfo->getId();
} catch(RuntimeException $ex) {
$userId = 0;
}
$warning = UserWarning::byUserIdActive((int)$userId);
}
if($warning === null)
$banInfo = $this->bans->tryGetActiveBan($userId);
if($banInfo === null)
return ['is_ban' => false];
$isPerma = $warning->isPermanent();
$isPerma = $banInfo->isPermanent();
return [
'is_ban' => true,
'user_id' => (string)$warning->getUserId(),
'ip_addr' => $warning->getUserRemoteAddress(),
'user_id' => $banInfo->getUserId(),
'ip_addr' => '::',
'is_perma' => $isPerma,
'expires' => date('c', $isPerma ? 0xFFFFFFFF : $warning->getExpirationTime()),
'expires' => date('c', $isPerma ? 0x7FFFFFFF : $banInfo->getExpiresTime()),
];
}
@ -342,10 +341,16 @@ final class SharpChatRoutes {
if(empty($reason))
$reason = 'Banned through chat.';
$comment = sprintf('User IP address: %s, Moderator IP address: %s', $userAddr, $modAddr);
if($isPermanent)
$duration = -1;
elseif($duration < 1)
return 400;
$expires = null;
else {
$now = time();
$expires = $now + $duration;
if($expires < $now)
return 400;
}
// IPs cannot be banned on their own
// substituting with the unused Railgun account for now.
@ -367,15 +372,12 @@ final class SharpChatRoutes {
}
try {
UserWarning::create(
$this->bans->createBan(
$userInfo,
$modInfo,
UserWarning::TYPE_BAHN,
$duration,
$expires,
$reason,
null,
$userAddr,
$modAddr
$comment,
modInfo: $modInfo
);
} catch(RuntimeException $ex) {
return 500;
@ -397,21 +399,14 @@ final class SharpChatRoutes {
if(!hash_equals($realHash, $userHash) || $userTime < time() - 60)
return 403;
$warning = null;
switch($type) {
case 'addr':
$warning = UserWarning::byRemoteAddressActive($subject);
break;
case 'user':
$warning = UserWarning::byUserIdActive((int)$subject);
break;
}
if($warning === null)
if($type !== 'user')
return 404;
$warning->delete();
$banInfo = $this->bans->tryGetActiveBan($subject);
if($banInfo === null)
return 404;
$this->bans->deleteBans($banInfo);
return 204;
}

123
src/Users/BanInfo.php Normal file
View File

@ -0,0 +1,123 @@
<?php
namespace Misuzu\Users;
use Index\DateTime;
use Index\Data\IDbResult;
class BanInfo {
private string $id;
private string $userId;
private ?string $modId;
private int $severity;
private string $publicReason;
private string $privateReason;
private int $created;
private ?int $expires;
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->severity = $result->getInteger(3);
$this->publicReason = $result->getString(4);
$this->privateReason = $result->getString(5);
$this->created = $result->getInteger(6);
$this->expires = $result->isNull(7) ? null : $result->getInteger(7);
}
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 getSeverity(): int {
return $this->severity;
}
public function hasPublicReason(): bool {
return $this->publicReason !== '';
}
public function getPublicReason(): string {
return $this->publicReason;
}
public function hasPrivateReason(): bool {
return $this->privateReason !== '';
}
public function getPrivateReason(): string {
return $this->privateReason;
}
public function getCreatedTime(): int {
return $this->created;
}
public function getCreatedAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->created);
}
public function isPermanent(): bool {
return $this->expires === null;
}
public function getExpiresTime(): ?int {
return $this->expires;
}
public function getExpiresAt(): ?DateTime {
return $this->expires === null ? null : DateTime::fromUnixTimeSeconds($this->expires);
}
public function isActive(): bool {
return $this->expires === null || $this->expires > time();
}
public function isExpired(): bool {
return $this->expires !== null && $this->expires <= time();
}
private const DURATION_DIVS = [
31536000 => 'year',
2592000 => 'month',
604800 => 'week',
86400 => 'day',
3600 => 'hour',
60 => 'minute',
1 => 'second',
];
private static function getTimeString(?int $left, int $right): string {
if($left === null)
return 'permanent';
$duration = $left - $right;
foreach(self::DURATION_DIVS as $span => $name) {
$display = floor($duration / $span);
if($display > 0)
return number_format($display) . ' ' . $name . ($display == 1 ? '' : 's');
}
return 'an amount of time';
}
public function getDurationString(): string {
return self::getTimeString($this->expires, $this->created);
}
public function getRemainingString(): string {
return self::getTimeString($this->expires, time());
}
}

193
src/Users/Bans.php Normal file
View File

@ -0,0 +1,193 @@
<?php
namespace Misuzu\Users;
use InvalidArgumentException;
use RuntimeException;
use Index\DateTime;
use Index\Data\DbStatementCache;
use Index\Data\DbTools;
use Index\Data\IDbConnection;
use Misuzu\Pagination;
use Misuzu\Users\User;
class Bans {
public const SEVERITY_MAX = 10;
public const SEVERITY_MIN = -10;
public const SEVERITY_DEFAULT = 0;
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->dbConn = $dbConn;
$this->cache = new DbStatementCache($dbConn);
}
public function countBans(
User|string|null $userInfo = null,
?bool $activeOnly = null
): int {
if($userInfo instanceof User)
$userInfo = (string)$userInfo->getId();
$hasUserInfo = $userInfo !== null;
$hasActiveOnly = $activeOnly !== null;
$args = 0;
$query = 'SELECT COUNT(*) FROM msz_users_bans';
if($hasActiveOnly) {
++$args;
if($activeOnly)
$query .= ' WHERE ban_expires IS NULL OR ban_expires > NOW()';
else
$query .= ' WHERE ban_expires <= NOW()';
}
if($hasUserInfo)
$query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE');
$args = 0;
$stmt = $this->cache->get($query);
if($hasUserInfo)
$stmt->addParameter(++$args, $userInfo);
$stmt->execute();
$result = $stmt->getResult();
$count = 0;
if($result->next())
$count = $result->getInteger(0);
return $count;
}
public function getBans(
User|string|null $userInfo = null,
?bool $activeOnly = null,
?bool $activeFirst = null,
?Pagination $pagination = null
): array {
if($userInfo instanceof User)
$userInfo = (string)$userInfo->getId();
$hasUserInfo = $userInfo !== null;
$hasActiveOnly = $activeOnly !== null;
$hasActiveFirst = $activeFirst !== null;
$hasPagination = $pagination !== null;
$args = 0;
$query = 'SELECT ban_id, user_id, mod_id, ban_severity, ban_reason_public, ban_reason_private, UNIX_TIMESTAMP(ban_created), UNIX_TIMESTAMP(ban_expires) FROM msz_users_bans';
if($hasActiveOnly) {
++$args;
if($activeOnly)
$query .= ' WHERE ban_expires IS NULL OR ban_expires > NOW()';
else
$query .= ' WHERE ban_expires <= NOW()';
}
if($hasUserInfo)
$query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE');
$query .= ' ORDER BY ';
if($hasActiveFirst)
$query .= sprintf('ban_expires IS NULL %1$s, ban_expires %1$s', $activeFirst ? 'DESC' : 'ASC');
else $query .= 'ban_created DESC';
if($hasPagination)
$query .= ' LIMIT ? OFFSET ?';
$args = 0;
$stmt = $this->cache->get($query);
if($hasUserInfo)
$stmt->addParameter(++$args, $userInfo);
if($hasPagination) {
$stmt->addParameter(++$args, $pagination->getRange());
$stmt->addParameter(++$args, $pagination->getOffset());
}
$stmt->execute();
$result = $stmt->getResult();
$bans = [];
while($result->next())
$bans[] = new BanInfo($result);
return $bans;
}
public function getBan(string $banId): BanInfo {
$stmt = $this->cache->get('SELECT ban_id, user_id, mod_id, ban_severity, ban_reason_public, ban_reason_private, UNIX_TIMESTAMP(ban_created), UNIX_TIMESTAMP(ban_expires) FROM msz_users_bans WHERE ban_id = ?');
$stmt->addParameter(1, $banId);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('No ban with ID $banId found.');
return new BanInfo($result);
}
public function tryGetActiveBan(
User|string $userInfo,
int $minimumSeverity = self::SEVERITY_MIN
): ?BanInfo {
if($userInfo instanceof User)
$userInfo = (string)$userInfo->getId();
// orders by ban_expires descending with NULLs (permanent) first
$stmt = $this->cache->get('SELECT ban_id, user_id, mod_id, ban_severity, ban_reason_public, ban_reason_private, UNIX_TIMESTAMP(ban_created), UNIX_TIMESTAMP(ban_expires) FROM msz_users_bans WHERE user_id = ? AND ban_severity >= ? AND (ban_expires IS NULL OR ban_expires > NOW()) ORDER BY ban_expires IS NULL DESC, ban_expires DESC');
$stmt->addParameter(1, $userInfo);
$stmt->addParameter(2, $minimumSeverity);
$stmt->execute();
$result = $stmt->getResult();
return $result->next() ? new BanInfo($result) : null;
}
public function createBan(
User|string $userInfo,
DateTime|int|null $expires,
string $publicReason,
string $privateReason,
int $severity = self::SEVERITY_DEFAULT,
User|string|null $modInfo = null
): BanInfo {
if($severity < self::SEVERITY_MIN || $severity > self::SEVERITY_MAX)
throw new InvalidArgumentException('$severity may not be less than -10 or more than 10.');
if($userInfo instanceof User)
$userInfo = (string)$userInfo->getId();
if($modInfo instanceof User)
$modInfo = (string)$modInfo->getId();
if($expires instanceof DateTime)
$expires = $expires->getUnixTimeSeconds();
$stmt = $this->cache->get('INSERT INTO msz_users_bans (user_id, mod_id, ban_severity, ban_reason_public, ban_reason_private, ban_expires) VALUES (?, ?, ?, ?, ?, FROM_UNIXTIME(?))');
$stmt->addParameter(1, $userInfo);
$stmt->addParameter(2, $modInfo);
$stmt->addParameter(3, $severity);
$stmt->addParameter(4, $publicReason);
$stmt->addParameter(5, $privateReason);
$stmt->addParameter(6, $expires);
$stmt->execute();
return $this->getBan((string)$this->dbConn->getLastInsertId());
}
public function deleteBans(BanInfo|string|array $banInfos): void {
if(!is_array($banInfos))
$banInfos = [$banInfos];
$stmt = $this->cache->get(sprintf(
'DELETE FROM msz_users_bans WHERE ban_id IN (%s)',
DbTools::prepareListString($banInfos)
));
$args = 0;
foreach($banInfos as $banInfo) {
if($banInfo instanceof BanInfo)
$banInfo = $banInfo->getId();
elseif(!is_string($banInfo))
throw new InvalidArgumentException('$banInfos must be strings of instances of BanInfo.');
$stmt->addParameter(++$args, $banInfo);
}
$stmt->execute();
}
}

View File

@ -483,33 +483,6 @@ class User implements HasRankInterface {
return $this->forumPostCount;
}
/************
* WARNINGS *
************/
private $activeWarning = -1;
public function getActiveWarning(): ?UserWarning {
if($this->activeWarning === -1)
$this->activeWarning = UserWarning::byUserActive($this);
return $this->activeWarning;
}
public function hasActiveWarning(): bool {
return $this->getActiveWarning() !== null && !$this->getActiveWarning()->hasExpired();
}
public function isBanned(): bool {
return $this->hasActiveWarning() && $this->getActiveWarning()->isBan();
}
public function getActiveWarningExpiration(): int {
return !$this->hasActiveWarning() ? 0 : $this->getActiveWarning()->getExpirationTime();
}
public function isActiveWarningPermanent(): bool {
return $this->hasActiveWarning() && $this->getActiveWarning()->isPermanent();
}
public function getProfileWarnings(?self $viewer): array {
return UserWarning::byProfile($this, $viewer);
}
/**************
* LOCAL USER *
**************/

View File

@ -7,21 +7,6 @@ use Misuzu\DB;
use Misuzu\Pagination;
class UserWarning {
// Warning, only shows up to moderators and the user themselves
public const TYPE_WARN = 1;
// Banning, prevents a user from interacting in general
// 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_WARN, self::TYPE_BAHN];
private const VISIBLE_TO_STAFF = self::TYPES;
private const VISIBLE_TO_USER = [self::TYPE_WARN, self::TYPE_BAHN];
private const VISIBLE_TO_PUBLIC = [self::TYPE_BAHN];
private const HAS_DURATION = [self::TYPE_BAHN];
private const PROFILE_BACKLOG = 90;
// Database fields
@ -81,57 +66,8 @@ class UserWarning {
return $this->warning_created === null ? -1 : $this->warning_created;
}
public function getExpirationTime(): int {
return $this->warning_duration === null ? -1 : $this->warning_duration;
}
public function hasExpired(): bool {
return $this->hasDuration() && ($this->getExpirationTime() > 0 && $this->getExpirationTime() < time());
}
public function hasDuration(): bool {
return in_array($this->getType(), self::HAS_DURATION);
}
public function getDuration(): int {
return max(-1, $this->getExpirationTime() - $this->getCreatedTime());
}
private const DURATION_DIVS = [
31536000 => 'year',
2592000 => 'month',
604800 => 'week',
86400 => 'day',
3600 => 'hour',
60 => 'minute',
1 => 'second',
];
public function getDurationString(): string {
$duration = $this->getDuration();
if($duration < 1)
return 'permanent';
foreach(self::DURATION_DIVS as $span => $name) {
$display = floor($duration / $span);
if($display > 0)
return number_format($display) . ' ' . $name . ($display == 1 ? '' : 's');
}
return 'an amount of time';
}
public function isPermanent(): bool {
return $this->hasDuration() && $this->getDuration() < 0;
}
public function getType(): int { return $this->warning_type; }
public function isWarning(): bool { return $this->getType() === self::TYPE_WARN; }
public function isBan(): bool { return $this->getType() === self::TYPE_BAHN; }
public function isVisibleToUser(): bool {
return in_array($this->getType(), self::VISIBLE_TO_USER);
}
public function isVisibleToPublic(): bool {
return in_array($this->getType(), self::VISIBLE_TO_PUBLIC);
public function getType(): int {
return $this->warning_type;
}
public function getPublicNote(): string {
@ -154,38 +90,21 @@ class UserWarning {
public static function create(
User $user,
User $issuer,
int $type,
int $duration,
string $publicNote,
?string $privateNote = null,
?string $targetAddr = null,
?string $issuerAddr = null
): self {
if(!in_array($type, self::TYPES))
throw new InvalidArgumentException('Type was invalid.');
if(!in_array($type, self::HAS_DURATION))
$duration = 0;
else {
if($duration === 0)
throw new InvalidArgumentException('Duration must be non-zero.');
if($duration < 0)
$duration = -1;
}
$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(), IF(:set_duration, NOW() + INTERVAL :duration SECOND, NULL), :type, :public_note, :private_note)'
. ' 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('set_duration', $duration > 0 ? 1 : 0)
->bind('duration', $duration)
->bind('type', $type)
->bind('public_note', $publicNote)
->bind('private_note', $privateNote)
->executeGetId();
@ -199,14 +118,6 @@ class UserWarning {
private static function countQueryBase(): string {
return sprintf(self::QUERY_SELECT, 'COUNT(*)');
}
public static function countByRemoteAddress(string $address, bool $withDuration = true): int {
return (int)DB::prepare(
self::countQueryBase()
. ' WHERE `user_ip` = INET6_ATON(:address)'
. ' AND `warning_duration` >= NOW()'
. ($withDuration ? ' AND `warning_type` IN (' . implode(',', self::HAS_DURATION) . ')' : '')
)->bind('address', $address)->fetchColumn();
}
public static function countAll(?User $user = null): int {
$getCount = DB::prepare(self::countQueryBase() . ($user === null ? '' : ' WHERE `user_id` = :user'));
if($user !== null)
@ -226,47 +137,16 @@ class UserWarning {
throw new RuntimeException('Not warning with that ID could be found.');
return $object;
}
public static function byUserActive(User $user): ?self {
return self::byUserIdActive($user->getId());
}
public static function byUserIdActive(int $userId): ?self {
if($userId < 1)
return null;
return DB::prepare(
self::byQueryBase()
. ' WHERE `user_id` = :user'
. ' AND `warning_type` IN (' . implode(',', self::HAS_DURATION) . ')'
. ' AND (`warning_duration` IS NULL OR `warning_duration` >= NOW())'
. ' ORDER BY `warning_type` DESC, `warning_duration` DESC'
) ->bind('user', $userId)
->fetchObject(self::class);
}
public static function byRemoteAddressActive(string $ipAddress): ?self {
return DB::prepare(
self::byQueryBase()
. ' WHERE `user_ip` = INET6_ATON(:address)'
. ' AND `warning_type` IN (' . implode(',', self::HAS_DURATION) . ')'
. ' AND (`warning_duration` IS NULL OR `warning_duration` >= NOW())'
. ' ORDER BY `warning_type` DESC, `warning_duration` DESC'
) ->bind('address', $ipAddress)
->fetchObject(self::class);
}
public static function byProfile(User $user, ?User $viewer = null): array {
if($viewer === null)
return [];
$types = self::VISIBLE_TO_PUBLIC;
if(perms_check_user(MSZ_PERMS_USER, $viewer->getId(), MSZ_PERM_USER_MANAGE_WARNINGS))
$types = self::VISIBLE_TO_STAFF;
elseif($user->getId() === $viewer->getId())
$types = self::VISIBLE_TO_USER;
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_type` IN (' . implode(',', $types) . ')'
. ' AND (`warning_type` = 0 OR `warning_created` >= NOW() - INTERVAL ' . self::PROFILE_BACKLOG . ' DAY OR (`warning_duration` IS NOT NULL AND `warning_duration` >= NOW()))'
. ' AND `warning_created` >= NOW() - INTERVAL ' . self::PROFILE_BACKLOG . ' DAY'
. ' ORDER BY `warning_created` DESC'
);
@ -274,14 +154,6 @@ class UserWarning {
return $getObjects->fetchObjects(self::class);
}
public static function byActive(): array {
return DB::prepare(
self::byQueryBase()
. ' WHERE `warning_type` IN (' . implode(',', self::HAS_DURATION) . ')'
. ' AND (`warning_duration` IS NULL OR `warning_duration` >= NOW())'
. ' ORDER BY `warning_type` DESC, `warning_duration` DESC'
)->fetchObjects(self::class);
}
public static function all(?User $user = null, ?Pagination $pagination = null): array {
$query = self::byQueryBase()
. ($user === null ? '' : ' WHERE `user_id` = :user')

View File

@ -24,6 +24,8 @@ function manage_get_menu(int $userId): array {
$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');
if(perms_check_user(MSZ_PERMS_USER, $userId, MSZ_PERM_USER_MANAGE_BANS))
$menu['Users & Roles']['Bans'] = url('manage-users-bans');
if(perms_check_user(MSZ_PERMS_NEWS, $userId, MSZ_PERM_NEWS_MANAGE_POSTS))
$menu['News']['Posts'] = url('manage-news-posts');
@ -194,16 +196,21 @@ function manage_perms_list(array $rawPerms): array {
'title' => 'Can handle reports.',
'perm' => MSZ_PERM_USER_MANAGE_REPORTS,
],
[
'section' => 'manage-warnings',
'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,
],
[
'section' => 'manage-warnings',
'title' => 'Can manage user warnings.',
'perm' => MSZ_PERM_USER_MANAGE_WARNINGS,
],
[
'section' => 'manage-bans',
'title' => 'Can manage user bans.',
'perm' => MSZ_PERM_USER_MANAGE_BANS,
],
],
],
[

View File

@ -22,6 +22,7 @@ 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_PERM_USER_MANAGE_BANS', 0x08000000);
define('MSZ_PERMS_CHANGELOG', 'changelog');
define('MSZ_PERM_CHANGELOG_MANAGE_CHANGES', 0x00000001);

View File

@ -129,6 +129,9 @@ define('MSZ_URLS', [
'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-users-bans' => ['/manage/users/bans.php', ['u' => '<user>']],
'manage-users-ban' => ['/manage/users/ban.php', ['u' => '<user>']],
'manage-users-ban-delete' => ['/manage/users/ban.php', ['b' => '<ban>', 'delete' => '1', 'csrf' => '{csrf}']],
'manage-roles' => ['/manage/users/roles.php'],
'manage-role' => ['/manage/users/role.php', ['r' => '<role>']],

View File

@ -0,0 +1,74 @@
{% 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-ban fa-fw"></i> Issuing a ban on user #' ~ ban_user.id ~ ' ' ~ ban_user.username) }}
<form method="post" enctype="multipart/form-data" action="{{ url('manage-users-ban', {'user': ban_user.id}) }}" class="manage__ban">
{{ input_csrf() }}
<div class="manage__ban__field">
<div class="manage__ban__title">Duration</div>
<div class="manage__ban__desc">Specify the date/time when the ban will expire. There are various common presets, as well as the ability to specify a completely custom value and the ability to permanently ban someone. Custom values are UTC.</div>
<div class="manage__ban__duration">
<div class="manage__ban__duration__main">{{ input_select('ub_expires', ban_durations, ban_value_expires|default(0), null, null, null, null, {'tabindex': '1', 'id': 'ub_expires'}) }}{# this is fucking stupid #}</div>
<div class="manage__ban__duration__custom manage__ban__duration__value__custom--hidden">{{ input_text('ub_expires_custom', null, ban_value_expires_custom|default(), 'datetime-local', null, null, {'id': 'ub_expires_custom'}, '2') }}</div>
</div>
</div>
<div class="manage__ban__field">
<div class="manage__ban__title">Severity</div>
<div class="manage__ban__desc">An arbitrary severity rating of the ban ranging between -10 and 10 with a default value of 0. At this point this value does nothing but in the future it may be used to restrict what someone can still do while banned; a severity of -10 could basically function as not being banned at all aside from the appearance and 10 could prevent logging in entirely, but at this point it does nothing so just leave it at 0 or yoink it however you like.</div>
<div class="manage__ban__severity">
<div class="manage__ban__severity__slider"><input name="ub_severity" id="ub_severity" class="input__range" type="range" min="-10" max="10" value="0" step="1" tabindex="3" value="{{ ban_value_severity|default(0) }}"></div>
<div class="manage__ban__severity__display"><input id="ub_severity_display" class="input__text" type="number" readonly></div>
</div>
</div>
<div class="manage__ban__field">
<div class="manage__ban__title">Public reason</div>
<div class="manage__ban__desc">A concise explanation of why this ban is being placed. Reason displayed publicly to the banned user as well as at the top of their profile to other logged in users, reasons are not displayed to guests. May be left empty but that doesn't really help anyone, probably.</div>
<div class="manage__ban__reason">
<textarea name="ub_reason_pub" class="input__textarea" tabindex="4">{{ ban_value_reason_pub|default() }}</textarea>
</div>
</div>
<div class="manage__ban__field">
<div class="manage__ban__title">Private reason</div>
<div class="manage__ban__desc">Only displayed to other staff. Additional information for context so others can know why this ban was placed. If it's obvious or the details can also be found in the user notes section, you are free to leave this field untouched.</div>
<div class="manage__ban__reason">
<textarea name="ub_reason_priv" class="input__textarea" tabindex="5">{{ ban_value_reason_priv|default() }}</textarea>
</div>
</div>
<div class="manage__ban__actions">
<button class="input__button input__button--destroy" tabindex="6"><i class="fas fa-gavel"></i>&nbsp;HAMMER TIME&nbsp;<i class="fas fa-gavel"></i></button>
</div>
</form>
</div>
<script>
window.addEventListener('load', function() {
const ubExpires = $i('ub_expires'),
ubExpiresCustom = $i('ub_expires_custom'),
ubSeverity = $i('ub_severity'),
ubSeverityDisplay = $i('ub_severity_display');
const updateExpires = function() {
ubExpiresCustom.parentNode.classList.toggle('manage__ban__duration__value__custom--hidden', ubExpires.value != '-2');
};
const updateSeverity = function() {
ubSeverityDisplay.value = ubSeverity.value;
};
ubExpires.addEventListener('input', function(ev) { updateExpires(); });
ubSeverity.addEventListener('input', function(ev) { updateSeverity(); });
updateExpires();
updateSeverity();
});
</script>
{% endblock %}

View File

@ -0,0 +1,127 @@
{% extends 'manage/users/master.twig' %}
{% from 'macros.twig' import pagination, container_title, avatar %}
{% set bans_pagination = pagination(manage_bans_pagination, url('manage-users-bans', {'user': manage_bans_filter_user.id|default(0)})) %}
{% set bans_filtering = manage_bans_filter_user is not null %}
{% block manage_content %}
<div class="container manage__bans">
{{ container_title('<i class="fas fa-ban fa-fw"></i> Bans') }}
<div class="manage__description">
List of user bans.
{% if not bans_filtering %}Filter by a user to issue a new ban.{% endif %}
</div>
{% if bans_pagination|trim|length > 0 %}
<div class="manage__bans__pagination">
{{ bans_pagination }}
</div>
{% endif %}
{% if bans_filtering %}
<div class="manage__bans__actions">
<a href="{{ url('manage-users-ban', {'user': manage_bans_filter_user.id}) }}" class="input__button">Issue new Ban</a>
</div>
{% endif %}
<div class="manage__bans__list">
{% for ban in manage_bans %}
<div class="manage__bans__item">
<div class="manage__bans__item__header">
<div class="manage__bans__item__attributes">
{% if ban.mod is not null %}
<div class="manage__bans__item__attribute manage__bans__item__author" style="--user-colour: {{ ban.mod.colour }}">
<div class="manage__bans__item__author__prefix">Issued by</div>
<div class="manage__bans__item__author__avatar">
<a href="{{ url('user-profile', {'user': ban.mod.id}) }}">{{ avatar(ban.mod.id, 20, ban.mod.username) }}</a>
</div>
<div class="manage__bans__item__author__name">
<a href="{{ url('user-profile', {'user': ban.mod.id}) }}">{{ ban.mod.username }}</a>
</div>
</div>
{% endif %}
<div class="manage__bans__item__attribute manage__bans__item__created">
<div class="manage__bans__item__created__icon"><i class="fas fa-clock"></i></div>
<div class="manage__bans__item__created__time">
<time datetime="{{ ban.info.createdTime|date('c') }}" title="{{ ban.info.createdTime|date('r') }}">{{ ban.info.createdTime|time_format }}</time>
</div>
</div>
{% if ban.info.isPermanent %}
<div class="manage__bans__item__attribute manage__bans__item__permanent">
<div class="manage__bans__item__permanent__icon"><i class="fas fa-dumpster"></i></div>
<div class="manage__bans__item__permanent__time">
PERMANENT
</div>
</div>
{% else %}
<div class="manage__bans__item__attribute manage__bans__item__expires">
<div class="manage__bans__item__expires__icon"><i class="fas fa-stopwatch"></i></div>
<div class="manage__bans__item__expires__time" title="{{ ban.info.expiresTime|date('r') }}">
{% if ban.info.isActive %}
<span>{{ ban.info.remainingString }} remaining</span>
{% else %}
<span>{{ ban.info.durationString }}</span>
{% endif %}
</div>
{% if ban.info.isActive %}
<div class="manage__bans__item__expires__status manage__bans__item__expires__status--active">
<span>active</span>
</div>
{% else %}
<div class="manage__bans__item__expires__status manage__bans__item__expires__status--expired">
<span>expired</span>
</div>
{% endif %}
</div>
{% endif %}
<div class="manage__bans__item__attribute manage__bans__item__user" style="--user-colour: {{ ban.user.colour }}">
<div class="manage__bans__item__user__prefix">Subject</div>
<div class="manage__bans__item__user__avatar">
<a href="{{ url('manage-user', {'user': ban.user.id}) }}">{{ avatar(ban.user.id, 20, ban.user.username) }}</a>
</div>
<div class="manage__bans__item__user__name">
<a href="{{ url('manage-user', {'user': ban.user.id}) }}">{{ ban.user.username }}</a>
</div>
{% if not bans_filtering %}
<div class="manage__bans__item__user__filter">
<a href="{{ url('manage-users-bans', {'user': ban.user.id}) }}">Filter</a>
</div>
{% endif %}
</div>
</div>
<div class="manage__bans__item__actions">
<a href="{{ url('manage-users-ban-delete', {'ban': ban.info.id}) }}" title="Revoke/Delete" class="input__button input__button--autosize input__button--destroy manage__bans__item__action" onclick="return confirm('Are you sure?');"><i class="fas fa-times fa-fw"></i></a>
</div>
</div>
{% if ban.info.hasPublicReason %}
<div class="manage__bans__item__reason">
<div class="manage__bans__item__reason__title">Reason displayed publicly and to the user themselves:</div>
<div class="manage__bans__item__reason__body">{{ ban.info.publicReason }}</div>
</div>
{% else %}
<div class="manage__bans__item__reason">
<div class="manage__bans__item__reason__title">This ban does not display any reason.</div>
</div>
{% endif %}
{% if ban.info.hasPrivateReason %}
<div class="manage__bans__item__reason">
<div class="manage__bans__item__reason__title">Additional information for moderators:</div>
<div class="manage__bans__item__reason__body">{{ ban.info.privateReason }}</div>
</div>
{% else %}
<div class="manage__bans__item__reason">
<div class="manage__bans__item__reason__title">This ban does not provide additional information for moderators.</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% if bans_pagination|trim|length > 0 %}
<div class="manage__bans__pagination">
{{ bans_pagination }}
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -132,19 +132,25 @@
{% endif %}
</form>
{% if can_manage_notes %}
<div class="container manage__user__container">
{{ container_title('Manage notes') }}
<div class="container manage__user__container">
{{ container_title('Moderator tools') }}
<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 class="container__content">
<p>Links to various moderator tools to use on this user, provided you have access to them.</p>
</div>
{% endif %}
<div class="manage__user__buttons">
{% if can_manage_notes %}
<a href="{{ url('manage-users-notes', {'user': user_info.id}) }}" class="input__button manage__user__button"><i class="fas fa-sticky-note fa-fw"></i>&nbsp;Notes</a>
{% endif %}
{% if can_manage_warnings %}
<a href="{{ url('manage-users-warnings', {'user': user_info.id}) }}" class="input__button manage__user__button"><i class="fas fa-exclamation-circle fa-fw"></i>&nbsp;Warnings</a>
{% endif %}
{% if can_manage_bans %}
<a href="{{ url('manage-users-bans', {'user': user_info.id}) }}" class="input__button manage__user__button"><i class="fas fa-ban fa-fw"></i>&nbsp;Bans</a>
{% endif %}
</div>
</div>
{% if current_user.super %}
<form method="post" action="{{ url('manage-user', {'user': user_info.id}) }}" class="container manage__user__container">

View File

@ -26,10 +26,7 @@
{{ input_csrf() }}
{{ input_hidden('warning[user]', warnings.user.id) }}
{{ input_select('warning[type]', warnings.types) }}
{{ input_text('warning[note]', '', '', 'text', 'Public note') }}
{{ input_select('warning[duration]', warnings.durations) }}
{{ input_text('warning[duration_custom]', '', '', 'text', 'Custom Duration') }}
<button class="input__button">Add</button><br>
<textarea class="input__textarea" name="warning[private]" placeholder="Private note"></textarea>

View File

@ -126,10 +126,13 @@
{% endblock %}
<div class="main__wrapper">
{% if current_user.hasActiveWarning|default(false) %}
<div class="warning">
{% if current_user_ban_info is defined and current_user_ban_info is not null %}
<div class="warning warning--red">
<div class="warning__content">
You have been banned {% if current_user.isActiveWarningPermanent %}<strong>permanently</strong>{% else %}until <strong>{{ current_user.activeWarningExpiration|date('r') }}</strong>{% endif %}, view the account standing table on <a href="{{ url('user-account-standing', {'user': current_user.id}) }}" class="warning__link">your profile</a> to view why.
<p>You have been banned {% if current_user_ban_info.isPermanent %}<strong>permanently</strong>{% else %}for <strong title="{{ current_user_ban_info.expiresTime|date('r') }}">{{ current_user_ban_info.remainingString }}</strong>{% endif %} since <strong><time datetime="{{ current_user_ban_info.createdTime|date('c') }}" title="{{ current_user_ban_info.createdTime|date('r') }}">{{ current_user_ban_info.createdTime|time_format }}</time></strong>.</p>
{% if current_user_ban_info.hasPublicReason %}
<p>Reason: {{ current_user_ban_info.publicReason }}</p>
{% endif %}
</div>
</div>
{% endif %}

View File

@ -108,3 +108,14 @@
{% endif %}
</div>
</div>
{% if profile_is_banned %}
<div class="warning warning--red warning--bigger">
<div class="warning__content">
This user has been banned {% if profile_ban_info.isPermanent %}<strong>permanently</strong>{% else %}for <strong title="{{ profile_ban_info.expiresTime|date('r') }}">{{ profile_ban_info.remainingString }}</strong>{% endif %} since <strong><time datetime="{{ profile_ban_info.createdTime|date('c') }}" title="{{ profile_ban_info.createdTime|date('r') }}">{{ profile_ban_info.createdTime|time_format }}</time></strong>.
{% if not profile_is_guest and profile_ban_info.hasPublicReason %}
<p>Reason: {{ profile_ban_info.publicReason }}</p>
{% endif %}
</div>
</div>
{% endif %}

View File

@ -81,7 +81,7 @@
{% 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 = profile_is_guest or show_profile_fields or show_background_settings or show_birthdate or show_active_forum_info %}
{% 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 %}
{% if show_sidebar %}
<div class="profile__content__side">
@ -270,7 +270,7 @@
{% if profile_user is defined %}
<div class="profile__content__main">
{% if (profile_is_editing and perms.edit_about) or profile_user.hasProfileAbout %}
{% if (not profile_is_banned or profile_can_edit) and ((profile_is_editing and perms.edit_about) or profile_user.hasProfileAbout) %}
<div class="container profile__container profile__about" id="about">
{{ container_title('About ' ~ profile_user.username) }}
@ -287,7 +287,7 @@
</div>
{% endif %}
{% if (profile_is_editing and perms.edit_signature) or profile_user.hasForumSignature %}
{% if (not profile_is_banned or profile_can_edit) and ((profile_is_editing and perms.edit_signature) or profile_user.hasForumSignature) %}
<div class="container profile__container profile__signature" id="signature">
{{ container_title('Signature') }}

View File

@ -3,7 +3,7 @@
{% if profile_user is defined %}
{% set image = url('user-avatar', {'user': profile_user.id, 'res': 200}) %}
{% set manage_link = url('manage-user', {'user': profile_user.id}) %}
{% if profile_user.hasBackground %}
{% if (not profile_is_banned or profile_can_edit) and profile_user.hasBackground %}
{% set site_background = profile_user.backgroundInfo %}
{% endif %}
{% set stats = [

View File

@ -251,18 +251,8 @@
{% macro user_profile_warning(warning, show_private_note, show_user_info, delete_csrf) %}
{% from 'macros.twig' import avatar %}
{% if warning.isBan %}
{% set warning_text = 'Ban' %}
{% set warning_class = 'ban' %}
{% elseif warning.isWarning %}
{% set warning_text = 'Warning' %}
{% set warning_class = 'warning' %}
{% else %}
{% set warning_text = 'Note' %}
{% set warning_class = 'note' %}
{% endif %}
<div class="profile__warning profile__warning--{{ warning_class }}{% if show_user_info or delete_csrf %} profile__warning--extendo{% endif %}">
<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 %}
@ -304,24 +294,14 @@
<div class="profile__warning__content">
<div class="profile__warning__type">
{{ warning_text }}
Warning
</div>
<time datetime="{{ warning.createdTime|date('c') }}" title="{{ warning.createdTime|date('r') }}" class="profile__warning__created">
{{ warning.createdTime|time_format }}
</time>
{% if warning.isPermanent %}
<div class="profile__warning__duration">
<b>PERMANENT</b>
</div>
{% elseif warning.hasDuration %}
<div class="profile__warning__duration">
{{ warning.durationString }}
</div>
{% else %}
<div class="profile__warning__duration"></div>
{% endif %}
<div class="profile__warning__duration"></div>
<div class="profile__warning__note">
{{ warning.publicNote }}