PluginProbe
Passster – Password Protect Pages and Content / 4.3.14
Passster – Password Protect Pages and Content v4.3.14
4.3.15 4.3.14 4.3.12 4.3.13 4.3.11 4.3.10 4.3.9 4.3.8 4.3.7 4.3.6 4.3.5 trunk 3.5.4 3.5.5.2 3.5.5.8 3.5.5.9 4.0 4.1.4 4.2.10 4.2.11 4.2.12 4.2.13 4.2.14 4.2.15 4.2.16 All 47 releases
content-protector / inc / class-ps-rest-api.php

class-ps-rest-api.php in Passster – Password Protect Pages and Content 4.3.14, at inc/class-ps-rest-api.php

1,295 lines 49.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * REST API endpoints for Passster.
5 *
6 * @package Passster
7 */
8 namespace passster;
9
10 defined( 'ABSPATH' ) || exit;
11 /**
12 * REST API handler class.
13 */
14 class PS_Rest_API {
15 /**
16 * Singleton instance.
17 *
18 * @var PS_Rest_API|null
19 */
20 private static $instance = null;
21
22 /**
23 * Get singleton instance.
24 *
25 * @return PS_Rest_API
26 */
27 public static function get_instance() {
28 if ( null === self::$instance ) {
29 self::$instance = new self();
30 }
31 return self::$instance;
32 }
33
34 /**
35 * Constructor.
36 */
37 public function __construct() {
38 add_action( 'rest_api_init', array($this, 'register_routes') );
39 }
40
41 /**
42 * Register REST routes.
43 */
44 public function register_routes() {
45 // Unlock content endpoint.
46 register_rest_route( 'passster/v1', '/unlock', array(
47 'methods' => 'POST',
48 'callback' => array($this, 'unlock_content'),
49 'permission_callback' => '__return_true',
50 'args' => array(
51 'password' => array(
52 'required' => true,
53 'type' => 'string',
54 'sanitize_callback' => function ( $value ) {
55 return wp_unslash( $value );
56 },
57 ),
58 'type' => array(
59 'required' => true,
60 'type' => 'string',
61 'enum' => array(
62 'password',
63 'passwords',
64 'password_list',
65 'password_lists'
66 ),
67 'sanitize_callback' => 'sanitize_text_field',
68 ),
69 'post_id' => array(
70 'required' => true,
71 'type' => 'integer',
72 'sanitize_callback' => 'absint',
73 ),
74 'area_id' => array(
75 'required' => false,
76 'type' => 'integer',
77 'sanitize_callback' => 'absint',
78 ),
79 'block_id' => array(
80 'required' => false,
81 'type' => 'string',
82 'sanitize_callback' => 'sanitize_text_field',
83 ),
84 'list_id' => array(
85 'required' => false,
86 'type' => 'integer',
87 'sanitize_callback' => 'absint',
88 ),
89 'lists' => array(
90 'required' => false,
91 'type' => 'string',
92 'sanitize_callback' => 'sanitize_text_field',
93 ),
94 'redirect' => array(
95 'required' => false,
96 'type' => 'string',
97 'sanitize_callback' => 'esc_url_raw',
98 ),
99 'protection' => array(
100 'required' => false,
101 'type' => 'string',
102 'sanitize_callback' => 'sanitize_text_field',
103 ),
104 'acf' => array(
105 'required' => false,
106 'type' => 'string',
107 'sanitize_callback' => 'sanitize_text_field',
108 ),
109 'term_id' => array(
110 'required' => false,
111 'type' => 'integer',
112 'sanitize_callback' => 'absint',
113 ),
114 ),
115 ) );
116 // Hash password endpoint.
117 register_rest_route( 'passster/v1', '/hash', array(
118 'methods' => 'POST',
119 'callback' => array($this, 'hash_password'),
120 'permission_callback' => '__return_true',
121 'args' => array(
122 'password' => array(
123 'required' => true,
124 'type' => 'string',
125 'sanitize_callback' => function ( $value ) {
126 return wp_unslash( $value );
127 },
128 ),
129 'post_id' => array(
130 'required' => true,
131 'type' => 'integer',
132 'sanitize_callback' => 'absint',
133 ),
134 ),
135 ) );
136 // reCAPTCHA/hCaptcha validation endpoint.
137 register_rest_route( 'passster/v1', '/captcha', array(
138 'methods' => 'POST',
139 'callback' => array($this, 'validate_captcha'),
140 'permission_callback' => '__return_true',
141 'args' => array(
142 'token' => array(
143 'required' => true,
144 'type' => 'string',
145 'sanitize_callback' => 'sanitize_text_field',
146 ),
147 'type' => array(
148 'required' => true,
149 'type' => 'string',
150 'enum' => array(
151 'recaptcha_v2',
152 'recaptcha_v3',
153 'hcaptcha',
154 'turnstile'
155 ),
156 'sanitize_callback' => 'sanitize_text_field',
157 ),
158 'post_id' => array(
159 'required' => true,
160 'type' => 'integer',
161 'sanitize_callback' => 'absint',
162 ),
163 'area_id' => array(
164 'required' => false,
165 'type' => 'integer',
166 'sanitize_callback' => 'absint',
167 ),
168 'redirect' => array(
169 'required' => false,
170 'type' => 'string',
171 'sanitize_callback' => 'esc_url_raw',
172 ),
173 'protection' => array(
174 'required' => false,
175 'type' => 'string',
176 'sanitize_callback' => 'sanitize_text_field',
177 ),
178 'captcha_id' => array(
179 'required' => false,
180 'type' => 'string',
181 'sanitize_callback' => 'sanitize_text_field',
182 ),
183 ),
184 ) );
185 // Logout endpoint (for concurrent sessions).
186 register_rest_route( 'passster/v1', '/logout', array(
187 'methods' => 'POST',
188 'callback' => array($this, 'handle_logout'),
189 'permission_callback' => '__return_true',
190 ) );
191 }
192
193 /**
194 * Unlock content endpoint.
195 *
196 * @param \WP_REST_Request $request Request object.
197 * @return \WP_REST_Response|\WP_Error
198 */
199 public function unlock_content( \WP_REST_Request $request ) {
200 $options = get_option( 'passster', array() );
201 $input = $request->get_param( 'password' );
202 $type = $request->get_param( 'type' );
203 $post_id = $request->get_param( 'post_id' );
204 $area_id = $request->get_param( 'area_id' );
205 $block_id = $request->get_param( 'block_id' );
206 $list_id = $request->get_param( 'list_id' );
207 $lists = $request->get_param( 'lists' );
208 $redirect = $request->get_param( 'redirect' );
209 $protection = $request->get_param( 'protection' );
210 $acf = $request->get_param( 'acf' );
211 $term_id = absint( $request->get_param( 'term_id' ) );
212 // Default error response.
213 $error_message = $options['error'] ?? __( 'Invalid password.', 'content-protector' );
214 $remove_spaces = apply_filters( 'passster_remove_spaces_from_list', true );
215 if ( empty( $protection ) ) {
216 $protection = false;
217 }
218 // Category archive protection: term_id is passed directly from the form.
219 if ( $term_id > 0 ) {
220 $result = $this->validate_category_unlock(
221 $input,
222 $type,
223 $term_id,
224 null,
225 '',
226 $redirect,
227 $options,
228 $remove_spaces
229 );
230 if ( $result['valid'] ) {
231 do_action( 'passster_validation_success', $input );
232 PS_Helper::remember_unlock( hash_hmac( 'sha256', $input, get_option( 'passster_secure_key' ) ) );
233 $term_redirect = get_term_meta( $term_id, 'passster_redirect_url', true );
234 if ( !empty( $term_redirect ) ) {
235 return new \WP_REST_Response(array(
236 'success' => true,
237 'redirect' => esc_url_raw( $term_redirect ),
238 ), 200);
239 }
240 return new \WP_REST_Response(array(
241 'success' => true,
242 'requires_reload' => true,
243 ), 200);
244 }
245 return new \WP_REST_Response(array(
246 'success' => false,
247 'error' => $error_message,
248 ), 200);
249 }
250 // Parent page protection inheritance.
251 $parent_id = wp_get_post_parent_id( $post_id );
252 if ( $parent_id ) {
253 $activate_protection = get_post_meta( $parent_id, 'passster_activate_protection', true );
254 $children_protection = get_post_meta( $parent_id, 'passster_protect_child_pages', true );
255 if ( $activate_protection && $children_protection ) {
256 $post_id = $parent_id;
257 }
258 }
259 // Prepare content.
260 $post = get_post( $post_id );
261 $content = '';
262 if ( $post ) {
263 $content = apply_filters( 'passster_compatibility_actions', $post->post_content, $post_id );
264 }
265 // ACF field support.
266 if ( !empty( $acf ) ) {
267 $content = \get_field( $acf, $post_id );
268 }
269 // Category/taxonomy protection: if the post itself has no protection,
270 // check if it belongs to a protected category and validate against term meta.
271 $post_protection = get_post_meta( $post_id, 'passster_activate_protection', true );
272 if ( !$post_protection && 'full' === $protection && class_exists( 'passster\\PS_Category_Lock' ) ) {
273 $term_data = PS_Category_Lock::get_instance()->get_protected_term_for_post( $post_id );
274 if ( $term_data ) {
275 // Use term redirect if no redirect was sent from the frontend.
276 if ( empty( $redirect ) ) {
277 $redirect = get_term_meta( $term_data['term_id'], 'passster_redirect_url', true );
278 }
279 $result = $this->validate_category_unlock(
280 $input,
281 $type,
282 $term_data['term_id'],
283 $post,
284 $content,
285 $redirect,
286 $options,
287 $remove_spaces
288 );
289 if ( $result['valid'] ) {
290 $response_data = array(
291 'success' => true,
292 );
293 if ( !empty( $redirect ) ) {
294 $response_data['redirect'] = $redirect;
295 } else {
296 // For category/taxonomy protection we can't return the full archive HTML via REST.
297 // Redirect to the term archive instead.
298 $term = get_term( $term_data['term_id'] );
299 $term_link = ( $term && !is_wp_error( $term ) ? get_term_link( $term ) : '' );
300 $response_data['redirect'] = ( $term_link ?: wp_get_referer() );
301 }
302 do_action(
303 'passsster_track_record',
304 $post_id,
305 $input,
306 'full'
307 );
308 do_action( 'passster_validation_success', $input );
309 PS_Helper::remember_unlock( hash_hmac( 'sha256', $input, get_option( 'passster_secure_key' ) ) );
310 return new \WP_REST_Response($response_data, 200);
311 }
312 // Category protection exists but validation failed.
313 return new \WP_REST_Response(array(
314 'success' => false,
315 'error' => $error_message,
316 ), 200);
317 }
318 }
319 // Validate based on type.
320 $valid = false;
321 $result_content = '';
322 // Block-based protection (Gutenberg blocks).
323 if ( !empty( $block_id ) ) {
324 switch ( $type ) {
325 case 'password':
326 $result = $this->get_block_password( $post_id, $block_id );
327 if ( !empty( $result['password'] ) && $input === $result['password'] ) {
328 $valid = true;
329 $result_content = $result['content'];
330 }
331 break;
332 case 'passwords':
333 $result = $this->get_block_passwords( $post_id, $block_id );
334 if ( !empty( $result['passwords'] ) ) {
335 $passwords_str = $result['passwords'];
336 if ( $remove_spaces ) {
337 $passwords_str = str_replace( ' ', '', $passwords_str );
338 }
339 $passwords = explode( ',', $passwords_str );
340 if ( in_array( $input, $passwords, true ) ) {
341 $valid = true;
342 $result_content = $result['content'];
343 }
344 }
345 break;
346 }
347 } elseif ( \passster_fs()->is_plan_or_trial__premium_only( 'pro' ) ) {
348 // PRO: shortcode/area/full protection.
349 switch ( $type ) {
350 case 'password':
351 $result = $this->validate_password_pro(
352 $input,
353 $post_id,
354 $area_id,
355 $protection,
356 $post,
357 $content,
358 $redirect,
359 $options
360 );
361 $valid = $result['valid'];
362 $result_content = $result['content'];
363 break;
364 case 'passwords':
365 $result = $this->validate_passwords_pro(
366 $input,
367 $post_id,
368 $area_id,
369 $protection,
370 $post,
371 $content,
372 $redirect,
373 $options,
374 $remove_spaces
375 );
376 $valid = $result['valid'];
377 $result_content = $result['content'];
378 break;
379 case 'password_list':
380 $result = $this->validate_password_list_pro(
381 $input,
382 $list_id,
383 $post_id,
384 $area_id,
385 $protection,
386 $post,
387 $content,
388 $redirect,
389 $options,
390 $remove_spaces
391 );
392 $valid = $result['valid'];
393 $result_content = $result['content'];
394 break;
395 case 'password_lists':
396 $result = $this->validate_password_lists_pro(
397 $input,
398 $lists,
399 $post_id,
400 $area_id,
401 $protection,
402 $post,
403 $content,
404 $redirect,
405 $options,
406 $remove_spaces
407 );
408 $valid = $result['valid'];
409 $result_content = $result['content'];
410 break;
411 }
412 } else {
413 // Free version: only password type.
414 if ( 'password' === $type ) {
415 $result = $this->validate_password_free(
416 $input,
417 $post_id,
418 $area_id,
419 $protection,
420 $post,
421 $content,
422 $redirect,
423 $options
424 );
425 $valid = $result['valid'];
426 $result_content = $result['content'];
427 }
428 }
429 if ( !$valid ) {
430 return new \WP_REST_Response(array(
431 'success' => false,
432 'error' => $error_message,
433 ), 200);
434 }
435 // Success - prepare response.
436 $response_data = array(
437 'success' => true,
438 );
439 if ( !empty( $redirect ) ) {
440 $response_data['redirect'] = $redirect;
441 } else {
442 // Page builders (Divi, WPBakery, Elementor, etc.) require their full frontend
443 // context (scripts, styles, theme builder) to render correctly. In a REST API
444 // response this is not available, so signal the client to do a full page reload
445 // instead of attempting inline content injection.
446 if ( empty( $block_id ) && $this->content_uses_page_builder( $result_content, $post_id ) ) {
447 $response_data['requires_reload'] = true;
448 } else {
449 // Block content is already rendered via render_block(); apply the_content filter only for shortcode/area protection.
450 $response_data['content'] = ( empty( $block_id ) ? apply_filters( 'the_content', str_replace( '{post-id}', $post_id, $result_content ) ) : $result_content );
451 }
452 }
453 // Determine source for tracking.
454 $source = 'shortcode';
455 if ( !empty( $block_id ) ) {
456 $source = 'block';
457 } elseif ( 'full' === $protection ) {
458 $source = 'full';
459 } elseif ( !empty( $area_id ) || 'area' === $protection ) {
460 $source = 'area';
461 }
462 do_action(
463 'passsster_track_record',
464 $post_id,
465 $input,
466 $source
467 );
468 do_action( 'passster_validation_success', $input );
469 PS_Helper::remember_unlock( hash_hmac( 'sha256', $input, get_option( 'passster_secure_key' ) ) );
470 return new \WP_REST_Response($response_data, 200);
471 }
472
473 /**
474 * Validate single password (PRO).
475 *
476 * @param string $input User input.
477 * @param int $post_id Post ID.
478 * @param int $area_id Area ID.
479 * @param string|bool $protection Protection type.
480 * @param \WP_Post $post Post object.
481 * @param string $content Post content.
482 * @param string $redirect Redirect URL.
483 * @param array $options Plugin options.
484 * @return array
485 */
486 private function validate_password_pro(
487 $input,
488 $post_id,
489 $area_id,
490 $protection,
491 $post,
492 $content,
493 $redirect,
494 $options
495 ) {
496 switch ( $protection ) {
497 case 'full':
498 $password = get_post_meta( $post_id, 'passster_password', true );
499 if ( !empty( $password ) && $input === $password ) {
500 if ( $post && 'publish' === $post->post_status ) {
501 return array(
502 'valid' => true,
503 'content' => $content,
504 );
505 }
506 }
507 break;
508 case 'area':
509 if ( !empty( $area_id ) ) {
510 $password = get_post_meta( $area_id, 'passster_password', true );
511 if ( !empty( $password ) && $input === $password ) {
512 $area = get_post( $area_id );
513 if ( $area && 'protected_areas' === $area->post_type && 'publish' === $area->post_status ) {
514 $area_content = apply_filters( 'passster_compatibility_actions', $area->post_content, $area_id );
515 return array(
516 'valid' => true,
517 'content' => $area_content,
518 );
519 }
520 }
521 }
522 break;
523 default:
524 $shortcode_content = apply_filters( 'passster_compatibility_actions', PS_Helper::get_shortcode_content( $content, $input ) );
525 if ( !empty( $shortcode_content ) ) {
526 return array(
527 'valid' => true,
528 'content' => $shortcode_content,
529 );
530 } elseif ( !empty( $options['toggle_ajax'] ) && 'on' === $options['toggle_ajax'] ) {
531 return array(
532 'valid' => true,
533 'content' => '',
534 );
535 }
536 break;
537 }
538 return array(
539 'valid' => false,
540 'content' => '',
541 );
542 }
543
544 /**
545 * Validate multiple passwords (PRO).
546 *
547 * @param string $input User input.
548 * @param int $post_id Post ID.
549 * @param int $area_id Area ID.
550 * @param string|bool $protection Protection type.
551 * @param \WP_Post $post Post object.
552 * @param string $content Post content.
553 * @param string $redirect Redirect URL.
554 * @param array $options Plugin options.
555 * @param bool $remove_spaces Whether to remove spaces.
556 * @return array
557 */
558 private function validate_passwords_pro(
559 $input,
560 $post_id,
561 $area_id,
562 $protection,
563 $post,
564 $content,
565 $redirect,
566 $options,
567 $remove_spaces
568 ) {
569 switch ( $protection ) {
570 case 'full':
571 $passwords_str = get_post_meta( $post_id, 'passster_passwords', true );
572 if ( $remove_spaces ) {
573 $passwords_str = str_replace( ' ', '', $passwords_str );
574 }
575 $passwords = explode( ',', $passwords_str );
576 if ( !empty( $passwords ) && in_array( $input, $passwords, true ) ) {
577 if ( $post && 'publish' === $post->post_status ) {
578 return array(
579 'valid' => true,
580 'content' => $content,
581 );
582 }
583 }
584 break;
585 case 'area':
586 if ( !empty( $area_id ) ) {
587 $passwords_str = get_post_meta( $area_id, 'passster_passwords', true );
588 if ( $remove_spaces ) {
589 $passwords_str = str_replace( ' ', '', $passwords_str );
590 }
591 $passwords = explode( ',', $passwords_str );
592 if ( !empty( $passwords ) && in_array( $input, $passwords, true ) ) {
593 $area = get_post( $area_id );
594 if ( $area && 'protected_areas' === $area->post_type && 'publish' === $area->post_status ) {
595 $area_content = apply_filters( 'passster_compatibility_actions', $area->post_content, $area_id );
596 return array(
597 'valid' => true,
598 'content' => $area_content,
599 );
600 }
601 }
602 }
603 break;
604 default:
605 $shortcode_content = apply_filters( 'passster_compatibility_actions', PS_Helper::get_shortcode_content( $content, $input ) );
606 if ( !empty( $shortcode_content ) ) {
607 return array(
608 'valid' => true,
609 'content' => $shortcode_content,
610 );
611 } elseif ( !empty( $options['toggle_ajax'] ) && 'on' === $options['toggle_ajax'] ) {
612 return array(
613 'valid' => true,
614 'content' => '',
615 );
616 }
617 break;
618 }
619 return array(
620 'valid' => false,
621 'content' => '',
622 );
623 }
624
625 /**
626 * Validate password from single list (PRO).
627 *
628 * @param string $input User input.
629 * @param int $list_id Password list ID.
630 * @param int $post_id Post ID.
631 * @param int $area_id Area ID.
632 * @param string|bool $protection Protection type.
633 * @param \WP_Post $post Post object.
634 * @param string $content Post content.
635 * @param string $redirect Redirect URL.
636 * @param array $options Plugin options.
637 * @param bool $remove_spaces Whether to remove spaces.
638 * @return array
639 */
640 private function validate_password_list_pro(
641 $input,
642 $list_id,
643 $post_id,
644 $area_id,
645 $protection,
646 $post,
647 $content,
648 $redirect,
649 $options,
650 $remove_spaces
651 ) {
652 if ( empty( $list_id ) ) {
653 return array(
654 'valid' => false,
655 'content' => '',
656 );
657 }
658 $passwords_str = get_post_meta( $list_id, 'passster_passwords', true );
659 if ( $remove_spaces ) {
660 $passwords_str = str_replace( ' ', '', $passwords_str );
661 }
662 $passwords = explode( ',', $passwords_str );
663 return $this->validate_single_list(
664 $input,
665 $passwords,
666 $list_id,
667 $post_id,
668 $area_id,
669 $protection,
670 $post,
671 $content,
672 $options
673 );
674 }
675
676 /**
677 * Validate password from multiple lists (PRO).
678 *
679 * @param string $input User input.
680 * @param string $lists Pipe-separated list IDs.
681 * @param int $post_id Post ID.
682 * @param int $area_id Area ID.
683 * @param string|bool $protection Protection type.
684 * @param \WP_Post $post Post object.
685 * @param string $content Post content.
686 * @param string $redirect Redirect URL.
687 * @param array $options Plugin options.
688 * @param bool $remove_spaces Whether to remove spaces.
689 * @return array
690 */
691 private function validate_password_lists_pro(
692 $input,
693 $lists,
694 $post_id,
695 $area_id,
696 $protection,
697 $post,
698 $content,
699 $redirect,
700 $options,
701 $remove_spaces
702 ) {
703 if ( empty( $lists ) ) {
704 return array(
705 'valid' => false,
706 'content' => '',
707 );
708 }
709 $password_list_ids = explode( '|', $lists );
710 foreach ( $password_list_ids as $pid ) {
711 $passwords_str = get_post_meta( $pid, 'passster_passwords', true );
712 if ( $remove_spaces ) {
713 $passwords_str = str_replace( ' ', '', $passwords_str );
714 }
715 $passwords = explode( ',', $passwords_str );
716 $result = $this->validate_single_list(
717 $input,
718 $passwords,
719 $pid,
720 $post_id,
721 $area_id,
722 $protection,
723 $post,
724 $content,
725 $options
726 );
727 if ( $result['valid'] ) {
728 return $result;
729 }
730 }
731 return array(
732 'valid' => false,
733 'content' => '',
734 );
735 }
736
737 /**
738 * Validate input against a single password list with protection type handling.
739 *
740 * @param string $input User input.
741 * @param array $passwords Passwords array.
742 * @param int $list_id Password list ID.
743 * @param int $post_id Post ID.
744 * @param int $area_id Area ID.
745 * @param string|bool $protection Protection type.
746 * @param \WP_Post $post Post object.
747 * @param string $content Post content.
748 * @param array $options Plugin options.
749 * @return array
750 */
751 private function validate_single_list(
752 $input,
753 $passwords,
754 $list_id,
755 $post_id,
756 $area_id,
757 $protection,
758 $post,
759 $content,
760 $options
761 ) {
762 $valid = false;
763 $result_content = '';
764 switch ( $protection ) {
765 case 'full':
766 if ( !empty( $passwords ) && in_array( $input, $passwords, true ) ) {
767 if ( $post && 'publish' === $post->post_status ) {
768 $valid = true;
769 $result_content = $content;
770 }
771 do_action(
772 'passster_validation_success_list',
773 $input,
774 $list_id,
775 $post_id
776 );
777 PS_Conditional::maybe_expire_password_from_list__premium_only( $input, $passwords, $list_id );
778 }
779 break;
780 case 'area':
781 if ( !empty( $area_id ) && !empty( $passwords ) && in_array( $input, $passwords, true ) ) {
782 $area = get_post( $area_id );
783 if ( $area && 'protected_areas' === $area->post_type && 'publish' === $area->post_status ) {
784 $valid = true;
785 $result_content = apply_filters( 'passster_compatibility_actions', $area->post_content, $area_id );
786 }
787 do_action(
788 'passster_validation_success_list',
789 $input,
790 $list_id,
791 $area_id
792 );
793 PS_Conditional::maybe_expire_password_from_list__premium_only( $input, $passwords, $list_id );
794 }
795 break;
796 default:
797 if ( in_array( $input, $passwords, true ) ) {
798 $shortcode_content = apply_filters( 'passster_compatibility_actions', PS_Helper::get_shortcode_content( $content, $list_id ) );
799 if ( !empty( $shortcode_content ) ) {
800 $valid = true;
801 $result_content = $shortcode_content;
802 } elseif ( !empty( $options['toggle_ajax'] ) && 'on' === $options['toggle_ajax'] ) {
803 $valid = true;
804 }
805 do_action(
806 'passster_validation_success_list',
807 $input,
808 $list_id,
809 $post_id
810 );
811 PS_Conditional::maybe_expire_password_from_list__premium_only( $input, $passwords, $list_id );
812 }
813 break;
814 }
815 return array(
816 'valid' => $valid,
817 'content' => $result_content,
818 );
819 }
820
821 /**
822 * Validate single password (Free version).
823 *
824 * @param string $input User input.
825 * @param int $post_id Post ID.
826 * @param int $area_id Area ID.
827 * @param string|bool $protection Protection type.
828 * @param \WP_Post $post Post object.
829 * @param string $content Post content.
830 * @param string $redirect Redirect URL.
831 * @param array $options Plugin options.
832 * @return array
833 */
834 private function validate_password_free(
835 $input,
836 $post_id,
837 $area_id,
838 $protection,
839 $post,
840 $content,
841 $redirect,
842 $options
843 ) {
844 switch ( $protection ) {
845 case 'full':
846 $password = get_post_meta( $post_id, 'passster_password', true );
847 if ( !empty( $password ) && $input === $password ) {
848 if ( $post && 'publish' === $post->post_status ) {
849 return array(
850 'valid' => true,
851 'content' => $content,
852 );
853 }
854 }
855 break;
856 case 'area':
857 if ( !empty( $area_id ) ) {
858 $password = get_post_meta( $area_id, 'passster_password', true );
859 if ( !empty( $password ) && $input === $password ) {
860 $area = get_post( $area_id );
861 if ( $area && 'protected_areas' === $area->post_type && 'publish' === $area->post_status ) {
862 $area_content = apply_filters( 'passster_compatibility_actions', $area->post_content, $area_id );
863 return array(
864 'valid' => true,
865 'content' => $area_content,
866 );
867 }
868 }
869 }
870 break;
871 default:
872 $shortcode_content = apply_filters( 'passster_compatibility_actions', PS_Helper::get_shortcode_content( $content, $input ) );
873 if ( !empty( $shortcode_content ) ) {
874 return array(
875 'valid' => true,
876 'content' => $shortcode_content,
877 );
878 } elseif ( !empty( $options['toggle_ajax'] ) && 'on' === $options['toggle_ajax'] ) {
879 return array(
880 'valid' => true,
881 'content' => '',
882 );
883 }
884 break;
885 }
886 return array(
887 'valid' => false,
888 'content' => '',
889 );
890 }
891
892 /**
893 * Get password from block attributes.
894 *
895 * @param int $post_id Post ID.
896 * @param string $block_id Block ID.
897 * @return array
898 */
899 /**
900 * Validate unlock for category-protected posts (password stored in term meta).
901 *
902 * @param string $input User input.
903 * @param string $type Protection type (password, passwords, password_list, password_lists).
904 * @param int $term_id Protected term ID.
905 * @param \WP_Post $post Post object.
906 * @param string $content Post content.
907 * @param string $redirect Redirect URL.
908 * @param array $options Plugin options.
909 * @param bool $remove_spaces Whether to remove spaces from password lists.
910 * @return array
911 */
912 private function validate_category_unlock(
913 $input,
914 $type,
915 $term_id,
916 $post,
917 $content,
918 $redirect,
919 $options,
920 $remove_spaces
921 ) {
922 switch ( $type ) {
923 case 'password':
924 $password = get_term_meta( $term_id, 'passster_password', true );
925 if ( !empty( $password ) && $input === $password ) {
926 return array(
927 'valid' => true,
928 'content' => $content,
929 );
930 }
931 break;
932 case 'passwords':
933 break;
934 case 'password_list':
935 break;
936 case 'password_lists':
937 break;
938 }
939 return array(
940 'valid' => false,
941 'content' => '',
942 );
943 }
944
945 private function get_block_password( $post_id, $block_id ) {
946 $post = get_post( $post_id );
947 if ( !$post ) {
948 return array(
949 'password' => '',
950 'content' => '',
951 );
952 }
953 $blocks = parse_blocks( $post->post_content );
954 $block = $this->find_block_by_id( $blocks, $block_id );
955 if ( !$block ) {
956 return array(
957 'password' => '',
958 'content' => '',
959 );
960 }
961 $password = $block['attrs']['password'] ?? '';
962 $content = $this->render_inner_blocks( $block );
963 return array(
964 'password' => $password,
965 'content' => $content,
966 );
967 }
968
969 /**
970 * Get passwords from block attributes.
971 *
972 * @param int $post_id Post ID.
973 * @param string $block_id Block ID.
974 * @return array
975 */
976 private function get_block_passwords( $post_id, $block_id ) {
977 $post = get_post( $post_id );
978 if ( !$post ) {
979 return array(
980 'passwords' => '',
981 'content' => '',
982 );
983 }
984 $blocks = parse_blocks( $post->post_content );
985 $block = $this->find_block_by_id( $blocks, $block_id );
986 if ( !$block ) {
987 return array(
988 'passwords' => '',
989 'content' => '',
990 );
991 }
992 $passwords = $block['attrs']['passwords'] ?? '';
993 $content = $this->render_inner_blocks( $block );
994 return array(
995 'passwords' => $passwords,
996 'content' => $content,
997 );
998 }
999
1000 /**
1001 * Find block by ID recursively.
1002 *
1003 * @param array $blocks Blocks array.
1004 * @param string $block_id Block ID.
1005 * @return array|null
1006 */
1007 private function find_block_by_id( $blocks, $block_id ) {
1008 foreach ( $blocks as $block ) {
1009 if ( 'passster/content-lock' === $block['blockName'] ) {
1010 if ( isset( $block['attrs']['blockId'] ) && $block['attrs']['blockId'] === $block_id ) {
1011 return $block;
1012 }
1013 }
1014 // Check inner blocks.
1015 if ( !empty( $block['innerBlocks'] ) ) {
1016 $found = $this->find_block_by_id( $block['innerBlocks'], $block_id );
1017 if ( $found ) {
1018 return $found;
1019 }
1020 }
1021 }
1022 return null;
1023 }
1024
1025 /**
1026 * Check if content uses a page builder that requires a full page reload to render.
1027 *
1028 * @param string $content Post content.
1029 * @param int $post_id Post ID.
1030 * @return bool
1031 */
1032 private function content_uses_page_builder( $content, $post_id = 0 ) {
1033 // Divi Builder shortcodes.
1034 if ( $content && strpos( $content, '[et_pb_' ) !== false ) {
1035 return true;
1036 }
1037 // WPBakery Page Builder.
1038 if ( $content && strpos( $content, '[vc_row' ) !== false ) {
1039 return true;
1040 }
1041 // Fusion Builder (Avada).
1042 if ( $content && strpos( $content, '[fusion_builder' ) !== false ) {
1043 return true;
1044 }
1045 // Elementor stores its layout in post meta, not in post_content.
1046 if ( $post_id && 'builder' === get_post_meta( $post_id, '_elementor_edit_mode', true ) ) {
1047 return true;
1048 }
1049 return false;
1050 }
1051
1052 /**
1053 * Render inner blocks content.
1054 *
1055 * @param array $block Block data.
1056 * @return string
1057 */
1058 private function render_inner_blocks( $block ) {
1059 if ( empty( $block['innerBlocks'] ) ) {
1060 return '';
1061 }
1062 $content = '';
1063 foreach ( $block['innerBlocks'] as $inner_block ) {
1064 $content .= render_block( $inner_block );
1065 }
1066 return $content;
1067 }
1068
1069 /**
1070 * Hash password endpoint.
1071 *
1072 * @param \WP_REST_Request $request Request object.
1073 * @return \WP_REST_Response
1074 */
1075 public function hash_password( \WP_REST_Request $request ) {
1076 $password = $request->get_param( 'password' );
1077 $post_id = $request->get_param( 'post_id' );
1078 $real_password = get_post_meta( $post_id, 'passster_password', true );
1079 if ( empty( $real_password ) || !hash_equals( $real_password, $password ) ) {
1080 return new \WP_REST_Response(array(
1081 'success' => false,
1082 'error' => __( 'Invalid password.', 'content-protector' ),
1083 ), 200);
1084 }
1085 $hashed = hash_hmac( 'sha256', $password, get_option( 'passster_secure_key' ) );
1086 PS_Helper::remember_unlock( $hashed );
1087 return new \WP_REST_Response(array(
1088 'success' => true,
1089 'hash' => $hashed,
1090 ), 200);
1091 }
1092
1093 /**
1094 * Validate captcha endpoint.
1095 *
1096 * @param \WP_REST_Request $request Request object.
1097 * @return \WP_REST_Response
1098 */
1099 public function validate_captcha( \WP_REST_Request $request ) {
1100 $options = get_option( 'passster', array() );
1101 $token = $request->get_param( 'token' );
1102 $type = $request->get_param( 'type' );
1103 $post_id = $request->get_param( 'post_id' );
1104 $area_id = $request->get_param( 'area_id' );
1105 $redirect = $request->get_param( 'redirect' );
1106 $protection = $request->get_param( 'protection' );
1107 $captcha_id = $request->get_param( 'captcha_id' );
1108 if ( empty( $protection ) ) {
1109 $protection = false;
1110 }
1111 if ( empty( $captcha_id ) ) {
1112 // Default captcha ID based on type.
1113 $captcha_id = str_replace( array('_v2', '_v3'), '', $type );
1114 }
1115 $error_message = $options['error'] ?? __( 'Captcha validation failed.', 'content-protector' );
1116 // Validate captcha based on type.
1117 $valid = false;
1118 switch ( $type ) {
1119 case 'recaptcha_v2':
1120 case 'recaptcha_v3':
1121 $valid = $this->verify_recaptcha( $token, $options, $type );
1122 break;
1123 case 'hcaptcha':
1124 $valid = $this->verify_hcaptcha( $token, $options );
1125 break;
1126 case 'turnstile':
1127 $valid = $this->verify_turnstile( $token, $options );
1128 break;
1129 }
1130 if ( !$valid ) {
1131 return new \WP_REST_Response(array(
1132 'success' => false,
1133 'error' => $error_message,
1134 ), 200);
1135 }
1136 // Get content based on protection type (mirrors AJAX logic).
1137 $content = '';
1138 $requires_reload = false;
1139 $captcha_protection_types = array('recaptcha', 'turnstile');
1140 if ( 'full' !== $protection ) {
1141 if ( 'area' === $protection ) {
1142 if ( !empty( $area_id ) ) {
1143 $area = get_post( $area_id );
1144 $area_protection_type = get_post_meta( $area_id, 'passster_protection_type', true );
1145 if ( $area && 'protected_areas' === $area->post_type && 'publish' === $area->post_status && in_array( $area_protection_type, $captcha_protection_types, true ) ) {
1146 $content = apply_filters( 'passster_compatibility_actions', $area->post_content, $area_id );
1147 }
1148 }
1149 } else {
1150 // Shortcode protection - extract content using captcha_id.
1151 $post = get_post( $post_id );
1152 if ( $post ) {
1153 $post_content = apply_filters( 'passster_compatibility_actions', $post->post_content, $post_id );
1154 $content = apply_filters( 'passster_compatibility_actions', PS_Helper::get_shortcode_content( $post_content, $captcha_id, 'captcha' ) );
1155 }
1156 }
1157 } else {
1158 $post = get_post( $post_id );
1159 $post_protection = get_post_meta( $post_id, 'passster_activate_protection', true );
1160 $post_protection_type = get_post_meta( $post_id, 'passster_protection_type', true );
1161 if ( $post && 'publish' === $post->post_status && $post_protection && in_array( $post_protection_type, $captcha_protection_types, true ) ) {
1162 $content = apply_filters( 'passster_compatibility_actions', $post->post_content, $post_id );
1163 } elseif ( $post && 'publish' === $post->post_status && !$post_protection && class_exists( 'passster\\PS_Category_Lock' ) && PS_Category_Lock::get_instance()->get_protected_term_for_post( $post_id ) ) {
1164 $requires_reload = true;
1165 }
1166 }
1167 // Track record.
1168 $source = 'shortcode';
1169 if ( 'full' === $protection ) {
1170 $source = 'full';
1171 } elseif ( !empty( $area_id ) || 'area' === $protection ) {
1172 $source = 'area';
1173 }
1174 do_action(
1175 'passsster_track_record',
1176 $post_id,
1177 $captcha_id,
1178 $source
1179 );
1180 // Success response.
1181 $response_data = array(
1182 'success' => true,
1183 );
1184 PS_Helper::remember_unlock( hash_hmac( 'sha256', 'captcha-verified', get_option( 'passster_secure_key' ) ) );
1185 if ( !empty( $redirect ) ) {
1186 $response_data['redirect'] = $redirect;
1187 } elseif ( $requires_reload ) {
1188 $response_data['requires_reload'] = true;
1189 } else {
1190 $response_data['content'] = $content;
1191 }
1192 return new \WP_REST_Response($response_data, 200);
1193 }
1194
1195 /**
1196 * Verify reCAPTCHA token.
1197 *
1198 * @param string $token Token from client.
1199 * @param array $options Plugin options.
1200 * @param string $type recaptcha_v2 or recaptcha_v3.
1201 * @return bool
1202 */
1203 private function verify_recaptcha( $token, $options, $type ) {
1204 $secret = $options['recaptcha_secret'] ?? '';
1205 if ( empty( $secret ) ) {
1206 return false;
1207 }
1208 $response = wp_remote_post( 'https://www.google.com/recaptcha/api/siteverify', array(
1209 'body' => array(
1210 'secret' => $secret,
1211 'response' => $token,
1212 ),
1213 ) );
1214 if ( is_wp_error( $response ) ) {
1215 return false;
1216 }
1217 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1218 if ( 'recaptcha_v2' === $type ) {
1219 return !empty( $body['success'] );
1220 }
1221 // v3 - check score.
1222 return !empty( $body['success'] ) && isset( $body['score'] ) && $body['score'] >= 0.5 && isset( $body['action'] ) && 'validate_input' === $body['action'];
1223 }
1224
1225 /**
1226 * Verify hCaptcha token.
1227 *
1228 * @param string $token Token from client.
1229 * @param array $options Plugin options.
1230 * @return bool
1231 */
1232 private function verify_hcaptcha( $token, $options ) {
1233 $secret = $options['recaptcha_secret'] ?? '';
1234 if ( empty( $secret ) ) {
1235 return false;
1236 }
1237 $response = wp_remote_post( 'https://hcaptcha.com/siteverify', array(
1238 'body' => array(
1239 'secret' => $secret,
1240 'response' => $token,
1241 ),
1242 ) );
1243 if ( is_wp_error( $response ) ) {
1244 return false;
1245 }
1246 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1247 return !empty( $body['success'] );
1248 }
1249
1250 /**
1251 * Verify Turnstile token.
1252 *
1253 * @param string $token Token from client.
1254 * @param array $options Plugin options.
1255 * @return bool
1256 */
1257 private function verify_turnstile( $token, $options ) {
1258 $secret = $options['turnstile_secret'] ?? '';
1259 if ( empty( $secret ) ) {
1260 return false;
1261 }
1262 $response = wp_remote_post( 'https://challenges.cloudflare.com/turnstile/v0/siteverify', array(
1263 'body' => array(
1264 'secret' => $secret,
1265 'response' => $token,
1266 ),
1267 ) );
1268 if ( is_wp_error( $response ) ) {
1269 return false;
1270 }
1271 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1272 return !empty( $body['success'] );
1273 }
1274
1275 /**
1276 * Handle logout endpoint.
1277 *
1278 * @param \WP_REST_Request $request Request object.
1279 * @return \WP_REST_Response
1280 */
1281 public function handle_logout( \WP_REST_Request $request ) {
1282 // Clear concurrent session if PRO and class exists.
1283 if ( \passster_fs()->is_plan_or_trial__premium_only( 'pro' ) && class_exists( 'passster\\PS_Concurrent' ) ) {
1284 $cookie = ( isset( $_COOKIE['passster'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['passster'] ) ) : '' );
1285 if ( !empty( $cookie ) && method_exists( PS_Concurrent::class, 'clear_session__premium_only' ) ) {
1286 PS_Concurrent::clear_session__premium_only( $cookie );
1287 }
1288 }
1289 return new \WP_REST_Response(array(
1290 'success' => true,
1291 ), 200);
1292 }
1293
1294 }
1295