60, 'display' => \esc_html__( 'Every Minute', '0-day-analytics' ), ); } return $schedules; } /** * Unschedule cron events on module disable. * * @return void * * @since 5.1.0 */ public static function unschedule_cron(): void { $timestamp = \wp_next_scheduled( self::CRON_AGGREGATE_HOOK ); if ( $timestamp ) { \wp_unschedule_event( $timestamp, self::CRON_AGGREGATE_HOOK ); } $timestamp = \wp_next_scheduled( self::CRON_PRUNE_HOOK ); if ( $timestamp ) { \wp_unschedule_event( $timestamp, self::CRON_PRUNE_HOOK ); } } /** * Run pruning task. * * @return void * * @since 5.1.0 */ public static function run_prune(): void { $days = (int) Settings::get_option( 'traffic_retention_days' ); if ( $days < 30 ) { $days = 365; } Traffic_Aggregator::prune( $days ); } /** * Collect traffic data server-side. * * @return void * * @since 5.1.0 */ public static function collect_server_side(): void { // Only track frontend requests. if ( \is_admin() || \wp_doing_ajax() || \wp_doing_cron() || \defined( 'REST_REQUEST' ) || \defined( 'XMLRPC_REQUEST' ) ) { return; } // Do not track preview / customizer. if ( \is_preview() || \is_customize_preview() ) { return; } // Do not track feed. if ( \is_feed() ) { return; } // Do not track 404 pages. if ( \is_404() ) { return; } /* * is_404() alone is not enough. A plugin that answers for its own * URLs can set a 404 status without touching the query, which leaves * is_404() false while the response is already a 404. Ask what is * actually going out, not only what the query thinks. */ if ( \function_exists( 'http_response_code' ) ) { $status = \http_response_code(); if ( \is_int( $status ) && $status >= 400 ) { return; } } // Skip if search engine / bot. if ( self::is_bot() ) { return; } // Skip non-content paths (favicon.ico, robots.txt, assets, etc.). if ( self::is_excluded_path( self::get_current_path() ) ) { return; } // Check user role exclusions. if ( self::is_excluded_user() ) { return; } // Build pageview data. $record = self::build_record(); self::store_record( $record ); } /** * Enqueue the JS tracking script on the frontend. * * @return void * * @since 5.1.0 */ public static function enqueue_tracking_script(): void { // Don't track admin, bots, or excluded users. if ( \is_admin() || self::is_excluded_user() ) { return; } /* * The same exclusions track() applies. * * They have to be repeated here rather than left to the collector, * because by the time the script's request arrives it is an * admin-ajax call: is_404(), is_feed() and the rest all describe * that request, not the page the visitor was looking at. The handler * has only the path the browser sent it, and a path alone cannot say * whether it resolved. * * Without this a 404 enqueues the tracker like any other page and * gets recorded as a normal pageview - which is how URLs that do not * exist on a site turned up in its traffic report. */ if ( \is_404() || \is_preview() || \is_customize_preview() || \is_feed() ) { return; } if ( self::is_bot() || self::is_excluded_path( self::get_current_path() ) ) { return; } // Inline the tracking script for minimal overhead. \wp_enqueue_script( 'aadvana-traffic-tracker', ADVAN_PLUGIN_ROOT_URL . 'js/traffic-tracker.js', array(), ADVAN_VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); $site = \get_current_blog_id(); \wp_localize_script( 'aadvana-traffic-tracker', 'aadvana_traffic', array( 'ajax_url' => \admin_url( 'admin-ajax.php' ), 'fast_url' => Traffic_Endpoint::url(), 'action' => self::AJAX_ACTION, 'nonce' => \wp_create_nonce( self::AJAX_ACTION ), 'token' => Traffic_Endpoint::token( $site ), 'site' => (string) $site, 'site_url' => \home_url(), 'path' => self::get_current_path(), ) ); } /** * Handle AJAX tracking request from the JS tracker. * * @return void * * @since 5.1.0 */ public static function handle_ajax_collect(): void { /* * The nonce, or the token the fast endpoint uses. A page served from * a cache for more than a day carries an expired nonce, but its * token is still good for a week - without it those pageviews were * refused here. */ $token = isset( $_POST['token'] ) && is_string( $_POST['token'] ) ? \sanitize_text_field( \wp_unslash( $_POST['token'] ) ) : ''; if ( ! \check_ajax_referer( self::AJAX_ACTION, 'nonce', false ) && ! Traffic_Endpoint::verify_token( $token, \get_current_blog_id() ) ) { \wp_send_json_error( 'Invalid nonce', 403 ); return; } // Another site's page making its visitors' browsers report here - see Traffic_Endpoint::same_origin(). if ( ! Traffic_Endpoint::same_origin( \home_url() ) ) { \wp_send_json_error( 'Wrong origin', 403 ); return; } // Simple per-IP rate limiting: max 60 requests per minute. $ip_hash = Rate_Limiter::get_ip_hash(); if ( Traffic_Endpoint::rate_limited( $ip_hash ) ) { \wp_send_json_success(); // Silently accept but don't store. return; } // The browser tried the fast endpoint first and got an error status. if ( isset( $_POST['fast_failed'] ) && is_string( $_POST['fast_failed'] ) && preg_match( '/^[1-5]\d\d$/D', $_POST['fast_failed'] ) ) { Traffic_Endpoint::report_failure( (int) $_POST['fast_failed'], $ip_hash ); } $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) && is_string( $_SERVER['HTTP_USER_AGENT'] ) ? \wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized in build_record(). $record = Traffic_Endpoint::build_record( \wp_unslash( $_POST ), // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified above. $user_agent, $ip_hash, (string) \wp_parse_url( \home_url(), PHP_URL_HOST ) ); if ( null !== $record && ! Traffic_Endpoint::buffer_full() ) { self::store_record( $record ); } // Minimal response. \wp_send_json_success(); } /** * Build a pageview record from the current server-side request. * * @return array * * @since 5.1.0 */ private static function build_record(): array { $path = self::get_current_path(); // Get and normalize referrer. $referrer = ''; if ( isset( $_SERVER['HTTP_REFERER'] ) ) { $raw_referrer = \sanitize_url( \wp_unslash( $_SERVER['HTTP_REFERER'] ) ); $site_host = \wp_parse_url( \home_url(), PHP_URL_HOST ); $ref_host = \wp_parse_url( $raw_referrer, PHP_URL_HOST ); if ( $ref_host && $site_host !== $ref_host ) { $ref_parts = \wp_parse_url( $raw_referrer ); $referrer = ( isset( $ref_parts['scheme'] ) ? $ref_parts['scheme'] : 'https' ) . '://' . $ref_host; if ( isset( $ref_parts['path'] ) && '/' !== $ref_parts['path'] ) { $referrer .= $ref_parts['path']; } if ( isset( $ref_parts['query'] ) && '' !== $ref_parts['query'] ) { $referrer .= '?' . $ref_parts['query']; } } } $ua_info = self::parse_user_agent(); return array( 'date' => \current_time( 'Y-m-d' ), 'path' => $path, 'referrer' => $referrer, 'is_new' => self::is_new_visitor(), 'timestamp' => time(), 'device_type' => $ua_info['device_type'], 'browser' => $ua_info['browser'], 'os' => $ua_info['os'], 'ip_hash' => self::get_visitor_ip_hash(), ); } /** * In-memory buffer for records when filesystem is unavailable (P2). * * @var array * * @since 5.1.0 */ private static $memory_buffer = array(); /** * Whether the shutdown hook for flushing memory buffer is registered. * * @var bool * * @since 5.1.0 */ private static $shutdown_registered = false; /** * Store a record via buffer (file) or direct DB write. * * @param array $record Pageview data. * * @return void * * @since 5.1.0 */ private static function store_record( array $record ): void { // Try file buffer first. if ( Traffic_Buffer::is_available() ) { Traffic_Buffer::append( $record ); } else { // Queue in memory and flush at shutdown to batch DB writes. self::$memory_buffer[] = $record; if ( ! self::$shutdown_registered ) { self::$shutdown_registered = true; \add_action( 'shutdown', array( __CLASS__, 'flush_memory_buffer' ), 5 ); } } } /** * Flush in-memory buffer to database at shutdown. * * @return void * * @since 5.1.0 */ public static function flush_memory_buffer(): void { if ( empty( self::$memory_buffer ) ) { return; } // Batch all records through the aggregator's batch write. Traffic_Aggregator::write_batch( self::$memory_buffer ); self::$memory_buffer = array(); } /** * Get the current request path, normalized. * * @return string * * @since 5.1.0 */ private static function get_current_path(): string { // Decode percent-encoded UTF-8 characters (Cyrillic, Chinese, etc.) before // sanitizing — sanitize_text_field() strips %XX sequences directly. $raw = isset( $_SERVER['REQUEST_URI'] ) ? \sanitize_text_field( \rawurldecode( \wp_unslash( $_SERVER['REQUEST_URI'] ) ) ) : '/'; // Separate path and query string. $query_string = ''; $pos = strpos( $raw, '?' ); if ( false !== $pos ) { $path = substr( $raw, 0, $pos ); $query_string = substr( $raw, $pos ); // includes the '?'. } else { $path = $raw; } // Strip WordPress subdirectory prefix if any. $site_path = \wp_parse_url( \home_url(), PHP_URL_PATH ); if ( $site_path && '/' !== $site_path ) { $site_path = rtrim( $site_path, '/' ); if ( 0 === strpos( $path, $site_path ) ) { $path = substr( $path, strlen( $site_path ) ); } } // Ensure leading slash. if ( empty( $path ) || '/' !== $path[0] ) { $path = '/' . $path; } // Strip trailing slash (except for root). if ( '/' !== $path ) { $path = rtrim( $path, '/' ); } // Re-append query string so URL parameters are tracked. return $path . $query_string; } /** * Check if the current request is from a bot. * * @return bool * * @since 5.1.0 */ private static function is_bot(): bool { $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; return Traffic_Endpoint::is_bot( $ua ); } /** * Get a hashed identifier for the current visitor's IP address. * * Used for deduplicating realtime visitor counts. * Delegates to Rate_Limiter::get_ip_hash() for secure, consistent * HMAC-SHA256 hashing with AUTH_SALT. * * @return string * * @since 5.1.0 */ private static function get_visitor_ip_hash(): string { return Rate_Limiter::get_ip_hash(); } /** * Check if a path should be excluded from tracking (non-content assets). * * @param string $path Normalized path to check. * * @return bool * * @since 5.1.0 */ private static function is_excluded_path( string $path ): bool { return Traffic_Endpoint::is_excluded_path( $path ); } /** * Check if the current user should be excluded from tracking. * * @return bool * * @since 5.1.0 */ private static function is_excluded_user(): bool { if ( ! \is_user_logged_in() ) { return false; } // Exclude administrators if the setting is enabled. $exclude_admins = Settings::get_option( 'traffic_exclude_admins' ); if ( $exclude_admins && \current_user_can( 'manage_options' ) ) { return true; } return false; } /** * Determine if the current visitor is new (first visit today). * * @return bool * * @since 5.1.0 */ private static function is_new_visitor(): bool { return Traffic_Endpoint::is_new_visitor(); } /** * Parse the User-Agent string to extract device type, browser, and OS. * * @param string $ua Raw User-Agent string; the current request's when empty. * * @return array{device_type: string, browser: string, os: string} * * @since 5.1.0 */ public static function parse_user_agent( string $ua = '' ): array { if ( '' === $ua && isset( $_SERVER['HTTP_USER_AGENT'] ) ) { $ua = \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ); } return Traffic_Endpoint::parse_user_agent( $ua ); } } }