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
← All changes | inc/class-ps-rest-api.php +1480 -1294 4.3.144.3.16 View file →
@@ -1,1294 +1,1480 @@
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 -}
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 +}