diff --git a/assets/admin/src/pages/users/UsersBan.vue b/assets/admin/src/pages/users/UsersBan.vue index 89c3caba..45e729a1 100644 --- a/assets/admin/src/pages/users/UsersBan.vue +++ b/assets/admin/src/pages/users/UsersBan.vue @@ -52,6 +52,9 @@ + + + @@ -84,15 +87,22 @@ const stats = reactive({ total: 0, active: 0, banned: 0 }) const addForm = reactive({ user_id: null, - type: 'muted', + type: 'mute', + scope: 'global', reason: '', duration: '7d', }) const restrictionTypes = [ - { label: '禁言', value: 'muted' }, - { label: '限制', value: 'restricted' }, - { label: '封禁', value: 'banned' }, + { label: '警告', value: 'warn' }, + { label: '禁言', value: 'mute' }, + { label: '停权', value: 'suspend' }, + { label: '封禁', value: 'ban' }, +] + +const scopeOptions = [ + { label: '全局', value: 'global' }, + { label: '仅评论', value: 'comment' }, ] const durationOptions = [ @@ -126,15 +136,22 @@ const columns = [ ] function getTypeTag(type) { - const map = { muted: 'warning', restricted: 'error', banned: 'error' } + const map = { warn: 'info', mute: 'warning', suspend: 'warning', ban: 'error' } return map[type] || 'default' } function getTypeLabel(type) { - const map = { muted: '禁言', restricted: '限制', banned: '封禁' } + const map = { warn: '警告', mute: '禁言', suspend: '停权', ban: '封禁' } return map[type] || type } +function durationToExpiresAt(duration) { + if (!duration || duration === 'permanent') return '' + const days = parseInt(duration, 10) + if (!Number.isFinite(days) || days <= 0) return '' + return new Date(Date.now() + days * 86400000).toISOString() +} + async function loadStats() { try { const res = await api.request('/users/restrictions/stats') @@ -174,12 +191,13 @@ async function addRestriction() { } adding.value = true try { - await api.request(`/users/${addForm.user_id}/restrictions`, { + await api.request(`/users/${addForm.user_id}/restrict`, { method: 'POST', data: { type: addForm.type, + scope: addForm.scope, reason: addForm.reason, - duration: addForm.duration, + expires_at: durationToExpiresAt(addForm.duration), }, }) message.success('限制已添加') diff --git a/functions.php b/functions.php index 0a838461..bfd1abbe 100644 --- a/functions.php +++ b/functions.php @@ -252,9 +252,6 @@ if (class_exists('\AKSRT\Ai\AI')) { if (class_exists('\AKSRT\Payment\WeChatNotify')) { \AKSRT\Payment\WeChatNotify::get_instance()->init(); } -if (class_exists('\AKSRT\Messages\ReportCenter')) { - \AKSRT\Messages\ReportCenter::get_instance()->init(); -} if (class_exists('\AKSRT\Core\CommentActions')) { \AKSRT\Core\CommentActions::get_instance()->init(); } diff --git a/inc/helpers/comments/item.php b/inc/helpers/comments/item.php index 7b150e8a..f9497456 100644 --- a/inc/helpers/comments/item.php +++ b/inc/helpers/comments/item.php @@ -143,6 +143,36 @@ if (!function_exists('aksrt_get_comment_header_html')) : } endif; +if (!function_exists('aksrt_get_comment_report_html')) : + /** + * 评论举报入口 — 登录用户可举报他人评论(前台走 report.js 的 data-aksrt-report 契约)。 + * + * @param WP_Comment $comment Comment. + * @return string + */ + function aksrt_get_comment_report_html(WP_Comment $comment): string { + if (!is_user_logged_in() || !class_exists('\AKSRT\Settings\Settings')) { + return ''; + } + + $author_id = (int) $comment->user_id; + $reporter_id = get_current_user_id(); + if ($author_id > 0 && $author_id === $reporter_id) { + return ''; + } + + $settings = \AKSRT\Settings\Settings::get_user_identity_settings(); + if (empty($settings['report']['enabled'])) { + return ''; + } + if (function_exists('aksrt_user_can') && !aksrt_user_can('user_report', $reporter_id, $author_id)) { + return ''; + } + + return '
  • comment_ID . '}\'>' . esc_html__('举报', 'aksrt') . '
  • '; + } +endif; + if (!function_exists('aksrt_get_comments_action_dropdown_html')) : /** * More-actions menu (legacy: legacy) — pin / edit / approve / delete. @@ -178,6 +208,7 @@ if (!function_exists('aksrt_get_comments_action_dropdown_html')) : if (!empty($perms['can_delete'])) { $lists .= '
  • ' . esc_html__('删除', 'aksrt') . '
  • '; } + $lists .= aksrt_get_comment_report_html($comment); if ($lists === '') { return ''; diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php index e3e18470..09df64dc 100644 --- a/src/Admin/Native/UsersMenus.php +++ b/src/Admin/Native/UsersMenus.php @@ -573,21 +573,7 @@ class UsersMenus { update_user_meta($user_id, \AKSRT\User\BanAppeal::META_HISTORY, array_slice($history, -30)); if ($decision === 'approve' && class_exists('\AKSRT\User\Restriction')) { - global $wpdb; - $table = class_exists('\AKSRT\Core\DBMigrator') ? \AKSRT\Core\DBMigrator::table('user_restriction') : ''; - if ($table !== '') { - $wpdb->query($wpdb->prepare( - "UPDATE {$table} SET status = 'lifted', expires_at = %s WHERE user_id = %d AND type IN ('ban','suspend','mute')", - current_time('mysql'), - $user_id - )); - } - $svc = \AKSRT\User\Restriction::get_instance(); - if (method_exists($svc, 'clear_user_bans')) { - $svc->clear_user_bans($user_id); - } elseif (method_exists($svc, 'lift_ban')) { - $svc->lift_ban($user_id); - } + \AKSRT\User\Restriction::get_instance()->lift_user_restriction($user_id); } if (class_exists('\AKSRT\Messages\Message')) { diff --git a/src/Admin/UserController.php b/src/Admin/UserController.php index 0f55f0c2..8eed1c46 100644 --- a/src/Admin/UserController.php +++ b/src/Admin/UserController.php @@ -144,11 +144,6 @@ class UserController extends ControllerBase { ]); // Report ticket list (admin) - register_rest_route(self::API_NAMESPACE, '/report/list', [ - 'methods' => 'GET', - 'callback' => [$this, 'get_report_list'], - 'permission_callback' => [$this, 'check_permission'], - ]); register_rest_route(self::API_NAMESPACE, '/users/reports', [ 'methods' => 'GET', 'callback' => [$this, 'get_report_list'], @@ -165,20 +160,6 @@ class UserController extends ControllerBase { 'permission_callback' => [$this, 'check_permission'], ]); - // Report status update (admin) - register_rest_route(self::API_NAMESPACE, '/report/update-status', [ - 'methods' => 'POST', - 'callback' => [$this, 'update_report_status'], - 'permission_callback' => [$this, 'check_permission'], - ]); - - // Report counts (admin) - register_rest_route(self::API_NAMESPACE, '/report/counts', [ - 'methods' => 'GET', - 'callback' => [$this, 'get_report_counts'], - 'permission_callback' => [$this, 'check_permission'], - ]); - // Report submit (logged-in users, front-end) register_rest_route(self::API_NAMESPACE, '/report/submit', [ 'methods' => 'POST', diff --git a/src/Admin/UserControllerReportOperations.php b/src/Admin/UserControllerReportOperations.php index 0c024f09..045e94f0 100644 --- a/src/Admin/UserControllerReportOperations.php +++ b/src/Admin/UserControllerReportOperations.php @@ -121,27 +121,6 @@ trait UserControllerReportOperations { ]); } - public function update_report_status(\WP_REST_Request $request): \WP_REST_Response { - if (!class_exists('\AKSRT\Messages\ReportCenter')) { - return $this->error(__('举报系统未启用', 'aksrt')); - } - - $params = $this->get_json_params($request); - $report_id = (int) ($params['report_id'] ?? 0); - if ($report_id <= 0) { - return $this->error(__('参数无效', 'aksrt')); - } - - return $this->handle_report_process($report_id, $params); - } - - public function get_report_counts(): \WP_REST_Response { - if (!class_exists('\AKSRT\Messages\ReportCenter')) { - return $this->success(['pending' => 0, 'reviewing' => 0, 'resolved' => 0, 'dismissed' => 0, 'total' => 0]); - } - return $this->success(\AKSRT\Messages\ReportCenter::get_instance()->get_status_counts()); - } - public function get_report_detail(\WP_REST_Request $request): \WP_REST_Response { global $wpdb; @@ -214,7 +193,8 @@ trait UserControllerReportOperations { $effect = $this->apply_report_action( sanitize_key((string) $row['target_type']), (int) $row['target_id'], - $action_taken + $action_taken, + (string) ($row['reason'] ?? '') ); $notes = json_decode((string) ($row['notes_json'] ?? '[]'), true); @@ -346,27 +326,70 @@ trait UserControllerReportOperations { $summary['display_name'] = $user->display_name; $summary['title'] = $user->display_name; $summary['url'] = function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($target_id) : ''; - $summary['ban_state'] = (string) get_user_meta($target_id, 'aksrt_user_ban_state', true); + $summary['ban_state'] = $this->get_user_restriction_state($target_id); } } return $summary; } - private function apply_report_action(string $target_type, int $target_id, string $action_taken): string { + /** + * 用户当前限制状态摘要(banned|restricted|muted|空),与 Restriction 表实时同步。 + */ + private function get_user_restriction_state(int $user_id): string { + if ($user_id <= 0 || !class_exists('\AKSRT\User\Restriction')) { + return ''; + } + $svc = \AKSRT\User\Restriction::get_instance(); + if ($svc->is_banned($user_id)) { + return 'banned'; + } + if ($svc->is_restricted($user_id, \AKSRT\User\Restriction::TYPE_SUSPEND)) { + return 'restricted'; + } + if ($svc->is_restricted($user_id, \AKSRT\User\Restriction::TYPE_MUTE)) { + return 'muted'; + } + return ''; + } + + private function apply_report_action(string $target_type, int $target_id, string $action_taken, string $reason = ''): string { if ($target_id <= 0 || $action_taken === 'none') { return ''; } if ($target_type === 'user') { - if (in_array($action_taken, ['muted', 'restricted', 'banned'], true)) { - update_user_meta($target_id, 'aksrt_user_ban_state', $action_taken); - return sprintf(__('已同步用户限制状态:%s', 'aksrt'), $action_taken); + if (!class_exists('\AKSRT\User\Restriction')) { + return ''; } + $svc = \AKSRT\User\Restriction::get_instance(); if ($action_taken === 'clear') { - delete_user_meta($target_id, 'aksrt_user_ban_state'); - return __('已清除用户限制状态', 'aksrt'); + $removed = $svc->lift_user_restriction($target_id); + return $removed > 0 + ? sprintf(__('已解除 %d 条生效中的限制', 'aksrt'), $removed) + : __('该用户没有生效中的限制', 'aksrt'); + } + + if (in_array($action_taken, \AKSRT\User\Restriction::TYPES, true)) { + $labels = [ + \AKSRT\User\Restriction::TYPE_WARN => __('警告', 'aksrt'), + \AKSRT\User\Restriction::TYPE_MUTE => __('禁言', 'aksrt'), + \AKSRT\User\Restriction::TYPE_SUSPEND => __('停权', 'aksrt'), + \AKSRT\User\Restriction::TYPE_BAN => __('封禁', 'aksrt'), + ]; + $result = $svc->restrict( + $target_id, + $action_taken, + $reason !== '' ? $reason : __('举报处理处罚', 'aksrt'), + \AKSRT\User\Restriction::SCOPE_GLOBAL, + '', + get_current_user_id() + ); + if (!empty($result['success'])) { + return sprintf(__('已对用户执行限制:%s', 'aksrt'), $labels[$action_taken] ?? $action_taken); + } + return ''; } return ''; @@ -413,10 +436,10 @@ trait UserControllerReportOperations { if ($target_type === 'user' || $target_type === '') { $common = array_merge($common, [ - ['label' => __('发送警告', 'aksrt'), 'value' => 'warned'], - ['label' => __('禁言', 'aksrt'), 'value' => 'muted'], - ['label' => __('限制功能', 'aksrt'), 'value' => 'restricted'], - ['label' => __('封禁账号', 'aksrt'), 'value' => 'banned'], + ['label' => __('发送警告', 'aksrt'), 'value' => 'warn'], + ['label' => __('禁言', 'aksrt'), 'value' => 'mute'], + ['label' => __('停权', 'aksrt'), 'value' => 'suspend'], + ['label' => __('封禁账号', 'aksrt'), 'value' => 'ban'], ['label' => __('解除限制', 'aksrt'), 'value' => 'clear'], ]); } @@ -455,6 +478,11 @@ trait UserControllerReportOperations { if ($reporter_id <= 0) { return $this->error(__('请先登录', 'aksrt'), 401); } + + // 同一人对同一目标的待处理工单只允许存在一条 + if (\AKSRT\Messages\ReportCenter::get_instance()->has_pending_report($reporter_id, $target_type, $target_id)) { + return $this->error(__('你已提交过相同的举报,请等待处理结果', 'aksrt'), 409); + } if ($target_type === 'user') { if ($target_id === $reporter_id) { return $this->error(__('不能举报自己', 'aksrt'), 400); diff --git a/src/Messages/ReportCenter.php b/src/Messages/ReportCenter.php index 01cf1827..c32db01c 100644 --- a/src/Messages/ReportCenter.php +++ b/src/Messages/ReportCenter.php @@ -190,129 +190,18 @@ class ReportCenter { } /** - * 一次性迁移:把 option 中的举报数据搬到表中。 - * 幂等 — 已存在的 ID 不会重复插入。 + * 同一举报人对同一目标是否已有待处理工单(防止重复提交)。 */ - public function migrate_from_option(): int { + public function has_pending_report(int $reporter_id, string $target_type, int $target_id): bool { $table = $this->table(); - if ($table === '') return 0; - - $reports = get_option('aksrt_reports', []); - if (!is_array($reports) || empty($reports)) return 0; + if ($table === '') return false; global $wpdb; - $migrated = 0; - - foreach ($reports as $old_id => $report) { - // 检查是否已迁移(通过 reporter_id + target_type + target_id + created_at 去重) - $existing = $wpdb->get_var($wpdb->prepare( - "SELECT id FROM {$table} WHERE reporter_id = %d AND target_type = %s AND target_id = %d AND created_at = %s", - (int) ($report['reporter_id'] ?? 0), - sanitize_key((string) ($report['target_type'] ?? '')), - (int) ($report['target_id'] ?? 0), - (string) ($report['created_at'] ?? '') - )); - if ($existing) continue; - - $notes_json = ''; - if (!empty($report['notes']) && is_array($report['notes'])) { - $notes_json = wp_json_encode($report['notes']); - } - - $wpdb->insert($table, [ - 'reporter_id' => (int) ($report['reporter_id'] ?? 0), - 'target_type' => sanitize_key((string) ($report['target_type'] ?? '')), - 'target_id' => (int) ($report['target_id'] ?? 0), - 'reason' => sanitize_textarea_field((string) ($report['reason'] ?? '')), - 'category' => sanitize_key((string) ($report['category'] ?? '')), - 'status' => sanitize_key((string) ($report['status'] ?? 'pending')), - 'created_at' => (string) ($report['created_at'] ?? current_time('mysql')), - 'updated_at' => (string) ($report['updated_at'] ?? ''), - 'notes_json' => $notes_json, - ], ['%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s']); - - if ($wpdb->insert_id) $migrated++; - } - - if ($migrated > 0) { - update_option('aksrt_report_migrated_to_table', time()); - } - - return $migrated; - } - - /** - * Initialize AJAX hooks for admin operations. - */ - public function init(): void { - add_action('wp_ajax_aksrt_report_update_status', [$this, 'ajax_update_status']); - add_action('wp_ajax_aksrt_report_list', [$this, 'ajax_list']); - add_action('wp_ajax_aksrt_report_counts', [$this, 'ajax_counts']); - - // 一次性迁移钩子 - add_action('admin_init', [$this, 'maybe_migrate'], 20); - } - - /** - * 自动迁移旧 option 数据(幂等) - */ - public function maybe_migrate(): void { - if (get_option('aksrt_report_migrated_to_table')) return; - $this->migrate_from_option(); - } - - /** - * AJAX: Update report status (admin only). - */ - public function ajax_update_status(): void { - if (!current_user_can('manage_options')) { - wp_send_json_error(['message' => __('权限不足', 'aksrt')]); - } - - check_ajax_referer('aksrt_report', '_nonce'); - - $report_id = (int) ($_POST['report_id'] ?? 0); - $new_status = sanitize_key((string) ($_POST['status'] ?? '')); - $note = sanitize_textarea_field((string) ($_POST['note'] ?? '')); - - if ($report_id <= 0 || $new_status === '') { - wp_send_json_error(['message' => __('参数无效', 'aksrt')]); - } - - $result = $this->update_status($report_id, $new_status, $note); - if ($result) { - wp_send_json_success(['message' => __('状态已更新', 'aksrt')]); - } - - wp_send_json_error(['message' => __('更新失败', 'aksrt')]); - } - - /** - * AJAX: Get report list (admin only). - */ - public function ajax_list(): void { - if (!current_user_can('manage_options')) { - wp_send_json_error(['message' => __('权限不足', 'aksrt')]); - } - - $status = sanitize_key((string) ($_GET['status'] ?? '')); - $limit = max(1, min(100, (int) ($_GET['limit'] ?? 20))); - $offset = max(0, (int) ($_GET['offset'] ?? 0)); - - $reports = $this->get_reports(['status' => $status, 'limit' => $limit, 'offset' => $offset]); - $counts = $this->get_status_counts(); - - wp_send_json_success(['reports' => $reports, 'counts' => $counts]); - } - - /** - * AJAX: Get status counts (admin only). - */ - public function ajax_counts(): void { - if (!current_user_can('manage_options')) { - wp_send_json_error(['message' => __('权限不足', 'aksrt')]); - } - - wp_send_json_success($this->get_status_counts()); + return (bool) $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$table} WHERE reporter_id = %d AND target_type = %s AND target_id = %d AND status = 'pending' LIMIT 1", + $reporter_id, + sanitize_key($target_type), + $target_id + )); } } diff --git a/src/Settings/MailNotifications.php b/src/Settings/MailNotifications.php index 391c073d..68b22318 100644 --- a/src/Settings/MailNotifications.php +++ b/src/Settings/MailNotifications.php @@ -176,7 +176,7 @@ final class MailNotifications { public static function report_submitted(int $report_id, array $report): void { $reporter = get_userdata((int) ($report['reporter_id'] ?? 0)); - self::send_admin('report_admin', ['username' => $reporter instanceof \WP_User ? $reporter->display_name : __('用户', 'aksrt'), 'title' => (string) ($report['target_type'] ?? ''), 'url' => admin_url('users.php?page=aksrt_reports')]); + self::send_admin('report_admin', ['username' => $reporter instanceof \WP_User ? $reporter->display_name : __('用户', 'aksrt'), 'title' => (string) ($report['target_type'] ?? ''), 'url' => admin_url('users.php?page=aksrt_user_ban&tab=reports')]); } public static function report_status_changed(int $report_id, int $reporter_id, string $status): void { diff --git a/src/User/Home.php b/src/User/Home.php index b46a6307..56fa7d49 100644 --- a/src/User/Home.php +++ b/src/User/Home.php @@ -355,8 +355,18 @@ class Home { return []; } - $state = sanitize_key((string) get_user_meta($user_id, 'aksrt_user_ban_state', true)); - if (!in_array($state, ['muted', 'restricted', 'banned'], true)) { + $state = ''; + if (class_exists('\AKSRT\User\Restriction')) { + $svc = \AKSRT\User\Restriction::get_instance(); + if ($svc->is_banned($user_id)) { + $state = 'banned'; + } elseif ($svc->is_restricted($user_id, \AKSRT\User\Restriction::TYPE_SUSPEND)) { + $state = 'restricted'; + } elseif ($svc->is_restricted($user_id, \AKSRT\User\Restriction::TYPE_MUTE)) { + $state = 'muted'; + } + } + if ($state === '') { return []; } diff --git a/src/User/Restriction.php b/src/User/Restriction.php index aa3c0eda..cf797c00 100644 --- a/src/User/Restriction.php +++ b/src/User/Restriction.php @@ -75,8 +75,11 @@ class Restriction { return ['success' => false, 'message' => 'Database error.']; } - // Update user meta for quick lookup - $this->sync_user_restriction_meta($user_id); + // 封禁立即生效:销毁目标用户全部登录会话 + if ($type === self::TYPE_BAN && $user_id > 0) { + \WP_Session_Tokens::get_instance($user_id)->destroy_all(); + } + do_action('aksrt_user_restriction_changed', $user_id, $type, 'applied', (string) $data['reason']); return ['success' => true, 'id' => $wpdb->insert_id]; @@ -92,12 +95,39 @@ class Restriction { $result = $wpdb->delete($this->table(), ['id' => $restriction_id]); if ($result) { - $this->sync_user_restriction_meta((int) $restriction['user_id']); do_action('aksrt_user_restriction_changed', (int) $restriction['user_id'], (string) ($restriction['type'] ?? ''), 'removed', ''); } return (bool) $result; } + /** + * 解除用户生效中的限制(可选按类型过滤),用于申诉通过或举报工单"解除限制"。 + * + * @param int $user_id 用户 ID + * @param array $types 要解除的类型,默认解除 ban/suspend/mute(保留 warn 警告记录) + * @return int 解除的记录数 + */ + public function lift_user_restriction(int $user_id, array $types = [self::TYPE_BAN, self::TYPE_SUSPEND, self::TYPE_MUTE]): int { + global $wpdb; + + $types = array_values(array_intersect($types, self::TYPES)); + if ($user_id <= 0 || empty($types)) { + return 0; + } + + $placeholders = implode(',', array_fill(0, count($types), '%s')); + $deleted = $wpdb->query($wpdb->prepare( + "DELETE FROM {$this->table()} WHERE user_id = %d AND (expires_at IS NULL OR expires_at > %s) AND type IN ({$placeholders})", + array_merge([$user_id, current_time('mysql', true)], $types) + )); + + if ($deleted > 0) { + do_action('aksrt_user_restriction_changed', $user_id, implode(',', $types), 'lifted', ''); + } + + return (int) $deleted; + } + /** * Get a single restriction by ID. */ @@ -194,16 +224,6 @@ class Restriction { return $post_id; } - /** - * Sync restriction meta for quick lookup. - */ - private function sync_user_restriction_meta(int $user_id): void { - $active = $this->get_user_restrictions($user_id, true); - $types = array_unique(array_column($active, 'type')); - update_user_meta($user_id, '_aksrt_active_restrictions', $types); - update_user_meta($user_id, '_aksrt_is_banned', in_array(self::TYPE_BAN, $types, true) ? 1 : 0); - } - /** * Get restriction stats for admin overview. */ diff --git a/tests/widget-system/report-ban-contract.test.cjs b/tests/widget-system/report-ban-contract.test.cjs new file mode 100644 index 00000000..2c1022df --- /dev/null +++ b/tests/widget-system/report-ban-contract.test.cjs @@ -0,0 +1,116 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const root = path.resolve(__dirname, '..', '..') +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8') + +const userController = read('src/Admin/UserController.php') +const reportOps = read('src/Admin/UserControllerReportOperations.php') +const restrictionOps = read('src/Admin/UserControllerRestrictionOperations.php') +const restriction = read('src/User/Restriction.php') +const reportCenter = read('src/Messages/ReportCenter.php') +const usersMenus = read('src/Admin/Native/UsersMenus.php') +const mailNotifications = read('src/Settings/MailNotifications.php') +const home = read('src/User/Home.php') +const commentItem = read('inc/helpers/comments/item.php') +const usersBan = read('assets/admin/src/pages/users/UsersBan.vue') +const functions = read('functions.php') +const uninstall = read('uninstall.php') + +test('report REST surface is canonical: submit + users/reports only', () => { + assert.match(userController, /'\/report\/submit'/) + assert.match(userController, /'\/users\/reports'/) + assert.match(userController, /'\/users\/reports\/\(\?P\\d\+\)'/) + assert.match(userController, /'\/users\/reports\/\(\?P\\d\+\)\/process'/) + for (const dead of ["'/report/list'", "'/report/update-status'", "'/report/counts'"]) { + assert.ok(!userController.includes(dead), `alias route should be removed: ${dead}`) + } + assert.ok(!reportOps.includes('public function update_report_status'), 'dead update_report_status method') + assert.ok(!reportOps.includes('public function get_report_counts'), 'dead get_report_counts method') +}) + +test('report center has no legacy ajax handlers or option migration', () => { + assert.ok(!reportCenter.includes('ajax_update_status')) + assert.ok(!reportCenter.includes('ajax_list')) + assert.ok(!reportCenter.includes('ajax_counts')) + assert.ok(!reportCenter.includes('migrate_from_option')) + assert.ok(!reportCenter.includes('maybe_migrate')) + assert.ok(!reportCenter.includes("get_option('aksrt_reports'")) + assert.ok(!functions.includes('ReportCenter::get_instance()->init()'), 'ReportCenter init() is gone') + assert.match(reportCenter, /public function has_pending_report/) + assert.match(reportOps, /has_pending_report\(\$reporter_id, \$target_type, \$target_id\)/) +}) + +test('report processing writes real restrictions, not a display-only meta', () => { + assert.match(reportOps, /->restrict\(\s*\$target_id,\s*\$action_taken,/) + assert.match(reportOps, /lift_user_restriction\(\$target_id\)/) + assert.match(reportOps, /in_array\(\$action_taken, \\AKSRT\\User\\Restriction::TYPES, true\)/) + assert.ok(!reportOps.includes('aksrt_user_ban_state'), 'dual-track ban state meta must not be written or read') + for (const deadAction of ["'value' => 'warned'", "'value' => 'muted'", "'value' => 'restricted'", "'value' => 'banned'"]) { + assert.ok(!reportOps.includes(deadAction), `action value must map to Restriction types: ${deadAction}`) + } + for (const action of ["'value' => 'warn'", "'value' => 'mute'", "'value' => 'suspend'", "'value' => 'ban'", "'value' => 'clear'"]) { + assert.ok(reportOps.includes(action), `missing action option: ${action}`) + } + assert.match(reportOps, /get_user_restriction_state/) +}) + +test('restriction class supports lifting and kicks sessions on ban', () => { + assert.match(restriction, /public function lift_user_restriction\(int \$user_id/) + assert.match(restriction, /public function lift_user_restriction\(int \$user_id, array \$types = \[self::TYPE_BAN, self::TYPE_SUSPEND, self::TYPE_MUTE\]\): int/) + assert.match(restriction, /WP_Session_Tokens::get_instance\(\$user_id\)->destroy_all\(\)/) + assert.ok(!restriction.includes('sync_user_restriction_meta'), 'write-only meta sync is dead code') + assert.ok(!restriction.includes('_aksrt_is_banned')) +}) + +test('ban admin REST endpoints stay in sync with the SPA client', () => { + assert.match(userController, /'\/users\/\(\?P\\d\+\)\/restrictions'/) + assert.match(userController, /'\/users\/\(\?P\\d\+\)\/restrict'/) + assert.match(usersBan, /\/users\/\$\{addForm\.user_id\}\/restrict`/) + assert.ok(!usersBan.includes('/restrictions`, {'), 'SPA must not POST to the unregistered /restrictions endpoint') + assert.match(usersBan, /expires_at: durationToExpiresAt\(addForm\.duration\)/) + for (const t of ["value: 'warn'", "value: 'mute'", "value: 'suspend'", "value: 'ban'"]) { + assert.ok(usersBan.includes(t), `SPA restriction type missing: ${t}`) + } + for (const dead of ["value: 'muted'", "value: 'restricted'", "value: 'banned'"]) { + assert.ok(!usersBan.includes(dead), `invalid SPA restriction type: ${dead}`) + } +}) + +test('ban appeal approval actually lifts restrictions', () => { + assert.match(usersMenus, /lift_user_restriction\(\$user_id\)/) + assert.ok(!usersMenus.includes("SET status = 'lifted'"), 'restriction table has no status column') + assert.ok(!usersMenus.includes('clear_user_bans')) + assert.ok(!usersMenus.includes('lift_ban')) +}) + +test('profile governance notice reads from the restriction table', () => { + assert.match(home, /->is_banned\(\$user_id\)/) + assert.match(home, /\\AKSRT\\User\\Restriction::get_instance\(\)/) + assert.ok(!home.includes('aksrt_user_ban_state')) +}) + +test('admin report notification links to the real page', () => { + assert.match(mailNotifications, /users\.php\?page=aksrt_user_ban&tab=reports/) + assert.ok(!mailNotifications.includes('page=aksrt_reports&'), 'dead admin page link') +}) + +test('comments expose a report entry for other users when reporting is enabled', () => { + assert.match(commentItem, /data-aksrt-report=\\?'\{"target_type":"comment","target_id":/) + assert.match(commentItem, /function aksrt_get_comment_report_html/) + assert.match(commentItem, /report'\]\['enabled'\]/) + assert.match(commentItem, /\$author_id === \$reporter_id/) +}) + +test('uninstall cleans up every ban/report meta key written by the theme', () => { + for (const key of ['_aksrt_active_restrictions', '_aksrt_is_banned', 'aksrt_user_ban_state']) { + assert.ok(uninstall.includes(`'${key}'`), `missing uninstall cleanup: ${key}`) + } +}) + +test('restriction REST operations pass expiry through to the service', () => { + assert.match(restrictionOps, /\$params\['expires_at'\] \?\? ''/) + assert.match(restrictionOps, /Restriction::get_instance\(\)->restrict\(/) +}) diff --git a/uninstall.php b/uninstall.php index 5b09cce8..1d35aede 100644 --- a/uninstall.php +++ b/uninstall.php @@ -67,6 +67,7 @@ $meta_keys = [ 'aksrt_unread_message_count', '_aksrt_active_restrictions', '_aksrt_is_banned', + 'aksrt_user_ban_state', '_aksrt_favorite_posts', 'aksrt_wechat_openid', 'aksrt_wechat_subscriptions',