'log', 'plural' => 'logs', 'ajax' => false, ) ); add_screen_option( 'per_page', array( 'default' => 20, 'label' => __( 'Activities', 'activity-track' ), 'option' => 'edit_atk_per_page', ) ); add_filter( 'set-screen-option', array( &$this, 'set_screen_option' ), 10, 3 ); add_filter( 'wp_kses_allowed_html', array( $this, 'allowed_html' ), 10, 2 ); set_screen_options(); } /** * Allow HTML tags in the table. * * @param array $allowedposttags Allowed HTML tags. * @param string $context Context. * @return array Allowed HTML tags. * */ public function allowed_html( $allowed, $context ) { if ( 'post' === $context ) { // Allow ', absint( $item->log_id ), esc_html( sprintf( /* translators: %d: activity log ID. */ __( 'Select log #%d', 'activity-track' ), $item->log_id ) ) ); } public function column_user_id( $item ) { global $wp_roles; if ( ! empty( $item->user_id ) && 0 !== (int) $item->user_id ) { $user = get_user_by( 'id', $item->user_id ); if ( $user instanceof WP_User && 0 !== $user->ID ) { $ex_class = ( isset( $_GET['byuser'] ) && ! empty( $_GET['byuser'] ) ) ? ' !text-blue-600 font-medium' : ''; return sprintf( '%s
%s
%s
', esc_url( $this->user_filtered_url( 'byuser', $user->ID ) ), get_avatar( $user->ID, 40 ), esc_attr( $ex_class ), esc_html( $user->display_name ), esc_html( isset( $user->roles[0], $wp_roles->role_names[ $user->roles[0] ] ) ? $wp_roles->role_names[ $user->roles[0] ] : __( 'Unknown', 'activity-track' ) ) ); } } return '
' . esc_html__( 'User not recorded', 'activity-track' ) . '' . esc_html( ! empty( $item->user_id ) ? sprintf( /* translators: %d: unavailable WordPress user ID. */ __( 'User Id %d · account unavailable', 'activity-track' ), $item->user_id ) : __( 'No signed-in user identified', 'activity-track' ) ) . '
'; } /** * Prepare download list items. */ public function prepare_items() { global $wpdb; // 1) Nonce-check via WP helper (auto-sanitizes & dies on failure) if ( isset( $_REQUEST['atk-filter'] ) ) { check_admin_referer( 'atk_filter', 'atk_filter_nonce' ); } // 2) Deletion happens via POST; make sure you sanitize inside handle_deletion() $this->handle_deletion(); // 3) Items per page is always an integer $items_per_page = absint( $this->get_items_per_page( 'edit_atk_per_page', 20 ) ); // 4) Column headers & sorting columns (no user data here) $this->_column_headers = [ $this->get_columns(), get_hidden_columns( $this->screen ), $this->get_sortable_columns(), ]; // 5) Current page as a positive integer $current_page = max( 1, absint( $this->get_pagenum() ) ); // 6) Sanitize “orderby” against your whitelist $orderby = isset( $_REQUEST['orderby'] ) ? sanitize_key( wp_unslash( $_REQUEST['orderby'] ) ) : 'created_date'; $allowed_orderbys = array_keys( $this->get_sortable_columns() ); if ( ! in_array( $orderby, $allowed_orderbys, true ) ) { $orderby = 'created_date'; } // 7) Sanitize “order” direction $order = isset( $_REQUEST['order'] ) && in_array( strtolower( wp_unslash( $_REQUEST['order'] ) ), [ 'asc', 'desc' ], true ) ? strtoupper( sanitize_text_field( wp_unslash( $_REQUEST['order'] ) ) ) : 'DESC'; // 8) Finally, fetch the items $this->get_items( $current_page, $items_per_page, $orderby, $order ); } public function set_screen_option( $status, $option, $value ) { if ( 'edit_atk_per_page' === $option ) return $value; return $status; } private function handle_deletion() { global $wpdb; if ( ! isset( $_REQUEST['atk-delete-selected'] ) ) { return; } // Check if any items were selected if ( empty( $_REQUEST['item_ids'] ) ) { // No items selected, add an admin notice add_action( 'admin_notices', array( $this, 'show_admin_del_item_error_notice' ) ); return; } // Verify the nonce for security. $raw_nonce = isset( $_REQUEST['atk_action_nonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['atk_action_nonce'] ) ) : ''; if ( ! $raw_nonce || ! wp_verify_nonce( $raw_nonce, 'atk_action_nonce' ) ) { add_action( 'admin_notices', array( $this, 'show_admin_del_item_security_notice' ) ); return; } // Align deletion capability with page access rules. if ( ! current_user_can( 'manage_options' ) && ! is_super_admin() ) { add_action( 'admin_notices', array( $this, 'show_admin_del_item_permission_notice' ) ); return; } $table = $wpdb->prefix . self::ATKAI_LOG_TABLE; // Grab, unslash, & sanitize the incoming IDs. $item_ids = isset( $_REQUEST['item_ids'] ) ? array_map( 'absint', (array) wp_unslash( $_REQUEST['item_ids'] ) ) : []; if ( empty( $item_ids ) ) { return; } $deleted_count = 0; $db_error = false; $delete_grouped_rows = $this->should_delete_grouped_rows(); foreach ( $item_ids as $item_id ) { if ( $delete_grouped_rows ) { $deleted = $this->delete_grouped_logs_by_log_id( $table, $item_id ); } else { $deleted = $wpdb->delete( $table, [ 'log_id' => $item_id ], [ '%d' ] ); } if ( false === $deleted ) { $db_error = true; } else { $deleted_count += (int) $deleted; } } if ( $db_error ) { add_action( 'admin_notices', array( $this, 'show_admin_del_item_db_error_notice' ) ); return; } if ( $deleted_count > 0 ) { add_action( 'admin_notices', function() use ( $deleted_count ) { ?>

get_row( $wpdb->prepare( "SELECT user_id, action, action_name, action_type FROM `{$table}` WHERE log_id = %d", $log_id ), ARRAY_A ); if ( empty( $group_row ) ) { return 0; } // Delete the whole group, not just one physical row. return $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE user_id = %d AND action = %s AND action_name = %s AND action_type = %s", absint( $group_row['user_id'] ), (string) $group_row['action'], (string) $group_row['action_name'], (string) $group_row['action_type'] ) ); } /** * Detect when the Pro "group similar logs" view is active and deletion should * remove the full underlying group. * * @return bool */ private function should_delete_grouped_rows() { if ( ! defined( 'ATKAI_PRO_PLUGIN_ACTIVE' ) || ! ATKAI_PRO_PLUGIN_ACTIVE ) { return false; } $atk_options = get_option( 'atk_options' ); $group_enabled = ! empty( $atk_options['atk_pro_group_logs'] ); if ( ! $group_enabled ) { return false; } // Pro disables grouping when activity type filter is active. $action_filter = isset( $_REQUEST['action_type_filter'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action_type_filter'] ) ) : ''; return '' === $action_filter; } public function show_admin_del_item_security_notice() { ?>

array('created_date', false), // False means it's initially sorted in ascending order 'user_id' => array('user_id', false), 'action' => array('action', false), 'action_name' => array('action_name', false), 'action_type' => array('action_type', false), 'user_ip_address' => array('user_ip_address', false), // Add other sortable columns here ); } public function get_items( $current_page, $items_per_page, $orderby, $order ) { global $wpdb; // Capture for pagination links $current_date_filter = isset( $_REQUEST['date_filter'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['date_filter'] ) ) : ''; $current_action_filter = isset( $_REQUEST['action_type_filter'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action_type_filter'] ) ) : ''; $current_search = isset( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : ''; $current_byuser = isset( $_REQUEST['byuser'] ) ? absint( wp_unslash( $_REQUEST['byuser'] ) ) : 0; // 1) Calculate offset $items_per_page = absint( $items_per_page ); $current_page = max( 1, absint( $current_page ) ); $offset = ( $current_page - 1 ) * $items_per_page; // 2) Whitelist ORDER BY column $allowed = [ 'created_date','user_id','action','action_name','action_type','user_ip_address','log_id' ]; if ( ! in_array( $orderby, $allowed, true ) ) { $orderby = 'created_date'; } // 3) Sanitize direction $order = strtoupper( $order ); if ( 'ASC' !== $order && 'DESC' !== $order ) { $order = 'DESC'; } // 4) Allow extensions to override SELECT & COUNT expressions $select_columns = apply_filters( 'atkai_admin_table_log_select_query', '*' ); $count_expression = apply_filters( 'atkai_admin_table_log_query_select_total','COUNT(log_id)' ); // 5) Table name $table = $wpdb->prefix . ATKAI_Admin_Activity_Table::get_table_name(); // 6) Build WHERE clauses $query_from = "FROM `{$table}` AS logs WHERE 1=1"; // Built-in filters $filters = $this->get_filter_vars(); if ( isset( $filters->created_date ) && null !== $filters->created_date ) { $query_from .= $wpdb->prepare( ' AND created_date = %s', $filters->created_date ); } if ( isset( $filters->user_id ) && null !== $filters->user_id ) { $query_from .= $wpdb->prepare( ' AND user_id = %d', $filters->user_id ); } if ( isset( $filters->action ) && null !== $filters->action ) { $query_from .= $wpdb->prepare( ' AND action = %s', $filters->action ); } if ( isset( $filters->action_name ) && null !== $filters->action_name ) { $query_from .= $wpdb->prepare( ' AND action_name = %s', $filters->action_name ); } if ( isset( $filters->action_type ) && null !== $filters->action_type ) { $query_from .= $wpdb->prepare( ' AND action_type = %s', $filters->action_type ); } if ( isset( $filters->user_ip_address ) && null !== $filters->user_ip_address ) { $query_from .= $wpdb->prepare( ' AND user_ip_address = %s', $filters->user_ip_address ); } // 7) “Action type” dropdown $current_action = isset( $_GET['action_type_filter'] ) ? sanitize_text_field( wp_unslash( $_GET['action_type_filter'] ) ) : ''; if ( $current_action !== '' ) { $query_from .= $wpdb->prepare( ' AND action = %s', $current_action ); } // 8) “Date” dropdown $current_date = isset( $_REQUEST['date_filter'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['date_filter'] ) ) : ''; if ( $current_date ) { switch ( $current_date ) { case 'today': $query_from .= " AND created_date >= DATE_SUB( NOW(), INTERVAL 24 HOUR )"; break; case 'yesterday': $query_from .= " AND DATE(created_date) = DATE_SUB( CURDATE(), INTERVAL 1 DAY )"; break; case 'week': $query_from .= " AND created_date >= DATE_SUB( CURDATE(), INTERVAL 6 DAY )"; break; case 'month': $query_from .= " AND created_date >= DATE_SUB( CURDATE(), INTERVAL 29 DAY )"; break; } } // 9) “Search” filter $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; if ( '' !== $search ) { $user = get_user_by( 'login', $search ); if ( $user ) { $query_from .= $wpdb->prepare( ' AND logs.user_id = %d', $user->ID ); } else { $like = '%' . $wpdb->esc_like( $search ) . '%'; $query_from .= $wpdb->prepare( ' AND (logs.action_type LIKE %s OR logs.user_id IN (SELECT ID FROM %i WHERE user_login LIKE %s OR display_name LIKE %s))', $like, $wpdb->users, $like, $like ); } } // 10) “By user” filter $byuser_id = isset( $_GET['byuser'] ) ? absint( sanitize_text_field( wp_unslash( $_GET['byuser'] ) ) ) : 0; if ( $byuser_id > 0 ) { $query_from .= $wpdb->prepare( ' AND user_id = %d', $byuser_id ); } // 11) Build, filter, and fetch paged items // First, safely escape the orderby and order parts (which are whitelisted): $limit_sql = $wpdb->prepare( 'ORDER BY ' . esc_sql( $orderby ) . ' ' . esc_sql( $order ) . ' LIMIT %d, %d', $offset, $items_per_page ); // Allow filters on the final LIMIT clause: $maybe_group = apply_filters( 'atkai_admin_table_log_prepared_query', $limit_sql ); // Validate it still looks like a simple GROUP BY if ( preg_match( '/^\s*GROUP\s+BY\s+[A-Za-z0-9_`,\s]+(?:\s+(?:ASC|DESC))?$/i', $maybe_group ) && ! preg_match( '/;\s*(DELETE|DROP|INSERT|UPDATE)\b/i', $maybe_group ) ) { // If it’s gone rogue, revert to our sanitized version $limit_sql = $maybe_group; } //phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $this->items = $wpdb->get_results( "SELECT {$select_columns} {$query_from} {$limit_sql}" ); // Apply any “FROM total” override for grouped counts $maybe_from = apply_filters( 'atkai_admin_table_log_query_from_total', $query_from ); // 2) Validate: must start with FROM, and must not contain any dangerous keywords or semicolons if ( preg_match( '/^\s*FROM\b/i', $maybe_from ) && ! preg_match( '/;\s*(DELETE|DROP|INSERT|UPDATE)\b/i', $maybe_from ) ) { $query_from_total = $maybe_from; } else { // Either the filter was malicious or malformed—ignore it $query_from_total = $query_from; } //phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $total_items = (int) $wpdb->get_var( "SELECT {$count_expression} {$query_from_total}" ); $add_args = array_filter( [ 'date_filter' => $current_date_filter, 'action_type_filter' => $current_action_filter, 's' => $current_search, 'byuser' => $current_byuser, ], function( $v ) { return '' !== $v && null !== $v; } ); $this->set_pagination_args( [ 'total_items' => $total_items, 'per_page' => $items_per_page, 'total_pages' => ceil( $total_items / $items_per_page ), 'add_args' => $add_args, ] ); } /** * No items found text. */ public function no_items() { $filtered = false; foreach ( [ 's', 'byuser', 'user_id', 'user_ip_address', 'date_filter', 'action_type_filter' ] as $key ) { if ( isset( $_GET[ $key ] ) && is_scalar( $_GET[ $key ] ) && '' !== trim( (string) $_GET[ $key ] ) && '0' !== (string) $_GET[ $key ] ) $filtered = true; } echo '

' . esc_html( $filtered ? __( 'No matching activity', 'activity-track' ) : __( 'No activity recorded yet', 'activity-track' ) ) . '

' . esc_html( $filtered ? __( 'Try a different search or reset the filters.', 'activity-track' ) : __( 'New events will appear here when enabled activity types are recorded.', 'activity-track' ) ) . '

' . ( $filtered ? ' ' : '' ) . esc_html( $filtered ? __( 'Reset Filters', 'activity-track' ) : __( 'Activity Settings', 'activity-track' ) ) . '
'; } /** * Get filters from querystring. * * @return object */ protected function get_filter_vars() { $user_id = ! empty( $_GET['user_id'] ) ? absint( wp_unslash( $_GET['user_id'] ) ) : null; // user_ip_address → plain text then your own cleaner. $user_ip_address = ! empty( $_GET['user_ip_address'] ) ? atk_clean( sanitize_text_field( wp_unslash( $_GET['user_ip_address'] ) ) ) : null; return (object) array( 'user_id' => $user_id, 'user_ip_address' => $user_ip_address, ); } private function user_filtered_url( $name = '', $value = '' ) { $base_page_url = menu_page_url( 'activity-track-ai', false ); if ( empty( $name ) ) { return $base_page_url; } return add_query_arg( $name, $value, $base_page_url ); } }