PluginProbe ʕ •ᴥ•ʔ
Post Views Counter / 1.7.11
Post Views Counter v1.7.11
1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2.0 1.2.1 1.2.10 1.2.11 1.2.12 1.2.13 1.2.14 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 1.3 1.3.1 1.3.10 1.3.11 1.3.12 1.3.13 1.3.2 1.3.2.1 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.7.0 1.7.1 1.7.10 1.7.2 1.7.3 1.7.4 1.7.5 1.7.6 1.7.7 1.7.8 1.7.9
post-views-counter / includes / class-counter.php
post-views-counter / includes Last commit date
class-admin.php 3 months ago class-columns-modal.php 3 months ago class-columns.php 3 months ago class-counter.php 3 months ago class-crawler-detect.php 3 months ago class-cron.php 3 months ago class-dashboard.php 3 months ago class-emails-mailer.php 3 months ago class-emails-period.php 3 months ago class-emails-query.php 3 months ago class-emails-scheduler.php 3 months ago class-emails-template.php 3 months ago class-emails.php 3 months ago class-frontend.php 3 months ago class-functions.php 3 months ago class-import.php 3 months ago class-integration-gutenberg.php 3 months ago class-integrations.php 3 months ago class-query.php 3 months ago class-settings-api.php 3 months ago class-settings-display.php 3 months ago class-settings-emails.php 3 months ago class-settings-general.php 3 months ago class-settings-integrations.php 3 months ago class-settings-other.php 3 months ago class-settings-reports.php 3 months ago class-settings.php 3 months ago class-toolbar.php 3 months ago class-traffic-signals.php 3 months ago class-update.php 3 months ago class-widgets.php 3 months ago functions.php 3 months ago
class-counter.php
2412 lines
1 <?php
2 // exit if accessed directly
3 if ( ! defined( 'ABSPATH' ) )
4 exit;
5
6 /**
7 * Post_Views_Counter_Counter class.
8 *
9 * @class Post_Views_Counter_Counter
10 */
11 class Post_Views_Counter_Counter {
12
13 private $storage = [];
14 private $storage_type = 'cookies';
15 private $queue = [];
16 private $queue_mode = false;
17 private $db_insert_values = '';
18 private $cookie = [];
19
20 /**
21 * Class constructor.
22 *
23 * @return void
24 */
25 public function __construct() {
26 // actions
27 add_action( 'plugins_loaded', [ $this, 'check_cookie' ], 1 );
28 add_action( 'init', [ $this, 'init_counter' ] );
29 add_action( 'deleted_post', [ $this, 'delete_post_views' ] );
30 }
31
32 /**
33 * Add Post ID to queue.
34 *
35 * @param int $post_id
36 *
37 * @return void
38 */
39 public function add_to_queue( $post_id ) {
40 $this->queue[] = (int) $post_id;
41 }
42
43 /**
44 * Run manual pvc_view_post queue.
45 *
46 * @return void
47 */
48 public function queue_count() {
49 // check conditions
50 if ( ! isset( $_POST['action'], $_POST['ids'], $_POST['pvc_nonce'] ) || ! wp_verify_nonce( $_POST['pvc_nonce'], 'pvc-view-posts' ) || $_POST['ids'] === '' || ! is_string( $_POST['ids'] ) )
51 exit;
52
53 // get post ids
54 $ids = explode( ',', $_POST['ids'] );
55
56 $counted = [];
57
58 if ( ! empty( $ids ) ) {
59 $ids = array_filter( array_map( 'intval', $ids ) );
60
61 if ( ! empty( $ids ) ) {
62 // turn on queue mode
63 $this->queue_mode = true;
64
65 foreach ( $ids as $id ) {
66 $counted[$id] = ! ( $this->check_post( $id ) === null );
67 }
68
69 // turn off queue mode
70 $this->queue_mode = false;
71 }
72 }
73
74 echo wp_json_encode(
75 [
76 'post_ids' => $ids,
77 'counted' => $counted
78 ]
79 );
80
81 exit;
82 }
83
84 /**
85 * Print JavaScript with queue in the footer.
86 *
87 * @return void
88 */
89 public function print_queue_count() {
90 // get main instance
91 $pvc = Post_Views_Counter();
92
93 // only load manual counter for js mode, not for rest_api mode
94 if ( $pvc->options['general']['counter_mode'] !== 'js' )
95 return;
96
97 // any ids to "view"?
98 if ( ! empty( $this->queue ) ) {
99 echo "
100 <script>
101 ( function( window, document, undefined ) {
102 document.addEventListener( 'DOMContentLoaded', function() {
103 let pvcLoadManualCounter = function( url, counter ) {
104 let pvcScriptTag = document.createElement( 'script' );
105
106 // append script
107 document.body.appendChild( pvcScriptTag );
108
109 // set attributes
110 pvcScriptTag.onload = counter;
111 pvcScriptTag.onreadystatechange = counter;
112 pvcScriptTag.src = url;
113 };
114
115 let pvcExecuteManualCounter = function() {
116 let pvcManualCounterArgs = {
117 url: '" . esc_url( admin_url( 'admin-ajax.php' ) ) . "',
118 nonce: '" . wp_create_nonce( 'pvc-view-posts' ) . "',
119 ids: '" . implode( ',', $this->queue ) . "'
120 };
121
122 // main javascript file was loaded?
123 if ( typeof PostViewsCounter !== 'undefined' && PostViewsCounter.promise !== null ) {
124 PostViewsCounter.promise.then( function() {
125 PostViewsCounterManual.init( pvcManualCounterArgs );
126 } );
127 // PostViewsCounter is undefined or promise is null
128 } else {
129 PostViewsCounterManual.init( pvcManualCounterArgs );
130 }
131 }
132
133 pvcLoadManualCounter( '" . POST_VIEWS_COUNTER_URL . "/js/counter.js', pvcExecuteManualCounter );
134 }, false );
135 } )( window, document );
136 </script>";
137 }
138 }
139
140 /**
141 * Initialize counter.
142 *
143 * @return void
144 */
145 public function init_counter() {
146 // admin?
147 if ( is_admin() && ! wp_doing_ajax() )
148 return;
149
150 // get main instance
151 $pvc = Post_Views_Counter();
152
153 // actions
154 add_action( 'wp_ajax_pvc-view-posts', [ $this, 'queue_count' ] );
155 add_action( 'wp_ajax_nopriv_pvc-view-posts', [ $this, 'queue_count' ] );
156 add_action( 'wp_print_footer_scripts', [ $this, 'print_queue_count' ], 11 );
157
158 // php counter
159 if ( $pvc->options['general']['counter_mode'] === 'php' )
160 add_action( 'wp', [ $this, 'check_post_php' ] );
161 // javascript (ajax) counter
162 elseif ( $pvc->options['general']['counter_mode'] === 'js' ) {
163 add_action( 'wp_ajax_pvc-check-post', [ $this, 'check_post_js' ] );
164 add_action( 'wp_ajax_nopriv_pvc-check-post', [ $this, 'check_post_js' ] );
165 }
166
167 // rest api
168 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
169 }
170
171 /**
172 * Check whether to count visit.
173 *
174 * @param int $post_id
175 * @param array $content_data
176 *
177 * @return null|int
178 */
179 public function check_post( $post_id = 0, $content_data = [] ) {
180 // force check cookie in short init mode
181 if ( defined( 'SHORTINIT' ) && SHORTINIT )
182 $this->check_cookie();
183
184 // get post id
185 $post_id = (int) ( empty( $post_id ) ? get_the_ID() : $post_id );
186
187 // empty id?
188 if ( empty( $post_id ) )
189 return null;
190
191 // get main instance
192 $pvc = Post_Views_Counter();
193
194 // get user id, from current user or static var in rest api request
195 $user_id = get_current_user_id();
196
197 // get user ip address
198 $user_ip = $this->get_user_ip();
199 $hook_content_data = $this->get_public_storage_hook_data( $content_data, 'post', $this->storage_type );
200
201 // before visit action
202 do_action( 'pvc_before_check_visit', $post_id, $user_id, $user_ip, 'post', $hook_content_data );
203
204 // check all conditions to count visit
205 add_filter( 'pvc_count_conditions_met', [ $this, 'check_conditions' ], 10, 6 );
206
207 // check conditions - excluded ips, excluded groups
208 $conditions_met = apply_filters( 'pvc_count_conditions_met', true, $post_id, $user_id, $user_ip, 'post', $hook_content_data );
209
210 // conditions failed?
211 if ( ! $conditions_met )
212 return null;
213
214 // do not count visit by default
215 $count_visit = false;
216
217 // cookieless data storage?
218 if ( $pvc->options['general']['data_storage'] === 'cookieless' && $this->storage_type === 'cookieless' ) {
219 $count_visit = $this->save_data_storage( $post_id, 'post', $content_data );
220 } elseif ( $pvc->options['general']['data_storage'] === 'cookies' && $this->storage_type === 'cookies' ) {
221 // php counter mode?
222 if ( $pvc->options['general']['counter_mode'] === 'php' )
223 $count_visit = $this->save_cookie( $post_id, $this->cookie );
224 else
225 $count_visit = $this->save_cookie_storage( $post_id, $content_data );
226 }
227
228 // filter visit counting
229 $count_visit = (bool) apply_filters( 'pvc_count_visit', $count_visit, $post_id, $user_id, $user_ip, 'post', $hook_content_data );
230
231 // count visit
232 if ( $count_visit ) {
233 // before count visit action
234 do_action( 'pvc_before_count_visit', $post_id, $user_id, $user_ip, 'post', $hook_content_data );
235
236 return $this->count_visit( $post_id );
237 }
238 }
239
240 /**
241 * Check whether counting conditions are met.
242 *
243 * @param bool $allow_counting
244 * @param int $post_id
245 * @param int $user_id
246 * @param string $user_ip
247 * @param string $content_type
248 * @param array $content_data
249 *
250 * @return bool
251 */
252 public function check_conditions( $allow_counting, $post_id, $user_id, $user_ip, $content_type, $content_data ) {
253 // already failed?
254 if ( ! $allow_counting )
255 return false;
256
257 // get main instance
258 $pvc = Post_Views_Counter();
259
260 // get ips
261 $ips = $pvc->options['general']['exclude_ips'];
262
263 // whether to count this ip
264 if ( ! empty( $ips ) && $this->validate_user_ip( $user_ip ) ) {
265 // check ips
266 foreach ( $ips as $ip ) {
267 if ( $this->is_excluded_ip( $user_ip, $ip ) )
268 return false;
269 }
270 }
271
272 // get groups to check them faster
273 $groups = isset( $pvc->options['general']['exclude']['groups'] ) && is_array( $pvc->options['general']['exclude']['groups'] ) ? $pvc->options['general']['exclude']['groups'] : [];
274
275 // whether to count this user
276 if ( ! empty( $user_id ) ) {
277 // exclude logged in users?
278 if ( in_array( 'users', $groups, true ) )
279 return false;
280 // exclude specific roles?
281 elseif ( in_array( 'roles', $groups, true ) && $this->is_user_role_excluded( $user_id, $pvc->options['general']['exclude']['roles'] ) )
282 return false;
283 // exclude guests?
284 } elseif ( in_array( 'guests', $groups, true ) )
285 return false;
286
287 // whether to count robots
288 if ( in_array( 'robots', $groups, true ) && $pvc->crawler->is_crawler() )
289 return false;
290
291 return $allow_counting;
292 }
293
294 /**
295 * Check whether real home page is displayed.
296 *
297 * @param object $object
298 *
299 * @return bool
300 */
301 public function is_homepage( $object ) {
302 $is_homepage = false;
303
304 // get show on front option
305 $show_on_front = get_option( 'show_on_front' );
306
307 if ( $show_on_front === 'posts' )
308 $is_homepage = is_home() && is_front_page();
309 else {
310 // home page
311 $homepage = (int) get_option( 'page_on_front' );
312
313 // posts page
314 $postspage = (int) get_option( 'page_for_posts' );
315
316 // both pages are set
317 if ( $homepage && $postspage )
318 $is_homepage = is_front_page();
319 // only home page is set
320 elseif ( $homepage && ! $postspage )
321 $is_homepage = is_front_page();
322 // only posts page is set
323 elseif( ! $homepage && $postspage )
324 $is_homepage = is_home() && ( empty( $object ) || get_queried_object_id() === 0 );
325 }
326
327 return $is_homepage;
328 }
329
330 /**
331 * Check whether posts page (archive) is displayed.
332 *
333 * @param object $object
334 *
335 * @return bool
336 */
337 public function is_posts_page( $object ) {
338 // get show on front option
339 $show_on_front = get_option( 'show_on_front' );
340
341 // get page for posts option
342 $page_for_posts = (int) get_option( 'page_for_posts' );
343
344 // check page
345 $result = ( $show_on_front === 'page' && ! empty( $object ) && is_home() && is_a( $object, 'WP_Post' ) && (int) $object->ID === $page_for_posts );
346
347 return apply_filters( 'pvc_is_posts_page', $result, $object );
348 }
349
350 /**
351 * Check whether to count visit via PHP request.
352 *
353 * @return void
354 */
355 public function check_post_php() {
356 // do not count admin entries
357 if ( is_admin() && ! wp_doing_ajax() )
358 return;
359
360 // skip special requests
361 if ( is_preview() || is_feed() || is_trackback() || ( function_exists( 'is_favicon' ) && is_favicon() ) || is_customize_preview() )
362 return;
363
364 // get main instance
365 $pvc = Post_Views_Counter();
366
367 // do we use php as counter?
368 if ( $pvc->options['general']['counter_mode'] !== 'php' )
369 return;
370
371 // get countable post types
372 $post_types = $pvc->options['general']['post_types_count'];
373
374 // whether to count this post type
375 if ( empty( $post_types ) || ! is_singular( $post_types ) )
376 return;
377
378 // get current post id
379 $post_id = (int) get_the_ID();
380
381 // allow to run check post?
382 if ( ! (bool) apply_filters( 'pvc_run_check_post', true, $post_id ) )
383 return;
384
385 $this->check_post( $post_id );
386 }
387
388 /**
389 * Check whether to count visit via JavaScript (AJAX) request.
390 *
391 * @return void
392 */
393 public function check_post_js() {
394 // check conditions
395 if ( ! isset( $_POST['action'], $_POST['id'], $_POST['storage_type'], $_POST['storage_data'], $_POST['pvc_nonce'] ) || ! wp_verify_nonce( $_POST['pvc_nonce'], 'pvc-check-post' ) )
396 exit;
397
398 // get post id
399 $post_id = (int) $_POST['id'];
400
401 if ( $post_id <= 0 )
402 exit;
403
404 // get main instance
405 $pvc = Post_Views_Counter();
406
407 // do we use javascript as counter?
408 if ( $pvc->options['general']['counter_mode'] !== 'js' )
409 exit;
410
411 // get countable post types
412 $post_types = $pvc->options['general']['post_types_count'];
413
414 // check if post exists
415 $post = get_post( $post_id );
416
417 // whether to count this post type or not
418 if ( empty( $post_types ) || empty( $post ) || ! in_array( $post->post_type, $post_types, true ) )
419 exit;
420
421 // get storage type
422 $storage_type = sanitize_key( $_POST['storage_type'] );
423
424 // invalid storage type?
425 if ( ! in_array( $storage_type, [ 'cookies', 'cookieless' ], true ) )
426 exit;
427
428 // set storage type
429 $this->storage_type = $storage_type;
430
431 // cookieless data storage?
432 if ( $storage_type === 'cookieless' && $pvc->options['general']['data_storage'] === 'cookieless' )
433 $storage_data = $this->sanitize_storage_payload_set( $_POST['storage_data'], 'post', 'cookieless', isset( $_POST['storage_data_all'] ) ? $_POST['storage_data_all'] : '' );
434 // cookies?
435 elseif ( $storage_type === 'cookies' && $pvc->options['general']['data_storage'] === 'cookies' )
436 $storage_data = $this->sanitize_storage_payload_set( $_POST['storage_data'], 'post', 'cookies', isset( $_POST['storage_data_all'] ) ? $_POST['storage_data_all'] : '' );
437 else
438 $storage_data = [];
439
440 echo wp_json_encode(
441 [
442 'post_id' => $post_id,
443 'counted' => ! ( $this->check_post( $post_id, $storage_data ) === null ),
444 'storage' => $this->storage,
445 'type' => 'post'
446 ]
447 );
448
449 exit;
450 }
451
452 /**
453 * Check whether to count visit via REST API request.
454 *
455 * @param object $request
456 *
457 * @return object|array
458 */
459 public function check_post_rest_api( $request ) {
460 // get main instance
461 $pvc = Post_Views_Counter();
462
463 // get post id (already sanitized)
464 $post_id = $request->get_param( 'id' );
465
466 // do we use REST API as counter?
467 if ( $pvc->options['general']['counter_mode'] !== 'rest_api' )
468 return new WP_Error( 'pvc_rest_api_disabled', __( 'REST API method is disabled.', 'post-views-counter' ), [ 'status' => 404 ] );
469
470 //TODO get current user id in direct api endpoint calls
471 // check if post exists
472 $post = get_post( $post_id );
473
474 if ( ! $post )
475 return new WP_Error( 'pvc_post_invalid_id', __( 'Invalid post ID.', 'post-views-counter' ), [ 'status' => 404 ] );
476
477 // get countable post types
478 $post_types = $pvc->options['general']['post_types_count'];
479
480 // whether to count this post type
481 if ( empty( $post_types ) || ! in_array( $post->post_type, $post_types, true ) )
482 return new WP_Error( 'pvc_post_type_excluded', __( 'Post type excluded.', 'post-views-counter' ), [ 'status' => 404 ] );
483
484 // get storage type
485 $storage_type = sanitize_key( $request->get_param( 'storage_type' ) );
486
487 // invalid storage type?
488 if ( ! in_array( $storage_type, [ 'cookies', 'cookieless' ], true ) )
489 return new WP_Error( 'pvc_invalid_storage_type', __( 'Invalid storage type.', 'post-views-counter' ), [ 'status' => 404 ] );
490
491 // apply crawler/bot check filter
492 $allowed = apply_filters( 'pvc_rest_api_count_post_check', true, $request, $post_id );
493
494 if ( ! $allowed ) {
495 return new WP_REST_Response( [
496 'post_id' => $post_id,
497 'counted' => false,
498 'reason' => 'filtered',
499 'storage' => [],
500 'type' => 'post'
501 ], 200 );
502 }
503
504 // set storage type
505 $this->storage_type = $storage_type;
506
507 // cookieless data storage?
508 if ( $storage_type === 'cookieless' && $pvc->options['general']['data_storage'] === 'cookieless' )
509 $storage_data = $this->sanitize_storage_payload_set( $request->get_param( 'storage_data' ), 'post', 'cookieless', $request->get_param( 'storage_data_all' ) );
510 // cookies?
511 elseif ( $storage_type === 'cookies' && $pvc->options['general']['data_storage'] === 'cookies' )
512 $storage_data = $this->sanitize_storage_payload_set( $request->get_param( 'storage_data' ), 'post', 'cookies', $request->get_param( 'storage_data_all' ) );
513 else
514 $storage_data = [];
515
516 return [
517 'post_id' => $post_id,
518 'counted' => ! ( $this->check_post( $post_id, $storage_data ) === null ),
519 'storage' => $this->storage,
520 'type' => 'post'
521 ];
522 }
523
524 /**
525 * Initialize cookie session. Use $cookie to force custom data instead of real $_COOKIE.
526 *
527 * @param array $cookie
528 *
529 * @return void
530 */
531 public function check_cookie( $cookie = [] ) {
532 // do not run in admin except for ajax requests
533 if ( is_admin() && ! wp_doing_ajax() )
534 return;
535
536 $this->cookie = $this->get_empty_storage_state();
537
538 if ( empty( $cookie ) || ! is_array( $cookie ) ) {
539 // assign cookie name
540 $cookie_name = 'pvc_visits' . ( is_multisite() ? '_' . get_current_blog_id() : '' );
541
542 // is cookie set?
543 if ( isset( $_COOKIE[$cookie_name] ) && ! empty( $_COOKIE[$cookie_name] ) )
544 $cookie = $_COOKIE[$cookie_name];
545 }
546
547 // cookie data?
548 if ( $cookie && is_array( $cookie ) )
549 $this->cookie = $this->sanitize_cookies_data( $this->combine_cookie_chunks( $cookie ), 'post' );
550 }
551
552 /**
553 * Get empty normalized storage state.
554 *
555 * @return array
556 */
557 public function get_empty_storage_state() {
558 return [
559 'format' => 'empty',
560 'version' => null,
561 'session_id' => null,
562 'started_at' => null,
563 'expires_at' => null,
564 'visited' => $this->get_empty_storage_buckets(),
565 'legacy' => [
566 'expirations' => $this->get_empty_storage_buckets()
567 ],
568 'is_expired' => false,
569 'is_valid' => true,
570 'needs_writeback' => false
571 ];
572 }
573
574 /**
575 * Check whether normalized storage allows counting content.
576 *
577 * @param array $storage_state
578 * @param int $content_id
579 * @param string $content_type
580 * @param int $current_time
581 *
582 * @return bool
583 */
584 public function storage_state_allows_count( $storage_state, $content_id, $content_type = 'post', $current_time = 0 ) {
585 $content_type = $this->normalize_storage_bucket( $content_type );
586 $current_time = (int) ( $current_time > 0 ? $current_time : current_time( 'timestamp', true ) );
587
588 if ( ! $this->is_normalized_storage_state( $storage_state ) || ! $storage_state['is_valid'] )
589 return true;
590
591 if ( $storage_state['format'] === 'session' ) {
592 if ( ! $this->use_session_storage_payload_writes() )
593 return true;
594
595 if ( $storage_state['is_expired'] )
596 return true;
597
598 return ! isset( $storage_state['visited'][$content_type][(int) $content_id] );
599 }
600
601 $legacy_expirations = $this->get_storage_state_bucket_expirations( $storage_state, $content_type, $current_time );
602
603 return ! ( isset( $legacy_expirations[(int) $content_id] ) && $current_time < $legacy_expirations[(int) $content_id] );
604 }
605
606 /**
607 * Get relevant legacy expirations for normalized storage state.
608 *
609 * @param array $storage_state
610 * @param string $content_type
611 * @param int $current_time
612 *
613 * @return array
614 */
615 public function get_storage_state_bucket_expirations( $storage_state, $content_type = 'post', $current_time = 0 ) {
616 $content_type = $this->normalize_storage_bucket( $content_type );
617 $current_time = (int) ( $current_time > 0 ? $current_time : current_time( 'timestamp', true ) );
618
619 if ( ! $this->is_normalized_storage_state( $storage_state ) || ! $storage_state['is_valid'] )
620 return [];
621
622 if ( $storage_state['format'] === 'session' ) {
623 if ( $storage_state['is_expired'] || empty( $storage_state['visited'][$content_type] ) || empty( $storage_state['expires_at'] ) )
624 return [];
625
626 $expires_at = (int) $storage_state['expires_at'];
627
628 if ( $expires_at <= $current_time )
629 return [];
630
631 $expirations = [];
632
633 foreach ( array_keys( $storage_state['visited'][$content_type] ) as $bucket_content_id ) {
634 $expirations[(int) $bucket_content_id] = $expires_at;
635 }
636
637 return $expirations;
638 }
639
640 $expirations = [];
641
642 foreach ( $storage_state['legacy']['expirations'][$content_type] as $bucket_content_id => $expiration ) {
643 $bucket_content_id = (int) $bucket_content_id;
644 $expiration = (int) $expiration;
645
646 if ( $bucket_content_id > 0 && $expiration > $current_time )
647 $expirations[$bucket_content_id] = $expiration;
648 }
649
650 return $expirations;
651 }
652
653 /**
654 * Get write expiration for normalized storage state.
655 *
656 * @param array $storage_state
657 * @param int $default_expiration
658 * @param int $current_time
659 *
660 * @return int
661 */
662 public function get_storage_state_write_expiration( $storage_state, $default_expiration, $current_time = 0 ) {
663 $current_time = (int) ( $current_time > 0 ? $current_time : current_time( 'timestamp', true ) );
664 $default_expiration = (int) $default_expiration;
665
666 if ( ! $this->is_normalized_storage_state( $storage_state ) || ! $storage_state['is_valid'] )
667 return $default_expiration;
668
669 if ( $storage_state['format'] === 'session' ) {
670 $expires_at = (int) $storage_state['expires_at'];
671
672 if ( ! $storage_state['is_expired'] && $expires_at > $current_time )
673 return $expires_at;
674 }
675
676 return $default_expiration;
677 }
678
679 /**
680 * Build canonical session payload for storage state.
681 *
682 * @param array $storage_state
683 * @param int $content_id
684 * @param string $content_type
685 * @param int $default_expiration
686 * @param int $current_time
687 *
688 * @return array
689 */
690 public function build_session_storage_payload( $storage_state, $content_id = 0, $content_type = 'post', $default_expiration = 0, $current_time = 0 ) {
691 $session_state = $this->create_session_storage_state( $storage_state, $content_id, $content_type, $default_expiration, $current_time );
692
693 return $this->get_public_session_storage_payload( $session_state );
694 }
695
696 /**
697 * Merge normalized storage states into one canonical state.
698 *
699 * @param array $storage_states
700 * @param int $current_time
701 *
702 * @return array
703 */
704 public function merge_storage_states( $storage_states, $current_time = 0 ) {
705 $current_time = (int) ( $current_time > 0 ? $current_time : current_time( 'timestamp', true ) );
706 $merged_state = $this->get_empty_storage_state();
707 $active_session = null;
708 $has_legacy_entries = false;
709
710 if ( ! is_array( $storage_states ) )
711 return $merged_state;
712
713 foreach ( $storage_states as $storage_state ) {
714 if ( ! $this->is_normalized_storage_state( $storage_state ) || ! $storage_state['is_valid'] )
715 continue;
716
717 if ( $storage_state['format'] === 'session' && ! $storage_state['is_expired'] && ! empty( $storage_state['session_id'] ) && ! empty( $storage_state['started_at'] ) && ! empty( $storage_state['expires_at'] ) ) {
718 if ( $active_session === null )
719 $active_session = $storage_state;
720
721 // merge all buckets from source state, including unregistered ones
722 foreach ( array_keys( $storage_state['visited'] ) as $bucket ) {
723 if ( ! isset( $merged_state['visited'][$bucket] ) ) {
724 $merged_state['visited'][$bucket] = [];
725 $merged_state['legacy']['expirations'][$bucket] = [];
726 }
727
728 foreach ( $storage_state['visited'][$bucket] as $bucket_content_id => $is_visited ) {
729 if ( $is_visited )
730 $merged_state['visited'][$bucket][(int) $bucket_content_id] = true;
731 }
732 }
733 }
734
735 foreach ( array_keys( $merged_state['legacy']['expirations'] ) as $bucket ) {
736 foreach ( $this->get_storage_state_bucket_expirations( $storage_state, $bucket, $current_time ) as $bucket_content_id => $expiration ) {
737 $bucket_content_id = (int) $bucket_content_id;
738 $expiration = (int) $expiration;
739
740 if ( $bucket_content_id <= 0 || $expiration <= $current_time )
741 continue;
742
743 $merged_state['legacy']['expirations'][$bucket][$bucket_content_id] = isset( $merged_state['legacy']['expirations'][$bucket][$bucket_content_id] ) ? max( $merged_state['legacy']['expirations'][$bucket][$bucket_content_id], $expiration ) : $expiration;
744 $merged_state['visited'][$bucket][$bucket_content_id] = true;
745 $has_legacy_entries = true;
746 }
747 }
748 }
749
750 if ( $active_session !== null ) {
751 $merged_state['format'] = 'session';
752 $merged_state['version'] = 1;
753 $merged_state['session_id'] = $active_session['session_id'];
754 $merged_state['started_at'] = (int) $active_session['started_at'];
755 $merged_state['expires_at'] = (int) $active_session['expires_at'];
756 $merged_state['is_valid'] = true;
757 $merged_state['is_expired'] = false;
758 $merged_state['needs_writeback'] = false;
759
760 return $merged_state;
761 }
762
763 if ( $has_legacy_entries ) {
764 $merged_state['format'] = 'legacy_map';
765 $merged_state['is_valid'] = true;
766 }
767
768 return $merged_state;
769 }
770
771 /**
772 * Create normalized session storage state.
773 *
774 * @param array $storage_state
775 * @param int $content_id
776 * @param string $content_type
777 * @param int $default_expiration
778 * @param int $current_time
779 *
780 * @return array
781 */
782 private function create_session_storage_state( $storage_state, $content_id = 0, $content_type = 'post', $default_expiration = 0, $current_time = 0 ) {
783 $content_type = $this->normalize_storage_bucket( $content_type );
784 $content_id = (int) $content_id;
785 $current_time = (int) ( $current_time > 0 ? $current_time : current_time( 'timestamp', true ) );
786 $default_expiration = (int) $default_expiration;
787 $seed_state = $this->merge_storage_states( [ $storage_state ], $current_time );
788
789 if ( $default_expiration < 0 )
790 $default_expiration = 0;
791
792 $session_expiration = $default_expiration > $current_time ? $default_expiration : $current_time;
793
794 if ( $seed_state['format'] === 'session' && ! $seed_state['is_expired'] && ! empty( $seed_state['session_id'] ) && ! empty( $seed_state['started_at'] ) && ! empty( $seed_state['expires_at'] ) )
795 $session_state = $seed_state;
796 else {
797 $session_state = $this->get_empty_storage_state();
798 $session_state['format'] = 'session';
799 $session_state['version'] = 1;
800 $session_state['session_id'] = $this->generate_session_storage_id();
801 $session_state['started_at'] = $current_time;
802 $session_state['expires_at'] = $session_expiration;
803
804 // new session created -- entrance/visit hook for the triggering content item
805 if ( $content_id > 0 ) {
806 /**
807 * Fires when a new anonymous session is created.
808 *
809 * The content item that triggered the session is the entrance (landing page).
810 * Listeners can use this to record per-content visit/entrance metrics.
811 *
812 * @param array $session_state Normalized session state (format, session_id, started_at, expires_at, visited).
813 * @param int $content_id Content ID that triggered session creation.
814 * @param string $content_type Content bucket: 'post', 'term', 'user', 'other'.
815 */
816 do_action( 'pvc_session_created', $session_state, $content_id, $content_type );
817 }
818 }
819
820 $session_state['format'] = 'session';
821 $session_state['version'] = 1;
822 $session_state['is_valid'] = true;
823 $session_state['is_expired'] = ( (int) $session_state['expires_at'] <= $current_time );
824 $session_state['needs_writeback'] = false;
825
826 if ( $content_id > 0 )
827 $session_state['visited'][$content_type][$content_id] = true;
828
829 return $session_state;
830 }
831
832 /**
833 * Convert normalized session state to the public payload.
834 *
835 * @param array $storage_state
836 *
837 * @return array
838 */
839 private function get_public_session_storage_payload( $storage_state ) {
840 if ( ! $this->is_normalized_storage_state( $storage_state ) || ! $storage_state['is_valid'] || $storage_state['format'] !== 'session' )
841 return [];
842
843 $payload = [
844 'version' => 1,
845 'session_id' => (string) $storage_state['session_id'],
846 'started_at' => (int) $storage_state['started_at'],
847 'expires_at' => (int) $storage_state['expires_at'],
848 'visited' => $this->get_empty_storage_buckets()
849 ];
850
851 // emit all buckets present in state, including unregistered ones preserved by the tolerant reader
852 foreach ( array_keys( $storage_state['visited'] ) as $bucket ) {
853 if ( ! isset( $payload['visited'][$bucket] ) )
854 $payload['visited'][$bucket] = [];
855
856 $bucket_ids = array_map( 'intval', array_keys( $storage_state['visited'][$bucket] ) );
857 sort( $bucket_ids, SORT_NUMERIC );
858 $payload['visited'][$bucket] = $bucket_ids;
859 }
860
861 return $payload;
862 }
863
864 /**
865 * Generate an anonymous session identifier.
866 *
867 * @return string
868 */
869 private function generate_session_storage_id() {
870 if ( function_exists( 'wp_generate_uuid4' ) )
871 return wp_generate_uuid4();
872
873 return md5( uniqid( (string) wp_rand(), true ) );
874 }
875
876 /**
877 * Clear stale cookie chunks that are no longer used by the current payload.
878 *
879 * @param string $cookie_name
880 * @param int $valid_chunk_count
881 * @param bool $php_at_least_73
882 *
883 * @return void
884 */
885 private function clear_stale_cookie_chunks( $cookie_name, $valid_chunk_count, $php_at_least_73 ) {
886 if ( ! isset( $_COOKIE[$cookie_name] ) || ! is_array( $_COOKIE[$cookie_name] ) )
887 return;
888
889 foreach ( array_keys( $_COOKIE[$cookie_name] ) as $chunk_index ) {
890 $chunk_index = (int) $chunk_index;
891
892 if ( $chunk_index < $valid_chunk_count )
893 continue;
894
895 if ( $php_at_least_73 ) {
896 setcookie(
897 $cookie_name . '[' . $chunk_index . ']',
898 '',
899 [
900 'expires' => 1,
901 'path' => COOKIEPATH,
902 'domain' => COOKIE_DOMAIN,
903 'secure' => is_ssl(),
904 'httponly' => false,
905 'samesite' => 'LAX'
906 ]
907 );
908 } else {
909 setcookie( $cookie_name . '[' . $chunk_index . ']', '', 1, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), false );
910 }
911 }
912 }
913
914 /**
915 * Sanitize storage data.
916 *
917 * @param string $storage_data
918 * @param string|null $content_type
919 *
920 * @return array
921 */
922 public function sanitize_storage_data( $storage_data, $content_type = null ) {
923 $normalized_state = $this->normalize_storage_state( $storage_data, is_string( $content_type ) ? $content_type : 'post', 'auto' );
924
925 if ( $content_type === null )
926 return $this->get_legacy_storage_data_result( $normalized_state );
927
928 return $normalized_state;
929 }
930
931 /**
932 * Sanitize cookies.
933 *
934 * @param string $storage_data
935 * @param string|null $content_type
936 *
937 * @return array
938 */
939 public function sanitize_cookies_data( $storage_data, $content_type = null ) {
940 $normalized_state = $this->normalize_storage_state( $storage_data, is_string( $content_type ) ? $content_type : 'post', 'auto' );
941
942 if ( $content_type === null )
943 return $this->get_legacy_cookie_data_result( $normalized_state );
944
945 return $normalized_state;
946 }
947
948 /**
949 * Sanitize and merge a set of storage payloads.
950 *
951 * @param mixed $storage_data
952 * @param string $content_type
953 * @param string $storage_type
954 * @param mixed $storage_data_all
955 *
956 * @return array
957 */
958 public function sanitize_storage_payload_set( $storage_data, $content_type, $storage_type, $storage_data_all = '' ) {
959 $content_type = $this->normalize_storage_bucket( $content_type );
960 $storage_payloads = $this->parse_storage_payload_map( $storage_data_all );
961
962 if ( empty( $storage_payloads ) ) {
963 if ( $storage_type === 'cookies' )
964 return $this->sanitize_cookies_data( $storage_data, $content_type );
965
966 return $this->sanitize_storage_data( $storage_data, $content_type );
967 }
968
969 if ( ! array_key_exists( $content_type, $storage_payloads ) && ( is_scalar( $storage_data ) || is_array( $storage_data ) ) )
970 $storage_payloads[$content_type] = $storage_data;
971
972 $storage_states = [];
973
974 foreach ( $storage_payloads as $bucket => $bucket_storage_data ) {
975 if ( $storage_type === 'cookies' )
976 $storage_states[] = $this->sanitize_cookies_data( $bucket_storage_data, $bucket );
977 else
978 $storage_states[] = $this->sanitize_storage_data( $bucket_storage_data, $bucket );
979 }
980
981 return $this->merge_storage_states( $storage_states );
982 }
983
984 /**
985 * Parse a serialized map of storage payloads.
986 *
987 * @param mixed $storage_data_all
988 *
989 * @return array
990 */
991 public function parse_storage_payload_map( $storage_data_all ) {
992 if ( is_scalar( $storage_data_all ) ) {
993 $storage_data_all = trim( (string) $storage_data_all );
994
995 if ( $storage_data_all === '' )
996 return [];
997
998 $decoded_payloads = json_decode( stripslashes( $storage_data_all ), true, 8 );
999
1000 if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $decoded_payloads ) )
1001 return [];
1002 } elseif ( is_array( $storage_data_all ) )
1003 $decoded_payloads = $storage_data_all;
1004 else
1005 return [];
1006
1007 $storage_payloads = [];
1008
1009 foreach ( array_keys( $this->get_empty_storage_buckets() ) as $bucket ) {
1010 if ( isset( $decoded_payloads[$bucket] ) && ( is_scalar( $decoded_payloads[$bucket] ) || is_array( $decoded_payloads[$bucket] ) ) )
1011 $storage_payloads[$bucket] = $decoded_payloads[$bucket];
1012 }
1013
1014 return $storage_payloads;
1015 }
1016
1017 /**
1018 * Check whether the active Pro plugin supports session payload writes.
1019 *
1020 * @return bool
1021 */
1022 private function is_active_session_storage_payload_writes() {
1023 if ( ! class_exists( 'Post_Views_Counter_Pro' ) )
1024 return true;
1025
1026 if ( ! function_exists( 'Post_Views_Counter_Pro' ) )
1027 return false;
1028
1029 $pro = Post_Views_Counter_Pro();
1030
1031 return ( is_object( $pro ) && method_exists( $pro, 'supports_session_storage_payload_writes' ) && $pro->supports_session_storage_payload_writes() );
1032 }
1033
1034 /**
1035 * Check whether session payload writes are enabled.
1036 *
1037 * Session payload writes are the default for PVC-only installs. When Pro is active,
1038 * PVC uses Pro's explicit capability declaration and allows this filter to override
1039 * the computed default for controlled testing or emergency rollback.
1040 *
1041 * @return bool
1042 */
1043 public function use_session_storage_payload_writes() {
1044 return (bool) apply_filters( 'pvc_use_session_storage_payload_writes', $this->is_active_session_storage_payload_writes() );
1045 }
1046
1047 /**
1048 * Build legacy expiration payload for a storage bucket.
1049 *
1050 * @param array $storage_state
1051 * @param int $content_id
1052 * @param string $content_type
1053 * @param int $default_expiration
1054 * @param int $current_time
1055 *
1056 * @return array
1057 */
1058 public function build_legacy_storage_payload( $storage_state, $content_id = 0, $content_type = 'post', $default_expiration = 0, $current_time = 0 ) {
1059 $content_type = $this->normalize_storage_bucket( $content_type );
1060 $content_id = (int) $content_id;
1061 $current_time = (int) ( $current_time > 0 ? $current_time : current_time( 'timestamp', true ) );
1062 $rewriting_session_payload = ( $this->is_normalized_storage_state( $storage_state ) && $storage_state['format'] === 'session' && ! $this->use_session_storage_payload_writes() );
1063 $bucket_expirations = [];
1064
1065 if ( ! $rewriting_session_payload )
1066 $bucket_expirations = $this->get_storage_state_bucket_expirations( $storage_state, $content_type, $current_time );
1067
1068 $write_expiration = $rewriting_session_payload ? (int) $default_expiration : $this->get_storage_state_write_expiration( $storage_state, $default_expiration, $current_time );
1069
1070 if ( $content_id > 0 && $write_expiration > $current_time )
1071 $bucket_expirations[$content_id] = $write_expiration;
1072
1073 ksort( $bucket_expirations, SORT_NUMERIC );
1074
1075 return $bucket_expirations;
1076 }
1077
1078 /**
1079 * Build chunked legacy cookie payload data.
1080 *
1081 * @param array $storage_state
1082 * @param string $cookie_name
1083 * @param int $content_id
1084 * @param string $content_type
1085 * @param int $default_expiration
1086 * @param int $current_time
1087 *
1088 * @return array
1089 */
1090 public function build_legacy_cookie_storage_data( $storage_state, $cookie_name, $content_id = 0, $content_type = 'post', $default_expiration = 0, $current_time = 0 ) {
1091 $bucket_expirations = $this->build_legacy_storage_payload( $storage_state, $content_id, $content_type, $default_expiration, $current_time );
1092 $payload = $this->serialize_legacy_cookie_payload( $bucket_expirations );
1093
1094 if ( $payload === '' ) {
1095 return [
1096 'name' => [ $cookie_name . '[0]' ],
1097 'value' => [ '' ],
1098 'expiry' => [ 1 ]
1099 ];
1100 }
1101
1102 $cookies_data = [
1103 'name' => [],
1104 'value' => [],
1105 'expiry' => []
1106 ];
1107 $cookie_chunks = str_split( $payload, 3980 );
1108 $cookie_expiration = max( $bucket_expirations );
1109
1110 foreach ( $cookie_chunks as $key => $value ) {
1111 $cookies_data['name'][] = $cookie_name . '[' . $key . ']';
1112 $cookies_data['value'][] = $value;
1113 $cookies_data['expiry'][] = $cookie_expiration;
1114 }
1115
1116 return $cookies_data;
1117 }
1118
1119 /**
1120 * Get legacy-compatible cookieless storage data.
1121 *
1122 * @param array $storage_state
1123 *
1124 * @return array
1125 */
1126 private function get_legacy_storage_data_result( $storage_state ) {
1127 return $this->flatten_storage_state_expirations( $storage_state );
1128 }
1129
1130 /**
1131 * Get legacy-compatible cookie data.
1132 *
1133 * @param array $storage_state
1134 *
1135 * @return array
1136 */
1137 private function get_legacy_cookie_data_result( $storage_state ) {
1138 $expirations = $this->flatten_storage_state_expirations( $storage_state );
1139
1140 return [
1141 'visited' => $expirations,
1142 'expiration' => empty( $expirations ) ? 0 : max( $expirations )
1143 ];
1144 }
1145
1146 /**
1147 * Flatten normalized storage state to legacy expiration map.
1148 *
1149 * @param array $storage_state
1150 *
1151 * @return array
1152 */
1153 private function flatten_storage_state_expirations( $storage_state ) {
1154 $expirations = [];
1155
1156 if ( ! $this->is_normalized_storage_state( $storage_state ) || ! $storage_state['is_valid'] )
1157 return $expirations;
1158
1159 foreach ( array_keys( $storage_state['legacy']['expirations'] ) as $bucket ) {
1160 foreach ( $this->get_storage_state_bucket_expirations( $storage_state, $bucket ) as $content_id => $expiration ) {
1161 $expirations[(int) $content_id] = (int) $expiration;
1162 }
1163 }
1164
1165 return $expirations;
1166 }
1167
1168 /**
1169 * Get legacy-compatible hook payload for storage state.
1170 *
1171 * @param array $storage_state
1172 * @param string $content_type
1173 * @param string $storage_type
1174 *
1175 * @return array
1176 */
1177 private function get_public_storage_hook_data( $storage_state, $content_type, $storage_type ) {
1178 if ( ! $this->is_normalized_storage_state( $storage_state ) )
1179 return $storage_state;
1180
1181 $bucket_expirations = $this->get_storage_state_bucket_expirations( $storage_state, $content_type );
1182
1183 if ( $storage_type === 'cookies' ) {
1184 return [
1185 'visited' => $bucket_expirations,
1186 'expiration' => empty( $bucket_expirations ) ? 0 : max( $bucket_expirations )
1187 ];
1188 }
1189
1190 return $bucket_expirations;
1191 }
1192
1193 /**
1194 * Get legacy-compatible cookie filter payload.
1195 *
1196 * @param array $storage_state
1197 * @param string $content_type
1198 *
1199 * @return array
1200 */
1201 private function get_public_cookie_filter_data( $storage_state, $content_type ) {
1202 if ( ! $this->is_normalized_storage_state( $storage_state ) )
1203 return $storage_state;
1204
1205 $bucket_expirations = $this->get_storage_state_bucket_expirations( $storage_state, $content_type );
1206
1207 if ( empty( $bucket_expirations ) )
1208 return [];
1209
1210 return [
1211 'exists' => true,
1212 'visited_posts' => $bucket_expirations,
1213 'expiration' => max( $bucket_expirations )
1214 ];
1215 }
1216
1217 /**
1218 * Serialize legacy cookie payload.
1219 *
1220 * @param array $bucket_expirations
1221 *
1222 * @return string
1223 */
1224 private function serialize_legacy_cookie_payload( $bucket_expirations ) {
1225 if ( empty( $bucket_expirations ) || ! is_array( $bucket_expirations ) )
1226 return '';
1227
1228 ksort( $bucket_expirations, SORT_NUMERIC );
1229
1230 $segments = [];
1231
1232 foreach ( $bucket_expirations as $bucket_content_id => $expiration ) {
1233 $bucket_content_id = (int) $bucket_content_id;
1234 $expiration = (int) $expiration;
1235
1236 if ( $bucket_content_id > 0 && $expiration > 0 )
1237 $segments[] = $expiration . 'b' . $bucket_content_id;
1238 }
1239
1240 return implode( 'a', $segments );
1241 }
1242
1243 /**
1244 * Reconstruct a cookie payload from chunks.
1245 *
1246 * Legacy chunked cookies need an "a" separator between chunks, while JSON payloads need a direct concat.
1247 *
1248 * @param array $cookie_chunks
1249 *
1250 * @return string
1251 */
1252 private function combine_cookie_chunks( $cookie_chunks ) {
1253 $chunks = [];
1254
1255 foreach ( $cookie_chunks as $chunk ) {
1256 if ( is_scalar( $chunk ) )
1257 $chunks[] = (string) $chunk;
1258 }
1259
1260 if ( empty( $chunks ) )
1261 return '';
1262
1263 $json_payload = implode( '', $chunks );
1264
1265 if ( $this->looks_like_json_storage( trim( $json_payload ) ) ) {
1266 $json_data = json_decode( stripslashes( $json_payload ), true, 8 );
1267
1268 if ( json_last_error() === JSON_ERROR_NONE && is_array( $json_data ) && isset( $json_data['version'] ) )
1269 return $json_payload;
1270 }
1271
1272 return implode( 'a', $chunks );
1273 }
1274
1275 /**
1276 * Normalize storage state.
1277 *
1278 * @param mixed $storage_data
1279 * @param string $content_type
1280 * @param string $format_hint
1281 *
1282 * @return array
1283 */
1284 private function normalize_storage_state( $storage_data, $content_type = 'post', $format_hint = 'auto' ) {
1285 $content_type = $this->normalize_storage_bucket( $content_type );
1286 $state = $this->get_empty_storage_state();
1287
1288 if ( is_array( $storage_data ) )
1289 return $this->normalize_json_storage_state( $storage_data, $content_type );
1290
1291 if ( ! is_scalar( $storage_data ) ) {
1292 $state['format'] = 'invalid';
1293 $state['is_valid'] = false;
1294
1295 return $state;
1296 }
1297
1298 $storage_data = trim( (string) $storage_data );
1299
1300 if ( $storage_data === '' )
1301 return $state;
1302
1303 if ( $format_hint !== 'legacy_cookie' && $this->looks_like_json_storage( $storage_data ) ) {
1304 $json_storage = json_decode( stripslashes( $storage_data ), true, 8 );
1305
1306 if ( json_last_error() === JSON_ERROR_NONE && is_array( $json_storage ) )
1307 return $this->normalize_json_storage_state( $json_storage, $content_type );
1308 }
1309
1310 if ( $format_hint !== 'legacy_map' && preg_match( '/^(([0-9]+b[0-9]+a?)+)$/', $storage_data ) === 1 )
1311 return $this->normalize_legacy_cookie_state( $storage_data, $content_type );
1312
1313 $state['format'] = 'invalid';
1314 $state['is_valid'] = false;
1315
1316 return $state;
1317 }
1318
1319 /**
1320 * Normalize decoded JSON storage state.
1321 *
1322 * @param array $storage_data
1323 * @param string $content_type
1324 *
1325 * @return array
1326 */
1327 private function normalize_json_storage_state( $storage_data, $content_type ) {
1328 if ( empty( $storage_data ) )
1329 return $this->get_empty_storage_state();
1330
1331 if ( isset( $storage_data['version'] ) )
1332 return $this->normalize_session_storage_state( $storage_data );
1333
1334 return $this->normalize_legacy_map_state( $storage_data, $content_type );
1335 }
1336
1337 /**
1338 * Normalize session storage state.
1339 *
1340 * @param array $storage_data
1341 *
1342 * @return array
1343 */
1344 private function normalize_session_storage_state( $storage_data ) {
1345 $state = $this->get_empty_storage_state();
1346 $state['format'] = 'session';
1347 $state['version'] = isset( $storage_data['version'] ) ? (int) $storage_data['version'] : null;
1348
1349 if ( $state['version'] !== 1 ) {
1350 $state['format'] = 'invalid';
1351 $state['is_valid'] = false;
1352
1353 return $state;
1354 }
1355
1356 $session_id = isset( $storage_data['session_id'] ) && is_scalar( $storage_data['session_id'] ) ? sanitize_text_field( wp_unslash( (string) $storage_data['session_id'] ) ) : '';
1357 $started_at = isset( $storage_data['started_at'] ) ? (int) $storage_data['started_at'] : 0;
1358 $expires_at = isset( $storage_data['expires_at'] ) ? (int) $storage_data['expires_at'] : 0;
1359
1360 if ( $session_id === '' || $started_at <= 0 || $expires_at <= 0 || $expires_at < $started_at || ! isset( $storage_data['visited'] ) || ! is_array( $storage_data['visited'] ) ) {
1361 $state['format'] = 'invalid';
1362 $state['is_valid'] = false;
1363
1364 return $state;
1365 }
1366
1367 $state['session_id'] = $session_id;
1368 $state['started_at'] = $started_at;
1369 $state['expires_at'] = $expires_at;
1370 $state['is_expired'] = current_time( 'timestamp', true ) >= $expires_at;
1371
1372 // populate registered buckets from payload
1373 foreach ( array_keys( $state['visited'] ) as $bucket ) {
1374 if ( isset( $storage_data['visited'][$bucket] ) )
1375 $state['visited'][$bucket] = $this->normalize_session_bucket_membership( $storage_data['visited'][$bucket] );
1376 }
1377
1378 // preserve unregistered buckets from payload (tolerant reader)
1379 foreach ( $storage_data['visited'] as $bucket => $bucket_data ) {
1380 if ( ! isset( $state['visited'][$bucket] ) && is_array( $bucket_data ) ) {
1381 $bucket = sanitize_key( $bucket );
1382
1383 if ( $bucket !== '' ) {
1384 $state['visited'][$bucket] = $this->normalize_session_bucket_membership( $bucket_data );
1385 $state['legacy']['expirations'][$bucket] = [];
1386 }
1387 }
1388 }
1389
1390 return $state;
1391 }
1392
1393 /**
1394 * Normalize legacy map storage state.
1395 *
1396 * @param array $storage_data
1397 * @param string $content_type
1398 *
1399 * @return array
1400 */
1401 private function normalize_legacy_map_state( $storage_data, $content_type ) {
1402 $state = $this->get_empty_storage_state();
1403 $valid_items = 0;
1404 $state['format'] = 'legacy_map';
1405
1406 foreach ( $storage_data as $content_id => $expiration ) {
1407 $content_id = (int) $content_id;
1408 $expiration = (int) $expiration;
1409
1410 if ( $content_id <= 0 || $expiration <= 0 )
1411 continue;
1412
1413 $state['visited'][$content_type][$content_id] = true;
1414 $state['legacy']['expirations'][$content_type][$content_id] = $expiration;
1415 $valid_items++;
1416 }
1417
1418 if ( $valid_items === 0 ) {
1419 $state['format'] = 'invalid';
1420 $state['is_valid'] = false;
1421 }
1422
1423 return $state;
1424 }
1425
1426 /**
1427 * Normalize legacy cookie storage state.
1428 *
1429 * @param string $storage_data
1430 * @param string $content_type
1431 *
1432 * @return array
1433 */
1434 private function normalize_legacy_cookie_state( $storage_data, $content_type ) {
1435 $state = $this->get_empty_storage_state();
1436 $state['format'] = 'legacy_cookie';
1437
1438 foreach ( explode( 'a', $storage_data ) as $pair ) {
1439 $pair = explode( 'b', $pair );
1440
1441 if ( count( $pair ) !== 2 )
1442 continue;
1443
1444 $expiration = (int) $pair[0];
1445 $content_id = (int) $pair[1];
1446
1447 if ( $content_id <= 0 || $expiration <= 0 )
1448 continue;
1449
1450 $state['visited'][$content_type][$content_id] = true;
1451 $state['legacy']['expirations'][$content_type][$content_id] = $expiration;
1452 }
1453
1454 if ( empty( $state['legacy']['expirations'][$content_type] ) ) {
1455 $state['format'] = 'invalid';
1456 $state['is_valid'] = false;
1457 }
1458
1459 return $state;
1460 }
1461
1462 /**
1463 * Normalize session bucket membership.
1464 *
1465 * @param array $bucket_data
1466 *
1467 * @return array
1468 */
1469 private function normalize_session_bucket_membership( $bucket_data ) {
1470 $members = [];
1471
1472 if ( ! is_array( $bucket_data ) )
1473 return $members;
1474
1475 foreach ( $bucket_data as $key => $value ) {
1476 $content_id = 0;
1477
1478 if ( is_int( $key ) )
1479 $content_id = (int) $value;
1480 else {
1481 $content_id = (int) $key;
1482
1483 if ( $content_id <= 0 && is_scalar( $value ) )
1484 $content_id = (int) $value;
1485 }
1486
1487 if ( $content_id > 0 )
1488 $members[$content_id] = true;
1489 }
1490
1491 return $members;
1492 }
1493
1494 /**
1495 * Check whether string looks like JSON storage.
1496 *
1497 * @param string $storage_data
1498 *
1499 * @return bool
1500 */
1501 private function looks_like_json_storage( $storage_data ) {
1502 return ( strlen( $storage_data ) > 1 && $storage_data[0] === '{' && substr( $storage_data, -1 ) === '}' );
1503 }
1504
1505 /**
1506 * Check whether storage state is normalized.
1507 *
1508 * @param mixed $storage_state
1509 *
1510 * @return bool
1511 */
1512 private function is_normalized_storage_state( $storage_state ) {
1513 return ( is_array( $storage_state ) && isset( $storage_state['format'], $storage_state['visited'], $storage_state['legacy']['expirations'], $storage_state['is_valid'], $storage_state['is_expired'] ) );
1514 }
1515
1516 /**
1517 * Get empty storage buckets.
1518 *
1519 * Filterable via pvc_storage_buckets so that extensions can register additional content-type buckets.
1520 * PVC free registers only 'post'. Additional buckets can be added by integrations.
1521 *
1522 * @return array
1523 */
1524 public function get_empty_storage_buckets() {
1525 $buckets = apply_filters( 'pvc_storage_buckets', [
1526 'post' => []
1527 ] );
1528
1529 if ( ! is_array( $buckets ) || empty( $buckets ) )
1530 return [ 'post' => [] ];
1531
1532 // ensure all bucket values are arrays
1533 foreach ( $buckets as $key => $value ) {
1534 if ( ! is_array( $value ) )
1535 $buckets[$key] = [];
1536 }
1537
1538 return $buckets;
1539 }
1540
1541 /**
1542 * Normalize storage bucket name.
1543 *
1544 * Validates against the registered bucket list from get_empty_storage_buckets().
1545 *
1546 * @param string $content_type
1547 *
1548 * @return string
1549 */
1550 public function normalize_storage_bucket( $content_type ) {
1551 $content_type = sanitize_key( $content_type );
1552 $registered_buckets = array_keys( $this->get_empty_storage_buckets() );
1553
1554 return in_array( $content_type, $registered_buckets, true ) ? $content_type : 'post';
1555 }
1556
1557 /**
1558 * Save data storage.
1559 *
1560 * @param int $content
1561 * @param string $content_type
1562 * @param array $content_data
1563 *
1564 * @return bool
1565 */
1566 private function save_data_storage( $content, $content_type, $content_data ) {
1567 // get base instance
1568 $pvc = Post_Views_Counter();
1569
1570 // get expiration
1571 $expiration = $this->get_timestamp( $pvc->options['general']['time_between_counts']['type'], $pvc->options['general']['time_between_counts']['number'] );
1572 $current_time = current_time( 'timestamp', true );
1573 $count_visit = $this->storage_state_allows_count( $content_data, $content, $content_type, $current_time );
1574
1575 if ( ! $count_visit ) {
1576 $this->storage = [];
1577
1578 return false;
1579 }
1580
1581 if ( $this->use_session_storage_payload_writes() )
1582 $this->storage = $this->build_session_storage_payload( $content_data, $content, $content_type, $expiration, $current_time );
1583 else
1584 $this->storage = [ $content_type => $this->build_legacy_storage_payload( $content_data, $content, $content_type, $expiration, $current_time ) ];
1585
1586 return $count_visit;
1587 }
1588
1589 /**
1590 * Save cookie storage.
1591 *
1592 * @param int $content
1593 * @param array $content_data
1594 *
1595 * @return bool
1596 */
1597 private function save_cookie_storage( $content, $content_data ) {
1598 // early return?
1599 //TODO check this filter in js
1600 // if ( apply_filters( 'pvc_maybe_set_cookie', true, $content, $content_type, $content_data ) !== true )
1601 // return;
1602
1603 // get base instance
1604 $pvc = Post_Views_Counter();
1605
1606 // get expiration
1607 $expiration = $this->get_timestamp( $pvc->options['general']['time_between_counts']['type'], $pvc->options['general']['time_between_counts']['number'] );
1608 $current_time = current_time( 'timestamp', true );
1609 $count_visit = $this->storage_state_allows_count( $content_data, $content, 'post', $current_time );
1610
1611 if ( ! $count_visit ) {
1612 $this->storage = [];
1613
1614 return false;
1615 }
1616
1617 // assign cookie name
1618 $cookie_name = 'pvc_visits' . ( is_multisite() ? '_' . get_current_blog_id() : '' );
1619
1620 if ( ! $this->use_session_storage_payload_writes() ) {
1621 $this->storage = $this->build_legacy_cookie_storage_data( $content_data, $cookie_name, $content, 'post', $expiration, $current_time );
1622
1623 return $count_visit;
1624 }
1625
1626 $session_payload = $this->build_session_storage_payload( $content_data, $content, 'post', $expiration, $current_time );
1627 $session_json = wp_json_encode( $session_payload );
1628
1629 if ( ! is_string( $session_json ) || $session_json === '' ) {
1630 $this->storage = [];
1631
1632 return false;
1633 }
1634
1635 $cookies_data = [
1636 'name' => [],
1637 'value' => [],
1638 'expiry' => []
1639 ];
1640 $cookie_chunks = str_split( $session_json, 3980 );
1641 $cookie_expiration = (int) $session_payload['expires_at'];
1642
1643 foreach ( $cookie_chunks as $key => $value ) {
1644 $cookies_data['name'][] = $cookie_name . '[' . $key . ']';
1645 $cookies_data['value'][] = $value;
1646 $cookies_data['expiry'][] = $cookie_expiration;
1647 }
1648
1649 $this->storage = $cookies_data;
1650
1651 return $count_visit;
1652 }
1653
1654 /**
1655 * Save cookie function.
1656 *
1657 * @param int $id
1658 * @param array $cookie
1659 *
1660 * @return bool|void
1661 */
1662 private function save_cookie( $id, $cookie = [] ) {
1663 // early return?
1664 if ( apply_filters( 'pvc_maybe_set_cookie', true, $id, 'post', $this->get_public_cookie_filter_data( $cookie, 'post' ) ) !== true )
1665 return;
1666
1667 // get main instance
1668 $pvc = Post_Views_Counter();
1669
1670 // get expiration
1671 $expiration = $this->get_timestamp( $pvc->options['general']['time_between_counts']['type'], $pvc->options['general']['time_between_counts']['number'] );
1672 $current_time = current_time( 'timestamp', true );
1673 $count_visit = $this->storage_state_allows_count( $cookie, $id, 'post', $current_time );
1674
1675 if ( ! $count_visit )
1676 return false;
1677
1678 // assign cookie name
1679 $cookie_name = 'pvc_visits' . ( is_multisite() ? '_' . get_current_blog_id() : '' );
1680 $php_at_least_73 = version_compare( phpversion(), '7.3', '>=' );
1681
1682 if ( ! $this->use_session_storage_payload_writes() ) {
1683 $legacy_payload = $this->serialize_legacy_cookie_payload( $this->build_legacy_storage_payload( $cookie, $id, 'post', $expiration, $current_time ) );
1684 $cookies_data = $this->build_legacy_cookie_storage_data( $cookie, $cookie_name, $id, 'post', $expiration, $current_time );
1685
1686 foreach ( $cookies_data['name'] as $key => $cookie_chunk_name ) {
1687 if ( $php_at_least_73 ) {
1688 setcookie(
1689 $cookie_chunk_name,
1690 $cookies_data['value'][$key],
1691 [
1692 'expires' => $cookies_data['expiry'][$key],
1693 'path' => COOKIEPATH,
1694 'domain' => COOKIE_DOMAIN,
1695 'secure' => is_ssl(),
1696 'httponly' => false,
1697 'samesite' => 'LAX'
1698 ]
1699 );
1700 } else {
1701 setcookie( $cookie_chunk_name, $cookies_data['value'][$key], $cookies_data['expiry'][$key], COOKIEPATH, COOKIE_DOMAIN, is_ssl(), false );
1702 }
1703 }
1704
1705 $this->clear_stale_cookie_chunks( $cookie_name, count( $cookies_data['name'] ), $php_at_least_73 );
1706
1707 if ( $this->queue_mode )
1708 $this->cookie = $this->sanitize_cookies_data( $legacy_payload, 'post' );
1709
1710 return $count_visit;
1711 }
1712
1713 $session_payload = $this->build_session_storage_payload( $cookie, $id, 'post', $expiration, $current_time );
1714 $session_json = wp_json_encode( $session_payload );
1715
1716 if ( ! is_string( $session_json ) || $session_json === '' )
1717 return false;
1718
1719 // check whether php version is at least 7.3
1720 $cookie_chunks = str_split( $session_json, 3980 );
1721 $cookie_expiration = (int) $session_payload['expires_at'];
1722
1723 foreach ( $cookie_chunks as $key => $value ) {
1724 if ( $php_at_least_73 ) {
1725 setcookie(
1726 $cookie_name . '[' . $key . ']',
1727 $value,
1728 [
1729 'expires' => $cookie_expiration,
1730 'path' => COOKIEPATH,
1731 'domain' => COOKIE_DOMAIN,
1732 'secure' => is_ssl(),
1733 'httponly' => false,
1734 'samesite' => 'LAX'
1735 ]
1736 );
1737 } else {
1738 setcookie( $cookie_name . '[' . $key . ']', $value, $cookie_expiration, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), false );
1739 }
1740 }
1741
1742 $this->clear_stale_cookie_chunks( $cookie_name, count( $cookie_chunks ), $php_at_least_73 );
1743
1744 if ( $this->queue_mode )
1745 $this->cookie = $this->sanitize_cookies_data( $session_json, 'post' );
1746
1747 return $count_visit;
1748 }
1749
1750 /**
1751 * Count visit.
1752 *
1753 * @param int $post_id
1754 *
1755 * @return int|null
1756 */
1757 private function count_visit( $post_id ) {
1758 // increment amount
1759 $increment_amount = (int) apply_filters( 'pvc_views_increment_amount', 1, $post_id, 'post' );
1760
1761 if ( $increment_amount < 1 )
1762 $increment_amount = 1;
1763
1764 // get day, week, month and year
1765 $date = explode( '-', date( 'W-d-m-Y-o', current_time( 'timestamp', Post_Views_Counter()->options['general']['count_time'] === 'gmt' ) ) );
1766
1767 // prepare count data
1768 $count_data = [
1769 'content_id' => $post_id,
1770 'content_type' => 'post',
1771 'increment' => $increment_amount,
1772 'visits' => [
1773 0 => $date[3] . $date[2] . $date[1], // day like 20140324
1774 1 => $date[4] . $date[0], // week like 201439
1775 2 => $date[3] . $date[2], // month like 201405
1776 3 => $date[3], // year like 2014
1777 4 => 'total' // total views
1778 ]
1779 ];
1780
1781 // attempt to count the visit and check for success
1782 if ( call_user_func( apply_filters( 'pvc_count_visit_multi', [ $this, 'count_visit_multi' ] ), $count_data ) ) {
1783 do_action( 'pvc_after_count_visit', $post_id, 'post' );
1784
1785 return $post_id;
1786 }
1787
1788 // return null on failure to indicate the count did not succeed
1789 return null;
1790 }
1791
1792 /**
1793 * Prepare values to be inserted into database.
1794 *
1795 * @param array $data
1796 *
1797 * @return bool
1798 */
1799 public function count_visit_multi( $data ) {
1800 // no count data?
1801 if ( empty( $data ) )
1802 return false;
1803
1804 $success = true;
1805
1806 foreach ( $data['visits'] as $type => $period ) {
1807 // hit the database directly and check for failure
1808 if ( ! $this->db_insert( $data['content_id'], $type, $period, $data['increment'] ) )
1809 $success = false;
1810 }
1811
1812 return $success;
1813 }
1814
1815 /**
1816 * Remove post views from database when post is deleted.
1817 *
1818 * @global object $wpdb
1819 *
1820 * @param int $post_id
1821 *
1822 * @return void
1823 */
1824 public function delete_post_views( $post_id ) {
1825 global $wpdb;
1826
1827 $data = [
1828 'where' => [ 'id' => $post_id ],
1829 'format' => [ '%d' ]
1830 ];
1831
1832 $data = apply_filters( 'pvc_delete_post_views_where_clause', $data, $post_id );
1833
1834 $wpdb->delete( $wpdb->prefix . 'post_views', $data['where'], $data['format'] );
1835 }
1836
1837 /**
1838 * Get timestamp convertion.
1839 *
1840 * @param string $type
1841 * @param int $number
1842 * @param bool $timestamp
1843 *
1844 * @return int
1845 */
1846 public function get_timestamp( $type, $number, $timestamp = true ) {
1847 $converter = [
1848 'minutes' => MINUTE_IN_SECONDS,
1849 'hours' => HOUR_IN_SECONDS,
1850 'days' => DAY_IN_SECONDS,
1851 'weeks' => WEEK_IN_SECONDS,
1852 'months' => MONTH_IN_SECONDS,
1853 'years' => YEAR_IN_SECONDS
1854 ];
1855
1856 return (int) ( ( $timestamp ? current_time( 'timestamp', true ) : 0 ) + $number * $converter[$type] );
1857 }
1858
1859 /**
1860 * Check if object cache is in use.
1861 *
1862 * @param bool $only_interval
1863 *
1864 * @return bool
1865 */
1866 public function using_object_cache( $only_interval = false ) {
1867 $using = wp_using_ext_object_cache();
1868
1869 // is object cache active?
1870 if ( $using ) {
1871 // get main instance
1872 $pvc = Post_Views_Counter();
1873
1874 // check object cache
1875 if ( ! $only_interval && ! $pvc->options['general']['object_cache'] )
1876 $using = false;
1877
1878 // check interval
1879 if ( $pvc->options['general']['flush_interval']['number'] <= 0 )
1880 $using = false;
1881 }
1882
1883 return $using;
1884 }
1885
1886 /**
1887 * Flush views data stored in the persistent object cache into
1888 * our custom table and clear the object cache keys when done.
1889 *
1890 * @return bool
1891 */
1892 public function flush_cache_to_db() {
1893 // get keys
1894 $key_names = wp_cache_get( 'cached_key_names', 'pvc' );
1895
1896 if ( ! $key_names )
1897 $key_names = [];
1898 else {
1899 // create an array out of a string that's stored in the cache
1900 $key_names = explode( '|', $key_names );
1901 }
1902
1903 // any data?
1904 if ( ! empty( $key_names ) ) {
1905 foreach ( $key_names as $key_name ) {
1906 // get values stored within the key name itself
1907 list( $id, $type, $period ) = explode( '.', $key_name );
1908
1909 // get the cached count value
1910 $count = wp_cache_get( $key_name, 'pvc' );
1911
1912 // store cached value in the database
1913 $this->db_prepare_insert( $id, $type, $period, $count );
1914
1915 // clear the cache key we just flushed
1916 wp_cache_delete( $key_name, 'pvc' );
1917 }
1918
1919 // flush values to database
1920 $this->db_commit_insert();
1921
1922 // delete the key holding the list
1923 wp_cache_delete( 'cached_key_names', 'pvc' );
1924 }
1925
1926 // remove last flush
1927 wp_cache_delete( 'last-flush', 'pvc' );
1928
1929 return true;
1930 }
1931
1932 /**
1933 * Insert or update views count.
1934 *
1935 * @global object $wpdb
1936 *
1937 * @param int $id
1938 * @param int $type
1939 * @param string $period
1940 * @param int $count
1941 *
1942 * @return bool
1943 */
1944 private function db_insert( $id, $type, $period, $count ) {
1945 global $wpdb;
1946
1947 // skip single query?
1948 if ( (bool) apply_filters( 'pvc_skip_single_query', false, $id, $type, $period, $count, 'post' ) )
1949 return true; // consider skipped as "successful" for this context
1950
1951 $result = $wpdb->query( $wpdb->prepare( 'INSERT INTO ' . $wpdb->prefix . 'post_views (`id`, `type`, `period`, `count`) VALUES (%d, %d, %s, %d) ON DUPLICATE KEY UPDATE count = count + %d', $id, $type, $period, $count, $count ) );
1952
1953 // check for query failure
1954 if ( $result === false ) {
1955 // log the error for debugging
1956 error_log( sprintf( 'Post Views Counter: Failed to insert/update views for ID %d, type %d, period %s. MySQL error: %s', $id, $type, $period, $wpdb->last_error ) );
1957 return false;
1958 }
1959
1960 return true;
1961 }
1962
1963 /**
1964 * Prepare bulk insert or update views count.
1965 *
1966 * @param int $id
1967 * @param int $type
1968 * @param string $period
1969 * @param int $count
1970 *
1971 * @return void
1972 */
1973 private function db_prepare_insert( $id, $type, $period, $count = 1 ) {
1974 // cast count
1975 $count = (int) $count;
1976
1977 if ( ! $count )
1978 $count = 1;
1979
1980 // any queries?
1981 if ( ! empty( $this->db_insert_values ) )
1982 $this->db_insert_values .= ', ';
1983
1984 // append insert queries
1985 $this->db_insert_values .= sprintf( '(%d, %d, "%s", %d)', $id, $type, $period, $count );
1986
1987 if ( strlen( $this->db_insert_values ) > 25000 )
1988 $this->db_commit_insert();
1989 }
1990
1991 /**
1992 * Insert accumulated values to database.
1993 *
1994 * @global object $wpdb
1995 *
1996 * @return int|bool
1997 */
1998 private function db_commit_insert() {
1999 global $wpdb;
2000
2001 if ( empty( $this->db_insert_values ) )
2002 return false;
2003
2004 $result = $wpdb->query(
2005 "INSERT INTO " . $wpdb->prefix . "post_views (id, type, period, count)
2006 VALUES " . $this->db_insert_values . "
2007 ON DUPLICATE KEY UPDATE count = count + VALUES(count)"
2008 );
2009
2010 $this->db_insert_values = '';
2011
2012 return $result;
2013 }
2014
2015 /**
2016 * Check whether user has excluded roles.
2017 *
2018 * @param int $user_id
2019 * @param array $option
2020 *
2021 * @return bool
2022 */
2023 public function is_user_role_excluded( $user_id, $option = [] ) {
2024 $option = is_array( $option ) ? $option : [];
2025
2026 // get user by ID
2027 $user = get_user_by( 'id', $user_id );
2028
2029 // no user?
2030 if ( empty( $user ) )
2031 return false;
2032
2033 // get user roles
2034 $roles = (array) $user->roles;
2035
2036 // any roles?
2037 if ( ! empty( $roles ) ) {
2038 foreach ( $roles as $role ) {
2039 if ( in_array( $role, $option, true ) )
2040 return true;
2041 }
2042 }
2043
2044 return false;
2045 }
2046
2047 /**
2048 * Check if IPv4 is in range.
2049 *
2050 * @param string $ip
2051 * @param string $range
2052 *
2053 * @return bool
2054 */
2055 public function ipv4_in_range( $ip, $range ) {
2056 $start = str_replace( '*', '0', $range );
2057 $end = str_replace( '*', '255', $range );
2058 $ip = (float) sprintf( "%u", ip2long( $ip ) );
2059
2060 return ( $ip >= (float) sprintf( "%u", ip2long( $start ) ) && $ip <= (float) sprintf( "%u", ip2long( $end ) ) );
2061 }
2062
2063 /**
2064 * Normalize an IP address for consistent comparisons.
2065 *
2066 * @param string $ip
2067 *
2068 * @return string
2069 */
2070 public function normalize_ip( $ip ) {
2071 $ip = $this->sanitize_ip( trim( $ip ) );
2072
2073 if ( $ip === '' || filter_var( $ip, FILTER_VALIDATE_IP ) === false )
2074 return '';
2075
2076 if ( function_exists( 'inet_pton' ) && function_exists( 'inet_ntop' ) ) {
2077 $packed_ip = inet_pton( $ip );
2078
2079 if ( $packed_ip !== false ) {
2080 $normalized_ip = inet_ntop( $packed_ip );
2081
2082 if ( is_string( $normalized_ip ) )
2083 $ip = $normalized_ip;
2084 }
2085 }
2086
2087 return strtolower( $ip );
2088 }
2089
2090 /**
2091 * Validate and normalize an IP exclusion rule.
2092 *
2093 * Exact IPv4 and IPv6 addresses are supported. Wildcards remain IPv4-only.
2094 *
2095 * @param string $ip
2096 *
2097 * @return string
2098 */
2099 public function validate_excluded_ip( $ip ) {
2100 $ip = $this->sanitize_ip( trim( $ip ) );
2101
2102 if ( $ip === '' )
2103 return '';
2104
2105 if ( strpos( $ip, '*' ) !== false ) {
2106 $wildcard_ip = str_replace( '*', '0', $ip );
2107
2108 if ( filter_var( $wildcard_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) !== false )
2109 return $ip;
2110
2111 return '';
2112 }
2113
2114 return $this->normalize_ip( $ip );
2115 }
2116
2117 /**
2118 * Check whether a visitor IP matches an exclusion rule.
2119 *
2120 * @param string $user_ip
2121 * @param string $excluded_ip
2122 *
2123 * @return bool
2124 */
2125 public function is_excluded_ip( $user_ip, $excluded_ip ) {
2126 $user_ip = $this->normalize_ip( $user_ip );
2127 $excluded_ip = $this->validate_excluded_ip( $excluded_ip );
2128
2129 if ( $user_ip === '' || $excluded_ip === '' )
2130 return false;
2131
2132 if ( strpos( $excluded_ip, '*' ) !== false ) {
2133 if ( filter_var( $user_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) === false )
2134 return false;
2135
2136 return $this->ipv4_in_range( $user_ip, $excluded_ip );
2137 }
2138
2139 if ( function_exists( 'inet_pton' ) ) {
2140 $user_ip_binary = inet_pton( $user_ip );
2141 $excluded_ip_binary = inet_pton( $excluded_ip );
2142
2143 if ( $user_ip_binary !== false && $excluded_ip_binary !== false )
2144 return hash_equals( $excluded_ip_binary, $user_ip_binary );
2145 }
2146
2147 return ( $user_ip === strtolower( $excluded_ip ) );
2148 }
2149
2150 /**
2151 * Get user real IP address.
2152 *
2153 * @return string
2154 */
2155 public function get_user_ip() {
2156 // Default strategy: respect only REMOTE_ADDR (most secure, backward compatible)
2157 $strategy = apply_filters( 'pvc_ip_resolution_strategy', 'remote_addr' );
2158
2159 // Validate strategy - only allow known values to prevent silent weakening
2160 $valid_strategies = [ 'remote_addr', 'trusted_proxy_only', 'auto' ];
2161 if ( ! in_array( $strategy, $valid_strategies, true ) )
2162 $strategy = 'remote_addr';
2163
2164 // Always get REMOTE_ADDR first (most reliable)
2165 $remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : '';
2166 $remote_addr = $this->sanitize_ip( $remote_addr );
2167
2168 // If strategy is remote_addr only, return REMOTE_ADDR if valid
2169 if ( $strategy === 'remote_addr' ) {
2170 if ( $this->validate_user_ip( $remote_addr ) )
2171 return $this->normalize_ip( $remote_addr );
2172
2173 return '';
2174 }
2175
2176 // For other strategies, check if REMOTE_ADDR is a trusted proxy
2177 $trusted_proxies = apply_filters( 'pvc_trusted_proxy_cidrs', [] );
2178 $is_proxy_request = ! empty( $trusted_proxies ) && $this->is_ip_in_cidrs( $remote_addr, $trusted_proxies );
2179
2180 // If strategy is trusted_proxy_only, require REMOTE_ADDR to be trusted proxy
2181 if ( $strategy === 'trusted_proxy_only' && ! $is_proxy_request )
2182 return '';
2183
2184 // If strategy is 'auto' or unknown (shouldn't happen after validation), use forwarded headers if available
2185 // Priority: check forwarded headers only if we have a valid base IP
2186 $ip_headers = [ 'HTTP_X_FORWARDED_FOR', 'HTTP_CLIENT_IP', 'HTTP_X_FORWARDED', 'HTTP_X_CLUSTER_CLIENT_IP', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED' ];
2187
2188 foreach ( $ip_headers as $key ) {
2189 if ( array_key_exists( $key, $_SERVER ) === true ) {
2190 $ips = explode( ',', $_SERVER[$key] );
2191
2192 foreach ( $ips as $header_ip ) {
2193 $header_ip = $this->sanitize_ip( trim( $header_ip ) );
2194
2195 // Skip if same as remote addr (prevent loops)
2196 if ( $header_ip === $remote_addr )
2197 continue;
2198
2199 // Validate the IP
2200 if ( $this->validate_user_ip( $header_ip ) )
2201 return $this->normalize_ip( $header_ip );
2202 }
2203 }
2204 }
2205
2206 // Fallback to REMOTE_ADDR if valid
2207 if ( $this->validate_user_ip( $remote_addr ) )
2208 return $this->normalize_ip( $remote_addr );
2209
2210 return '';
2211 }
2212
2213 /**
2214 * Sanitize an IP address.
2215 *
2216 * @param string $ip
2217 *
2218 * @return string
2219 */
2220 private function sanitize_ip( $ip ) {
2221 return sanitize_text_field( wp_unslash( $ip ) );
2222 }
2223
2224 /**
2225 * Check if IP matches any CIDR range.
2226 *
2227 * @param string $ip
2228 * @param array $cidrs
2229 *
2230 * @return bool
2231 */
2232 private function is_ip_in_cidrs( $ip, $cidrs ) {
2233 if ( empty( $cidrs ) || ! is_array( $cidrs ) )
2234 return false;
2235
2236 $ip_long = ip2long( $ip );
2237 if ( $ip_long === false )
2238 return false;
2239
2240 foreach ( $cidrs as $cidr ) {
2241 $cidr = trim( $cidr );
2242
2243 if ( strpos( $cidr, '/' ) === false )
2244 $cidr .= '/32';
2245
2246 list( $subnet, $mask ) = explode( '/', $cidr );
2247
2248 $subnet_long = ip2long( $subnet );
2249 if ( $subnet_long === false )
2250 continue;
2251
2252 $mask = (int) $mask;
2253
2254 // Validate mask range to prevent ArithmeticError
2255 if ( $mask < 0 || $mask > 32 )
2256 continue;
2257
2258 // Apply mask
2259 if ( ( $ip_long & ~( ( 1 << ( 32 - $mask ) ) - 1 ) ) === ( $subnet_long & ~( ( 1 << ( 32 - $mask ) ) - 1 ) ) )
2260 return true;
2261 }
2262
2263 return false;
2264 }
2265
2266 /**
2267 * Ensure an IP address is public and routable.
2268 *
2269 * @param string $ip
2270 *
2271 * @return bool
2272 */
2273 public function validate_user_ip( $ip ) {
2274 $ip = $this->normalize_ip( $ip );
2275
2276 if ( $ip === '' )
2277 return false;
2278
2279 if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false )
2280 return false;
2281
2282 return true;
2283 }
2284
2285 /**
2286 * Register REST API endpoints.
2287 *
2288 * @return void
2289 */
2290 public function rest_api_init() {
2291 // view post route
2292 register_rest_route(
2293 'post-views-counter',
2294 '/view-post/(?P<id>\d+)|/view-post/',
2295 [
2296 'methods' => [ 'POST' ],
2297 'callback' => [ $this, 'check_post_rest_api' ],
2298 'permission_callback' => [ $this, 'view_post_permissions_check' ],
2299 'args' => apply_filters( 'pvc_rest_api_view_post_args', [
2300 'id' => [
2301 'default' => 0,
2302 'sanitize_callback' => 'absint'
2303 ],
2304 'storage_type' => [
2305 'default' => 'cookies'
2306 ],
2307 'storage_data' => [
2308 'default' => ''
2309 ],
2310 'storage_data_all' => [
2311 'default' => ''
2312 ]
2313 ] )
2314 ]
2315 );
2316
2317 // get views route
2318 register_rest_route(
2319 'post-views-counter',
2320 '/get-post-views/(?P<id>(\d+,?)+)',
2321 [
2322 'methods' => [ 'GET', 'POST' ],
2323 'callback' => [ $this, 'get_post_views_rest_api' ],
2324 'permission_callback' => [ $this, 'get_post_views_permissions_check' ],
2325 'args' => apply_filters( 'pvc_rest_api_get_post_views_args', [
2326 'id' => [
2327 'default' => 0,
2328 'sanitize_callback' => [ $this, 'validate_rest_api_data' ]
2329 ]
2330 ] )
2331 ]
2332 );
2333 }
2334
2335 /**
2336 * Get post views via REST API request.
2337 *
2338 * @param object $request
2339 *
2340 * @return int
2341 */
2342 public function get_post_views_rest_api( $request ) {
2343 return pvc_get_post_views( $request->get_param( 'id' ) );
2344 }
2345
2346 /**
2347 * Check if a given request has access to get views.
2348 *
2349 * @param object $request
2350 *
2351 * @return bool|\WP_Error
2352 */
2353 public function get_post_views_permissions_check( $request ) {
2354 // GET views is always public by default (read-only operation)
2355 $default = true;
2356
2357 return (bool) apply_filters( 'pvc_rest_api_get_post_views_check', $default, $request );
2358 }
2359
2360 /**
2361 * Check if a given request has access to view post.
2362 *
2363 * @param object $request
2364 *
2365 * @return bool|\WP_Error
2366 */
2367 public function view_post_permissions_check( $request ) {
2368 // Default: allow if REST API mode is enabled
2369 $pvc = post_views_counter();
2370 $default = isset( $pvc->options['general']['counter_mode'] ) && $pvc->options['general']['counter_mode'] === 'rest_api';
2371
2372 $result = (bool) apply_filters( 'pvc_rest_api_view_post_check', $default, $request );
2373
2374 // If filter denied access, return WP_Error for clearer feedback
2375 if ( ! $result && $default ) {
2376 return new \WP_Error(
2377 'rest_not_allowed',
2378 __( 'You do not have permission to count post views via REST API.', 'post-views-counter' ),
2379 [ 'status' => 403 ]
2380 );
2381 }
2382
2383 return $result;
2384 }
2385
2386 /**
2387 * Validate REST API incoming data.
2388 *
2389 * @param int|array|string $data
2390 *
2391 * @return int|array
2392 */
2393 public function validate_rest_api_data( $data ) {
2394 // POST array?
2395 if ( is_array( $data ) )
2396 $data = array_unique( array_filter( array_map( 'absint', $data ) ), SORT_NUMERIC );
2397 // multiple comma-separated values?
2398 elseif ( strpos( $data, ',' ) !== false ) {
2399 $data = explode( ',', $data );
2400
2401 if ( is_array( $data ) && ! empty( $data ) )
2402 $data = array_unique( array_filter( array_map( 'absint', $data ) ), SORT_NUMERIC );
2403 else
2404 $data = [];
2405 // single value?
2406 } else
2407 $data = absint( $data );
2408
2409 return $data;
2410 }
2411 }
2412