PluginProbe
Passster – Password Protect Pages and Content / 4.3.16
Passster – Password Protect Pages and Content v4.3.16
4.3.16 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 All 48 releases
content-protector / inc / class-ps-rest-api.php

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

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