'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_attr( $row->location ) . '' . esc_html( $row->location ) . ''; } } public function render() { $this->authorize(); $recording_status = ATKAI_404_Events::recording_status(); ?>

filters() as $key => $value ) : ?>

' . esc_html__( '404 storage is unavailable. Visit Settings to retry installation.', 'activity-track' ) . '

'; return; } global $wpdb; $filters = $this->filters(); $page = max( 1, absint( $this->input( 'paged' ) ) ); $where = $this->where( $filters ); $grouped = $this->should_group(); $group_by = $grouped ? $this->group_by() : ''; $count_query = $grouped ? "SELECT COUNT(*) FROM (SELECT MAX(log_id) FROM %i WHERE $where GROUP BY $group_by) AS grouped_logs" : "SELECT COUNT(*) FROM %i WHERE $where"; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Both query templates are internal, where() binds filter values, and group_by() uses only GROUP_FIELDS. $total_items = (int) $wpdb->get_var( $wpdb->prepare( $count_query, ATKAI_404_Events::table() ) ); $total_pages = max( 1, (int) ceil( $total_items / 50 ) ); $page = min( $page, $total_pages ); if ( $grouped ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT logs.*, grouped_logs.event_count FROM %i AS logs INNER JOIN (SELECT MAX(log_id) AS log_id, COUNT(*) AS event_count FROM %i WHERE $where GROUP BY $group_by ORDER BY log_id DESC LIMIT 50 OFFSET %d) AS grouped_logs ON logs.log_id = grouped_logs.log_id ORDER BY logs.log_id DESC", ATKAI_404_Events::table(), ATKAI_404_Events::table(), ( $page - 1 ) * 50 ) ); } else { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE $where ORDER BY log_id DESC LIMIT 50 OFFSET %d", ATKAI_404_Events::table(), ( $page - 1 ) * 50 ) ); } ?>
' . esc_html( $label ) . ''; ?>'; ?> get_results( $wpdb->prepare( 'SELECT log_id, MAX(incident_id) AS incident_id FROM %i WHERE log_id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') GROUP BY log_id', array_merge( [ ATKAI_Incidents::scans() ], $ids ) ) ); foreach ( (array) $links as $link ) $incident_of[ (int) $link->log_id ] = (int) $link->incident_id; } $rapid_of = []; $hashes = array_values( array_unique( array_filter( array_column( $rows, 'visitor_hash' ) ) ) ); if ( $hashes ) { global $wpdb; $bursts = $wpdb->get_results( $wpdb->prepare( 'SELECT visitor_hash, COUNT(*) AS requests, MIN(captured_at) AS first_at, MAX(captured_at) AS last_at FROM %i WHERE visitor_hash IN (' . implode( ',', array_fill( 0, count( $hashes ), '%s' ) ) . ') GROUP BY visitor_hash', array_merge( [ ATKAI_404_Events::table() ], $hashes ) ) ); foreach ( (array) $bursts as $burst ) { $span = strtotime( $burst->last_at . ' UTC' ) - strtotime( $burst->first_at . ' UTC' ); // People do not hit 20+ missing pages a few seconds apart; crawlers do, whatever their user agent says. if ( (int) $burst->requests >= 20 && $span <= (int) $burst->requests * 20 ) $rapid_of[ $burst->visitor_hash ] = (int) $burst->requests; } } ?>
' . esc_html__( 'No 404 records in this view. New errors appear after tracking is enabled.', 'activity-track' ) . '
captured_at . ' UTC' ), time() ) . ' ' . __( 'ago', 'activity-track' ) ); ?>
captured_at, 'M j, Y H:i:s' ) . ' ' . wp_timezone_string() ); ?>
target_url ); ?>event_count ?? 0 ) > 1 ) : ?> event_count, 'activity-track' ), number_format_i18n( $row->event_count ) ) ); ?>

request_method . ' · HTTP 404 · ' . ( $row->user_id ? sprintf( /* translators: %d: WordPress user ID. */ __( 'User ID %d', 'activity-track' ), $row->user_id ) : __( 'Guest', 'activity-track' ) ) ); ?>

user_agent ?: __( 'User agent not provided', 'activity-track' ) ); ?>

user_agent, $row->target_url ), (int) $row->user_id, '', ! empty( $rapid_of[ $row->visitor_hash ?? '' ] ) && ! $row->user_id ? __( 'Automated (rapid requests)', 'activity-track' ) : '' ) : esc_html( ATKAI_404_Events::device( $row->user_agent, $row->target_url ) ) ); // Badge escapes its parts. ?>location_cell( $row ); ?>
referrer_url, $row->target_url, ATKAI_404_Events::device( $row->user_agent, $row->target_url ), $rapid_of[ $row->visitor_hash ?? '' ] ?? 0 ); ?>
atkai_log_source = '404'; echo wp_kses( apply_filters( 'atkai_render_logs_ai_column', '', $row ), [ 'button' => [ 'type' => true, 'class' => true, 'data-log-id' => true, 'data-log-source' => true ], 'svg' => [ 'class' => true, 'width' => true, 'height' => true, 'viewbox' => true, 'fill' => true, 'xmlns' => true ], 'path' => [ 'd' => true, 'fill-rule' => true, 'clip-rule' => true, 'fill' => true ], 'span' => [ 'class' => true ], ] ); ?>