'atk-404-overview-' . get_current_blog_id() . '-' . get_current_user_id(),
'show' => __( 'Show overview', 'activity-track' ),
'hide' => __( 'Hide overview', 'activity-track' ),
] );
}
private function authorize() {
if ( ! current_user_can( 'manage_options' ) || ! ATKAI_404_Events::enabled() ) wp_die( esc_html__( 'You cannot access 404 logs.', 'activity-track' ) );
}
private function input( $key, $source = null ) {
$source = $source ?? $_GET;
return isset( $source[ $key ] ) && is_string( $source[ $key ] ) ? sanitize_text_field( wp_unslash( $source[ $key ] ) ) : '';
}
private function filters( $source = null ) {
$search = substr( $this->input( 's', $source ), 0, 200 );
$days = absint( $this->input( 'days', $source ) );
$bots = $this->input( 'bots', $source );
return [ 's' => $search, 'days' => in_array( $days, [ 1, 7, 30 ], true ) ? $days : 0, 'bots' => in_array( $bots, [ 'exclude', 'only' ], true ) ? $bots : 'all' ];
}
private function where( $filters ) {
global $wpdb;
$where = '1=1';
if ( $filters['days'] ) $where .= $wpdb->prepare( ' AND captured_at >= %s', gmdate( 'Y-m-d H:i:s', time() - $filters['days'] * DAY_IN_SECONDS ) );
if ( $filters['s'] ) $where .= $wpdb->prepare( ' AND target_url LIKE %s', '%' . $wpdb->esc_like( $filters['s'] ) . '%' );
if ( 'all' !== $filters['bots'] ) {
$bot_pattern = implode( '|', ATKAI_404_Events::BOT_CLIENTS ) . '|' . ATKAI_404_Events::GENERIC_BOT_PATTERN;
$bot_condition = $wpdb->prepare(
'(LOWER(user_agent) REGEXP %s OR (NOT (LOWER(user_agent) REGEXP %s) AND LOWER(SUBSTRING_INDEX(target_url, \'?\', 1)) REGEXP %s))',
strtolower( $bot_pattern ), strtolower( ATKAI_404_Events::BROWSER_PATTERN ), ATKAI_404_Events::SCANNER_PATH_PATTERN
);
$where .= ' AND ' . ( 'exclude' === $filters['bots'] ? 'NOT ' : '' ) . $bot_condition;
}
return $where;
}
private function url( $args = [] ) {
return add_query_arg( array_merge( [ 'page' => 'activity-track-ai', 'tab' => '404' ], $args ), admin_url( 'admin.php' ) );
}
public function recording() {
$this->authorize();
check_admin_referer( 'atk_404_recording' );
$state = $this->input( 'recording', $_POST );
if ( ! in_array( $state, [ 'pause', 'resume' ], true ) ) wp_die( esc_html__( 'Invalid recording action.', 'activity-track' ) );
update_option( 'atk_404_paused', 'pause' === $state, false );
wp_safe_redirect( $this->url( $this->filters( $_POST ) ) );
exit;
}
public function delete() {
$this->authorize();
check_admin_referer( 'atk_404_delete' );
global $wpdb;
$ids = isset( $_POST['logs'] ) && is_array( $_POST['logs'] ) ? array_slice( array_filter( array_map( 'absint', $_POST['logs'] ) ), 0, 50 ) : [];
if ( $ids && $this->should_group() ) {
$rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE log_id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( [ ATKAI_404_Events::table() ], $ids ) ) );
$where = $this->where( $this->filters( $_POST ) );
foreach ( $rows as $row ) {
$matches = [];
$values = [ ATKAI_404_Events::table(), $row->log_id ];
foreach ( self::GROUP_FIELDS as $field ) {
$matches[] = "SHA2($field, 256) = %s";
$values[] = hash( 'sha256', (string) $row->$field );
}
// Keep later arrivals and records outside the displayed filters.
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- GROUP_FIELDS is a fixed column allowlist; each hash uses a bound %s, and where() prepares filter values.
$wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE $where AND log_id <= %d AND " . implode( ' AND ', $matches ), $values ) );
}
} elseif ( $ids ) {
$where = $this->where( $this->filters( $_POST ) );
$wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE $where AND log_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( [ ATKAI_404_Events::table() ], $ids ) ) );
}
wp_safe_redirect( $this->url( $this->filters( $_POST ) ) );
exit;
}
public function export() {
$this->authorize();
check_admin_referer( 'atk_404_export' );
global $wpdb;
$where = $this->where( $this->filters() );
$snapshot = absint( $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(log_id) FROM %i', ATKAI_404_Events::table() ) ) );
nocache_headers();
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename=activity-track-404.csv' );
$out = fopen( 'php://output', 'w' );
fputcsv( $out, [ 'Date & Time (UTC)', 'Target URL', 'User Device', 'User Location', 'IP Address', 'Referrer URL', 'Source', 'Method', 'User Agent', 'User ID', 'Daily Visitor Fingerprint' ] );
$last = $snapshot + 1;
for ( $batch = 0; $batch < 20; $batch++ ) {
$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE $where AND log_id < %d ORDER BY log_id DESC LIMIT 500", ATKAI_404_Events::table(), $last ) );
foreach ( $rows as $row ) {
$cells = [ $row->captured_at, $row->target_url, ATKAI_404_Events::device( $row->user_agent, $row->target_url ), $row->location, empty( \ATKAI_OPTIONS\Option::get( 'options' )['atk_no_ip'] ) ? $row->user_ip_address : '', $row->referrer_url, ATKAI_404_Events::source( $row->referrer_url, $row->target_url, ATKAI_404_Events::device( $row->user_agent, $row->target_url ) )['label'], $row->request_method, $row->user_agent, $row->user_id, $row->visitor_hash ];
// Prevent request-derived values from becoming spreadsheet formulas.
$cells = array_map( static function( $cell ) { return preg_match( '/^[=+@\-\t\r\n]/', (string) $cell ) ? "'" . $cell : $cell; }, $cells );
fputcsv( $out, $cells );
$last = $row->log_id;
}
if ( count( $rows ) < 500 ) break;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes php://output CSV response stream, not a filesystem file.
fclose( $out );
exit;
}
/** Short, recognisable IP label: 203.0.113.x, 2001:db8:… or "Hashed IP". The full value stays in the tooltip. */
public static function masked_ip( $ip ) {
$ip = (string) $ip;
if ( in_array( $ip, [ 'localhost', '127.0.0.1', '::1' ], true ) ) return 'localhost';
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) return preg_replace( '/\.\d+$/', '.x', $ip );
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) return implode( ':', array_slice( explode( ':', $ip ), 0, 2 ) ) . ':…';
return 0 === strpos( $ip, '#' ) ? __( 'Hashed IP', 'activity-track' ) : __( 'Unknown', 'activity-track' );
}
private function location_cell( $row ) {
$options = \ATKAI_OPTIONS\Option::get( 'options' );
if ( ! empty( $options['atk_no_ip'] ) ) {
echo esc_html__( 'IP Log', 'activity-track' ) . ' ' . esc_html__( 'Disabled', 'activity-track' ) . '';
return;
}
$ip = $row->user_ip_address ?? '';
$label = $row->location ?: ( $ip ? self::masked_ip( $ip ) : __( 'Unknown', 'activity-track' ) );
$tooltip = $ip ? apply_filters( 'atkai_modify_user_ip_address', $ip ) : __( 'IP address was not recorded for this entry.', 'activity-track' );
if ( $row->location ) $tooltip .= ' · ' . $row->location;
echo '';
if ( $ip && defined( 'ATKAI_PRO_PLUGIN_ACTIVE' ) && has_action( 'atkai_address_pro' ) ) {
do_action( 'atkai_address_pro', $ip, $label, $row->country_code ?? '' );
} else {
echo '' . esc_html( $label ?: __( 'Unknown', 'activity-track' ) ) . '';
}
echo '' . esc_html( $tooltip ) . '';
$code = $row->country_code ?? '';
if ( ! defined( 'ATKAI_PRO_PLUGIN_ACTIVE' ) && preg_match( '/^[a-z]{2}$/', $code ) ) {
echo ' |
' . esc_html( $row->location ) . '';
}
}
public function render() {
$this->authorize();
$recording_status = ATKAI_404_Events::recording_status();
?>