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