PluginProbe ʕ •ᴥ•ʔ
WP Job Manager / 2.4.6
WP Job Manager v2.4.6
2.4.6 2.4.5 2.4.4 2.4.3 2.4.2 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.1.0 1.1.1 1.1.2 1.1.3 1.10.0 1.11.0 1.11.1 1.12.0 1.12.1 1.13.0 1.14.0 1.15.0 1.16.0 1.16.1 1.17.0 1.18.0 1.19.0 1.2.0 1.20.0 1.20.1 1.21.0 1.21.1 1.21.2 1.21.3 1.21.4 1.22.0 1.22.1 1.22.2 1.22.3 1.23.0 1.23.1 1.23.10 1.23.11 1.23.12 1.23.13 1.23.2 1.23.3 1.23.4 1.23.5 1.23.6 1.23.7 1.23.8 1.23.9 1.24.0 1.24.0.1 1.25.0 1.25.0.1 1.25.1 1.25.1.1 1.25.2 1.25.2.1 1.25.3 1.25.3.1 1.26.0 1.26.0.1 1.26.1 1.26.1.1 1.26.2 1.26.2.1 1.27.0 1.27.0.1 1.28.0 1.28.0.1 1.29.0 1.29.0.1 1.29.1 1.29.1.1 1.29.2 1.29.2.1 1.29.3 1.29.3.1 1.3.0 1.3.1 1.30.0 1.30.0.1 1.30.1 1.30.1.1 1.30.2 1.30.2.1 1.31.0 1.31.0.1 1.31.1 1.31.1.1 1.31.2 1.31.3 1.32.0 1.32.1 1.32.2 1.32.3 1.33.0 1.33.1 1.33.2 1.33.3 1.33.4 1.33.5 1.34.0 1.34.1 1.34.2 1.34.3 1.34.4 1.34.5 1.35.0 1.35.1 1.35.2 1.35.3 1.36.0 1.36.1 1.36.2 1.37.0 1.38.0 1.38.1 1.39.0 1.4.0 1.40.0 1.40.1 1.40.2 1.41.0 1.42.0 1.5.0 1.5.1 1.5.2 1.6.0 1.7.0 1.7.1 1.7.3 1.8.0 1.8.1 1.8.2 1.9.0 1.9.1 1.9.2 1.9.3 2.0.0 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.4.1
wp-job-manager / includes / class-stats-script.php
wp-job-manager / includes Last commit date
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