class-swm-visitors.php
<?php
/**
* Description: Website visits table.
* PHP version 8.0.1
*
* @category Visits
* @package SiteWatchman
* @author Oleg Klenitsky <klenitskiy.oleg@mail.ru>
* @version 1.0.0
* @license GPLv2 or later
*/
namespace sitewatchman\swm;
if ( ! defined( 'ABSPATH' ) ) {
exit();
}
if ( ! class_exists( 'WP_List_Table' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
/**
* Website visits table.
*/
class SWM_Visitors extends \WP_List_Table {
/**
* Description: Saves the current values variables in the array for all modules of plugin.
*
* @var array
*/
private static $swm_data;
/**
* Constructor.
*/
public function __construct() {
parent::__construct(
array(
'singular' => 'visitor',
'plural' => 'visitors',
'ajax' => false,
)
);
}
/**
* Description: Search message.
*/
public function no_items() {
$prm_search = get_option( 'swm_search' );
if ( '' === $prm_search ) {
echo 'No items found.';
} else {
echo wp_kses(
'No results were found for this filter value: <b>' . esc_html( $prm_search ) . '</b>',
array( 'b' => array() )
);
update_option( 'swm_search', '' );
}
}
/**
* Description: Saves variables for future use.
*
* @param string $name Set name variable.
* @param string $value Set value variable.
*/
public static function swm_set( $name, $value ) {
self::$swm_data[ $name ] = $value;
}
/**
* Description: Gets variables for future use.
*
* @param string $name The name of the variable to get the value.
* @return array|false
*/
public static function swm_get( $name ) {
return ( isset( self::$swm_data[ $name ] ) ) ? self::$swm_data[ $name ] : false;
}
/**
* Description: Creates column names for a table.
*
* @return array Name of columns.
*/
public function get_columns() {
return array(
'cb' => '<input type="checkbox" />',
'id' => 'ID',
'uid' => 'UID',
'user_login' => 'Login',
'user_role' => 'Role',
'time_visit' => 'Date visit',
'user_ip' => 'Visitor IP',
'black_list' => 'Black list',
'page_visit' => 'Page visit',
'page_from' => 'Page from',
'info' => 'Info',
);
}
/**
* Description: Determines which columns of the table can be sorted.
*
* @return array Sortable columns
*/
public function get_sortable_columns() {
$sortable_columns = array(
'id' => array( 'id', true ),
'uid' => array( 'uid', true ),
'user_login' => array( 'user_login', true ),
'user_role' => array( 'user_role', true ),
'time_visit' => array( 'time_visit', true ),
'user_ip' => array( 'user_ip', true ),
'page_visit' => array( 'page_visit', true ),
'page_from' => array( 'page_from', true ),
);
return $sortable_columns;
}
/**
* 9. Главный управляющий метод таблицы (Сборщик данных).
*
* Срабатывает ПЕРЕД выводом HTML-таблицы на экран. Наполняет массив $this->items.
* Сюда стекаются все GET-запросы от фильтров, поиска, пагинации и кликов сортировки.
*/
public function prepare_items() {
global $wpdb;
$this->process_bulk_action();
// Обработка поисковых запросов формы.
if ( isset( $_POST['swm_level1_search_nonce'] ) &&
wp_verify_nonce( sanitize_key( wp_unslash( $_POST['swm_level1_search_nonce'] ) ), self::ACTION_SEARCH_LEVEL1 ) ) {
// Проверяем и сохраняем именно filter_level1_search.
if ( isset( $_POST['filter_level1_search'] ) ) {
update_option( 'swm_search', sanitize_text_field( wp_unslash( $_POST['filter_level1_search'] ) ) );
}
}
// 1. Получаем гарантированную строку WHERE и аргументы.
$where_data = $this->swm_make_where_query();
$where_string = $where_data['query_string'];
$where_args = $where_data['args'];
// 2. Определяем текущую активную вкладку (фильтр 2-го уровня)
$current_filter = isset( $_REQUEST['filter_level2_visit'] ) ? sanitize_key( wp_unslash( $_REQUEST['filter_level2_visit'] ) ) : 'all';
$cache_group = 'swm_visitors_counts';
$cache_key_suffix = md5( $where_string );
// --- 1. Для базовой вкладки "Все" ---
$cache_key_all = 'all_' . $cache_key_suffix;
$all = wp_cache_get( $cache_key_all, $cache_group );
if ( false === $all ) {
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$all = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors {$where_string}",
$where_args
)
);
wp_cache_set( $cache_key_all, $all, $cache_group, 60 );
}
// --- 2. Для radio buttons statuses ---
// Так как $where_string гарантированно содержит ' WHERE ...', условия статусов добавляются через AND.
$status_query_base = "SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors " . $where_string . ' AND `status` = %s';
// Статус: unlogged.
$cache_key_unlogged = 'unlogged_' . $cache_key_suffix;
$unlogged = wp_cache_get( $cache_key_unlogged, $cache_group );
if ( false === $unlogged ) {
$unlogged_args = array_merge( $where_args, array( 'unlogged' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$unlogged = (int) $wpdb->get_var(
$wpdb->prepare(
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors {$where_string} AND `status` = %s",
$unlogged_args
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
wp_cache_set( $cache_key_unlogged, $unlogged, $cache_group, 60 );
}
// Статус: success.
$cache_key_success = 'success_' . $cache_key_suffix;
$success = wp_cache_get( $cache_key_success, $cache_group );
if ( false === $success ) {
$success_args = array_merge( $where_args, array( 'success' ) );
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$success = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors {$where_string} AND `status` = %s",
$success_args
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
wp_cache_set( $cache_key_success, $success, $cache_group, 60 );
}
// Статус: failed.
$cache_key_failed = 'failed_' . $cache_key_suffix;
$failed = wp_cache_get( $cache_key_failed, $cache_group );
if ( false === $failed ) {
$failed_args = array_merge( $where_args, array( 'failed' ) );
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$failed = (int) $wpdb->get_var(
$wpdb->prepare(
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors {$where_string} AND `status` = %s",
$failed_args
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
wp_cache_set( $cache_key_failed, $failed, $cache_group, 60 );
}
// Статус: robot.
$cache_key_robots = 'robots_' . $cache_key_suffix;
$robots = wp_cache_get( $cache_key_robots, $cache_group );
if ( false === $robots ) {
$robots_args = array_merge( $where_args, array( 'robot' ) );
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$robots = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors {$where_string} AND `status` = %s",
$robots_args
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
wp_cache_set( $cache_key_robots, $robots, $cache_group, 60 );
}
// --- 3. Для черного списка ---
$cache_key_blacklist = 'blacklist_' . $cache_key_suffix;
$blacklist = wp_cache_get( $cache_key_blacklist, $cache_group );
if ( false === $blacklist ) {
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$blacklist = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors {$where_string} AND `black_list` <> ''",
$where_args
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
wp_cache_set( $cache_key_blacklist, $blacklist, $cache_group, 60 );
}
// Выбираем верное значение общего количества элементов для пагинации текущего таба.
switch ( $current_filter ) {
case 'unlogged':
$total_items = $unlogged;
break;
case 'success':
$total_items = $success;
break;
case 'failed':
$total_items = $failed;
break;
case 'robots':
$total_items = $robots;
break;
case 'blacklist':
$total_items = $blacklist;
break;
case 'all':
default:
$total_items = $all;
break;
}
// Сохраняем актуальные цифры для вывода количества в HTML-вкладках.
$this->swm_set( 'allTotal', $all );
$this->swm_set( 'successTotal', $success );
$this->swm_set( 'failedTotal', $failed );
$this->swm_set( 'visitsTotal', $unlogged );
$this->swm_set( 'robotsTotal', $robots );
$this->swm_set( 'blacklistTotal', $blacklist );
// 4. Настройка лимитов отображения (Количество элементов на страницу).
$per_page = get_user_option( 'swm_visitors_per_page' );
if ( false === $per_page || $per_page <= 0 ) {
$per_page = 10;
}
// Вычисляем корректное смещение (Offset).
$offset = $per_page * ( $this->get_pagenum() - 1 );
// 5. Инициализация заголовков таблицы.
$columns = $this->get_columns();
$screen = get_current_screen();
$hidden_cols = $screen instanceof WP_Screen ? get_user_option( 'manage' . $screen->id . 'columnshidden' ) : array();
$hidden = is_array( $hidden_cols ) ? $hidden_cols : array();
$sortable = $this->get_sortable_columns();
$this->_column_headers = array( $columns, $hidden, $sortable );
// 6. Обработка параметров сортировки.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$orderby = isset( $_REQUEST['orderby'] ) ? sanitize_key( wp_unslash( $_REQUEST['orderby'] ) ) : 'id';
if ( empty( $orderby ) ) {
$orderby = 'id';
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$_order = isset( $_REQUEST['order'] ) ? sanitize_key( wp_unslash( $_REQUEST['order'] ) ) : 'desc';
$order = ( 'asc' === strtolower( $_order ) ) ? 'asc' : 'desc';
// 7. Получаем отфильтрованные данные именно для текущей страницы
$this->items = $this->swm_visit_get_data( $orderby, $order, $per_page, $offset, $where_args );
// 8. Передаем правильные аргументы в пагинатор WordPress
$this->set_pagination_args(
array(
'total_items' => $total_items,
'per_page' => $per_page,
'total_pages' => ( $total_items > 0 ) ? ceil( $total_items / $per_page ) : 1,
)
);
}
/**
* Forms the SQL WHERE string and arguments array.
* Never returns an empty string; uses defaults instead.
*
* @return array Contains 'query_string' (string) and 'args' (array) for wpdb->prepare().
*/
/**
* Forms the SQL WHERE string and arguments array.
* Never returns an empty string; uses defaults instead.
*
* @return array Contains 'query_string' (string) and 'args' (array) for wpdb->prepare().
*/
private function swm_make_where_query() {
global $wpdb;
$where_clauses = array();
$where_args = array();
// 1. Читаем параметры из универсального $_REQUEST (поддерживает и GET, и AJAX-POST).
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$_req_role = isset( $_REQUEST['filter_role'] ) ? sanitize_key( wp_unslash( $_REQUEST['filter_role'] ) ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$_req_time = isset( $_REQUEST['filter_time'] ) ? sanitize_key( wp_unslash( $_REQUEST['filter_time'] ) ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$_req_country = isset( $_REQUEST['filter_country'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['filter_country'] ) ) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$_req_search = isset( $_REQUEST['filter_search'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['filter_search'] ) ) : '';
// 2. Сбрасываем дефолтные текстовые заглушки селектов.
if ( 'all roles' === strtolower( $_req_role ) || 'all_roles' === strtolower( $_req_role ) ) {
$_req_role = '';
}
if ( 'all months' === strtolower( $_req_time ) || 'all_months' === strtolower( $_req_time ) ) {
$_req_time = '';
}
if ( 'all countries' === strtolower( $_req_country ) || 'all_countries' === strtolower( $_req_country ) ) {
$_req_country = '';
}
// 3. Условие для фильтра по РОЛИ.
if ( ! empty( $_req_role ) ) {
$role_clean = sanitize_key( $_req_role );
if ( '0' === $role_clean || 0 === $role_clean ) {
$where_clauses['filter_level1_role'] = 'uid <> 0 AND user_role LIKE %s';
$where_args[] = $role_clean;
} else {
$where_clauses['filter_level1_role'] = 'user_role LIKE %s';
$where_args[] = $role_clean;
}
} else {
$where_clauses['filter_level1_role'] = 'user_role LIKE %s';
$where_args[] = '%';
}
// 4. Условие для фильтра по ВРЕМЕНИ.
if ( ! empty( $_req_time ) && strlen( $_req_time ) >= 6 ) {
$year = absint( substr( $_req_time, 0, 4 ) );
$month = absint( substr( $_req_time, -2 ) );
if ( $year > 0 && $month > 0 ) {
$where_clauses['filter_level1_time'] = 'YEAR(time_visit) = %d AND MONTH(time_visit) = %d';
$where_args[] = $year;
$where_args[] = $month;
}
} else {
$where_clauses['filter_level1_time'] = 'time_visit LIKE %s';
$where_args[] = '%';
}
// 5. Условие для фильтра по СТРАНЕ.
if ( ! empty( $_req_country ) ) {
$where_clauses['filter_level1_country'] = 'user_ip LIKE %s';
$where_args[] = '%<br>' . $wpdb->esc_like( $_req_country ) . '<br>%';
} else {
$where_clauses['filter_level1_country'] = 'user_ip LIKE %s';
$where_args[] = '%';
}
// 6. Условие для живого поиска (поиск по login или ip).
if ( ! empty( $_req_search ) ) {
$where_clauses['filter_login_ip'] = '(user_login LIKE %s OR user_ip LIKE %s)';
$search_wildcard = '%' . $wpdb->esc_like( $_req_search ) . '%';
$where_args[] = $search_wildcard;
$where_args[] = $search_wildcard;
} else {
$where_clauses['filter_login_ip'] = '(user_login LIKE %s OR user_ip LIKE %s)';
$where_args[] = '%';
$where_args[] = '%';
}
// Склеиваем все условия через AND. Строка гарантированно будет содержать плейсхолдеры.
$query_string = ' WHERE ' . implode( ' AND ', $where_clauses );
return array(
'query_string' => $query_string,
'args' => $where_args,
);
}
/**
* Getting data to display in the main plugin table.
*
* @param boolean $orderby [description].
* @param boolean $order [description].
* @param integer $limit [description].
* @param integer $offset [description].
* @param array $where_args [description].
* @return [type] [description]
*/
private function swm_visit_get_data( $orderby = false, $order = false, $limit = 0, $offset = 0, $where_args = array() ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$_result = isset( $_REQUEST['filter_level2_visit'] ) ? sanitize_key( wp_unslash( $_REQUEST['filter_level2_visit'] ) ) : 'all';
global $wpdb;
// 1. Получаем базовую структуру запроса 1-го уровня.
$where_data = $this->swm_make_where_query();
$query_string = $where_data['query_string'];
$base_args = $where_data['args'];
// Если по какой-то причине передан пустой массив аргументов извне, страхуемся внутренним.
if ( empty( $where_args ) ) {
$where_args = $base_args;
}
// 2. Дополнительные фильтры 2-го уровня (вкладки статусов).
$level2_clauses = array();
switch ( $_result ) {
case '':
case 'all':
$level2_clauses[] = '`status` IS NOT NULL AND LENGTH(`status`) > 0';
break;
case 'unlogged':
$level2_clauses[] = "`status` = 'unlogged'";
break;
case 'success':
$level2_clauses[] = "`status` = 'success'";
break;
case 'failed':
$level2_clauses[] = "`status` = 'failed'";
break;
case 'robots':
$level2_clauses[] = "`status` = 'robot'";
break;
case 'blacklist':
$level2_clauses[] = "black_list <> ''";
break;
}
// Так как $query_string гарантированно начинается с " WHERE ...",
// новые условия второго уровня дописываются через AND к общему SQL-запросу.
$final_where = $query_string;
if ( ! empty( $level2_clauses ) ) {
$final_where .= ' AND ' . implode( ' AND ', $level2_clauses );
}
// 3. Валидация параметров сортировки.
$orderby = ( ! isset( $orderby ) || '' === $orderby ) ? 'time_visit' : $orderby;
$order = ( ! isset( $order ) || '' === $order ) ? 'DESC' : $order;
// Валидация направления сортировки (белый список).
$order = ( 'asc' === strtolower( $order ) ) ? 'ASC' : 'DESC';
// Валидация колонок (белый список), чтобы избежать инъекций через ORDER BY.
if ( ! in_array( $orderby, array( 'id', 'uid', 'user_login', 'user_role', 'time_visit', 'user_ip', 'page_visit', 'page_from' ), true ) ) {
$orderby = 'time_visit';
}
// 4. Формируем аргументы.
$final_args = $where_args;
$final_args[] = absint( $limit );
$final_args[] = absint( $offset );
// 5. Собираем правильный шаблон.
$raw_sql = 'SELECT * FROM ' . $wpdb->prefix . 'swm_visitors ' . $final_where . ' ORDER BY ' . $orderby . ' ' . $order . ' LIMIT %d OFFSET %d';
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
/**
* Bypass strict static analysis for a complex, safely pre-validated dynamic SQL query structure.
*
* @psalm-suppress All
* @phpstan-ignore-next-line
*/
$results = $wpdb->get_results(
$wpdb->prepare( (string) $raw_sql, $final_args ),
'ARRAY_A'
);
// phpcs:enable
return $results;
}
/**
* Вывод дефолтной колонки.
*
* @param [type] $item [description].
* @param [type] $column_name [description].
* @return [type] [description].
*/
public function column_default( $item, $column_name ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$_mode = isset( $_GET['mode'] ) ? sanitize_key( wp_unslash( $_GET['mode'] ) ) : '';
switch ( $column_name ) {
case 'id':
case 'uid':
case 'time_visit':
case 'user_login':
case 'user_role':
return esc_html( $item[ $column_name ] );
case 'page_visit':
case 'page_from':
case 'info':
$data = rawurldecode( $item[ $column_name ] );
$output = ( isset( $_mode ) && 'excerpt' === $_mode ) ? $data : substr( $data, 0, 115 ) . '...';
return wp_kses(
$output,
array(
'span' => array( 'style' => true ),
'br' => array(),
)
);
default:
return $item[ $column_name ];
}
}
/**
* Вывод чекбокса.
*
* @param [type] $item [description].
* @return [type] [description].
*/
public function column_cb( $item ) {
return sprintf( '<input type="checkbox" name="bulk_ids[]" value="%d" />', absint( $item['id'] ) );
}
/**
* Fills table cells with data in column user_login (Login).
*
* @param array $item Content cell of table.
* @return string
*/
public function column_user_login( $item ) {
$user_login = ! empty( $item['user_login'] ) ? esc_html( $item['user_login'] ) : '—';
// Если есть ID пользователя, добавляем аватар сверху.
if ( ! empty( $item['uid'] ) ) {
$avatar = get_avatar( $item['uid'], 30 );
if ( $avatar ) {
$user_login = $avatar . '<br>' . $user_login;
}
}
return '<span style="display: inline-block; word-break: break-word;">' . $user_login . '</span>';
}
/**
* [column_user_ip description]
*
* @param [type] $item [description].
* @return [type] [description].
*/
public function column_user_ip( $item ) {
// Безопасно получаем значение IP и приводим его к строке.
$raw_ip = isset( $item['user_ip'] ) ? (string) $item['user_ip'] : '';
// Если поле совсем пустое, возвращаем пустую строку.
if ( trim( $raw_ip ) === '' ) {
return '—';
}
// Разделяем строку по тегу <br>.
$arr = explode( '<br>', $raw_ip );
// Проверяем, удалось ли вытащить код страны (индекс 1).
$country_code = ( isset( $arr[1] ) && strlen( trim( $arr[1] ) ) === 2 ) ? strtoupper( trim( $arr[1] ) ) : '';
// Собираем дополнительные строки геоданных (город, регион), если они есть.
$sub_str2 = ( isset( $arr[2] ) ) ? '<br>' . $arr[2] : '';
$sub_str3 = ( isset( $arr[3] ) ) ? '<br>' . $arr[3] : '';
$sub_str4 = ( isset( $arr[4] ) ) ? '<br>' . $arr[4] : '';
// Если код страны успешно найден, формируем HTML-тег флага.
$flag = '';
if ( '' !== $country_code ) {
$path_flag = plugins_url( '/images/flags/', __FILE__ ) . $country_code . '.gif';
$flag = ' <img src="' . esc_url( $path_flag ) . '" alt="' . esc_attr( $country_code ) . '" style="vertical-align: middle; margin-left: 5px;">';
}
// Собираем итоговую строку для вывода в таблицу WP_List_Table.
$ip_address = isset( $arr[0] ) ? $arr[0] : '';
$str = $ip_address . '<br>' . $country_code . $flag . $sub_str2 . $sub_str3 . $sub_str4;
return $str;
}
/**
* [column_black_list description]
*
* @param [type] $item [description].
* @return [type] [description]
*/
public function column_black_list( $item ) {
// 1. Получаем ID и текущее значение ячейки (JSON строка).
$item_id = is_array( $item ) ? $item['id'] : $item->id;
$current_value = is_array( $item ) ? $item['black_list'] : $item->black_list;
// 2. ИСПРАВЛЕНО ДЛЯ AJAX: Убираем прямой URL перехода.
// Вместо него делаем заглушку "#", но маскируем под сервисную кнопку.
// Добавляем класс "swm-service-btn", атрибут "data-service" и "data-id" строки.
$actions = array(
'blacklist' => sprintf(
'<a href="#" class="swm-service-btn" data-service="blacklist" data-id="%d">%s</a>',
intval( $item_id ),
'Edit'
),
);
// 4. Формируем текстовое содержимое ячейки на основе JSON (Ваш оригинальный код).
if ( empty( $current_value ) ) {
$cell_text = '—';
} else {
$blacklist_data = json_decode( $current_value, true );
if ( ! is_array( $blacklist_data ) ) {
$cell_text = esc_html( $current_value );
} else {
// Красивый читаемый текст для ссылки ячейки.
$cell_text = sprintf(
'<strong>Start:</strong> %1$s<br><strong>End:</strong> %2$s<br><strong>Msg:</strong> %3$s',
esc_html( $blacklist_data['ban_start_date'] ?? '' ),
esc_html( $blacklist_data['ban_end_date'] ?? '' ),
esc_html( $blacklist_data['ban_message'] ?? '' )
);
}
}
// 5. Возвращаем обычный текст ячейки и синюю ссылку "Edit" при наведении.
$output = sprintf(
'%1$s %2$s',
$cell_text,
$this->row_actions( $actions )
);
// ИСПРАВЛЕНО ДЛЯ БЕЗОПАСНОСТИ WP: Расширяем белый список тегов и атрибутов для wp_kses.
// Если не разрешить атрибуты class, data-service и data-id, WordPress вырежет их при выводе!
return wp_kses(
$output,
array(
'strong' => array(),
'br' => array(),
'div' => array( 'class' => true ),
'span' => array( 'class' => true ),
'a' => array(
'href' => true,
'class' => true,
'data-service' => true,
'data-id' => true,
),
)
);
}
/**
* Description: Creates additional controls (Filters 2 level).
*
* @param string $which Displayed top or bottom.
*/
public function extra_tablenav( $which ) {
if ( 'top' === $which ) {
// 1. Проверяем общий nonce перед чтением кастомного фильтра из $_REQUEST.
if ( isset( $_REQUEST['swm_nonce'] ) && wp_verify_nonce( wp_unslash( $_REQUEST['swm_nonce'] ), 'swm_nonce' ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$current_filter = isset( $_REQUEST['filter_level2_visit'] ) ? sanitize_key( wp_unslash( $_REQUEST['filter_level2_visit'] ) ) : 'all';
} else {
// Если nonce отсутствует (например, при первом открытии страницы), задаем значение по умолчанию.
$current_filter = 'all';
}
?>
<input class="radio-level2" id="radio-1" name="filter_level2_visit" type="radio" value="all" <?php checked( $current_filter, 'all' ); ?> onclick="swm_visit(this.id)">
<label class="radio" for="radio-1"><?php echo esc_html( 'All Visits' ); ?> (<?php echo esc_html( $this->swm_get( 'allTotal' ) ); ?>)</label>
<input class="radio-level2" id="radio-2" name="filter_level2_visit" type="radio" value="unlogged" <?php checked( $current_filter, 'unlogged' ); ?> onclick="swm_visit(this.id)">
<label class="radio" for="radio-2"><?php echo esc_html( 'Unlogged' ); ?> (<?php echo esc_html( $this->swm_get( 'visitsTotal' ) ); ?>)</label>
<input class="radio-level2" id="radio-3" name="filter_level2_visit" type="radio" value="success" <?php checked( $current_filter, 'success' ); ?> onclick="swm_visit(this.id)">
<label class="radio" for="radio-3"><?php echo esc_html( 'Success' ); ?> (<?php echo esc_html( $this->swm_get( 'successTotal' ) ); ?>)</label>
<input class="radio-level2" id="radio-4" name="filter_level2_visit" type="radio" value="failed" <?php checked( $current_filter, 'failed' ); ?> onclick="swm_visit(this.id)">
<label class="radio" for="radio-4"><?php echo esc_html( 'Failed' ); ?> (<?php echo esc_html( $this->swm_get( 'failedTotal' ) ); ?>)</label>
<input class="radio-level2" id="radio-5" name="filter_level2_visit" type="radio" value="robots" <?php checked( $current_filter, 'robots' ); ?> onclick="swm_visit(this.id)">
<label class="radio" for="radio-5"><?php echo esc_html( 'Robots' ); ?> (<?php echo esc_html( $this->swm_get( 'robotsTotal' ) ); ?>)</label>
<input class="radio-level2" id="radio-6" name="filter_level2_visit" type="radio" value="blacklist" <?php checked( $current_filter, 'blacklist' ); ?> onclick="swm_visit(this.id)">
<label class="radio" for="radio-6"><?php echo esc_html( 'Black list' ); ?> (<?php echo esc_html( $this->swm_get( 'blacklistTotal' ) ); ?>)</label>
<?php
}
// Переключатель видов (Compact/Extended).
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$_mode = isset( $_GET['mode'] ) ? sanitize_key( wp_unslash( $_GET['mode'] ) ) : '';
$mode = $_mode ? $_mode : 'list';
$this->view_switcher( $mode );
if ( 'bottom' === $which ) {
// Оставляем только чистый класс моргания 'blinking', если лог не пустой.
$class_button = ( swm_check_debug_log() ) ? 'blinking' : '';
?>
<div id="btns_service_container" style="display: inline-block;">
<input type="button" value="index" id="btn_service1" class="swm-service-btn" name="btns_service" title="<?php echo esc_attr( 'index.php of site' ); ?>" style="width:75px;">
<input type="button" value="robots" id="btn_service2" class="swm-service-btn" name="btns_service" title="<?php echo esc_attr( 'robots.txt of site' ); ?>" style="width:75px;">
<input type="button" value="htaccess" id="btn_service3" class="swm-service-btn" name="btns_service" title="<?php echo esc_attr( '.htaccess of site' ); ?>" style="width:75px;">
<input type="button" value="wp-config" id="btn_service4" class="swm-service-btn" name="btns_service" title="<?php echo esc_attr( 'wp-config.php of site' ); ?>" style="width:75px;">
<input type="button" value="wp-cron" id="btn_service5" class="swm-service-btn" name="btns_service" title="<?php echo esc_attr( 'wp-cron events of site' ); ?>" style="width:75px;">
<input type="button" value="statistic" id="btn_service6" class="swm-service-btn" name="btns_service" title="<?php echo esc_attr( 'statistic of visits to site' ); ?>" style="width:75px;">
<input type="button" value="console" id="btn_service8" class="swm-service-btn" name="btns_service" title="<?php echo esc_attr( 'console' ); ?>" style="width:75px;">
<!-- Теперь тут будет чисто: class="swm-service-btn" или class="swm-service-btn blinking" -->
<input type="button" value="debug.log" id="btn_service9" class="swm-service-btn <?php echo esc_attr( $class_button ); ?>" name="btns_service" title="<?php echo esc_attr( 'error log' ); ?>" style="width:75px;">
</div>
<?php
}
}
/**
* Переопределяем стандартный переключатель видов WordPress,
* сохраняя текущую страницу пагинации при смене режима.
*
* @param [type] $current_mode [description].
*/
protected function view_switcher( $current_mode ) {
$modes = array(
'list' => 'List View',
'excerpt' => 'Excerpt View',
);
// Считываем текущую страницу пагинации, чтобы не сбрасывать её.
// // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$current_paged = isset( $_REQUEST['paged'] ) ? absint( $_REQUEST['paged'] ) : 1;
if ( $current_paged < 1 ) {
$current_paged = 1;
}
?>
<div class="view-switch">
<?php
foreach ( $modes as $mode => $title ) {
$class = ( $current_mode === $mode ) ? 'current' : '';
// Формируем правильный URL, передавая ТЕКУЩУЮ страницу paged.
$url = add_query_arg(
[
'page' => 'swm_visitors',
'paged' => $current_paged,
'mode' => $mode,
],
admin_url( 'admin.php' )
);
printf(
'<a href="%s" class="view-%s %s" title="%s"><span class="screen-reader-text">%s</span></a>',
esc_url( $url ),
esc_attr( $mode ),
esc_attr( $class ),
esc_attr( $title ),
esc_html( $title )
);
}
?>
</div>
<?php
}
/**
* Переопределяем стандартный вывод таблицы, чтобы гарантировать
* отрисовку нижних кнопок в extra_tablenav('bottom') даже при пустой БД.
*/
public function display() {
// Если элементы в базе есть, отдаем управление стандартному методу WordPress.
if ( $this->has_items() ) {
parent::display();
return;
}
// ЕСЛИ ТАБЛИЦА ПУСТАЯ: воспроизводим структуру WP вручную, но принудительно вызываем подвал.
$singular = $this->_args['singular'];
$this->views();
?>
<div class="tablenav top">
<?php $this->extra_tablenav( 'top' ); ?>
<br class="clear" />
</div>
<table class="wp-list-table <?php echo esc_attr( implode( ' ', $this->get_table_classes() ) ); ?>">
<thead>
<tr><?php $this->print_column_headers(); ?></tr>
</thead>
<tbody id="the-list" data-wp-lists="list:<?php echo esc_attr( $singular ); ?>">
<tr class="no-items">
<td class="colspanchange" colspan="<?php echo esc_attr( $this->get_column_count() ); ?>">
<?php $this->no_items(); ?>
</td>
</tr>
</tbody>
<tfoot>
<tr><?php $this->print_column_headers( false ); ?></tr>
</footer>
</table>
<div class="tablenav bottom">
<!-- МАГИЯ: WordPress бы сюда не зашел, но мы вызываем этот хук вручную! -->
<?php $this->extra_tablenav( 'bottom' ); ?>
<br class="clear" />
</div>
<?php
}
/**
* Description: Determines list of bulk actions.
*
* @return array Bulk actions
*/
public function get_bulk_actions() {
$actions = array(
'clear' => esc_html( 'Clear' ),
'delete' => esc_html( 'Delete' ),
'report' => esc_html( 'Report' ),
);
return $actions;
}
/**
* Performs bulk actions (delete, report, clear).
*/
public function process_bulk_action() {
global $wpdb;
$affected_rows = 0;
// 1. Считываем сервисную команду И кастомный AJAX-параметр swm_bulk_action
$btn_service = isset( $_REQUEST['btns_service'] ) ? sanitize_key( wp_unslash( $_REQUEST['btns_service'] ) ) : '';
$action = isset( $_REQUEST['swm_bulk_action'] ) ? sanitize_key( wp_unslash( $_REQUEST['swm_bulk_action'] ) ) : '-1';
// =========================================================================
// ТОЧКА ПЕРЕХВАТА ДЛЯ EDIT (BLACKLIST): Обрабатываем ДО общей проверки nonce таблицы
// =========================================================================
if ( 'blacklist' === $btn_service || 'blacklist' === $action ) {
// Проверка прав авторизованного администратора.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Security check2 failed.' ) );
}
$id = isset( $_REQUEST['id'] ) ? intval( $_REQUEST['id'] ) : 0;
// Буферизируем HTML-вывод, чтобы не сломать JSON-структуру ответа.
ob_start();
swm_black_list( $id );
$popup_html = ob_get_clean();
// Отдаем чистый JSON в вашу готовую функцию swm_send_table_ajax.
wp_send_json_success(
array(
'html' => '',
'popup' => $popup_html,
)
);
}
// Если массовое действие (delete, clear, report) не выбрано, выходим.
if ( ! $action || '-1' === $action || 'swm_filter_table_ajax' === $action ) {
return;
}
// 2. Стандартная проверка безопасности (Nonce) ДЛЯ МАССОВЫХ ОПЕРАЦИЙ (Удаление, Очистка и т.д.)
$nonce = isset( $_REQUEST['swm_nonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['swm_nonce'] ) ) : '';
if ( ! $nonce || ! wp_verify_nonce( $nonce, 'swm_nonce' ) ) {
wp_send_json_error( array( 'message' => 'Security check1 failed.' ) );
}
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Security check2 failed.' ) );
}
// 3. Парсим строку с ID элементов визитов, разделенными запятыми.
$bulk_ids_raw = isset( $_REQUEST['bulk_ids'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['bulk_ids'] ) ) : '';
$ids = wp_parse_id_list( $bulk_ids_raw );
if ( empty( $ids ) ) {
wp_send_json_error( array( 'message' => 'Не выбраны элементы таблицы.' ) );
}
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
switch ( $action ) {
case 'delete':
$query = sprintf(
'DELETE FROM %s WHERE `id` IN (%s) AND LENGTH(`black_list`) = 0',
$wpdb->prefix . 'swm_visitors',
$placeholders
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
$affected_rows = $wpdb->query(
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$wpdb->prepare( $query, $ids )
);
break;
case 'report':
$query = sprintf(
'SELECT `id`, `uid`, `user_login`, `user_role`, `time_visit`, `user_ip`, `page_visit`, `page_from`, `info` FROM %s WHERE `id` IN (%s)',
$wpdb->prefix . 'swm_visitors',
$placeholders
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$result = $wpdb->get_results(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$query,
$ids
),
ARRAY_A
);
if ( ! empty( $result ) ) {
$this->swm_create_report( $result );
$report_url = plugins_url( 'report/report.html', __FILE__ );
wp_send_json_success(
array(
'redirect_url' => $report_url,
'html' => '',
'popup' => '',
)
);
}
wp_send_json_error( array( 'message' => 'Нет данных для генерации отчета.' ) );
break;
case 'clear':
// 1. Подготовка и выполнение SELECT-запроса
$query_select = sprintf(
'SELECT `user_ip` FROM %s WHERE `id` IN (%s)',
$wpdb->prefix . 'swm_visitors',
$placeholders
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$visitors = $wpdb->get_results(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Запрос собран динамически для оператора IN.
$query_select,
$ids
),
ARRAY_A
);
// 2. Подготовка и выполнение UPDATE-запроса
$query = sprintf(
'UPDATE %s SET `black_list` = NULL WHERE `id` IN (%s)',
$wpdb->prefix . 'swm_visitors',
$placeholders
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$count_clear = $wpdb->query(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Запрос собран динамически для оператора IN.
$query,
$ids
)
);
$affected_rows = ( false === $count_clear ) ? 0 : $count_clear;
if ( $count_clear && ! empty( $visitors ) ) {
foreach ( $visitors as $visitor ) {
$raw_ip = ! empty( $visitor['user_ip'] ) ? $visitor['user_ip'] : '';
if ( ! empty( $raw_ip ) ) {
$parts = preg_split( '/[\r\n<]+/', $raw_ip );
$user_ip = is_array( $parts ) && isset( $parts[0] ) ? trim( wp_strip_all_tags( $parts[0] ) ) : '';
if ( filter_var( $user_ip, FILTER_VALIDATE_IP ) ) {
swm_ip_del_from_file( $user_ip );
}
}
}
}
break;
}
return array(
'action' => $action,
'sent_count' => count( $ids ),
'affected_rows' => $affected_rows,
);
}
/**
* Description: Create report file of swm_visitors table.
*
* @param [type] $result [description].
*/
/**
* Создание HTML-отчета на основе выбранных строк из базы данных.
*
* @param array $result Массив строк из таблицы swm_visitors.
* @return void
*/
public function swm_create_report( $result ) {
// Подключаем файлы ядра WP для работы с диском (критично для AJAX).
require_once ABSPATH . 'wp-admin/includes/file.php';
// Инициализируем внутреннюю файловую систему WordPress.
if ( ! function_exists( 'WP_Filesystem' ) || ! WP_Filesystem() ) {
return;
}
global $wp_filesystem;
// Формируем абсолютный путь к папке отчетов.
$path = wp_normalize_path( WP_PLUGIN_DIR . '/sitewatchman/report' );
// Создаем директорию через объект WP_Filesystem, если её нет.
if ( ! $wp_filesystem->is_dir( $path ) ) {
$wp_filesystem->mkdir( $path, FS_CHMOD_DIR );
}
// Жестко определяем список выводимых ключей и их порядок, соответствующий шапке.
$allowed_columns = array(
'id',
'uid',
'user_login',
'user_role',
'time_visit',
'user_ip',
'page_visit',
'page_from',
'info',
);
$tbody = '';
foreach ( $result as $tr_value ) {
// Преобразуем строку в массив, если прилетел объект wpdb.
$row_data = (array) $tr_value;
$td = '';
$i = 0;
foreach ( $allowed_columns as $key ) {
$td_value = isset( $row_data[ $key ] ) ? $row_data[ $key ] : '';
// ОСОБАЯ ОБРАБОТКА КОЛОНКИ INFO: склеиваем её с black_list данными нарушения.
if ( 'info' === $key ) {
$info_output = $td_value; // Исходная строка (failed/unlogged + User-Agent).
// Если у этой строки есть запись в black_list — парсим JSON и добавляем в отчет.
if ( ! empty( $row_data['black_list'] ) ) {
$black_list = json_decode( $row_data['black_list'], true );
if ( is_array( $black_list ) ) {
$ban_details = '<br><span style="color:red; font-weight:bold;">[BANNED]</span><br>';
if ( ! empty( $black_list['ban_message'] ) ) {
$ban_details .= 'Reason: ' . esc_html( $black_list['ban_message'] ) . '<br>';
}
if ( ! empty( $black_list['ban_start_date'] ) ) {
$ban_details .= 'Start: ' . esc_html( $black_list['ban_start_date'] ) . '<br>';
}
if ( ! empty( $black_list['ban_end_date'] ) ) {
$ban_details .= 'End: ' . esc_html( $black_list['ban_end_date'] ) . '<br>';
}
$info_output .= $ban_details;
}
}
$td_value = $info_output;
}
$td .= "<td class='td$i'>" . $td_value . '</td>';
++$i;
}
$tr = '<tr>' . $td . '</tr>';
$tbody = $tbody . $tr;
}
$tbody = '<tbody>' . $tbody . '</tbody>';
$table = "
<table>
<thead>
<tr>
<th class='td0'>№</th>
<th class='td1'>UID</th>
<th class='td2'>Login</th>
<th class='td3'>Role</th>
<th class='td4'>Date visit</th>
<th class='td5'>Visitor IP</th>
<th class='td6'>Page visit</th>
<th class='td7'>Page from</th>
<th class='td8'>Info</th>
</tr>
</thead>" . $tbody . '</table>';
$copyright = 'Belarus, Minsk © 2026. Developer: Oleg Klenitsky';
$foot = "<footer><img src='" . plugins_url( 'images/flags/BY.gif', __FILE__ ) . "'>" . $copyright . '</footer>';
$template = "
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<title>Report of SiteWatchman: visitors of site</title>
<style type='text/css'>
table { table-layout: fixed; width: 100%; border: 1px solid black; border-collapse: collapse; }
th { text-align: center; padding: 5px; background-color: #E0E0E0; border: 1px solid black; }
td { text-align: left; word-wrap:break-word; padding: 5px; border: 1px solid black; }
.td0, .td1 { width: 5%; }
.td2, .td3, .td4, .td5 { width: 10%; }
.td6, .td7 { width: 15%; }
.td8 { width: 20%; }
.successful { font-weight: bold; color: green; }
.failed{ font-weight:bold; color:red; }
.robot{ font-weight:bold; color:black; }
.unlogged{ font-weight:bold; color:blue; }
</style>
</head>
<body>
<h1>Report of SiteWatchman: visitors of site (" . current_time( 'mysql' ) . ')</h1>' . $table . $foot . '</body></html>';
// Записываем файл через put_contents файловой системы WP.
$file_path = $path . '/report.html';
$wp_filesystem->put_contents( $file_path, $template, FS_CHMOD_FILE );
}
}