db = new Site_Traffic_Manager_DB(); $this->settings = new Site_Traffic_Manager_Settings( $this->options, $this->db ); $consent_options = wp_parse_args( get_option( Consent_Manager::OPTION_NAME, [] ), Consent_Manager::get_default_settings() ); if ( ! empty( $consent_options['enable_banner'] ) ) { $this->consent_manager = new Consent_Manager(); } add_action( 'init', [ $this, 'sync_flush_schedule' ] ); add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] ); add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); add_action( self::FLUSH_HOOK, [ $this, 'flush_cached_counts' ] ); add_action( 'adminoptim_deactivate_plugin', [ __CLASS__, 'deactivate' ] ); } /** * Register submenu page under Admin Optimizer. * * @return array|null */ protected function get_submenu_config(): ?array { return [ 'page_title' => __( 'Site Traffic Manager', 'admin-optimizer' ), 'menu_title' => __( 'Site Traffic Manager', 'admin-optimizer' ), 'menu_slug' => self::MENU_SLUG, 'callback' => [ $this->settings, 'render_settings_page' ], ]; } /** * Ensure the cache flush event is scheduled only when needed. * * @return void */ public function sync_flush_schedule() { if ( $this->supports_deferred_cache_counting() ) { if ( ! as_has_scheduled_action( self::FLUSH_HOOK ) ) { as_schedule_recurring_action( time() + 60, 5 * MINUTE_IN_SECONDS, self::FLUSH_HOOK, [], '', true ); } } elseif ( as_has_scheduled_action( self::FLUSH_HOOK ) ) { as_unschedule_all_actions( self::FLUSH_HOOK ); } } /** * Register REST routes. * * @return void */ public function register_rest_routes() { register_rest_route( self::REST_NAMESPACE, self::REST_ROUTE, [ 'methods' => 'POST', 'callback' => [ $this, 'update_page_view_count' ], 'permission_callback' => [ $this, 'validate_tracking_request' ], 'args' => [ 'post_id' => [ 'required' => false, 'sanitize_callback' => 'absint', ], 'url_path' => [ 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ], 'label' => [ 'required' => false, 'sanitize_callback' => 'sanitize_text_field', ], ], ] ); } /** * Update page view count. * * @param \WP_REST_Request $request Rest request. * * @return \WP_REST_Response */ public function update_page_view_count( \WP_REST_Request $request ) { $post_id = absint( $request->get_param( 'post_id' ) ); if ( $post_id ) { $view = $this->resolve_post_view( $post_id ); } else { $url_path = (string) $request->get_param( 'url_path' ); $label = (string) $request->get_param( 'label' ); $view = $this->resolve_url_view( $url_path, $label ); } if ( null === $view ) { return new \WP_REST_Response( [ 'success' => false, 'message' => 'Invalid or untracked view.', ], 400 ); } if ( ! $this->is_tracking_request_for_path( $view['expected_path'] ) ) { return new \WP_REST_Response( [ 'success' => false, 'message' => 'Tracking request does not match the requested view.', ], 400 ); } if ( ! $this->check_rate_limit( $view['rate_limit_key'] ) ) { // Return 200 silently — don't leak rate-limit status to the client. return new \WP_REST_Response( [ 'success' => true ], 200 ); } $this->track_page_view( $view ); return new \WP_REST_Response( [ 'success' => true, ], 200 ); } /** * Resolve and validate a singular post view from the request. * * @param int $post_id Post ID. * * @return array|null View identity, or null if the post isn't trackable. */ protected function resolve_post_view( int $post_id ): ?array { $post = get_post( $post_id ); if ( ! $post || 'publish' !== $post->post_status || ! $this->is_trackable_post_type( $post->post_type ) ) { return null; } $permalink = get_permalink( $post_id ); if ( ! $permalink ) { return null; } return [ 'post_id' => $post_id, 'url_path' => '', 'label' => '', 'expected_path' => (string) wp_parse_url( $permalink, PHP_URL_PATH ), 'rate_limit_key' => 'p' . $post_id, ]; } /** * Resolve and validate a non-post URL view from the request. * * Covers the home page, taxonomy/post-type archives, search results, and * any other front-end URL that doesn't resolve to a single post — one * fallback path instead of a per-view-type branch for each. * * The client (get_current_view_beacon_args()) is the source of truth for * whether a request is a post view: it already knows this via is_singular() * on the very page reporting in, so this method trusts that determination * rather than re-deriving it with url_to_postid(), which runs a full * rewrite-rule match against the DB and is too costly to pay on every * archive/search/404 pageview. * * @param string $url_path Raw URL or path submitted by the client. * @param string $label Human-readable label submitted by the client (e.g. document title). * * @return array|null View identity, or null if non-post tracking isn't enabled or the URL is off-site. */ protected function resolve_url_view( string $url_path, string $label ): ?array { if ( ! $this->is_view_type_trackable( 'non_post' ) ) { return null; } $normalized_path = $this->normalize_url_path( $url_path ); if ( '' === $normalized_path ) { return null; } $label = trim( wp_strip_all_tags( $label ) ); if ( '' !== $label ) { $label = mb_substr( $label, 0, 190 ); } return [ 'post_id' => 0, 'url_path' => $normalized_path, 'label' => $label, 'expected_path' => $normalized_path, 'rate_limit_key' => 'u' . md5( $normalized_path ), ]; } /** * Normalize a client-submitted URL or path to a bare site-relative path. * * Strips scheme/host (a same-site check happens separately via Referer/ * Origin) and query args, since query strings would otherwise fragment a * single archive/search page into unbounded distinct tracked rows. * * @param string $url_path Raw URL or path. * * @return string Normalized path (e.g. '/category/news/'), or '' if invalid/off-site. */ protected function normalize_url_path( string $url_path ): string { if ( '' === $url_path ) { return ''; } $path = (string) wp_parse_url( $url_path, PHP_URL_PATH ); if ( '' === $path ) { return ''; } $site_path = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ); return untrailingslashit( $path ) === untrailingslashit( $site_path ) ? '/' : $path; } /** * Allow only requests that originate from this site. * * Browsers always send Origin on cross-origin fetches and Referer on * same-origin ones. Checking either header is enough to block the vast * majority of off-site bot scripts without requiring user sessions or * nonces (which would break pages served from a full-page cache). * * @return bool */ public function validate_tracking_request(): bool { $site = trailingslashit( strtolower( get_site_url() ) ); $headers = [ isset( $_SERVER['HTTP_ORIGIN'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : '', isset( $_SERVER['HTTP_REFERER'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', ]; foreach ( $headers as $header ) { if ( '' !== $header && str_starts_with( trailingslashit( strtolower( $header ) ), $site ) ) { return true; } } return false; } /** * Check whether tracking is enabled for a post type. * * @param string $post_type Post type name. * * @return bool */ protected function is_trackable_post_type( $post_type ) { $selected_post_types = isset( $this->options['post_types'] ) && is_array( $this->options['post_types'] ) ? $this->options['post_types'] : []; if ( empty( $selected_post_types ) ) { return false; } return in_array( 'all', $selected_post_types, true ) || in_array( $post_type, $selected_post_types, true ); } /** * Check whether tracking is enabled for non-post views (home page, archives, search, etc). * * @param string $view_type Currently only 'non_post' is supported. * * @return bool */ protected function is_view_type_trackable( string $view_type ): bool { $selected_view_types = isset( $this->options['view_types'] ) && is_array( $this->options['view_types'] ) ? $this->options['view_types'] : []; return in_array( $view_type, $selected_view_types, true ); } /** * Check whether the tracking request's Referer/Origin matches the expected path. * * @param string $expected_path Site-relative path the request should have come from. * * @return bool */ protected function is_tracking_request_for_path( string $expected_path ): bool { if ( '' === $expected_path ) { return false; } $headers = [ isset( $_SERVER['HTTP_REFERER'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', isset( $_SERVER['HTTP_ORIGIN'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : '', ]; foreach ( $headers as $header ) { if ( '' === $header ) { continue; } $header_path = (string) wp_parse_url( $header, PHP_URL_PATH ); if ( '' !== $header_path && untrailingslashit( $header_path ) === untrailingslashit( $expected_path ) ) { return true; } } return false; } /** * Check whether this IP has exceeded the per-view rate limit. * * Allows up to 3 counts per IP per view per hour. This is generous * enough to count real return visitors within the same session while * capping targeted count-inflation from a single IP. * * @param string $rate_limit_key Identity key for the viewed post/URL. * * @return bool True if the request is within the allowed rate, false if it should be dropped. */ protected function check_rate_limit( string $rate_limit_key ): bool { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( '' === $ip ) { return true; // Cannot determine IP — allow through. } // Salted with wp_salt('auth') (same construction as Redirect Manager's 404 // Monitor ip_hash) so the transient key isn't a reversible bare hash of the // IP — a lookup table over the IPv4/common-IPv6 space would otherwise // recover it from an unsalted md5/sha1. The raw IP itself is never stored; // only this keyed hash exists, and only for the 1-hour transient window. $key = 'adminoptim_stm_rl_' . hash_hmac( 'sha256', $ip . '|' . $rate_limit_key, wp_salt( 'auth' ) ); $hits = (int) get_transient( $key ); if ( $hits >= 3 ) { return false; } set_transient( $key, $hits + 1, HOUR_IN_SECONDS ); return true; } /** * Track a pageview using the configured update mode. * * @param array $view View identity — ['post_id', 'url_path', 'label', 'rate_limit_key']. * * @return void */ protected function track_page_view( array $view ) { $post_id = $view['post_id'] ? $view['post_id'] : null; if ( $this->supports_deferred_cache_counting() ) { $date = current_time( 'Y-m-d' ); $total_cached = $this->cache_increment_total_page_view( $view ); $daily_cached = $this->cache_increment_daily_page_view( $view, $date ); if ( ! $total_cached ) { $this->db->increment_total_pv( $post_id, 1, $view['url_path'], $view['label'] ); } if ( ! $daily_cached ) { $this->db->increment_daily_pv( $post_id, $date, 1, $view['url_path'], $view['label'] ); } } else { $this->db->increment_total_pv( $post_id, 1, $view['url_path'], $view['label'] ); $this->db->increment_daily_pv( $post_id, '', 1, $view['url_path'], $view['label'] ); } } /** * Check whether cache-based deferred counting is supported. * * @return bool */ protected function supports_deferred_cache_counting() { return $this->is_cache_mode_enabled() && wp_using_ext_object_cache() && function_exists( 'wp_cache_incr' ); } /** * Check whether cache update mode is enabled. * * @return bool */ protected function is_cache_mode_enabled() { return isset( $this->options['update_mode'] ) && 'cache' === $this->options['update_mode']; } /** * Increment cached counters for the current pageview. * * @param array $view View identity — ['post_id', 'url_path', 'label', 'rate_limit_key']. * * @return bool */ protected function cache_increment_total_page_view( array $view ) { if ( ! $this->increment_cached_counter( $this->get_total_cache_key( $view['rate_limit_key'] ) ) ) { return false; } $this->queue_flush_item( 'total', $view['rate_limit_key'], [ 'post_id' => $view['post_id'], 'url_path' => $view['url_path'], 'label' => $view['label'], ] ); return true; } /** * Increment cached daily pageviews. * * @param array $view View identity — ['post_id', 'url_path', 'label', 'rate_limit_key']. * @param string $date Date string. * * @return bool */ protected function cache_increment_daily_page_view( array $view, $date ) { if ( ! $this->increment_cached_counter( $this->get_daily_cache_key( $view['rate_limit_key'], $date ) ) ) { return false; } $this->queue_flush_item( 'daily', $view['rate_limit_key'] . '|' . $date, [ 'post_id' => $view['post_id'], 'url_path' => $view['url_path'], 'label' => $view['label'], 'date' => $date, ] ); return true; } /** * Increment a cached counter atomically. * * @param string $key Cache key. * * @return bool */ protected function increment_cached_counter( $key ) { wp_cache_add( $key, 0, self::CACHE_GROUP, DAY_IN_SECONDS ); $result = wp_cache_incr( $key, 1, self::CACHE_GROUP ); if ( false !== $result ) { return true; } return wp_cache_set( $key, 1, self::CACHE_GROUP, DAY_IN_SECONDS ); } /** * Queue a cache item for DB flush. * * @param string $type Queue type. * @param string $item_id Queue item ID. * @param array $payload Queue payload. * * @return void */ protected function queue_flush_item( $type, $item_id, $payload ) { $lock_key = 'flush-queued-' . $type . '-' . $item_id; if ( ! wp_cache_add( $lock_key, 1, self::CACHE_GROUP, DAY_IN_SECONDS ) ) { return; } // Store each item as its own option (keyed by a hash of the item ID) to avoid // the read-modify-write race that would occur when multiple requests updated // a single shared queue array simultaneously. $option_key = self::FLUSH_OPTION . '-' . $type . '-' . md5( $item_id ); update_option( $option_key, $payload, false ); } /** * Flush cached counters into the database. * * @return void */ public function flush_cached_counts() { global $wpdb; $like = $wpdb->esc_like( self::FLUSH_OPTION . '-' ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s AND autoload = 'no'", $like ), ARRAY_A ); if ( empty( $rows ) ) { return; } $prefix = self::FLUSH_OPTION . '-'; foreach ( $rows as $row ) { $option_name = $row['option_name']; $payload = maybe_unserialize( $row['option_value'] ); if ( ! is_array( $payload ) ) { delete_option( $option_name ); continue; } $suffix = substr( $option_name, strlen( $prefix ) ); if ( 0 === strpos( $suffix, 'total-' ) ) { $this->flush_total_item( $option_name, $payload ); } elseif ( 0 === strpos( $suffix, 'daily-' ) ) { $this->flush_daily_item( $option_name, $payload ); } else { // Unknown suffix — remove stale entry. delete_option( $option_name ); } } } /** * Derive the rate-limit/cache-key identity string from a queue payload. * * @param array $payload Queue payload — ['post_id', 'url_path']. * * @return string Empty string if the payload doesn't identify a trackable view. */ private function payload_rate_limit_key( array $payload ): string { $post_id = isset( $payload['post_id'] ) ? absint( $payload['post_id'] ) : 0; $url_path = isset( $payload['url_path'] ) ? (string) $payload['url_path'] : ''; if ( $post_id ) { return 'p' . $post_id; } if ( '' !== $url_path ) { return 'u' . md5( $url_path ); } return ''; } /** * Flush a single queued total-pageview item. * * @param string $option_name Queue option name. * @param array $payload Queue payload. * * @return void */ private function flush_total_item( string $option_name, array $payload ): void { $rate_limit_key = $this->payload_rate_limit_key( $payload ); if ( '' === $rate_limit_key ) { delete_option( $option_name ); return; } $post_id = isset( $payload['post_id'] ) ? absint( $payload['post_id'] ) : 0; $url_path = isset( $payload['url_path'] ) ? (string) $payload['url_path'] : ''; $label = isset( $payload['label'] ) ? (string) $payload['label'] : ''; $cache_key = $this->get_total_cache_key( $rate_limit_key ); $lock_key = 'flush-queued-total-' . $rate_limit_key; $count = (int) wp_cache_get( $cache_key, self::CACHE_GROUP ); // Delete the cache key BEFORE the DB write so any new hits arriving // after this point accumulate in a fresh counter, closing the TOCTOU // window that a get-then-delete ordering would leave open. wp_cache_delete( $cache_key, self::CACHE_GROUP ); if ( $count > 0 && false === $this->db->increment_total_pv( $post_id ? $post_id : null, $count, $url_path, $label ) ) { $this->restore_cached_count( $cache_key, $count ); return; } // Release the per-item lock so a future view can re-queue this item. wp_cache_delete( $lock_key, self::CACHE_GROUP ); // If new views arrived during the flush window, restore the queue item so // those fresh counts are picked up by a later cron run. $remaining_count = (int) wp_cache_get( $cache_key, self::CACHE_GROUP ); if ( $remaining_count > 0 ) { $this->queue_flush_item( 'total', $rate_limit_key, [ 'post_id' => $post_id, 'url_path' => $url_path, 'label' => $label, ] ); } delete_option( $option_name ); } /** * Flush a single queued daily-pageview item. * * @param string $option_name Queue option name. * @param array $payload Queue payload. * * @return void */ private function flush_daily_item( string $option_name, array $payload ): void { $rate_limit_key = $this->payload_rate_limit_key( $payload ); $date = isset( $payload['date'] ) ? (string) $payload['date'] : ''; if ( '' === $rate_limit_key || '' === $date ) { delete_option( $option_name ); return; } $post_id = isset( $payload['post_id'] ) ? absint( $payload['post_id'] ) : 0; $url_path = isset( $payload['url_path'] ) ? (string) $payload['url_path'] : ''; $label = isset( $payload['label'] ) ? (string) $payload['label'] : ''; $cache_key = $this->get_daily_cache_key( $rate_limit_key, $date ); $lock_key = 'flush-queued-daily-' . $rate_limit_key . '|' . $date; $count = (int) wp_cache_get( $cache_key, self::CACHE_GROUP ); wp_cache_delete( $cache_key, self::CACHE_GROUP ); if ( $count > 0 && false === $this->db->increment_daily_pv( $post_id ? $post_id : null, $date, $count, $url_path, $label ) ) { $this->restore_cached_count( $cache_key, $count ); return; } wp_cache_delete( $lock_key, self::CACHE_GROUP ); $remaining_count = (int) wp_cache_get( $cache_key, self::CACHE_GROUP ); if ( $remaining_count > 0 ) { $this->queue_flush_item( 'daily', $rate_limit_key . '|' . $date, [ 'post_id' => $post_id, 'url_path' => $url_path, 'label' => $label, 'date' => $date, ] ); } delete_option( $option_name ); } /** * Restore a cached counter after a failed DB flush. * * If a fresh counter was created while the flush was in progress, merge the * flushed count back into that newer value instead of dropping it. * * @param string $cache_key Cache key. * @param int $count Count to restore. * * @return void */ protected function restore_cached_count( $cache_key, $count ) { $count = max( 0, absint( $count ) ); if ( 0 === $count ) { return; } if ( wp_cache_add( $cache_key, $count, self::CACHE_GROUP, DAY_IN_SECONDS ) ) { return; } $current_count = (int) wp_cache_get( $cache_key, self::CACHE_GROUP ); wp_cache_set( $cache_key, $current_count + $count, self::CACHE_GROUP, DAY_IN_SECONDS ); } /** * Get total cache key. * * @param string $rate_limit_key View identity key (e.g. 'p123', 'u5f4dcc...'). * * @return string */ protected function get_total_cache_key( $rate_limit_key ) { return 'total-' . sanitize_key( $rate_limit_key ); } /** * Get daily cache key. * * @param string $rate_limit_key View identity key (e.g. 'p123', 'u5f4dcc...'). * @param string $date Date string. * * @return string */ protected function get_daily_cache_key( $rate_limit_key, $date ) { return 'daily-' . sanitize_key( $rate_limit_key ) . '-' . sanitize_key( $date ); } /** * Enqueue the frontend tracking beacon. * * @return void */ public function enqueue_scripts() { $beacon_args = $this->get_current_view_beacon_args(); if ( null === $beacon_args ) { return; } wp_enqueue_script( 'adminoptim-site-traffic-manager', self::MODULE_URL . 'assets/js/page-view.min.js', [], ADMINOPTIMIZER_VERSION, true ); wp_localize_script( 'adminoptim-site-traffic-manager', 'adminoptimSiteTrafficManager', array_merge( [ 'endpoint' => rest_url( self::REST_NAMESPACE . self::REST_ROUTE ) ], $beacon_args ) ); } /** * Determine which identifying args (if any) the frontend beacon should * send for the current front-end request. * * Singular posts are identified by postId, matching the existing DB path. * Everything else (home page, archives, search, etc) is identified by the * current request's own path/title when non-post tracking is enabled — * one fallback covers every non-post view kind instead of a branch per type. * * @return array|null Localize-script args, or null if this view isn't tracked. */ protected function get_current_view_beacon_args(): ?array { if ( is_singular() ) { // get_queried_object() is already resolved and cached by WP core at this // point; using it avoids an extra get_post() call. $post = get_queried_object(); if ( ! ( $post instanceof \WP_Post ) || 'publish' !== $post->post_status ) { return null; } if ( ! $this->is_trackable_post_type( $post->post_type ) ) { return null; } return [ 'postId' => $post->ID ]; } if ( is_admin() || is_feed() || is_robots() || is_trackback() || is_404() ) { return null; } if ( ! $this->is_view_type_trackable( 'non_post' ) ) { return null; } return [ 'urlPath' => (string) wp_parse_url( home_url( add_query_arg( [] ) ), PHP_URL_PATH ), 'label' => wp_get_document_title(), ]; } /** * Deactivation cleanup. * * Removes queued flush option rows and clears the scheduled flush event. * Hooked to the shared `adminoptim_deactivate_plugin` action. * * @return void */ public static function deactivate(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( self::FLUSH_OPTION . '-' ) . '%' ) ); if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( self::FLUSH_HOOK ); } } /* -------------------------------------------------------------------- */ /* Public API — used by other modules to query collected traffic data. */ /* -------------------------------------------------------------------- */ /** * Get the posts with the highest page views for a given time window. * * @param array $args { * Optional. Query arguments. * * @type string $post_type Post type slug, 'all_post_types', or 'all' (includes non-post views). Default 'all'. * @type string $time_period 'all_time', 'last_3_days', 'last_7_days', 'last_30_days', 'last_90_days', or 'custom'. Default 'all_time'. * @type string $date_from Inclusive custom range start (`Y-m-d`). Only used when $time_period is 'custom'. * @type string $date_to Inclusive custom range end (`Y-m-d`). Only used when $time_period is 'custom'. * @type int $limit Maximum number of rows to return. Default 10. * } * * @return array Rows of ['post_id', 'url_path', 'label', 'pageview', 'post_title', 'post_type', 'post_date']. */ public static function get_top_posts( array $args = [] ): array { $args = wp_parse_args( $args, [ 'post_type' => 'all', 'time_period' => 'all_time', 'date_from' => '', 'date_to' => '', 'limit' => 10, ] ); $db = new Site_Traffic_Manager_DB(); return $db->get_top_total_pv_posts( (int) $args['limit'], (string) $args['post_type'], (string) $args['time_period'], 'all_time', (string) $args['date_from'], (string) $args['date_to'] ); } /** * Get the page view count for a single post. * * @param int $post_id Post ID. * @param string $time_period 'all_time' for the lifetime total, or a specific `Y-m-d` date for that day's count. Default 'all_time'. * * @return int */ public static function get_page_views( int $post_id, string $time_period = 'all_time' ): int { $db = new Site_Traffic_Manager_DB(); if ( 'all_time' === $time_period ) { return $db->get_total_pv( $post_id ); } return $db->get_daily_pv( $post_id, $time_period ); } }