get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, 'atk_404_' . $period . '_quota' ) ); $parts = explode( ':', (string) $value ); if ( $parts[0] === $bucket && (int) ( $parts[1] ?? 0 ) >= $limit ) return 'limit'; } return 'recording'; } public static function table() { global $wpdb; return $wpdb->prefix . 'atk_404_logs'; } public function install() { if ( self::VERSION === get_option( 'atk_404_db_version' ) ) return; global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); dbDelta( "CREATE TABLE $table ( log_id bigint(20) unsigned NOT NULL AUTO_INCREMENT, captured_at datetime NOT NULL, target_url text NOT NULL, target_hash char(64) NOT NULL, device varchar(120) NOT NULL DEFAULT '', location varchar(120) NOT NULL DEFAULT '', user_ip_address varchar(100) NOT NULL DEFAULT '', country_code varchar(2) NOT NULL DEFAULT '', referrer_url text NOT NULL, request_method varchar(4) NOT NULL DEFAULT 'GET', user_agent varchar(512) NOT NULL DEFAULT '', user_id bigint(20) unsigned NOT NULL DEFAULT 0, visitor_hash char(64) NOT NULL DEFAULT '', PRIMARY KEY (log_id), KEY captured_at (captured_at), KEY target_hash (target_hash) ) " . $wpdb->get_charset_collate() . ';' ); if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) === $table ) { add_option( 'atk_404_minute_quota', '0:0', '', false ); add_option( 'atk_404_day_quota', '0:0', '', false ); update_option( 'atk_404_db_version', self::VERSION, false ); } } public function status( $header, $code, $description = '', $protocol = '' ) { if ( 404 === (int) $code ) $this->candidate = true; return $header; } public function detect() { if ( is_404() ) $this->candidate = true; } /** Preserve encoded paths, discard credentials/fragments, redact every query value. */ public static function safe_url( $raw, $referrer = false ) { $raw = substr( preg_replace( '/[\x00-\x20\x7f]/', '', (string) $raw ), 0, 2048 ); $parts = wp_parse_url( $raw ); if ( ! is_array( $parts ) ) return ''; if ( $referrer && ( empty( $parts['host'] ) || ! in_array( strtolower( $parts['scheme'] ?? '' ), [ 'http', 'https' ], true ) ) ) return ''; $url = $referrer ? strtolower( $parts['scheme'] ) . '://' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . absint( $parts['port'] ) : '' ) : ''; $url .= '/' . ltrim( $parts['path'] ?? '/', '/' ); if ( ! empty( $parts['query'] ) ) { parse_str( $parts['query'], $query ); $keys = array_filter( array_map( 'sanitize_key', array_keys( $query ) ) ); if ( $keys ) $url .= '?' . implode( '&', array_map( static function( $key ) { return $key . '=[redacted]'; }, array_slice( $keys, 0, 20 ) ) ); } return substr( $url, 0, 2048 ); } /** * Explains how a 404 request arrived. Bots and typed or app links send no referrer, so those * requests are described by what they asked for instead of "Not provided". */ public static function source( $referrer, $target, $device = '', $rapid = 0 ) { $referrer = (string) $referrer; $host = $referrer ? strtolower( (string) wp_parse_url( $referrer, PHP_URL_HOST ) ) : ''; // A "referrer" equal to the requested page is not a link: some bots send their own URL as the referrer. if ( $host && strtolower( rtrim( (string) wp_parse_url( $referrer, PHP_URL_PATH ), '/' ) ) === strtolower( rtrim( (string) strtok( (string) $target, '?' ), '/' ) ) ) $host = ''; if ( $host ) { $site = function_exists( 'home_url' ) ? strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) : ''; if ( $host === $site || preg_replace( '/^www\./', '', $host ) === preg_replace( '/^www\./', '', $site ) ) { return [ 'kind' => 'internal', 'label' => __( 'Broken link on your site', 'activity-track' ), 'detail' => (string) wp_parse_url( $referrer, PHP_URL_PATH ) ?: '/', 'url' => $referrer ]; } if ( preg_match( '/(^|\.)(google|bing|duckduckgo|yahoo|baidu|yandex|ecosia|startpage)\./', $host ) ) { return [ 'kind' => 'search', 'label' => __( 'From a search engine', 'activity-track' ), 'detail' => $host, 'url' => '' ]; } return [ 'kind' => 'external', 'label' => __( 'Linked from another site', 'activity-track' ), 'detail' => $host, 'url' => $referrer ]; } $kind = class_exists( 'ATKAI_Incidents' ) ? ATKAI_Incidents::classify_404( $target ) : [ 'sensitive' => false, 'probe' => false ]; if ( $kind['sensitive'] ) return [ 'kind' => 'probe', 'label' => __( 'Sensitive file probe', 'activity-track' ), 'detail' => __( 'No referrer', 'activity-track' ), 'url' => '' ]; if ( $kind['probe'] ) return [ 'kind' => 'probe', 'label' => __( 'Script probe', 'activity-track' ), 'detail' => __( 'No referrer', 'activity-track' ), 'url' => '' ]; if ( preg_match( '~^/(?:contact(?:s|o|ar|ez|-us|us|-me)?|kontakt(?:y|e|-[a-z]+)?|contatti|contato|contacteer|impressum|imprint)/?$~i', (string) strtok( (string) $target, '?' ) ) ) return [ 'kind' => 'bot', 'label' => __( 'Contact-page guess', 'activity-track' ), 'detail' => __( 'Typical of form-spam bots', 'activity-track' ), 'url' => '' ]; if ( $rapid >= 20 && 0 !== stripos( (string) $device, 'Bot' ) ) return [ 'kind' => 'bot', 'label' => __( 'Rapid crawler', 'activity-track' ), 'detail' => sprintf( /* translators: %d: number of 404 requests from this visitor today. */ __( '%d requests today', 'activity-track' ), $rapid ), 'url' => '' ]; if ( 0 === stripos( (string) $device, 'Bot' ) || 0 === stripos( (string) $device, 'Suspected bot' ) ) return [ 'kind' => 'bot', 'label' => __( 'Bot request', 'activity-track' ), 'detail' => __( 'No referrer', 'activity-track' ), 'url' => '' ]; return [ 'kind' => 'direct', 'label' => __( 'Direct visit or unknown', 'activity-track' ), 'detail' => __( 'No referrer', 'activity-track' ), 'url' => '' ]; } public static function device( $ua, $target = '' ) { $ua = substr( (string) $ua, 0, 512 ); // These are header-based hints, not verified identities; User-Agents can be spoofed. foreach ( self::BOT_CLIENTS as $client ) { if ( false !== stripos( $ua, $client ) ) return sprintf( /* translators: %s: detected automated client name. */ __( 'Bot (%s)', 'activity-track' ), $client ); } if ( preg_match( '~' . self::GENERIC_BOT_PATTERN . '~i', $ua ) ) return __( 'Bot', 'activity-track' ); $browser = preg_match( '/Edg\//i', $ua ) ? 'Edge' : ( preg_match( '/(?:Chrome|CriOS)\//i', $ua ) ? 'Chrome' : ( preg_match( '/(?:Firefox|FxiOS)\//i', $ua ) ? 'Firefox' : ( false !== stripos( $ua, 'Safari/' ) ? 'Safari' : '' ) ) ); $os = preg_match( '/iPhone|iPad/i', $ua ) ? 'iOS' : ( false !== stripos( $ua, 'Android' ) ? 'Android' : ( false !== stripos( $ua, 'Windows' ) ? 'Windows' : ( false !== stripos( $ua, 'Macintosh' ) ? 'macOS' : ( false !== stripos( $ua, 'Linux' ) ? 'Linux' : '' ) ) ) ); // Unrecognized clients probing PHP files in these paths may be automated scanners. $path = wp_parse_url( substr( (string) $target, 0, 2048 ), PHP_URL_PATH ); if ( ! preg_match( '~' . self::BROWSER_PATTERN . '~i', $ua ) && is_string( $path ) && preg_match( '~' . self::SCANNER_PATH_PATTERN . '~i', $path ) ) return __( 'Suspected bot', 'activity-track' ); return trim( $os . ' ' . $browser ) ?: __( 'Unknown', 'activity-track' ); } private function location() { $ip = $_SERVER['REMOTE_ADDR'] ?? ''; if ( in_array( $ip, [ '127.0.0.1', '::1' ], true ) ) return 'Localhost'; if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) return ''; $cached = get_transient( 'atkai_geoiploc_' . $ip ); if ( ! is_array( $cached ) ) return ''; return substr( sanitize_text_field( implode( ', ', array_filter( [ $cached['city'] ?? '', $cached['country'] ?? '' ] ) ) ), 0, 120 ); } /** Atomic counters prevent concurrent scanner requests from exceeding capture limits. */ private function quota( $name, $bucket, $limit ) { global $wpdb; return 1 === $wpdb->query( $wpdb->prepare( "UPDATE %i SET option_value = IF(SUBSTRING_INDEX(option_value, ':', 1) = %s, CONCAT(%s, ':', CAST(SUBSTRING_INDEX(option_value, ':', -1) AS UNSIGNED) + 1), %s) WHERE option_name = %s AND (SUBSTRING_INDEX(option_value, ':', 1) <> %s OR CAST(SUBSTRING_INDEX(option_value, ':', -1) AS UNSIGNED) < %d)", $wpdb->options, $bucket, $bucket, $bucket . ':1', $name, $bucket, $limit ) ); } public function capture() { if ( $this->recorded || ! $this->candidate || 404 !== http_response_code() || ! self::enabled() || self::paused() || is_admin() || wp_doing_ajax() || wp_doing_cron() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'WP_CLI' ) && WP_CLI ) ) return; if ( ! in_array( $_SERVER['REQUEST_METHOD'] ?? '', [ 'GET', 'HEAD' ], true ) || self::VERSION !== get_option( 'atk_404_db_version' ) ) return; $this->recorded = true; global $wpdb; $previous = $wpdb->suppress_errors( true ); $locked = false; $lock = 'atk404_' . md5( ( $wpdb->dbname ?? '' ) . self::table() ); try { if ( ! $this->quota( 'atk_404_minute_quota', gmdate( 'YmdHi' ), self::MINUTE_LIMIT ) || ! $this->quota( 'atk_404_day_quota', gmdate( 'Ymd' ), self::DAY_LIMIT ) ) return; // Serialize the capacity check and insert without waiting on another request. $locked = '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lock ) ); if ( ! $locked ) { set_transient( 'atk_404_storage_limited', 1, 60 ); return; } // The primary-key index bounds this check to 10,000 entries. $capacity_sql = $wpdb->prepare( 'SELECT log_id FROM %i ORDER BY log_id ASC LIMIT 1 OFFSET %d', self::table(), self::RECORD_LIMIT - 1 ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query prepared immediately above; reused after bounded deletion below. $boundary = $wpdb->get_var( $capacity_sql ); if ( ! empty( $wpdb->last_error ) ) { set_transient( 'atk_404_storage_limited', 1, 60 ); return; } if ( null !== $boundary ) { $deleted = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i ORDER BY log_id ASC LIMIT %d', self::table(), self::DELETE_BATCH ) ); // Existing oversized tables drain in bounded batches; skip inserts until below the cap. // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Reuses the identical prepared capacity query. if ( false === $deleted || null !== $wpdb->get_var( $capacity_sql ) || ! empty( $wpdb->last_error ) ) { set_transient( 'atk_404_storage_limited', 1, 60 ); return; } } $target = self::safe_url( wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ) ); $options = \ATKAI_OPTIONS\Option::get( 'options' ); $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $ip = filter_var( $ip, FILTER_VALIDATE_IP ) ? ( '::1' === $ip ? 'localhost' : $ip ) : ''; $ip = empty( $options['atk_no_ip'] ) ? substr( sanitize_text_field( apply_filters( 'atkai_ip_filter', $ip ) ), 0, 100 ) : ''; $geo = get_transient( 'atkai_geoiploc_' . ( $_SERVER['REMOTE_ADDR'] ?? '' ) ); $country_code = is_array( $geo ) ? strtolower( $geo['country_code'] ?? '' ) : ''; $country_code = preg_match( '/^[a-z]{2}$/', $country_code ) ? $country_code : ''; $inserted = $wpdb->insert( self::table(), [ 'captured_at' => gmdate( 'Y-m-d H:i:s' ), 'target_url' => $target, 'target_hash' => hash( 'sha256', $target ), 'device' => self::device( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ), $target ), 'location' => $this->location(), 'user_ip_address' => $ip, 'country_code' => $country_code, 'referrer_url' => self::safe_url( wp_unslash( $_SERVER['HTTP_REFERER'] ?? '' ), true ), 'request_method' => $_SERVER['REQUEST_METHOD'], 'user_agent' => substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ), 0, 512 ), 'user_id' => get_current_user_id(), 'visitor_hash' => hash_hmac( 'sha256', gmdate( 'Ymd' ) . '|' . ( $_SERVER['REMOTE_ADDR'] ?? '' ) . '|' . ( $_SERVER['HTTP_USER_AGENT'] ?? '' ), wp_salt( 'auth' ) ), ], [ '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s' ] ); if ( false === $inserted ) { set_transient( 'atk_404_storage_limited', 1, 60 ); } else { delete_transient( 'atk_404_storage_limited' ); } } catch ( Throwable $error ) { // Logging must never change the response delivered to a visitor. set_transient( 'atk_404_storage_limited', 1, 60 ); } finally { if ( $locked ) $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); $wpdb->suppress_errors( $previous ); } } public function cleanup() { if ( self::VERSION !== get_option( 'atk_404_db_version' ) ) return; global $wpdb; $options = \ATKAI_OPTIONS\Option::get( 'options' ); $days = max( 1, absint( $options['atk_logs_days'] ?? 30 ) ); $cutoff = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); for ( $batch = 0; $batch < 10; $batch++ ) { $deleted = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE captured_at < %s LIMIT 1000', self::table(), $cutoff ) ); if ( $deleted < 1000 ) break; } } }