3rd-party
2 months ago
abstracts
3 months ago
admin
2 weeks ago
emails
2 weeks ago
forms
2 weeks ago
helper
3 months ago
promoted-jobs
2 weeks ago
ui
2 weeks ago
widgets
2 weeks ago
class-access-token.php
2 years ago
class-dev-tools.php
2 years ago
class-guest-session.php
2 years ago
class-guest-user.php
2 years ago
class-job-dashboard-shortcode.php
6 months ago
class-job-listing-stats.php
2 years ago
class-job-overlay.php
2 years ago
class-stats-dashboard.php
2 years ago
class-stats-script.php
3 months ago
class-stats.php
3 months ago
class-wp-job-manager-ajax.php
2 months ago
class-wp-job-manager-api.php
6 years ago
class-wp-job-manager-blocks.php
2 weeks ago
class-wp-job-manager-cache-helper.php
3 months ago
class-wp-job-manager-category-walker.php
6 years ago
class-wp-job-manager-com-api.php
2 years ago
class-wp-job-manager-data-cleaner.php
2 years ago
class-wp-job-manager-data-exporter.php
3 months ago
class-wp-job-manager-dependency-checker.php
2 years ago
class-wp-job-manager-email-notifications.php
2 years ago
class-wp-job-manager-forms.php
5 years ago
class-wp-job-manager-geocode.php
2 weeks ago
class-wp-job-manager-install.php
2 years ago
class-wp-job-manager-post-types.php
2 weeks ago
class-wp-job-manager-recaptcha.php
6 months ago
class-wp-job-manager-rest-api.php
2 months ago
class-wp-job-manager-shortcodes.php
2 weeks ago
class-wp-job-manager-usage-tracking-data.php
2 years ago
class-wp-job-manager-usage-tracking.php
2 years ago
class-wp-job-manager-widget.php
2 weeks ago
class-wp-job-manager.php
3 months ago
trait-singleton.php
2 years ago
class-stats-script.php
514 lines
| 1 | <?php |
| 2 | /** |
| 3 | * File containing the class WP_Job_Manager\Stats_Script |
| 4 | * |
| 5 | * @package wp-job-manager |
| 6 | */ |
| 7 | |
| 8 | namespace WP_Job_Manager; |
| 9 | |
| 10 | if ( ! defined( 'ABSPATH' ) ) { |
| 11 | exit; |
| 12 | } |
| 13 | |
| 14 | /** |
| 15 | * Add and handle frontend script to collect stats. |
| 16 | * |
| 17 | * @since 2.3.0 |
| 18 | */ |
| 19 | class Stats_Script { |
| 20 | use Singleton; |
| 21 | |
| 22 | /** |
| 23 | * Run any hooks related to stats. |
| 24 | * |
| 25 | * @return void |
| 26 | */ |
| 27 | private function __construct() { |
| 28 | add_action( 'wp_ajax_job_manager_log_stat', [ $this, 'ajax_log_stat' ] ); |
| 29 | add_action( 'wp_ajax_nopriv_job_manager_log_stat', [ $this, 'ajax_log_stat' ] ); |
| 30 | add_action( 'wp_enqueue_scripts', [ $this, 'maybe_enqueue_stats_scripts' ] ); |
| 31 | } |
| 32 | |
| 33 | /** |
| 34 | * Max stats accepted in a single AJAX request. |
| 35 | */ |
| 36 | const AJAX_BATCH_LIMIT = 50; |
| 37 | |
| 38 | /** |
| 39 | * Requests allowed per client per minute. |
| 40 | */ |
| 41 | const AJAX_RATE_LIMIT = 60; |
| 42 | |
| 43 | /** |
| 44 | * Log multiple stats in one go. Triggered in an ajax call. |
| 45 | * |
| 46 | * @return void |
| 47 | */ |
| 48 | public function ajax_log_stat() { |
| 49 | if ( ! wp_doing_ajax() ) { |
| 50 | return; |
| 51 | } |
| 52 | |
| 53 | // If stats collection has been toggled off (e.g. between page render and |
| 54 | // this deferred AJAX call), short-circuit as a no-op so we don't emit 500s |
| 55 | // for what is an expected state. |
| 56 | if ( ! Stats::is_enabled() ) { |
| 57 | wp_send_json_success(); |
| 58 | return; |
| 59 | } |
| 60 | |
| 61 | $post_data = stripslashes_deep( $_POST ); |
| 62 | $post_id = absint( $post_data['post_id'] ?? 0 ); |
| 63 | |
| 64 | if ( |
| 65 | ! isset( $post_data['_ajax_nonce'] ) |
| 66 | || ! $post_id |
| 67 | || ! wp_verify_nonce( $post_data['_ajax_nonce'], 'wpjm_log_stat_' . $post_id ) |
| 68 | ) { |
| 69 | wp_send_json_error( __( 'Invalid request.', 'wp-job-manager' ), 403 ); |
| 70 | return; |
| 71 | } |
| 72 | |
| 73 | if ( $this->is_rate_limited() ) { |
| 74 | wp_send_json_error( __( 'Too many requests.', 'wp-job-manager' ), 429 ); |
| 75 | return; |
| 76 | } |
| 77 | |
| 78 | $stats_raw = $post_data['stats'] ?? '[]'; |
| 79 | if ( ! is_string( $stats_raw ) ) { |
| 80 | wp_send_json_error( __( 'Invalid payload.', 'wp-job-manager' ), 400 ); |
| 81 | return; |
| 82 | } |
| 83 | $stats = json_decode( $stats_raw, true ); |
| 84 | if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $stats ) ) { |
| 85 | wp_send_json_error( __( 'Invalid payload.', 'wp-job-manager' ), 400 ); |
| 86 | return; |
| 87 | } |
| 88 | if ( empty( $stats ) ) { |
| 89 | wp_send_json_error( __( 'No stats to log.', 'wp-job-manager' ), 400 ); |
| 90 | return; |
| 91 | } |
| 92 | |
| 93 | $stats = array_slice( $stats, 0, self::AJAX_BATCH_LIMIT ); |
| 94 | |
| 95 | $today = gmdate( 'Y-m-d' ); |
| 96 | $stats = array_map( |
| 97 | function ( $stat ) use ( $today ) { |
| 98 | if ( ! is_array( $stat ) ) { |
| 99 | return null; |
| 100 | } |
| 101 | // Canonicalize types so every downstream step (validity check, dedup |
| 102 | // keying, DB write) operates on consistent scalars; strlen()/md5() on |
| 103 | // a non-string fatals on PHP 8+. |
| 104 | $stat['post_id'] = absint( $stat['post_id'] ?? 0 ); |
| 105 | $stat['name'] = is_string( $stat['name'] ?? null ) ? $stat['name'] : ''; |
| 106 | $stat['group'] = is_string( $stat['group'] ?? null ) ? $stat['group'] : ''; |
| 107 | $stat['count'] = 1; |
| 108 | $stat['date'] = $today; |
| 109 | // Mirror Stats::parse_stats() length constraints so a `false` return |
| 110 | // from batch_log_stats() unambiguously means a DB error. |
| 111 | if ( strlen( $stat['name'] ) > 50 || strlen( $stat['group'] ) > 50 ) { |
| 112 | return null; |
| 113 | } |
| 114 | return $stat; |
| 115 | }, |
| 116 | $stats |
| 117 | ); |
| 118 | $stats = array_filter( $stats ); |
| 119 | |
| 120 | // Snapshot the registered-stats filter result once for this request and thread |
| 121 | // it through the filter helpers so the `wpjm_get_registered_stats` filter fires |
| 122 | // once per AJAX request. |
| 123 | $registered_stats = $this->get_registered_stats(); |
| 124 | $registered_names = array_keys( $registered_stats ); |
| 125 | $stats = array_filter( |
| 126 | $stats, |
| 127 | function ( $stat ) use ( $registered_names ) { |
| 128 | return isset( $stat['name'] ) && in_array( $stat['name'], $registered_names, true ); |
| 129 | } |
| 130 | ); |
| 131 | |
| 132 | $stats = $this->filter_by_post_validity( $stats, $post_id, $registered_stats ); |
| 133 | $pending_unique = []; |
| 134 | $stats = $this->filter_server_unique( $stats, $pending_unique, $registered_stats ); |
| 135 | |
| 136 | if ( empty( $stats ) ) { |
| 137 | wp_send_json_success(); |
| 138 | return; |
| 139 | } |
| 140 | |
| 141 | if ( ! Stats::instance()->batch_log_stats( $stats ) ) { |
| 142 | wp_send_json_error( __( 'Unable to log stats.', 'wp-job-manager' ), 500 ); |
| 143 | return; |
| 144 | } |
| 145 | foreach ( $pending_unique as $key ) { |
| 146 | set_transient( $key, 1, DAY_IN_SECONDS ); |
| 147 | } |
| 148 | wp_send_json_success(); |
| 149 | } |
| 150 | |
| 151 | /** |
| 152 | * Validate stat post_ids against expected post type, status, and request scope. |
| 153 | * |
| 154 | * Two checks are applied per stat row: |
| 155 | * |
| 156 | * 1. Scope: for non-impression stats the stat's post_id must equal the request-level |
| 157 | * post_id (the page the client rendered, bound to the nonce). This stops a client |
| 158 | * with a valid nonce for one published post from logging `job_view`, `job_apply_click`, |
| 159 | * etc. for a different post. |
| 160 | * |
| 161 | * 2. Validity: the target post must be published, and listing-scope stats |
| 162 | * (page=listing or type=impression) must target a `job_listing`. |
| 163 | * |
| 164 | * Impression stats (type=impression) are exempt from the scope check because the |
| 165 | * `[jobs]` shortcode's JS client posts per-listing post_ids that legitimately differ |
| 166 | * from the page post_id. Residual risk: on a `[jobs]` page a client with a valid page |
| 167 | * nonce can still submit impressions for any published job_listing, not just ones |
| 168 | * visible on the page. Rate limiting + dedup constrain the volume; the dashboard |
| 169 | * footnote discloses that public-page stats are approximate. Eliminating this would |
| 170 | * require server-side impression recording, which is incompatible with full-page |
| 171 | * caching. |
| 172 | * |
| 173 | * @param array $stats Stat rows. |
| 174 | * @param int $request_post_id The page-level post_id the nonce is bound to. |
| 175 | * @param array $registered_stats Snapshot of `wpjm_get_registered_stats` for this request. |
| 176 | * @return array Filtered stat rows. |
| 177 | */ |
| 178 | private function filter_by_post_validity( $stats, $request_post_id, array $registered_stats ) { |
| 179 | $listing_stat_names = []; |
| 180 | $impression_stat_names = []; |
| 181 | foreach ( $registered_stats as $name => $def ) { |
| 182 | $is_listing_page = ( $def['page'] ?? '' ) === 'listing'; |
| 183 | $is_impression = ( $def['type'] ?? '' ) === 'impression'; |
| 184 | if ( $is_listing_page || $is_impression ) { |
| 185 | $listing_stat_names[] = $name; |
| 186 | } |
| 187 | if ( $is_impression ) { |
| 188 | $impression_stat_names[] = $name; |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | $cache = []; |
| 193 | return array_filter( |
| 194 | $stats, |
| 195 | function ( $stat ) use ( &$cache, $listing_stat_names, $impression_stat_names, $request_post_id ) { |
| 196 | $post_id = absint( $stat['post_id'] ?? 0 ); |
| 197 | if ( ! $post_id ) { |
| 198 | return false; |
| 199 | } |
| 200 | |
| 201 | $name = $stat['name'] ?? ''; |
| 202 | $is_impression = in_array( $name, $impression_stat_names, true ); |
| 203 | $requires_listing = in_array( $name, $listing_stat_names, true ); |
| 204 | |
| 205 | // Scope: non-impression stats must target the post the nonce was issued for. |
| 206 | if ( ! $is_impression && $post_id !== $request_post_id ) { |
| 207 | return false; |
| 208 | } |
| 209 | |
| 210 | $cache_key = $post_id . '|' . ( $requires_listing ? 'L' : 'P' ); |
| 211 | |
| 212 | if ( ! isset( $cache[ $cache_key ] ) ) { |
| 213 | $status = get_post_status( $post_id ); |
| 214 | $type = get_post_type( $post_id ); |
| 215 | if ( 'publish' !== $status ) { |
| 216 | $cache[ $cache_key ] = false; |
| 217 | } elseif ( $requires_listing && \WP_Job_Manager_Post_Types::PT_LISTING !== $type ) { |
| 218 | $cache[ $cache_key ] = false; |
| 219 | } else { |
| 220 | $cache[ $cache_key ] = true; |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | return $cache[ $cache_key ]; |
| 225 | } |
| 226 | ); |
| 227 | } |
| 228 | |
| 229 | /** |
| 230 | * Server-side per-client dedup for stats flagged `unique` in their definition. |
| 231 | * |
| 232 | * Authoritative: reads the `unique` flag from registered-stat definitions rather |
| 233 | * than guessing from the stat name. Covers `*_unique` stats and any other stat |
| 234 | * (e.g. `job_apply_click`) registered with `unique => true`. |
| 235 | * |
| 236 | * Uses a 24-hour sliding window starting at the first observed click. A calendar-day |
| 237 | * (midnight UTC) boundary would let a visitor near the boundary re-count within |
| 238 | * minutes of their first click. |
| 239 | * |
| 240 | * Dedup transient keys are collected into `$pending_keys` rather than written |
| 241 | * inline, so the caller can commit them only after the DB write succeeds. |
| 242 | * |
| 243 | * @param array $stats Stat rows. |
| 244 | * @param array $pending_keys Out-param: transient keys to set after a successful DB write. |
| 245 | * @param array $registered_stats Snapshot of `wpjm_get_registered_stats` for this request. |
| 246 | * @return array Filtered stat rows. |
| 247 | */ |
| 248 | private function filter_server_unique( $stats, array &$pending_keys, array $registered_stats ) { |
| 249 | $client = $this->get_client_ip(); |
| 250 | if ( '' === $client ) { |
| 251 | return $stats; |
| 252 | } |
| 253 | |
| 254 | $unique_stat_names = []; |
| 255 | foreach ( $registered_stats as $name => $def ) { |
| 256 | if ( ! empty( $def['unique'] ) ) { |
| 257 | $unique_stat_names[] = $name; |
| 258 | } |
| 259 | } |
| 260 | if ( empty( $unique_stat_names ) ) { |
| 261 | return $stats; |
| 262 | } |
| 263 | |
| 264 | return array_filter( |
| 265 | $stats, |
| 266 | function ( $stat ) use ( $client, $unique_stat_names, &$pending_keys ) { |
| 267 | $name = $stat['name'] ?? ''; |
| 268 | if ( ! in_array( $name, $unique_stat_names, true ) ) { |
| 269 | return true; |
| 270 | } |
| 271 | $key = 'wpjm_u_' . md5( $client . '|' . $name . '|' . ( $stat['post_id'] ?? 0 ) ); |
| 272 | // Reject duplicates already claimed earlier in the same batch, so an |
| 273 | // attacker can't pack 50 identical rows into one request. |
| 274 | if ( in_array( $key, $pending_keys, true ) || get_transient( $key ) ) { |
| 275 | return false; |
| 276 | } |
| 277 | $pending_keys[] = $key; |
| 278 | return true; |
| 279 | } |
| 280 | ); |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * Soft per-client rate limit. Not an auth boundary — absorbs drive-by abuse. |
| 285 | * |
| 286 | * Fixed-window implementation: the transient stores `{count, start}` and the |
| 287 | * window resets once `now - start >= MINUTE_IN_SECONDS`, so the TTL isn't |
| 288 | * refreshed by each write and an active client isn't falsely blocked. |
| 289 | * |
| 290 | * @return bool |
| 291 | */ |
| 292 | private function is_rate_limited() { |
| 293 | $client = $this->get_client_ip(); |
| 294 | if ( '' === $client ) { |
| 295 | return false; |
| 296 | } |
| 297 | $key = 'wpjm_rl_' . md5( $client ); |
| 298 | $now = time(); |
| 299 | $window = get_transient( $key ); |
| 300 | |
| 301 | if ( |
| 302 | ! is_array( $window ) |
| 303 | || ! isset( $window['count'], $window['start'] ) |
| 304 | || $now - (int) $window['start'] >= MINUTE_IN_SECONDS |
| 305 | ) { |
| 306 | $window = [ |
| 307 | 'count' => 0, |
| 308 | 'start' => $now, |
| 309 | ]; |
| 310 | } |
| 311 | |
| 312 | if ( (int) $window['count'] >= self::AJAX_RATE_LIMIT ) { |
| 313 | return true; |
| 314 | } |
| 315 | |
| 316 | $window['count']++; |
| 317 | set_transient( $key, $window, MINUTE_IN_SECONDS ); |
| 318 | return false; |
| 319 | } |
| 320 | |
| 321 | /** |
| 322 | * Client IP from REMOTE_ADDR only (X-Forwarded-For is spoofable by design here). |
| 323 | * |
| 324 | * @return string |
| 325 | */ |
| 326 | private function get_client_ip() { |
| 327 | $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP ) : ''; |
| 328 | return is_string( $ip ) ? $ip : ''; |
| 329 | } |
| 330 | |
| 331 | /** |
| 332 | * Register any frontend scripts for job listings. |
| 333 | * |
| 334 | * @access private |
| 335 | */ |
| 336 | public function maybe_enqueue_stats_scripts() { |
| 337 | |
| 338 | \WP_Job_Manager::register_script( |
| 339 | 'wp-job-manager-stats', |
| 340 | 'js/wpjm-stats.js', |
| 341 | [ |
| 342 | 'wp-dom-ready', |
| 343 | 'wp-hooks', |
| 344 | ], |
| 345 | true |
| 346 | ); |
| 347 | |
| 348 | global $post; |
| 349 | |
| 350 | $pages = []; |
| 351 | if ( is_wpjm_job_listing() ) { |
| 352 | $pages[] = 'listing'; |
| 353 | } |
| 354 | if ( $this->page_has_jobs_shortcode( $post ) ) { |
| 355 | $pages[] = 'jobs'; |
| 356 | } |
| 357 | |
| 358 | if ( empty( $pages ) ) { |
| 359 | return; |
| 360 | } |
| 361 | |
| 362 | $this->enqueue_stats_script( $pages, $post->ID ); |
| 363 | } |
| 364 | |
| 365 | /** |
| 366 | * Register the stats script for the given page contexts. |
| 367 | * |
| 368 | * A single request can match more than one context (e.g. a `job_listing` |
| 369 | * post whose content embeds the `[jobs]` shortcode). All applicable stat |
| 370 | * definitions are collected into one `wp_localize_script` call so the |
| 371 | * client receives the union of listing-page and jobs-page stats under a |
| 372 | * single nonce. |
| 373 | * |
| 374 | * @param string[] $pages Page contexts (`listing`, `jobs`). |
| 375 | * @param int $post_id The page-level post_id. |
| 376 | * |
| 377 | * @return void |
| 378 | */ |
| 379 | private function enqueue_stats_script( array $pages, $post_id = 0 ) { |
| 380 | |
| 381 | $stats = []; |
| 382 | foreach ( $pages as $page ) { |
| 383 | $stats = array_merge( $stats, $this->get_stats_for_ajax( $post_id, $page ) ); |
| 384 | } |
| 385 | |
| 386 | $script_data = [ |
| 387 | 'ajaxUrl' => admin_url( 'admin-ajax.php' ), |
| 388 | 'ajaxNonce' => wp_create_nonce( 'wpjm_log_stat_' . $post_id ), |
| 389 | 'postId' => $post_id, |
| 390 | 'stats' => $stats, |
| 391 | ]; |
| 392 | |
| 393 | wp_enqueue_script( 'wp-job-manager-stats' ); |
| 394 | wp_localize_script( |
| 395 | 'wp-job-manager-stats', |
| 396 | 'job_manager_stats', |
| 397 | $script_data |
| 398 | ); |
| 399 | |
| 400 | } |
| 401 | |
| 402 | /** |
| 403 | * Get all the registered stats. |
| 404 | * |
| 405 | * @return array |
| 406 | */ |
| 407 | private function get_registered_stats() { |
| 408 | return (array) apply_filters( |
| 409 | 'wpjm_get_registered_stats', |
| 410 | [ |
| 411 | Job_Listing_Stats::VIEW => [ |
| 412 | 'type' => 'action', |
| 413 | 'action' => 'page-load', |
| 414 | 'page' => 'listing', |
| 415 | ], |
| 416 | Job_Listing_Stats::VIEW_UNIQUE => [ |
| 417 | 'type' => 'action', |
| 418 | 'action' => 'page-load', |
| 419 | 'unique' => true, |
| 420 | 'page' => 'listing', |
| 421 | ], |
| 422 | Job_Listing_Stats::APPLY_CLICK => [ |
| 423 | 'type' => 'domEvent', |
| 424 | 'args' => [ |
| 425 | 'element' => 'input.application_button', |
| 426 | 'event' => 'click', |
| 427 | ], |
| 428 | 'unique' => true, |
| 429 | 'page' => 'listing', |
| 430 | ], |
| 431 | 'search_view' => [ |
| 432 | 'type' => 'action', |
| 433 | 'action' => 'page-load', |
| 434 | 'page' => 'jobs', |
| 435 | ], |
| 436 | 'search_view_unique' => [ |
| 437 | 'type' => 'action', |
| 438 | 'action' => 'page-load', |
| 439 | 'page' => 'jobs', |
| 440 | 'unique' => true, |
| 441 | ], |
| 442 | Job_Listing_Stats::SEARCH_IMPRESSION => [ |
| 443 | 'type' => 'impression', |
| 444 | 'args' => [ |
| 445 | 'container' => 'ul.job_listings', |
| 446 | 'item' => 'li.job_listing', |
| 447 | ], |
| 448 | 'page' => 'jobs', |
| 449 | ], |
| 450 | ] |
| 451 | ); |
| 452 | } |
| 453 | |
| 454 | /** |
| 455 | * Determine what stats should be added to the kind of page the user is viewing. |
| 456 | * |
| 457 | * @param int $post_id Optional post id. |
| 458 | * @param string $page The page in question. |
| 459 | * |
| 460 | * @return array |
| 461 | */ |
| 462 | private function get_stats_for_ajax( $post_id = 0, $page = 'listing' ) { |
| 463 | $ajax_stats = []; |
| 464 | foreach ( $this->get_registered_stats() as $stat_name => $stat_data ) { |
| 465 | if ( $page !== $stat_data['page'] ) { |
| 466 | continue; |
| 467 | } |
| 468 | |
| 469 | $stat_ajax = [ |
| 470 | 'name' => $stat_name, |
| 471 | 'post_id' => $post_id, |
| 472 | 'type' => $stat_data['type'] ?? '', |
| 473 | 'action' => $stat_data['action'] ?? '', |
| 474 | 'args' => $stat_data['args'] ?? '', |
| 475 | ]; |
| 476 | |
| 477 | if ( ! empty( $stat_data['unique'] ) ) { |
| 478 | $unique_callback = $stat_data['unique_callback'] ?? [ $this, 'get_post_id_unique_key' ]; |
| 479 | $stat_ajax['unique_key'] = call_user_func( $unique_callback, $stat_name, $post_id ); |
| 480 | } |
| 481 | |
| 482 | $ajax_stats[] = $stat_ajax; |
| 483 | } |
| 484 | |
| 485 | return $ajax_stats; |
| 486 | } |
| 487 | |
| 488 | /** |
| 489 | * Derive unique key by post id. |
| 490 | * |
| 491 | * @access private |
| 492 | * |
| 493 | * @param string $stat_name Name. |
| 494 | * @param int $post_id Post id. |
| 495 | * |
| 496 | * @return string |
| 497 | */ |
| 498 | public function get_post_id_unique_key( $stat_name, $post_id ) { |
| 499 | return $stat_name . '_' . $post_id; |
| 500 | } |
| 501 | |
| 502 | /** |
| 503 | * Any page containing a job shortcode is eligible. |
| 504 | * |
| 505 | * @param \WP_Post $post The post. |
| 506 | * |
| 507 | * @return bool |
| 508 | */ |
| 509 | private function page_has_jobs_shortcode( $post ) { |
| 510 | return $post && has_shortcode( $post->post_content, 'jobs' ); |
| 511 | } |
| 512 | |
| 513 | } |
| 514 |