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-category-lock.php +1169 -1163 4.3.84.3.16 View file →
@@ -1,1163 +1,1169 @@
1 -<?php
2 -
3 -namespace passster;
4 -
5 -/**
6 - * Class to handle category/taxonomy protection.
7 - * Adds password protection to hierarchical taxonomy terms.
8 - */
9 -class PS_Category_Lock {
10 - /**
11 - * Contains instance or null
12 - *
13 - * @var object|null
14 - */
15 - private static $instance = null;
16 -
17 - /**
18 - * Rendered form HTML to pass to the protected WooCommerce template.
19 - *
20 - * @var string
21 - */
22 - private static $woo_protected_form = '';
23 -
24 - /**
25 - * Rendered form HTML to pass to the protected regular category template.
26 - *
27 - * @var string
28 - */
29 - private static $protected_form = '';
30 -
31 - /**
32 - * Get the stored protected form HTML (used by the WooCommerce override template).
33 - *
34 - * @return string
35 - */
36 - public static function get_woo_protected_form() : string {
37 - return self::$woo_protected_form;
38 - }
39 -
40 - /**
41 - * Get the stored protected form HTML (used by the regular category override template).
42 - *
43 - * @return string
44 - */
45 - public static function get_protected_form() : string {
46 - return self::$protected_form;
47 - }
48 -
49 - /**
50 - * Returns instance of PS_Category_Lock.
51 - *
52 - * @return object
53 - */
54 - public static function get_instance() {
55 - if ( null === self::$instance ) {
56 - self::$instance = new self();
57 - }
58 - return self::$instance;
59 - }
60 -
61 - /**
62 - * Constructor.
63 - */
64 - public function __construct() {
65 - add_action( 'init', array($this, 'register_term_meta'), 99 );
66 - add_action( 'init', array($this, 'register_taxonomy_hooks'), 99 );
67 - // REST API endpoints for React metabox.
68 - add_action( 'rest_api_init', array($this, 'rest_api_init') );
69 - // Enqueue React scripts on term edit pages.
70 - add_action( 'admin_enqueue_scripts', array($this, 'add_term_meta_scripts') );
71 - // AJAX handler for quick edit modal.
72 - add_action( 'wp_ajax_passster_quick_edit_category', array($this, 'ajax_quick_edit_category') );
73 - // Frontend protection.
74 - add_filter( 'the_content', array($this, 'filter_content_by_category'), 11 );
75 - add_filter( 'get_the_excerpt', array($this, 'filter_content_by_category'), 11 );
76 - add_action( 'template_redirect', array($this, 'protect_category_archive') );
77 - }
78 -
79 - /**
80 - * Swap WooCommerce's archive template for a minimal protected-category template.
81 - *
82 - * @param string $template Current template path.
83 - * @return string
84 - */
85 - public function use_protected_category_template( string $template ) : string {
86 - return plugin_dir_path( __FILE__ ) . 'templates/woo-category-protected.php';
87 - }
88 -
89 - /**
90 - * Swap the archive template for the protected regular-category template (classic themes only).
91 - *
92 - * @param string $template Current template path.
93 - * @return string
94 - */
95 - public function use_protected_regular_category_template( string $template ) : string {
96 - return plugin_dir_path( __FILE__ ) . 'templates/category-protected.php';
97 - }
98 -
99 - /**
100 - * Replace the core/query block output with the password form (FSE/block themes only).
101 - *
102 - * @param string $block_content Rendered block HTML.
103 - * @param array $block Block data.
104 - * @return string
105 - */
106 - public function replace_query_block_with_form( string $block_content, array $block ) : string {
107 - if ( 'core/query' !== $block['blockName'] ) {
108 - return $block_content;
109 - }
110 - return self::$protected_form;
111 - }
112 -
113 - /**
114 - * Get all public hierarchical taxonomies.
115 - *
116 - * @return array
117 - */
118 - private function get_taxonomies() : array {
119 - return get_taxonomies( array(
120 - 'public' => true,
121 - 'hierarchical' => true,
122 - ), 'names' );
123 - }
124 -
125 - /**
126 - * Register term meta fields.
127 - *
128 - * @return void
129 - */
130 - public function register_term_meta() {
131 - $taxonomies = $this->get_taxonomies();
132 - foreach ( $taxonomies as $taxonomy ) {
133 - register_term_meta( $taxonomy, 'passster_activate_protection', array(
134 - 'type' => 'boolean',
135 - 'single' => true,
136 - 'show_in_rest' => false,
137 - 'sanitize_callback' => 'rest_sanitize_boolean',
138 - ) );
139 - register_term_meta( $taxonomy, 'passster_password', array(
140 - 'type' => 'string',
141 - 'single' => true,
142 - 'show_in_rest' => false,
143 - 'sanitize_callback' => 'sanitize_text_field',
144 - ) );
145 - register_term_meta( $taxonomy, 'passster_redirect_url', array(
146 - 'type' => 'string',
147 - 'single' => true,
148 - 'show_in_rest' => false,
149 - 'sanitize_callback' => 'esc_url_raw',
150 - ) );
151 - register_term_meta( $taxonomy, 'passster_headline', array(
152 - 'type' => 'string',
153 - 'single' => true,
154 - 'show_in_rest' => false,
155 - 'sanitize_callback' => 'sanitize_text_field',
156 - ) );
157 - register_term_meta( $taxonomy, 'passster_instruction', array(
158 - 'type' => 'string',
159 - 'single' => true,
160 - 'show_in_rest' => false,
161 - 'sanitize_callback' => 'wp_kses_post',
162 - ) );
163 - register_term_meta( $taxonomy, 'passster_placeholder', array(
164 - 'type' => 'string',
165 - 'single' => true,
166 - 'show_in_rest' => false,
167 - 'sanitize_callback' => 'sanitize_text_field',
168 - ) );
169 - register_term_meta( $taxonomy, 'passster_button', array(
170 - 'type' => 'string',
171 - 'single' => true,
172 - 'show_in_rest' => false,
173 - 'sanitize_callback' => 'sanitize_text_field',
174 - ) );
175 - register_term_meta( $taxonomy, 'passster_activate_overwrite_defaults', array(
176 - 'type' => 'boolean',
177 - 'single' => true,
178 - 'show_in_rest' => false,
179 - 'sanitize_callback' => 'rest_sanitize_boolean',
180 - ) );
181 - register_term_meta( $taxonomy, 'passster_activate_misc_settings', array(
182 - 'type' => 'boolean',
183 - 'single' => true,
184 - 'show_in_rest' => false,
185 - 'sanitize_callback' => 'rest_sanitize_boolean',
186 - ) );
187 - }
188 - }
189 -
190 - /**
191 - * Register hooks for each taxonomy (form fields, columns, row actions).
192 - *
193 - * @return void
194 - */
195 - public function register_taxonomy_hooks() {
196 - $taxonomies = $this->get_taxonomies();
197 - foreach ( $taxonomies as $taxonomy ) {
198 - // Add/Edit form fields.
199 - add_action( "{$taxonomy}_add_form_fields", array($this, 'add_form_fields') );
200 - add_action(
201 - "{$taxonomy}_edit_form_fields",
202 - array($this, 'edit_form_fields'),
203 - 10,
204 - 2
205 - );
206 - // Save term meta (only on create; edit uses React + REST API).
207 - add_action( "created_{$taxonomy}", array($this, 'save_term_meta') );
208 - // Admin columns.
209 - add_filter( "manage_edit-{$taxonomy}_columns", array($this, 'add_columns') );
210 - add_filter(
211 - "manage_{$taxonomy}_custom_column",
212 - array($this, 'render_column'),
213 - 10,
214 - 3
215 - );
216 - // Row actions.
217 - add_filter(
218 - "{$taxonomy}_row_actions",
219 - array($this, 'add_row_actions'),
220 - 10,
221 - 2
222 - );
223 - }
224 - // Quick edit modal UI.
225 - add_action( 'admin_footer-edit-tags.php', array($this, 'quick_edit_ui') );
226 - }
227 -
228 - // -------------------------------------------------------------------------
229 - // Add Form Fields (Add New Term screen)
230 - // -------------------------------------------------------------------------
231 - /**
232 - * Render React container on the "Add New" term form.
233 - * The actual UI is rendered by TermMetabox.jsx.
234 - * Hidden inputs are managed by React to submit data with the form.
235 - *
236 - * @param string $taxonomy Current taxonomy slug.
237 - * @return void
238 - */
239 - public function add_form_fields( $taxonomy ) {
240 - ?>
241 - <div class="form-field passster-term-fields">
242 - <div id="passster-term-metabox-add"></div>
243 - </div>
244 - <?php
245 - }
246 -
247 - // -------------------------------------------------------------------------
248 - // Edit Form Fields (Edit Term screen) — React-based UI
249 - // -------------------------------------------------------------------------
250 - /**
251 - * Render React container on the "Edit" term form.
252 - * The actual UI is rendered by TermMetabox.jsx via the React app.
253 - *
254 - * @param \WP_Term $term Current term object.
255 - * @param string $taxonomy Current taxonomy slug.
256 - * @return void
257 - */
258 - public function edit_form_fields( $term, $taxonomy ) {
259 - ?>
260 - <tr class="form-field">
261 - <th scope="row" colspan="2">
262 - <h3 style="margin:0;"><?php
263 - esc_html_e( 'Passster Protection', 'content-protector' );
264 - ?></h3>
265 - </th>
266 - </tr>
267 - <tr class="form-field">
268 - <td colspan="2">
269 - <div id="passster-term-metabox"></div>
270 - </td>
271 - </tr>
272 - <?php
273 - }
274 -
275 - // -------------------------------------------------------------------------
276 - // Enqueue React Scripts for Term Edit
277 - // -------------------------------------------------------------------------
278 - /**
279 - * Enqueue React scripts on term edit pages.
280 - *
281 - * @return void
282 - */
283 - public function add_term_meta_scripts() {
284 - $screen = get_current_screen();
285 - if ( !$screen || !in_array( $screen->base, array('term', 'edit-tags'), true ) ) {
286 - return;
287 - }
288 - $taxonomies = $this->get_taxonomies();
289 - if ( !in_array( $screen->taxonomy, $taxonomies, true ) ) {
290 - return;
291 - }
292 - wp_enqueue_script(
293 - 'passster-term-meta-settings',
294 - PASSSTER_URL . '/inc/admin/build/index.js',
295 - array(
296 - 'wp-api',
297 - 'wp-components',
298 - 'wp-element',
299 - 'wp-api-fetch',
300 - 'wp-data',
301 - 'wp-i18n'
302 - ),
303 - PASSSTER_VERSION,
304 - true
305 - );
306 - // On edit (term) page, get the term ID. On add new (edit-tags) page, term_id is 0.
307 - if ( 'term' === $screen->base ) {
308 - $term_id = ( isset( $_GET['tag_ID'] ) ? absint( $_GET['tag_ID'] ) : 0 );
309 - } else {
310 - $term_id = 0;
311 - }
312 - $options = get_option( 'passster' );
313 - $meta = array(
314 - 'passster_activate_protection' => get_term_meta( $term_id, 'passster_activate_protection', true ),
315 - 'passster_protection_type' => get_term_meta( $term_id, 'passster_protection_type', true ),
316 - 'passster_password' => get_term_meta( $term_id, 'passster_password', true ),
317 - 'passster_headline' => get_term_meta( $term_id, 'passster_headline', true ),
318 - 'passster_instruction' => get_term_meta( $term_id, 'passster_instruction', true ),
319 - 'passster_placeholder' => get_term_meta( $term_id, 'passster_placeholder', true ),
320 - 'passster_button' => get_term_meta( $term_id, 'passster_button', true ),
321 - 'passster_redirect_url' => get_term_meta( $term_id, 'passster_redirect_url', true ),
322 - 'passster_activate_overwrite_defaults' => get_term_meta( $term_id, 'passster_activate_overwrite_defaults', true ),
323 - 'passster_activate_misc_settings' => get_term_meta( $term_id, 'passster_activate_misc_settings', true ),
324 - );
325 - $args = array(
326 - 'screen' => 'term-meta',
327 - 'is_pro' => \passster_fs()->is_plan_or_trial__premium_only( 'pro' ),
328 - 'meta' => $meta,
329 - 'term_id' => $term_id,
330 - );
331 - if ( isset( $options['password_length'] ) ) {
332 - $args['password_length'] = esc_html( $options['password_length'] );
333 - } else {
334 - $args['password_length'] = 12;
335 - }
336 - if ( isset( $options['include_uppercase'] ) ) {
337 - $args['include_uppercase'] = esc_html( $options['include_uppercase'] );
338 - } else {
339 - $args['include_uppercase'] = true;
340 - }
341 - if ( isset( $options['include_numbers'] ) ) {
342 - $args['include_numbers'] = esc_html( $options['include_numbers'] );
343 - } else {
344 - $args['include_numbers'] = true;
345 - }
346 - if ( isset( $options['include_symbols'] ) ) {
347 - $args['include_symbols'] = esc_html( $options['include_symbols'] );
348 - } else {
349 - $args['include_symbols'] = true;
350 - }
351 - wp_localize_script( 'passster-term-meta-settings', 'options', $args );
352 - if ( function_exists( 'wp_set_script_translations' ) ) {
353 - wp_set_script_translations( 'passster-term-meta-settings', 'content-protector', PASSSTER_PATH . '/languages' );
354 - }
355 - wp_enqueue_style( 'passster-term-meta-style', PASSSTER_URL . '/inc/admin/build/index.css', array('wp-components') );
356 - }
357 -
358 - // -------------------------------------------------------------------------
359 - // REST API Endpoints for Term Meta
360 - // -------------------------------------------------------------------------
361 - /**
362 - * Register REST API routes for term meta.
363 - *
364 - * @return void
365 - */
366 - public function rest_api_init() {
367 - register_rest_route( 'passster/v1', '/term-meta', array(
368 - 'methods' => 'POST',
369 - 'callback' => array($this, 'save_term_meta_rest'),
370 - 'permission_callback' => function () {
371 - return current_user_can( apply_filters( 'passster_user_capability', 'manage_options' ) );
372 - },
373 - ) );
374 - register_rest_route( 'passster/v1', '/term-meta', array(
375 - 'methods' => 'GET',
376 - 'callback' => array($this, 'get_term_meta_rest'),
377 - 'permission_callback' => function () {
378 - return current_user_can( apply_filters( 'passster_user_capability', 'manage_options' ) );
379 - },
380 - ) );
381 - register_rest_route( 'passster/v1', '/term-password-lists', array(
382 - 'methods' => 'GET',
383 - 'callback' => array($this, 'get_term_password_lists_rest'),
384 - 'permission_callback' => function () {
385 - return current_user_can( apply_filters( 'passster_user_capability', 'manage_options' ) );
386 - },
387 - ) );
388 - }
389 -
390 - /**
391 - * Save term meta via REST API.
392 - *
393 - * @param \WP_REST_Request $request Request object.
394 - * @return string
395 - */
396 - public function save_term_meta_rest( \WP_REST_Request $request ) {
397 - $params = $request->get_params();
398 - $term_id = ( isset( $params['term_id'] ) ? absint( $params['term_id'] ) : 0 );
399 - $meta_key = ( isset( $params['meta_key'] ) ? sanitize_key( $params['meta_key'] ) : '' );
400 - if ( !$term_id || !$meta_key ) {
401 - return wp_json_encode( array(
402 - 'status' => 400,
403 - 'message' => 'Missing term_id or meta_key',
404 - ) );
405 - }
406 - if ( isset( $params['meta_value'] ) ) {
407 - if ( 'passster_instruction' === $meta_key ) {
408 - $meta_value = wp_kses_post( $params['meta_value'] );
409 - } else {
410 - $meta_value = sanitize_text_field( $params['meta_value'] );
411 - }
412 - update_term_meta( $term_id, $meta_key, $meta_value );
413 - } elseif ( isset( $params['delete'] ) ) {
414 - delete_term_meta( $term_id, $meta_key );
415 - }
416 - return wp_json_encode( array(
417 - 'status' => 200,
418 - 'message' => 'Ok',
419 - ) );
420 - }
421 -
422 - /**
423 - * Get term meta via REST API.
424 - *
425 - * @param \WP_REST_Request $request Request object.
426 - * @return string
427 - */
428 - public function get_term_meta_rest( \WP_REST_Request $request ) {
429 - $params = $request->get_params();
430 - $term_id = ( isset( $params['term_id'] ) ? absint( $params['term_id'] ) : 0 );
431 - $meta_key = ( isset( $params['meta_key'] ) ? sanitize_key( $params['meta_key'] ) : '' );
432 - if ( !$term_id || !$meta_key ) {
433 - return wp_json_encode( array(
434 - 'status' => 400,
435 - 'message' => 'Missing term_id or meta_key',
436 - 'data' => '',
437 - ) );
438 - }
439 - $meta = get_term_meta( $term_id, $meta_key, true );
440 - if ( !empty( $meta ) ) {
441 - return wp_json_encode( array(
442 - 'status' => 200,
443 - 'message' => 'Ok',
444 - 'data' => $meta,
445 - ) );
446 - }
447 - return wp_json_encode( array(
448 - 'status' => 400,
449 - 'message' => 'Empty value',
450 - 'data' => '',
451 - ) );
452 - }
453 -
454 - /**
455 - * Get password lists for term meta dropdowns via REST API.
456 - *
457 - * @return array
458 - */
459 - public function get_term_password_lists_rest() {
460 - $lists = $this->get_password_lists();
461 - $fetched_lists = array();
462 - foreach ( $lists as $list ) {
463 - $item = new \stdClass();
464 - $item->title = $list->post_title;
465 - $item->id = $list->ID;
466 - $fetched_lists[] = $item;
467 - }
468 - return $fetched_lists;
469 - }
470 -
471 - // -------------------------------------------------------------------------
472 - // Save Term Meta
473 - // -------------------------------------------------------------------------
474 - /**
475 - * Save term meta on create/edit.
476 - *
477 - * @param int $term_id Term ID.
478 - * @return void
479 - */
480 - public function save_term_meta( $term_id ) {
481 - if ( !current_user_can( 'manage_categories' ) ) {
482 - return;
483 - }
484 - $activate = ( !empty( $_POST['passster_activate_protection'] ) ? 1 : 0 );
485 - update_term_meta( $term_id, 'passster_activate_protection', $activate );
486 - if ( $activate ) {
487 - if ( isset( $_POST['passster_password'] ) ) {
488 - update_term_meta( $term_id, 'passster_password', sanitize_text_field( wp_unslash( $_POST['passster_password'] ) ) );
489 - }
490 - if ( isset( $_POST['passster_redirect_url'] ) ) {
491 - update_term_meta( $term_id, 'passster_redirect_url', esc_url_raw( wp_unslash( $_POST['passster_redirect_url'] ) ) );
492 - }
493 - // Overwrite defaults.
494 - if ( isset( $_POST['passster_headline'] ) ) {
495 - update_term_meta( $term_id, 'passster_headline', sanitize_text_field( wp_unslash( $_POST['passster_headline'] ) ) );
496 - }
497 - if ( isset( $_POST['passster_instruction'] ) ) {
498 - update_term_meta( $term_id, 'passster_instruction', wp_kses_post( wp_unslash( $_POST['passster_instruction'] ) ) );
499 - }
500 - if ( isset( $_POST['passster_placeholder'] ) ) {
501 - update_term_meta( $term_id, 'passster_placeholder', sanitize_text_field( wp_unslash( $_POST['passster_placeholder'] ) ) );
502 - }
503 - if ( isset( $_POST['passster_button'] ) ) {
504 - update_term_meta( $term_id, 'passster_button', sanitize_text_field( wp_unslash( $_POST['passster_button'] ) ) );
505 - }
506 - }
507 - }
508 -
509 - // -------------------------------------------------------------------------
510 - // Admin Columns
511 - // -------------------------------------------------------------------------
512 - /**
513 - * Add columns to taxonomy list table.
514 - *
515 - * @param array $columns Existing columns.
516 - * @return array
517 - */
518 - public function add_columns( array $columns ) : array {
519 - $columns['passster_password'] = __( 'Password', 'content-protector' );
520 - return $columns;
521 - }
522 -
523 - /**
524 - * Render column content for taxonomy list table.
525 - *
526 - * @param string $content Column content.
527 - * @param string $column_name Column name.
528 - * @param int $term_id Term ID.
529 - * @return string
530 - */
531 - public function render_column( $content, $column_name, $term_id ) {
532 - $activate = get_term_meta( $term_id, 'passster_activate_protection', true );
533 - if ( !$activate ) {
534 - if ( 'passster_password' === $column_name || 'passster_protection_type' === $column_name ) {
535 - return '-';
536 - }
537 - return $content;
538 - }
539 - $protection_type = ( get_term_meta( $term_id, 'passster_protection_type', true ) ?: 'password' );
540 - switch ( $column_name ) {
541 - case 'passster_password':
542 - return esc_html( get_term_meta( $term_id, 'passster_password', true ) );
543 - break;
544 - case 'passster_protection_type':
545 - $types = array(
546 - 'password' => __( 'Password', 'content-protector' ),
547 - 'passwords' => __( 'Passwords', 'content-protector' ),
548 - 'password_list' => __( 'Password List', 'content-protector' ),
549 - 'password_lists' => __( 'Password Lists', 'content-protector' ),
550 - 'recaptcha' => __( 'reCaptcha', 'content-protector' ),
551 - 'turnstile' => __( 'Turnstile', 'content-protector' ),
552 - );
553 - return esc_html( $types[$protection_type] ?? $protection_type );
554 - }
555 - return $content;
556 - }
557 -
558 - // -------------------------------------------------------------------------
559 - // Row Actions
560 - // -------------------------------------------------------------------------
561 - /**
562 - * Add Quick Settings link to term row actions.
563 - *
564 - * @param array $actions Existing actions.
565 - * @param \WP_Term $term Term object.
566 - * @return array
567 - */
568 - public function add_row_actions( $actions, $term ) {
569 - if ( current_user_can( 'manage_categories' ) ) {
570 - $actions['passster_quick_settings'] = sprintf(
571 - '<a href="#" class="passster-category-quick-settings" data-term-id="%d" data-nonce="%s">%s</a>',
572 - $term->term_id,
573 - wp_create_nonce( 'passster_quick_edit_cat_' . $term->term_id ),
574 - __( 'Quick Settings', 'content-protector' )
575 - );
576 - }
577 - return $actions;
578 - }
579 -
580 - // -------------------------------------------------------------------------
581 - // Quick Edit Modal UI
582 - // -------------------------------------------------------------------------
583 - /**
584 - * Output quick edit modal HTML, CSS and JS.
585 - *
586 - * @return void
587 - */
588 - public function quick_edit_ui() {
589 - $screen = get_current_screen();
590 - if ( !$screen ) {
591 - return;
592 - }
593 - $taxonomies = $this->get_taxonomies();
594 - if ( !in_array( $screen->taxonomy, $taxonomies, true ) ) {
595 - return;
596 - }
597 - $is_pro = \passster_fs()->is_plan_or_trial__premium_only( 'pro' );
598 - $lists = ( $is_pro ? $this->get_password_lists() : array() );
599 - ?>
600 - <div id="passster-cat-quick-edit-modal" style="display:none;">
601 - <div class="passster-cat-qe-backdrop"></div>
602 - <div class="passster-cat-qe-panel">
603 - <div class="passster-cat-qe-header">
604 - <h3><?php
605 - esc_html_e( 'Passster - Quick Settings', 'content-protector' );
606 - ?></h3>
607 - <button type="button" class="passster-cat-qe-close">&times;</button>
608 - </div>
609 - <div class="passster-cat-qe-body">
610 - <input type="hidden" id="passster-cat-qe-term-id" value="">
611 - <input type="hidden" id="passster-cat-qe-nonce" value="">
612 -
613 - <p class="passster-cat-qe-field">
614 - <label>
615 - <input type="checkbox" id="passster-cat-qe-activate">
616 - <?php
617 - esc_html_e( 'Activate Protection', 'content-protector' );
618 - ?>
619 - </label>
620 - </p>
621 -
622 - <div class="passster-cat-qe-settings" style="display:none;">
623 - <?php
624 - if ( $is_pro ) {
625 - ?>
626 - <p class="passster-cat-qe-field">
627 - <label for="passster-cat-qe-type"><?php
628 - esc_html_e( 'Protection Type', 'content-protector' );
629 - ?></label>
630 - <select id="passster-cat-qe-type">
631 - <option value="password"><?php
632 - esc_html_e( 'Password', 'content-protector' );
633 - ?></option>
634 - <option value="passwords"><?php
635 - esc_html_e( 'Passwords', 'content-protector' );
636 - ?></option>
637 - <option value="password_list"><?php
638 - esc_html_e( 'Password List', 'content-protector' );
639 - ?></option>
640 - <option value="password_lists"><?php
641 - esc_html_e( 'Password Lists', 'content-protector' );
642 - ?></option>
643 - <option value="recaptcha">reCAPTCHA</option>
644 - <option value="turnstile">Turnstile</option>
645 - </select>
646 - </p>
647 - <?php
648 - }
649 - ?>
650 -
651 - <p class="passster-cat-qe-field passster-cat-qe-password-field">
652 - <label for="passster-cat-qe-password"><?php
653 - esc_html_e( 'Password', 'content-protector' );
654 - ?></label>
655 - <input type="text" id="passster-cat-qe-password" value="">
656 - </p>
657 -
658 - <?php
659 - if ( $is_pro ) {
660 - ?>
661 - <p class="passster-cat-qe-field passster-cat-qe-passwords-field" style="display:none;">
662 - <label for="passster-cat-qe-passwords"><?php
663 - esc_html_e( 'Passwords (comma-separated)', 'content-protector' );
664 - ?></label>
665 - <input type="text" id="passster-cat-qe-passwords" value="">
666 - </p>
667 -
668 - <p class="passster-cat-qe-field passster-cat-qe-list-field" style="display:none;">
669 - <label for="passster-cat-qe-list"><?php
670 - esc_html_e( 'Password List', 'content-protector' );
671 - ?></label>
672 - <select id="passster-cat-qe-list">
673 - <option value="0"><?php
674 - esc_html_e( '— Select —', 'content-protector' );
675 - ?></option>
676 - <?php
677 - foreach ( $lists as $list ) {
678 - ?>
679 - <option value="<?php
680 - echo esc_attr( $list->ID );
681 - ?>"><?php
682 - echo esc_html( $list->post_title );
683 - ?></option>
684 - <?php
685 - }
686 - ?>
687 - </select>
688 - </p>
689 -
690 - <p class="passster-cat-qe-field passster-cat-qe-lists-field" style="display:none;">
691 - <label for="passster-cat-qe-lists"><?php
692 - esc_html_e( 'Password Lists (comma-separated IDs)', 'content-protector' );
693 - ?></label>
694 - <input type="text" id="passster-cat-qe-lists" value="">
695 - </p>
696 - <?php
697 - }
698 - ?>
699 - </div>
700 - </div>
701 - <div class="passster-cat-qe-footer">
702 - <button type="button" class="button passster-cat-qe-cancel"><?php
703 - esc_html_e( 'Cancel', 'content-protector' );
704 - ?></button>
705 - <button type="button" class="button button-primary passster-cat-qe-save"><?php
706 - esc_html_e( 'Save', 'content-protector' );
707 - ?></button>
708 - <span class="spinner"></span>
709 - </div>
710 - </div>
711 - </div>
712 -
713 - <style>
714 - .passster-cat-qe-backdrop {
715 - position: fixed; top: 0; left: 0; right: 0; bottom: 0;
716 - background: rgba(0,0,0,0.5); z-index: 100000;
717 - }
718 - .passster-cat-qe-panel {
719 - position: fixed; top: 50%; left: 50%;
720 - transform: translate(-50%,-50%);
721 - background: #fff; border-radius: 8px;
722 - box-shadow: 0 4px 20px rgba(0,0,0,0.2);
723 - width: 420px; max-width: 90vw; z-index: 100001;
724 - }
725 - .passster-cat-qe-header {
726 - display: flex; justify-content: space-between; align-items: center;
727 - padding: 16px 20px; border-bottom: 1px solid #ddd;
728 - }
729 - .passster-cat-qe-header h3 { margin: 0; font-size: 16px; }
730 - .passster-cat-qe-close {
731 - background: none; border: none; font-size: 24px;
732 - cursor: pointer; color: #666; padding: 0; line-height: 1;
733 - }
734 - .passster-cat-qe-close:hover { color: #d63638; }
735 - .passster-cat-qe-body { padding: 20px; }
736 - .passster-cat-qe-field { margin-bottom: 14px; }
737 - .passster-cat-qe-field label { display: block; margin-bottom: 4px; font-weight: 600; }
738 - .passster-cat-qe-field input[type="text"],
739 - .passster-cat-qe-field input[type="url"],
740 - .passster-cat-qe-field select { width: 100%; }
741 - .passster-cat-qe-footer {
742 - display: flex; justify-content: flex-end; align-items: center;
743 - gap: 8px; padding: 16px 20px;
744 - border-top: 1px solid #ddd; background: #f6f7f7;
745 - border-radius: 0 0 8px 8px;
746 - }
747 - .passster-cat-qe-footer .spinner { float: none; margin: 0; }
748 - </style>
749 -
750 - <script>
751 - jQuery(function($) {
752 - var $modal = $('#passster-cat-quick-edit-modal');
753 - var $activate = $('#passster-cat-qe-activate');
754 - var $typeSelect = $('#passster-cat-qe-type');
755 - var isPro = <?php
756 - echo ( $is_pro ? 'true' : 'false' );
757 - ?>;
758 -
759 - function toggleTypeFields() {
760 - if (!isPro) return;
761 - var type = $typeSelect.val();
762 - $('.passster-cat-qe-password-field').toggle(type === 'password');
763 - $('.passster-cat-qe-passwords-field').toggle(type === 'passwords');
764 - $('.passster-cat-qe-list-field').toggle(type === 'password_list');
765 - $('.passster-cat-qe-lists-field').toggle(type === 'password_lists');
766 - }
767 -
768 - $activate.on('change', function() {
769 - $('.passster-cat-qe-settings').toggle(this.checked);
770 - });
771 -
772 - if (isPro) {
773 - $typeSelect.on('change', toggleTypeFields);
774 - }
775 -
776 - // Open modal.
777 - $(document).on('click', '.passster-category-quick-settings', function(e) {
778 - e.preventDefault();
779 - var termId = $(this).data('term-id');
780 - var nonce = $(this).data('nonce');
781 -
782 - $('#passster-cat-qe-term-id').val(termId);
783 - $('#passster-cat-qe-nonce').val(nonce);
784 -
785 - $.post(ajaxurl, {
786 - action: 'passster_quick_edit_category',
787 - term_id: termId,
788 - nonce: nonce,
789 - edit_action: 'get'
790 - }, function(response) {
791 - if (response.success) {
792 - var data = response.data;
793 - $activate.prop('checked', !!data.activate);
794 - $('.passster-cat-qe-settings').toggle(!!data.activate);
795 - $('#passster-cat-qe-password').val(data.password || '');
796 -
797 - if (isPro) {
798 - $typeSelect.val(data.protection_type || 'password');
799 - $('#passster-cat-qe-passwords').val(data.passwords || '');
800 - $('#passster-cat-qe-list').val(data.password_list || 0);
801 - $('#passster-cat-qe-lists').val(data.password_lists || '');
802 - toggleTypeFields();
803 - }
804 -
805 - $modal.show();
806 - }
807 - });
808 - });
809 -
810 - // Close modal.
811 - $modal.on('click', '.passster-cat-qe-close, .passster-cat-qe-cancel, .passster-cat-qe-backdrop', function() {
812 - $modal.hide();
813 - });
814 -
815 - // Save.
816 - $modal.on('click', '.passster-cat-qe-save', function() {
817 - var $btn = $(this);
818 - var $spinner = $modal.find('.spinner');
819 -
820 - $btn.prop('disabled', true);
821 - $spinner.addClass('is-active');
822 -
823 - var postData = {
824 - action: 'passster_quick_edit_category',
825 - term_id: $('#passster-cat-qe-term-id').val(),
826 - nonce: $('#passster-cat-qe-nonce').val(),
827 - edit_action: 'save',
828 - activate: $activate.is(':checked') ? 1 : 0,
829 - password: $('#passster-cat-qe-password').val()
830 - };
831 -
832 - if (isPro) {
833 - postData.protection_type = $typeSelect.val();
834 - postData.passwords = $('#passster-cat-qe-passwords').val();
835 - postData.password_list = $('#passster-cat-qe-list').val();
836 - postData.password_lists = $('#passster-cat-qe-lists').val();
837 - }
838 -
839 - $.post(ajaxurl, postData, function(response) {
840 - $btn.prop('disabled', false);
841 - $spinner.removeClass('is-active');
842 -
843 - if (response.success) {
844 - $modal.hide();
845 - location.reload();
846 - } else {
847 - alert(response.data.message || 'Error saving settings.');
848 - }
849 - });
850 - });
851 -
852 - // ESC to close.
853 - $(document).on('keydown', function(e) {
854 - if (e.key === 'Escape' && $modal.is(':visible')) {
855 - $modal.hide();
856 - }
857 - });
858 - });
859 - </script>
860 -
861 - <?php
862 - }
863 -
864 - // -------------------------------------------------------------------------
865 - // AJAX Quick Edit Handler
866 - // -------------------------------------------------------------------------
867 - /**
868 - * AJAX handler for quick edit category settings.
869 - *
870 - * @return void
871 - */
872 - public function ajax_quick_edit_category() {
873 - $term_id = ( isset( $_POST['term_id'] ) ? absint( $_POST['term_id'] ) : 0 );
874 - $nonce = ( isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '' );
875 - $action = ( isset( $_POST['edit_action'] ) ? sanitize_text_field( wp_unslash( $_POST['edit_action'] ) ) : 'get' );
876 - if ( !wp_verify_nonce( $nonce, 'passster_quick_edit_cat_' . $term_id ) ) {
877 - wp_send_json_error( array(
878 - 'message' => __( 'Security check failed.', 'content-protector' ),
879 - ) );
880 - }
881 - if ( !current_user_can( 'manage_categories' ) ) {
882 - wp_send_json_error( array(
883 - 'message' => __( 'Permission denied.', 'content-protector' ),
884 - ) );
885 - }
886 - $term = get_term( $term_id );
887 - if ( !$term || is_wp_error( $term ) ) {
888 - wp_send_json_error( array(
889 - 'message' => __( 'Invalid term.', 'content-protector' ),
890 - ) );
891 - }
892 - if ( 'get' === $action ) {
893 - $data = array(
894 - 'term_id' => $term_id,
895 - 'activate' => (bool) get_term_meta( $term_id, 'passster_activate_protection', true ),
896 - 'password' => get_term_meta( $term_id, 'passster_password', true ),
897 - 'protection_type' => ( get_term_meta( $term_id, 'passster_protection_type', true ) ?: 'password' ),
898 - 'passwords' => get_term_meta( $term_id, 'passster_passwords', true ),
899 - 'password_list' => get_term_meta( $term_id, 'passster_password_list', true ),
900 - 'password_lists' => get_term_meta( $term_id, 'passster_password_lists', true ),
901 - );
902 - wp_send_json_success( $data );
903 - }
904 - if ( 'save' === $action ) {
905 - $activate = ( isset( $_POST['activate'] ) ? absint( $_POST['activate'] ) : 0 );
906 - update_term_meta( $term_id, 'passster_activate_protection', $activate );
907 - if ( $activate ) {
908 - $password = ( isset( $_POST['password'] ) ? sanitize_text_field( wp_unslash( $_POST['password'] ) ) : '' );
909 - update_term_meta( $term_id, 'passster_password', $password );
910 - }
911 - wp_send_json_success( array(
912 - 'message' => __( 'Settings saved.', 'content-protector' ),
913 - ) );
914 - }
915 - wp_send_json_error( array(
916 - 'message' => __( 'Invalid action.', 'content-protector' ),
917 - ) );
918 - }
919 -
920 - // -------------------------------------------------------------------------
921 - // Frontend: Protect Posts in Protected Categories
922 - // -------------------------------------------------------------------------
923 - /**
924 - * Filter the_content for posts belonging to a protected category.
925 - * Runs at priority 11, after PS_Public (priority 10).
926 - *
927 - * @param string $content Post content.
928 - * @return string
929 - */
930 - public function filter_content_by_category( $content ) {
931 - if ( is_admin() ) {
932 - return $content;
933 - }
934 - if ( is_category() || is_tax() ) {
935 - return $content;
936 - }
937 - $post_id = get_the_id();
938 - if ( !$post_id ) {
939 - return $content;
940 - }
941 - if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
942 - return $content;
943 - }
944 - $term_data = $this->get_active_category_lock( $post_id );
945 - if ( !$term_data ) {
946 - return $content;
947 - }
948 - $shortcode = $this->build_shortcode_from_term( $term_data['term_id'], $content );
949 - return do_shortcode( $shortcode );
950 - }
951 -
952 - /**
953 - * @param int $post_id Post ID.
954 - * @return array|null
955 - */
956 - public function get_active_category_lock( int $post_id ) {
957 - $post_protection = get_post_meta( $post_id, 'passster_activate_protection', true );
958 - if ( $post_protection ) {
959 - return null;
960 - }
961 - $term_data = $this->get_protected_term_for_post( $post_id );
962 - if ( !$term_data ) {
963 - return null;
964 - }
965 - $atts = $this->build_atts_from_term( $term_data['term_id'] );
966 - if ( PS_Conditional::is_valid( $atts ) ) {
967 - return null;
968 - }
969 - return $term_data;
970 - }
971 -
972 - /**
973 - * Protect category archive pages.
974 - *
975 - * @return void
976 - */
977 - public function protect_category_archive() {
978 - if ( is_admin() ) {
979 - return;
980 - }
981 - // WooCommerce single product: if the product belongs to a locked product_cat,
982 - // suppress all WooCommerce product hooks and show the password form inline.
983 - if ( class_exists( 'WooCommerce' ) && is_singular( 'product' ) ) {
984 - $post_id = get_the_ID();
985 - $term_data = ( $post_id ? $this->get_protected_term_for_post( $post_id ) : null );
986 - if ( $term_data && 'product_cat' === $term_data['taxonomy'] ) {
987 - $atts = $this->build_atts_from_term( $term_data['term_id'] );
988 - if ( !PS_Conditional::is_valid( $atts ) ) {
989 - $shortcode = $this->build_shortcode_from_term( $term_data['term_id'], '' );
990 - self::$woo_protected_form = do_shortcode( $shortcode );
991 - // Replace the entire single-product template with the protected form.
992 - add_filter( 'template_include', array($this, 'use_protected_category_template'), 99 );
993 - }
994 - }
995 - return;
996 - }
997 - if ( !is_category() && !is_tax() ) {
998 - return;
999 - }
1000 - $term = get_queried_object();
1001 - if ( !$term || !is_a( $term, 'WP_Term' ) ) {
1002 - return;
1003 - }
1004 - // Only handle hierarchical taxonomies.
1005 - $taxonomy_obj = get_taxonomy( $term->taxonomy );
1006 - if ( !$taxonomy_obj || !$taxonomy_obj->hierarchical || !$taxonomy_obj->public ) {
1007 - return;
1008 - }
1009 - // Check the term itself and then its ancestors for protection.
1010 - $protected_term_id = null;
1011 - $ids_to_check = array_merge( array($term->term_id), get_ancestors( $term->term_id, $term->taxonomy, 'taxonomy' ) );
1012 - foreach ( $ids_to_check as $check_id ) {
1013 - if ( get_term_meta( $check_id, 'passster_activate_protection', true ) ) {
1014 - $protected_term_id = $check_id;
1015 - break;
1016 - }
1017 - }
1018 - if ( !$protected_term_id ) {
1019 - return;
1020 - }
1021 - // Use the protected term (may be an ancestor) for all subsequent meta lookups.
1022 - $term = get_term( $protected_term_id );
1023 - if ( !$term || is_wp_error( $term ) ) {
1024 - return;
1025 - }
1026 - $atts = $this->build_atts_from_term( $term->term_id );
1027 - if ( PS_Conditional::is_valid( $atts ) ) {
1028 - return;
1029 - }
1030 - // Check for redirect.
1031 - $redirect = get_term_meta( $term->term_id, 'passster_redirect_url', true );
1032 - if ( !empty( $redirect ) ) {
1033 - wp_redirect( esc_url_raw( $redirect ) );
1034 - exit;
1035 - }
1036 - $shortcode = $this->build_shortcode_from_term( $term->term_id, '' );
1037 - $form_html = do_shortcode( $shortcode );
1038 - // WooCommerce product category archives use their own template system and never
1039 - // call the_content() on the main query posts, so the virtual-post trick doesn't work.
1040 - // Instead, inject the form via WooCommerce's own template hooks and suppress the product loop.
1041 - if ( class_exists( 'WooCommerce' ) && 'product_cat' === $term->taxonomy ) {
1042 - self::$woo_protected_form = $form_html;
1043 - // Replace WooCommerce's entire archive template with a minimal one that shows only the form.
1044 - add_filter( 'template_include', array($this, 'use_protected_category_template'), 99 );
1045 - return;
1046 - }
1047 - self::$protected_form = $form_html;
1048 - if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) {
1049 - // FSE/block themes: let the block template render normally; replace only the
1050 - // core/query block (the post loop) with the password form. This preserves the
1051 - // theme's own header/footer block templates.
1052 - add_filter(
1053 - 'render_block',
1054 - array($this, 'replace_query_block_with_form'),
1055 - 10,
1056 - 2
1057 - );
1058 - } else {
1059 - // Classic themes: swap the archive template for a minimal protected-category
1060 - // template that calls get_header()/get_footer() from the active theme.
1061 - add_filter( 'template_include', array($this, 'use_protected_regular_category_template'), 99 );
1062 - }
1063 - }
1064 -
1065 - // -------------------------------------------------------------------------
1066 - // Helper Methods
1067 - // -------------------------------------------------------------------------
1068 - /**
1069 - * Get the first protected term for a given post.
1070 - *
1071 - * @param int $post_id Post ID.
1072 - * @return array|null Array with 'term_id' and 'taxonomy', or null.
1073 - */
1074 - public function get_protected_term_for_post( int $post_id ) {
1075 - $taxonomies = $this->get_taxonomies();
1076 - foreach ( $taxonomies as $taxonomy ) {
1077 - $terms = get_the_terms( $post_id, $taxonomy );
1078 - if ( !$terms || is_wp_error( $terms ) ) {
1079 - continue;
1080 - }
1081 - foreach ( $terms as $term ) {
1082 - // Check the term itself, then walk up its ancestor chain.
1083 - $ids_to_check = array_merge( array($term->term_id), get_ancestors( $term->term_id, $taxonomy, 'taxonomy' ) );
1084 - foreach ( $ids_to_check as $check_id ) {
1085 - $activate = get_term_meta( $check_id, 'passster_activate_protection', true );
1086 - if ( $activate ) {
1087 - return array(
1088 - 'term_id' => $check_id,
1089 - 'taxonomy' => $taxonomy,
1090 - );
1091 - }
1092 - }
1093 - }
1094 - }
1095 - return null;
1096 - }
1097 -
1098 - /**
1099 - * Build $atts array from term meta (for PS_Conditional::is_valid).
1100 - *
1101 - * @param int $term_id Term ID.
1102 - * @return array
1103 - */
1104 - private function build_atts_from_term( int $term_id ) : array {
1105 - $atts = array();
1106 - $atts['password'] = get_term_meta( $term_id, 'passster_password', true );
1107 - return $atts;
1108 - }
1109 -
1110 - /**
1111 - * Build a passster shortcode string from term meta.
1112 - *
1113 - * @param int $term_id Term ID.
1114 - * @param string $content Content to wrap.
1115 - * @return string
1116 - */
1117 - private function build_shortcode_from_term( int $term_id, string $content ) : string {
1118 - $options = get_option( 'passster' );
1119 - $shortcode = '[passster ';
1120 - $password = get_term_meta( $term_id, 'passster_password', true );
1121 - $shortcode .= 'password="' . esc_attr( $password ) . '" ';
1122 - $shortcode .= 'protection="full" ';
1123 - $shortcode .= 'term_id="' . $term_id . '" ';
1124 - // Redirect.
1125 - $redirect = get_term_meta( $term_id, 'passster_redirect_url', true );
1126 - if ( !empty( $redirect ) ) {
1127 - $shortcode .= 'redirect="' . esc_url( $redirect ) . '" ';
1128 - }
1129 - // Overwrite defaults.
1130 - $headline = get_term_meta( $term_id, 'passster_headline', true );
1131 - if ( !empty( $headline ) ) {
1132 - $shortcode .= 'headline="' . esc_attr( $headline ) . '" ';
1133 - }
1134 - $instruction = get_term_meta( $term_id, 'passster_instruction', true );
1135 - if ( !empty( $instruction ) ) {
1136 - $shortcode .= 'instruction="' . base64_encode( $instruction ) . '" ';
1137 - }
1138 - $placeholder = get_term_meta( $term_id, 'passster_placeholder', true );
1139 - if ( !empty( $placeholder ) ) {
1140 - $shortcode .= 'placeholder="' . esc_attr( $placeholder ) . '" ';
1141 - }
1142 - $button = get_term_meta( $term_id, 'passster_button', true );
1143 - if ( !empty( $button ) ) {
1144 - $shortcode .= 'button="' . esc_attr( $button ) . '" ';
1145 - }
1146 - $shortcode .= ']' . $content . '[/passster]';
1147 - return $shortcode;
1148 - }
1149 -
1150 - /**
1151 - * Get published password lists.
1152 - *
1153 - * @return array
1154 - */
1155 - private function get_password_lists() : array {
1156 - return get_posts( array(
1157 - 'post_type' => 'password_lists',
1158 - 'post_status' => 'publish',
1159 - 'posts_per_page' => -1,
1160 - ) );
1161 - }
1162 -
1163 -}
1 +<?php
2 +
3 +namespace passster;
4 +
5 +/**
6 + * Class to handle category/taxonomy protection.
7 + * Adds password protection to hierarchical taxonomy terms.
8 + */
9 +class PS_Category_Lock {
10 + /**
11 + * Contains instance or null
12 + *
13 + * @var object|null
14 + */
15 + private static $instance = null;
16 +
17 + /**
18 + * Rendered form HTML to pass to the protected WooCommerce template.
19 + *
20 + * @var string
21 + */
22 + private static $woo_protected_form = '';
23 +
24 + /**
25 + * Rendered form HTML to pass to the protected regular category template.
26 + *
27 + * @var string
28 + */
29 + private static $protected_form = '';
30 +
31 + /**
32 + * Get the stored protected form HTML (used by the WooCommerce override template).
33 + *
34 + * @return string
35 + */
36 + public static function get_woo_protected_form() : string {
37 + return self::$woo_protected_form;
38 + }
39 +
40 + /**
41 + * Get the stored protected form HTML (used by the regular category override template).
42 + *
43 + * @return string
44 + */
45 + public static function get_protected_form() : string {
46 + return self::$protected_form;
47 + }
48 +
49 + /**
50 + * Returns instance of PS_Category_Lock.
51 + *
52 + * @return object
53 + */
54 + public static function get_instance() {
55 + if ( null === self::$instance ) {
56 + self::$instance = new self();
57 + }
58 + return self::$instance;
59 + }
60 +
61 + /**
62 + * Constructor.
63 + */
64 + public function __construct() {
65 + add_action( 'init', array($this, 'register_term_meta'), 99 );
66 + add_action( 'init', array($this, 'register_taxonomy_hooks'), 99 );
67 + // REST API endpoints for React metabox.
68 + add_action( 'rest_api_init', array($this, 'rest_api_init') );
69 + // Enqueue React scripts on term edit pages.
70 + add_action( 'admin_enqueue_scripts', array($this, 'add_term_meta_scripts') );
71 + // AJAX handler for quick edit modal.
72 + add_action( 'wp_ajax_passster_quick_edit_category', array($this, 'ajax_quick_edit_category') );
73 + // Frontend protection.
74 + add_filter( 'the_content', array($this, 'filter_content_by_category'), 11 );
75 + add_filter( 'get_the_excerpt', array($this, 'filter_content_by_category'), 11 );
76 + add_action( 'template_redirect', array($this, 'protect_category_archive') );
77 + }
78 +
79 + /**
80 + * Swap WooCommerce's archive template for a minimal protected-category template.
81 + *
82 + * @param string $template Current template path.
83 + * @return string
84 + */
85 + public function use_protected_category_template( string $template ) : string {
86 + return plugin_dir_path( __FILE__ ) . 'templates/woo-category-protected.php';
87 + }
88 +
89 + /**
90 + * Swap the archive template for the protected regular-category template (classic themes only).
91 + *
92 + * @param string $template Current template path.
93 + * @return string
94 + */
95 + public function use_protected_regular_category_template( string $template ) : string {
96 + return plugin_dir_path( __FILE__ ) . 'templates/category-protected.php';
97 + }
98 +
99 + /**
100 + * Replace the core/query block output with the password form (FSE/block themes only).
101 + *
102 + * @param string $block_content Rendered block HTML.
103 + * @param array $block Block data.
104 + * @return string
105 + */
106 + public function replace_query_block_with_form( string $block_content, array $block ) : string {
107 + if ( 'core/query' !== $block['blockName'] ) {
108 + return $block_content;
109 + }
110 + return self::$protected_form;
111 + }
112 +
113 + /**
114 + * Get all public hierarchical taxonomies.
115 + *
116 + * @return array
117 + */
118 + private function get_taxonomies() : array {
119 + return get_taxonomies( array(
120 + 'public' => true,
121 + 'hierarchical' => true,
122 + ), 'names' );
123 + }
124 +
125 + /**
126 + * Register term meta fields.
127 + *
128 + * @return void
129 + */
130 + public function register_term_meta() {
131 + $taxonomies = $this->get_taxonomies();
132 + foreach ( $taxonomies as $taxonomy ) {
133 + register_term_meta( $taxonomy, 'passster_activate_protection', array(
134 + 'type' => 'boolean',
135 + 'single' => true,
136 + 'show_in_rest' => false,
137 + 'sanitize_callback' => 'rest_sanitize_boolean',
138 + ) );
139 + register_term_meta( $taxonomy, 'passster_password', array(
140 + 'type' => 'string',
141 + 'single' => true,
142 + 'show_in_rest' => false,
143 + 'sanitize_callback' => 'sanitize_text_field',
144 + ) );
145 + register_term_meta( $taxonomy, 'passster_redirect_url', array(
146 + 'type' => 'string',
147 + 'single' => true,
148 + 'show_in_rest' => false,
149 + 'sanitize_callback' => 'esc_url_raw',
150 + ) );
151 + register_term_meta( $taxonomy, 'passster_headline', array(
152 + 'type' => 'string',
153 + 'single' => true,
154 + 'show_in_rest' => false,
155 + 'sanitize_callback' => 'sanitize_text_field',
156 + ) );
157 + register_term_meta( $taxonomy, 'passster_instruction', array(
158 + 'type' => 'string',
159 + 'single' => true,
160 + 'show_in_rest' => false,
161 + 'sanitize_callback' => 'wp_kses_post',
162 + ) );
163 + register_term_meta( $taxonomy, 'passster_placeholder', array(
164 + 'type' => 'string',
165 + 'single' => true,
166 + 'show_in_rest' => false,
167 + 'sanitize_callback' => 'sanitize_text_field',
168 + ) );
169 + register_term_meta( $taxonomy, 'passster_button', array(
170 + 'type' => 'string',
171 + 'single' => true,
172 + 'show_in_rest' => false,
173 + 'sanitize_callback' => 'sanitize_text_field',
174 + ) );
175 + register_term_meta( $taxonomy, 'passster_activate_overwrite_defaults', array(
176 + 'type' => 'boolean',
177 + 'single' => true,
178 + 'show_in_rest' => false,
179 + 'sanitize_callback' => 'rest_sanitize_boolean',
180 + ) );
181 + register_term_meta( $taxonomy, 'passster_activate_misc_settings', array(
182 + 'type' => 'boolean',
183 + 'single' => true,
184 + 'show_in_rest' => false,
185 + 'sanitize_callback' => 'rest_sanitize_boolean',
186 + ) );
187 + }
188 + }
189 +
190 + /**
191 + * Register hooks for each taxonomy (form fields, columns, row actions).
192 + *
193 + * @return void
194 + */
195 + public function register_taxonomy_hooks() {
196 + $taxonomies = $this->get_taxonomies();
197 + foreach ( $taxonomies as $taxonomy ) {
198 + // Add/Edit form fields.
199 + add_action( "{$taxonomy}_add_form_fields", array($this, 'add_form_fields') );
200 + add_action(
201 + "{$taxonomy}_edit_form_fields",
202 + array($this, 'edit_form_fields'),
203 + 10,
204 + 2
205 + );
206 + // Save term meta (only on create; edit uses React + REST API).
207 + add_action( "created_{$taxonomy}", array($this, 'save_term_meta') );
208 + // Admin columns.
209 + add_filter( "manage_edit-{$taxonomy}_columns", array($this, 'add_columns') );
210 + add_filter(
211 + "manage_{$taxonomy}_custom_column",
212 + array($this, 'render_column'),
213 + 10,
214 + 3
215 + );
216 + // Row actions.
217 + add_filter(
218 + "{$taxonomy}_row_actions",
219 + array($this, 'add_row_actions'),
220 + 10,
221 + 2
222 + );
223 + }
224 + // Quick edit modal UI.
225 + add_action( 'admin_footer-edit-tags.php', array($this, 'quick_edit_ui') );
226 + }
227 +
228 + // -------------------------------------------------------------------------
229 + // Add Form Fields (Add New Term screen)
230 + // -------------------------------------------------------------------------
231 + /**
232 + * Render React container on the "Add New" term form.
233 + * The actual UI is rendered by TermMetabox.jsx.
234 + * Hidden inputs are managed by React to submit data with the form.
235 + *
236 + * @param string $taxonomy Current taxonomy slug.
237 + * @return void
238 + */
239 + public function add_form_fields( $taxonomy ) {
240 + ?>
241 + <div class="form-field passster-term-fields">
242 + <div id="passster-term-metabox-add"></div>
243 + </div>
244 + <?php
245 + }
246 +
247 + // -------------------------------------------------------------------------
248 + // Edit Form Fields (Edit Term screen) — React-based UI
249 + // -------------------------------------------------------------------------
250 + /**
251 + * Render React container on the "Edit" term form.
252 + * The actual UI is rendered by TermMetabox.jsx via the React app.
253 + *
254 + * @param \WP_Term $term Current term object.
255 + * @param string $taxonomy Current taxonomy slug.
256 + * @return void
257 + */
258 + public function edit_form_fields( $term, $taxonomy ) {
259 + ?>
260 + <tr class="form-field">
261 + <th scope="row" colspan="2">
262 + <h3 style="margin:0;"><?php
263 + esc_html_e( 'Passster Protection', 'content-protector' );
264 + ?></h3>
265 + </th>
266 + </tr>
267 + <tr class="form-field">
268 + <td colspan="2">
269 + <div id="passster-term-metabox"></div>
270 + </td>
271 + </tr>
272 + <?php
273 + }
274 +
275 + // -------------------------------------------------------------------------
276 + // Enqueue React Scripts for Term Edit
277 + // -------------------------------------------------------------------------
278 + /**
279 + * Enqueue React scripts on term edit pages.
280 + *
281 + * @return void
282 + */
283 + public function add_term_meta_scripts() {
284 + $screen = get_current_screen();
285 + if ( !$screen || !in_array( $screen->base, array('term', 'edit-tags'), true ) ) {
286 + return;
287 + }
288 + $taxonomies = $this->get_taxonomies();
289 + if ( !in_array( $screen->taxonomy, $taxonomies, true ) ) {
290 + return;
291 + }
292 + wp_enqueue_script(
293 + 'passster-term-meta-settings',
294 + PASSSTER_URL . '/inc/admin/build/index.js',
295 + array(
296 + 'wp-api',
297 + 'wp-components',
298 + 'wp-element',
299 + 'wp-api-fetch',
300 + 'wp-data',
301 + 'wp-i18n'
302 + ),
303 + PASSSTER_VERSION,
304 + true
305 + );
306 + // On edit (term) page, get the term ID. On add new (edit-tags) page, term_id is 0.
307 + if ( 'term' === $screen->base ) {
308 + $term_id = ( isset( $_GET['tag_ID'] ) ? absint( $_GET['tag_ID'] ) : 0 );
309 + } else {
310 + $term_id = 0;
311 + }
312 + $options = get_option( 'passster' );
313 + $meta = array(
314 + 'passster_activate_protection' => get_term_meta( $term_id, 'passster_activate_protection', true ),
315 + 'passster_protection_type' => get_term_meta( $term_id, 'passster_protection_type', true ),
316 + 'passster_password' => get_term_meta( $term_id, 'passster_password', true ),
317 + 'passster_headline' => get_term_meta( $term_id, 'passster_headline', true ),
318 + 'passster_instruction' => get_term_meta( $term_id, 'passster_instruction', true ),
319 + 'passster_placeholder' => get_term_meta( $term_id, 'passster_placeholder', true ),
320 + 'passster_button' => get_term_meta( $term_id, 'passster_button', true ),
321 + 'passster_redirect_url' => get_term_meta( $term_id, 'passster_redirect_url', true ),
322 + 'passster_activate_overwrite_defaults' => get_term_meta( $term_id, 'passster_activate_overwrite_defaults', true ),
323 + 'passster_activate_misc_settings' => get_term_meta( $term_id, 'passster_activate_misc_settings', true ),
324 + );
325 + $args = array(
326 + 'screen' => 'term-meta',
327 + 'is_pro' => \passster_fs()->is_plan_or_trial__premium_only( 'pro' ),
328 + 'meta' => $meta,
329 + 'term_id' => $term_id,
330 + );
331 + if ( isset( $options['password_length'] ) ) {
332 + $args['password_length'] = esc_html( $options['password_length'] );
333 + } else {
334 + $args['password_length'] = 12;
335 + }
336 + if ( isset( $options['include_uppercase'] ) ) {
337 + $args['include_uppercase'] = esc_html( $options['include_uppercase'] );
338 + } else {
339 + $args['include_uppercase'] = true;
340 + }
341 + if ( isset( $options['include_numbers'] ) ) {
342 + $args['include_numbers'] = esc_html( $options['include_numbers'] );
343 + } else {
344 + $args['include_numbers'] = true;
345 + }
346 + if ( isset( $options['include_symbols'] ) ) {
347 + $args['include_symbols'] = esc_html( $options['include_symbols'] );
348 + } else {
349 + $args['include_symbols'] = true;
350 + }
351 + wp_localize_script( 'passster-term-meta-settings', 'options', $args );
352 + if ( function_exists( 'wp_set_script_translations' ) ) {
353 + wp_set_script_translations( 'passster-term-meta-settings', 'content-protector', PASSSTER_PATH . '/languages' );
354 + }
355 + wp_enqueue_style( 'passster-term-meta-style', PASSSTER_URL . '/inc/admin/build/index.css', array('wp-components') );
356 + }
357 +
358 + // -------------------------------------------------------------------------
359 + // REST API Endpoints for Term Meta
360 + // -------------------------------------------------------------------------
361 + /**
362 + * Register REST API routes for term meta.
363 + *
364 + * @return void
365 + */
366 + public function rest_api_init() {
367 + register_rest_route( 'passster/v1', '/term-meta', array(
368 + 'methods' => 'POST',
369 + 'callback' => array($this, 'save_term_meta_rest'),
370 + 'permission_callback' => function () {
371 + return current_user_can( apply_filters( 'passster_user_capability', 'manage_options' ) );
372 + },
373 + ) );
374 + register_rest_route( 'passster/v1', '/term-meta', array(
375 + 'methods' => 'GET',
376 + 'callback' => array($this, 'get_term_meta_rest'),
377 + 'permission_callback' => function () {
378 + return current_user_can( apply_filters( 'passster_user_capability', 'manage_options' ) );
379 + },
380 + ) );
381 + register_rest_route( 'passster/v1', '/term-password-lists', array(
382 + 'methods' => 'GET',
383 + 'callback' => array($this, 'get_term_password_lists_rest'),
384 + 'permission_callback' => function () {
385 + return current_user_can( apply_filters( 'passster_user_capability', 'manage_options' ) );
386 + },
387 + ) );
388 + }
389 +
390 + /**
391 + * Save term meta via REST API.
392 + *
393 + * @param \WP_REST_Request $request Request object.
394 + * @return string
395 + */
396 + public function save_term_meta_rest( \WP_REST_Request $request ) {
397 + $params = $request->get_params();
398 + $term_id = ( isset( $params['term_id'] ) ? absint( $params['term_id'] ) : 0 );
399 + $meta_key = ( isset( $params['meta_key'] ) ? sanitize_key( $params['meta_key'] ) : '' );
400 + if ( !$term_id || !$meta_key ) {
401 + return wp_json_encode( array(
402 + 'status' => 400,
403 + 'message' => 'Missing term_id or meta_key',
404 + ) );
405 + }
406 + if ( isset( $params['meta_value'] ) ) {
407 + if ( 'passster_instruction' === $meta_key ) {
408 + $meta_value = wp_kses_post( $params['meta_value'] );
409 + } else {
410 + $meta_value = sanitize_text_field( $params['meta_value'] );
411 + }
412 + update_term_meta( $term_id, $meta_key, $meta_value );
413 + } elseif ( isset( $params['delete'] ) ) {
414 + delete_term_meta( $term_id, $meta_key );
415 + }
416 + return wp_json_encode( array(
417 + 'status' => 200,
418 + 'message' => 'Ok',
419 + ) );
420 + }
421 +
422 + /**
423 + * Get term meta via REST API.
424 + *
425 + * @param \WP_REST_Request $request Request object.
426 + * @return string
427 + */
428 + public function get_term_meta_rest( \WP_REST_Request $request ) {
429 + $params = $request->get_params();
430 + $term_id = ( isset( $params['term_id'] ) ? absint( $params['term_id'] ) : 0 );
431 + $meta_key = ( isset( $params['meta_key'] ) ? sanitize_key( $params['meta_key'] ) : '' );
432 + if ( !$term_id || !$meta_key ) {
433 + return wp_json_encode( array(
434 + 'status' => 400,
435 + 'message' => 'Missing term_id or meta_key',
436 + 'data' => '',
437 + ) );
438 + }
439 + $meta = get_term_meta( $term_id, $meta_key, true );
440 + if ( !empty( $meta ) ) {
441 + return wp_json_encode( array(
442 + 'status' => 200,
443 + 'message' => 'Ok',
444 + 'data' => $meta,
445 + ) );
446 + }
447 + return wp_json_encode( array(
448 + 'status' => 400,
449 + 'message' => 'Empty value',
450 + 'data' => '',
451 + ) );
452 + }
453 +
454 + /**
455 + * Get password lists for term meta dropdowns via REST API.
456 + *
457 + * @return array
458 + */
459 + public function get_term_password_lists_rest() {
460 + $lists = $this->get_password_lists();
461 + $fetched_lists = array();
462 + foreach ( $lists as $list ) {
463 + $item = new \stdClass();
464 + $item->title = $list->post_title;
465 + $item->id = $list->ID;
466 + $fetched_lists[] = $item;
467 + }
468 + return $fetched_lists;
469 + }
470 +
471 + // -------------------------------------------------------------------------
472 + // Save Term Meta
473 + // -------------------------------------------------------------------------
474 + /**
475 + * Save term meta on create/edit.
476 + *
477 + * @param int $term_id Term ID.
478 + * @return void
479 + */
480 + public function save_term_meta( $term_id ) {
481 + if ( !current_user_can( 'manage_categories' ) ) {
482 + return;
483 + }
484 + $activate = ( !empty( $_POST['passster_activate_protection'] ) ? 1 : 0 );
485 + update_term_meta( $term_id, 'passster_activate_protection', $activate );
486 + if ( $activate ) {
487 + if ( isset( $_POST['passster_password'] ) ) {
488 + update_term_meta( $term_id, 'passster_password', sanitize_text_field( wp_unslash( $_POST['passster_password'] ) ) );
489 + }
490 + if ( isset( $_POST['passster_redirect_url'] ) ) {
491 + update_term_meta( $term_id, 'passster_redirect_url', esc_url_raw( wp_unslash( $_POST['passster_redirect_url'] ) ) );
492 + }
493 + // Overwrite defaults.
494 + if ( isset( $_POST['passster_headline'] ) ) {
495 + update_term_meta( $term_id, 'passster_headline', sanitize_text_field( wp_unslash( $_POST['passster_headline'] ) ) );
496 + }
497 + if ( isset( $_POST['passster_instruction'] ) ) {
498 + update_term_meta( $term_id, 'passster_instruction', wp_kses_post( wp_unslash( $_POST['passster_instruction'] ) ) );
499 + }
500 + if ( isset( $_POST['passster_placeholder'] ) ) {
501 + update_term_meta( $term_id, 'passster_placeholder', sanitize_text_field( wp_unslash( $_POST['passster_placeholder'] ) ) );
502 + }
503 + if ( isset( $_POST['passster_button'] ) ) {
504 + update_term_meta( $term_id, 'passster_button', sanitize_text_field( wp_unslash( $_POST['passster_button'] ) ) );
505 + }
506 + }
507 + }
508 +
509 + // -------------------------------------------------------------------------
510 + // Admin Columns
511 + // -------------------------------------------------------------------------
512 + /**
513 + * Add columns to taxonomy list table.
514 + *
515 + * @param array $columns Existing columns.
516 + * @return array
517 + */
518 + public function add_columns( array $columns ) : array {
519 + $columns['passster_password'] = __( 'Password', 'content-protector' );
520 + return $columns;
521 + }
522 +
523 + /**
524 + * Render column content for taxonomy list table.
525 + *
526 + * @param string $content Column content.
527 + * @param string $column_name Column name.
528 + * @param int $term_id Term ID.
529 + * @return string
530 + */
531 + public function render_column( $content, $column_name, $term_id ) {
532 + $activate = get_term_meta( $term_id, 'passster_activate_protection', true );
533 + if ( !$activate ) {
534 + if ( 'passster_password' === $column_name || 'passster_protection_type' === $column_name ) {
535 + return '-';
536 + }
537 + return $content;
538 + }
539 + $protection_type = ( get_term_meta( $term_id, 'passster_protection_type', true ) ?: 'password' );
540 + switch ( $column_name ) {
541 + case 'passster_password':
542 + return esc_html( get_term_meta( $term_id, 'passster_password', true ) );
543 + break;
544 + case 'passster_protection_type':
545 + $types = array(
546 + 'password' => __( 'Password', 'content-protector' ),
547 + 'passwords' => __( 'Passwords', 'content-protector' ),
548 + 'password_list' => __( 'Password List', 'content-protector' ),
549 + 'password_lists' => __( 'Password Lists', 'content-protector' ),
550 + 'recaptcha' => __( 'reCaptcha', 'content-protector' ),
551 + 'turnstile' => __( 'Turnstile', 'content-protector' ),
552 + );
553 + return esc_html( $types[$protection_type] ?? $protection_type );
554 + }
555 + return $content;
556 + }
557 +
558 + // -------------------------------------------------------------------------
559 + // Row Actions
560 + // -------------------------------------------------------------------------
561 + /**
562 + * Add Quick Settings link to term row actions.
563 + *
564 + * @param array $actions Existing actions.
565 + * @param \WP_Term $term Term object.
566 + * @return array
567 + */
568 + public function add_row_actions( $actions, $term ) {
569 + if ( current_user_can( 'manage_categories' ) ) {
570 + $actions['passster_quick_settings'] = sprintf(
571 + '<a href="#" class="passster-category-quick-settings" data-term-id="%d" data-nonce="%s">%s</a>',
572 + $term->term_id,
573 + wp_create_nonce( 'passster_quick_edit_cat_' . $term->term_id ),
574 + __( 'Quick Settings', 'content-protector' )
575 + );
576 + }
577 + return $actions;
578 + }
579 +
580 + // -------------------------------------------------------------------------
581 + // Quick Edit Modal UI
582 + // -------------------------------------------------------------------------
583 + /**
584 + * Output quick edit modal HTML, CSS and JS.
585 + *
586 + * @return void
587 + */
588 + public function quick_edit_ui() {
589 + $screen = get_current_screen();
590 + if ( !$screen ) {
591 + return;
592 + }
593 + $taxonomies = $this->get_taxonomies();
594 + if ( !in_array( $screen->taxonomy, $taxonomies, true ) ) {
595 + return;
596 + }
597 + $is_pro = \passster_fs()->is_plan_or_trial__premium_only( 'pro' );
598 + $lists = ( $is_pro ? $this->get_password_lists() : array() );
599 + ?>
600 + <div id="passster-cat-quick-edit-modal" style="display:none;">
601 + <div class="passster-cat-qe-backdrop"></div>
602 + <div class="passster-cat-qe-panel">
603 + <div class="passster-cat-qe-header">
604 + <h3><?php
605 + esc_html_e( 'Passster - Quick Settings', 'content-protector' );
606 + ?></h3>
607 + <button type="button" class="passster-cat-qe-close">&times;</button>
608 + </div>
609 + <div class="passster-cat-qe-body">
610 + <input type="hidden" id="passster-cat-qe-term-id" value="">
611 + <input type="hidden" id="passster-cat-qe-nonce" value="">
612 +
613 + <p class="passster-cat-qe-field">
614 + <label>
615 + <input type="checkbox" id="passster-cat-qe-activate">
616 + <?php
617 + esc_html_e( 'Activate Protection', 'content-protector' );
618 + ?>
619 + </label>
620 + </p>
621 +
622 + <div class="passster-cat-qe-settings" style="display:none;">
623 + <?php
624 + if ( $is_pro ) {
625 + ?>
626 + <p class="passster-cat-qe-field">
627 + <label for="passster-cat-qe-type"><?php
628 + esc_html_e( 'Protection Type', 'content-protector' );
629 + ?></label>
630 + <select id="passster-cat-qe-type">
631 + <option value="password"><?php
632 + esc_html_e( 'Password', 'content-protector' );
633 + ?></option>
634 + <option value="passwords"><?php
635 + esc_html_e( 'Passwords', 'content-protector' );
636 + ?></option>
637 + <option value="password_list"><?php
638 + esc_html_e( 'Password List', 'content-protector' );
639 + ?></option>
640 + <option value="password_lists"><?php
641 + esc_html_e( 'Password Lists', 'content-protector' );
642 + ?></option>
643 + <option value="recaptcha">reCAPTCHA</option>
644 + <option value="turnstile">Turnstile</option>
645 + </select>
646 + </p>
647 + <?php
648 + }
649 + ?>
650 +
651 + <p class="passster-cat-qe-field passster-cat-qe-password-field">
652 + <label for="passster-cat-qe-password"><?php
653 + esc_html_e( 'Password', 'content-protector' );
654 + ?></label>
655 + <input type="text" id="passster-cat-qe-password" value="">
656 + </p>
657 +
658 + <?php
659 + if ( $is_pro ) {
660 + ?>
661 + <p class="passster-cat-qe-field passster-cat-qe-passwords-field" style="display:none;">
662 + <label for="passster-cat-qe-passwords"><?php
663 + esc_html_e( 'Passwords (comma-separated)', 'content-protector' );
664 + ?></label>
665 + <input type="text" id="passster-cat-qe-passwords" value="">
666 + </p>
667 +
668 + <p class="passster-cat-qe-field passster-cat-qe-list-field" style="display:none;">
669 + <label for="passster-cat-qe-list"><?php
670 + esc_html_e( 'Password List', 'content-protector' );
671 + ?></label>
672 + <select id="passster-cat-qe-list">
673 + <option value="0"><?php
674 + esc_html_e( '— Select —', 'content-protector' );
675 + ?></option>
676 + <?php
677 + foreach ( $lists as $list ) {
678 + ?>
679 + <option value="<?php
680 + echo esc_attr( $list->ID );
681 + ?>"><?php
682 + echo esc_html( $list->post_title );
683 + ?></option>
684 + <?php
685 + }
686 + ?>
687 + </select>
688 + </p>
689 +
690 + <p class="passster-cat-qe-field passster-cat-qe-lists-field" style="display:none;">
691 + <label for="passster-cat-qe-lists"><?php
692 + esc_html_e( 'Password Lists (comma-separated IDs)', 'content-protector' );
693 + ?></label>
694 + <input type="text" id="passster-cat-qe-lists" value="">
695 + </p>
696 + <?php
697 + }
698 + ?>
699 + </div>
700 + </div>
701 + <div class="passster-cat-qe-footer">
702 + <button type="button" class="button passster-cat-qe-cancel"><?php
703 + esc_html_e( 'Cancel', 'content-protector' );
704 + ?></button>
705 + <button type="button" class="button button-primary passster-cat-qe-save"><?php
706 + esc_html_e( 'Save', 'content-protector' );
707 + ?></button>
708 + <span class="spinner"></span>
709 + </div>
710 + </div>
711 + </div>
712 +
713 + <style>
714 + .passster-cat-qe-backdrop {
715 + position: fixed; top: 0; left: 0; right: 0; bottom: 0;
716 + background: rgba(0,0,0,0.5); z-index: 100000;
717 + }
718 + .passster-cat-qe-panel {
719 + position: fixed; top: 50%; left: 50%;
720 + transform: translate(-50%,-50%);
721 + background: #fff; border-radius: 8px;
722 + box-shadow: 0 4px 20px rgba(0,0,0,0.2);
723 + width: 420px; max-width: 90vw; z-index: 100001;
724 + }
725 + .passster-cat-qe-header {
726 + display: flex; justify-content: space-between; align-items: center;
727 + padding: 16px 20px; border-bottom: 1px solid #ddd;
728 + }
729 + .passster-cat-qe-header h3 { margin: 0; font-size: 16px; }
730 + .passster-cat-qe-close {
731 + background: none; border: none; font-size: 24px;
732 + cursor: pointer; color: #666; padding: 0; line-height: 1;
733 + }
734 + .passster-cat-qe-close:hover { color: #d63638; }
735 + .passster-cat-qe-body { padding: 20px; }
736 + .passster-cat-qe-field { margin-bottom: 14px; }
737 + .passster-cat-qe-field label { display: block; margin-bottom: 4px; font-weight: 600; }
738 + .passster-cat-qe-field input[type="text"],
739 + .passster-cat-qe-field input[type="url"],
740 + .passster-cat-qe-field select { width: 100%; }
741 + .passster-cat-qe-footer {
742 + display: flex; justify-content: flex-end; align-items: center;
743 + gap: 8px; padding: 16px 20px;
744 + border-top: 1px solid #ddd; background: #f6f7f7;
745 + border-radius: 0 0 8px 8px;
746 + }
747 + .passster-cat-qe-footer .spinner { float: none; margin: 0; }
748 + </style>
749 +
750 + <script>
751 + jQuery(function($) {
752 + var $modal = $('#passster-cat-quick-edit-modal');
753 + var $activate = $('#passster-cat-qe-activate');
754 + var $typeSelect = $('#passster-cat-qe-type');
755 + var isPro = <?php
756 + echo ( $is_pro ? 'true' : 'false' );
757 + ?>;
758 +
759 + function toggleTypeFields() {
760 + if (!isPro) return;
761 + var type = $typeSelect.val();
762 + $('.passster-cat-qe-password-field').toggle(type === 'password');
763 + $('.passster-cat-qe-passwords-field').toggle(type === 'passwords');
764 + $('.passster-cat-qe-list-field').toggle(type === 'password_list');
765 + $('.passster-cat-qe-lists-field').toggle(type === 'password_lists');
766 + }
767 +
768 + $activate.on('change', function() {
769 + $('.passster-cat-qe-settings').toggle(this.checked);
770 + });
771 +
772 + if (isPro) {
773 + $typeSelect.on('change', toggleTypeFields);
774 + }
775 +
776 + // Open modal.
777 + $(document).on('click', '.passster-category-quick-settings', function(e) {
778 + e.preventDefault();
779 + var termId = $(this).data('term-id');
780 + var nonce = $(this).data('nonce');
781 +
782 + $('#passster-cat-qe-term-id').val(termId);
783 + $('#passster-cat-qe-nonce').val(nonce);
784 +
785 + $.post(ajaxurl, {
786 + action: 'passster_quick_edit_category',
787 + term_id: termId,
788 + nonce: nonce,
789 + edit_action: 'get'
790 + }, function(response) {
791 + if (response.success) {
792 + var data = response.data;
793 + $activate.prop('checked', !!data.activate);
794 + $('.passster-cat-qe-settings').toggle(!!data.activate);
795 + $('#passster-cat-qe-password').val(data.password || '');
796 +
797 + if (isPro) {
798 + $typeSelect.val(data.protection_type || 'password');
799 + $('#passster-cat-qe-passwords').val(data.passwords || '');
800 + $('#passster-cat-qe-list').val(data.password_list || 0);
801 + $('#passster-cat-qe-lists').val(data.password_lists || '');
802 + toggleTypeFields();
803 + }
804 +
805 + $modal.show();
806 + }
807 + });
808 + });
809 +
810 + // Close modal.
811 + $modal.on('click', '.passster-cat-qe-close, .passster-cat-qe-cancel, .passster-cat-qe-backdrop', function() {
812 + $modal.hide();
813 + });
814 +
815 + // Save.
816 + $modal.on('click', '.passster-cat-qe-save', function() {
817 + var $btn = $(this);
818 + var $spinner = $modal.find('.spinner');
819 +
820 + $btn.prop('disabled', true);
821 + $spinner.addClass('is-active');
822 +
823 + var postData = {
824 + action: 'passster_quick_edit_category',
825 + term_id: $('#passster-cat-qe-term-id').val(),
826 + nonce: $('#passster-cat-qe-nonce').val(),
827 + edit_action: 'save',
828 + activate: $activate.is(':checked') ? 1 : 0,
829 + password: $('#passster-cat-qe-password').val()
830 + };
831 +
832 + if (isPro) {
833 + postData.protection_type = $typeSelect.val();
834 + postData.passwords = $('#passster-cat-qe-passwords').val();
835 + postData.password_list = $('#passster-cat-qe-list').val();
836 + postData.password_lists = $('#passster-cat-qe-lists').val();
837 + }
838 +
839 + $.post(ajaxurl, postData, function(response) {
840 + $btn.prop('disabled', false);
841 + $spinner.removeClass('is-active');
842 +
843 + if (response.success) {
844 + $modal.hide();
845 + location.reload();
846 + } else {
847 + alert(response.data.message || 'Error saving settings.');
848 + }
849 + });
850 + });
851 +
852 + // ESC to close.
853 + $(document).on('keydown', function(e) {
854 + if (e.key === 'Escape' && $modal.is(':visible')) {
855 + $modal.hide();
856 + }
857 + });
858 + });
859 + </script>
860 +
861 + <?php
862 + }
863 +
864 + // -------------------------------------------------------------------------
865 + // AJAX Quick Edit Handler
866 + // -------------------------------------------------------------------------
867 + /**
868 + * AJAX handler for quick edit category settings.
869 + *
870 + * @return void
871 + */
872 + public function ajax_quick_edit_category() {
873 + $term_id = ( isset( $_POST['term_id'] ) ? absint( $_POST['term_id'] ) : 0 );
874 + $nonce = ( isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '' );
875 + $action = ( isset( $_POST['edit_action'] ) ? sanitize_text_field( wp_unslash( $_POST['edit_action'] ) ) : 'get' );
876 + if ( !wp_verify_nonce( $nonce, 'passster_quick_edit_cat_' . $term_id ) ) {
877 + wp_send_json_error( array(
878 + 'message' => __( 'Security check failed.', 'content-protector' ),
879 + ) );
880 + }
881 + if ( !current_user_can( 'manage_categories' ) ) {
882 + wp_send_json_error( array(
883 + 'message' => __( 'Permission denied.', 'content-protector' ),
884 + ) );
885 + }
886 + $term = get_term( $term_id );
887 + if ( !$term || is_wp_error( $term ) ) {
888 + wp_send_json_error( array(
889 + 'message' => __( 'Invalid term.', 'content-protector' ),
890 + ) );
891 + }
892 + if ( 'get' === $action ) {
893 + $data = array(
894 + 'term_id' => $term_id,
895 + 'activate' => (bool) get_term_meta( $term_id, 'passster_activate_protection', true ),
896 + 'password' => get_term_meta( $term_id, 'passster_password', true ),
897 + 'protection_type' => ( get_term_meta( $term_id, 'passster_protection_type', true ) ?: 'password' ),
898 + 'passwords' => get_term_meta( $term_id, 'passster_passwords', true ),
899 + 'password_list' => get_term_meta( $term_id, 'passster_password_list', true ),
900 + 'password_lists' => get_term_meta( $term_id, 'passster_password_lists', true ),
901 + );
902 + wp_send_json_success( $data );
903 + }
904 + if ( 'save' === $action ) {
905 + $activate = ( isset( $_POST['activate'] ) ? absint( $_POST['activate'] ) : 0 );
906 + update_term_meta( $term_id, 'passster_activate_protection', $activate );
907 + if ( $activate ) {
908 + $password = ( isset( $_POST['password'] ) ? sanitize_text_field( wp_unslash( $_POST['password'] ) ) : '' );
909 + update_term_meta( $term_id, 'passster_password', $password );
910 + }
911 + wp_send_json_success( array(
912 + 'message' => __( 'Settings saved.', 'content-protector' ),
913 + ) );
914 + }
915 + wp_send_json_error( array(
916 + 'message' => __( 'Invalid action.', 'content-protector' ),
917 + ) );
918 + }
919 +
920 + // -------------------------------------------------------------------------
921 + // Frontend: Protect Posts in Protected Categories
922 + // -------------------------------------------------------------------------
923 + /**
924 + * Filter the_content for posts belonging to a protected category.
925 + * Runs at priority 11, after PS_Public (priority 10).
926 + *
927 + * @param string $content Post content.
928 + * @return string
929 + */
930 + public function filter_content_by_category( $content ) {
931 + if ( is_admin() ) {
932 + return $content;
933 + }
934 + if ( is_category() || is_tax() ) {
935 + return $content;
936 + }
937 + $post_id = get_the_id();
938 + if ( !$post_id ) {
939 + return $content;
940 + }
941 + if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
942 + return $content;
943 + }
944 + $term_data = $this->get_active_category_lock( $post_id );
945 + if ( !$term_data ) {
946 + return $content;
947 + }
948 + $shortcode = $this->build_shortcode_from_term( $term_data['term_id'], $content );
949 + return do_shortcode( $shortcode );
950 + }
951 +
952 + /**
953 + * @param int $post_id Post ID.
954 + * @return array|null
955 + */
956 + public function get_active_category_lock( int $post_id ) {
957 + $post_protection = get_post_meta( $post_id, 'passster_activate_protection', true );
958 + if ( $post_protection ) {
959 + return null;
960 + }
961 + $term_data = $this->get_protected_term_for_post( $post_id );
962 + if ( !$term_data ) {
963 + return null;
964 + }
965 + $atts = $this->build_atts_from_term( $term_data['term_id'] );
966 + if ( PS_Conditional::is_valid( $atts ) ) {
967 + return null;
968 + }
969 + return $term_data;
970 + }
971 +
972 + /**
973 + * Protect category archive pages.
974 + *
975 + * @return void
976 + */
977 + public function protect_category_archive() {
978 + if ( is_admin() ) {
979 + return;
980 + }
981 + // WooCommerce single product: if the product belongs to a locked product_cat,
982 + // suppress all WooCommerce product hooks and show the password form inline.
983 + if ( class_exists( 'WooCommerce' ) && is_singular( 'product' ) ) {
984 + $post_id = get_the_ID();
985 + $term_data = ( $post_id ? $this->get_protected_term_for_post( $post_id ) : null );
986 + if ( $term_data && 'product_cat' === $term_data['taxonomy'] ) {
987 + $atts = $this->build_atts_from_term( $term_data['term_id'] );
988 + if ( !PS_Conditional::is_valid( $atts ) ) {
989 + $shortcode = $this->build_shortcode_from_term( $term_data['term_id'], '' );
990 + self::$woo_protected_form = do_shortcode( $shortcode );
991 + // Replace the entire single-product template with the protected form.
992 + add_filter( 'template_include', array($this, 'use_protected_category_template'), 99 );
993 + }
994 + }
995 + return;
996 + }
997 + if ( !is_category() && !is_tax() ) {
998 + return;
999 + }
1000 + $term = get_queried_object();
1001 + if ( !$term || !is_a( $term, 'WP_Term' ) ) {
1002 + return;
1003 + }
1004 + // Only handle hierarchical taxonomies.
1005 + $taxonomy_obj = get_taxonomy( $term->taxonomy );
1006 + if ( !$taxonomy_obj || !$taxonomy_obj->hierarchical || !$taxonomy_obj->public ) {
1007 + return;
1008 + }
1009 + // Check the term itself and then its ancestors for protection.
1010 + $protected_term_id = $this->get_active_protected_term_id( $term );
1011 + if ( !$protected_term_id ) {
1012 + return;
1013 + }
1014 + // Use the protected term (may be an ancestor) for all subsequent meta lookups.
1015 + $term = get_term( $protected_term_id );
1016 + if ( !$term || is_wp_error( $term ) ) {
1017 + return;
1018 + }
1019 + $atts = $this->build_atts_from_term( $term->term_id );
1020 + if ( PS_Conditional::is_valid( $atts ) ) {
1021 + return;
1022 + }
1023 + // Check for redirect.
1024 + $redirect = get_term_meta( $term->term_id, 'passster_redirect_url', true );
1025 + if ( !empty( $redirect ) ) {
1026 + wp_redirect( esc_url_raw( $redirect ) );
1027 + exit;
1028 + }
1029 + $shortcode = $this->build_shortcode_from_term( $term->term_id, '' );
1030 + $form_html = do_shortcode( $shortcode );
1031 + // WooCommerce product category archives use their own template system and never
1032 + // call the_content() on the main query posts, so the virtual-post trick doesn't work.
1033 + // Instead, inject the form via WooCommerce's own template hooks and suppress the product loop.
1034 + if ( class_exists( 'WooCommerce' ) && 'product_cat' === $term->taxonomy ) {
1035 + self::$woo_protected_form = $form_html;
1036 + // Replace WooCommerce's entire archive template with a minimal one that shows only the form.
1037 + add_filter( 'template_include', array($this, 'use_protected_category_template'), 99 );
1038 + return;
1039 + }
1040 + self::$protected_form = $form_html;
1041 + if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) {
1042 + // FSE/block themes: let the block template render normally; replace only the
1043 + // core/query block (the post loop) with the password form. This preserves the
1044 + // theme's own header/footer block templates.
1045 + add_filter(
1046 + 'render_block',
1047 + array($this, 'replace_query_block_with_form'),
1048 + 10,
1049 + 2
1050 + );
1051 + } else {
1052 + // Classic themes: swap the archive template for a minimal protected-category
1053 + // template that calls get_header()/get_footer() from the active theme.
1054 + add_filter( 'template_include', array($this, 'use_protected_regular_category_template'), 99 );
1055 + }
1056 + }
1057 +
1058 + // -------------------------------------------------------------------------
1059 + // Helper Methods
1060 + // -------------------------------------------------------------------------
1061 + /**
1062 + * Check a term and its ancestors for active protection.
1063 + *
1064 + * @param \WP_Term $term Term to check (and walk up from).
1065 + * @return int|null The protected term ID (itself or an ancestor), or null if none.
1066 + */
1067 + public function get_active_protected_term_id( \WP_Term $term ) {
1068 + $ids_to_check = array_merge( array($term->term_id), get_ancestors( $term->term_id, $term->taxonomy, 'taxonomy' ) );
1069 + foreach ( $ids_to_check as $check_id ) {
1070 + if ( get_term_meta( $check_id, 'passster_activate_protection', true ) ) {
1071 + return $check_id;
1072 + }
1073 + }
1074 + return null;
1075 + }
1076 +
1077 + /**
1078 + * Get the first protected term for a given post.
1079 + *
1080 + * @param int $post_id Post ID.
1081 + * @return array|null Array with 'term_id' and 'taxonomy', or null.
1082 + */
1083 + public function get_protected_term_for_post( int $post_id ) {
1084 + $taxonomies = $this->get_taxonomies();
1085 + foreach ( $taxonomies as $taxonomy ) {
1086 + $terms = get_the_terms( $post_id, $taxonomy );
1087 + if ( !$terms || is_wp_error( $terms ) ) {
1088 + continue;
1089 + }
1090 + foreach ( $terms as $term ) {
1091 + // Check the term itself, then walk up its ancestor chain.
1092 + $protected_term_id = $this->get_active_protected_term_id( $term );
1093 + if ( $protected_term_id ) {
1094 + return array(
1095 + 'term_id' => $protected_term_id,
1096 + 'taxonomy' => $taxonomy,
1097 + );
1098 + }
1099 + }
1100 + }
1101 + return null;
1102 + }
1103 +
1104 + /**
1105 + * Build $atts array from term meta (for PS_Conditional::is_valid).
1106 + *
1107 + * @param int $term_id Term ID.
1108 + * @return array
1109 + */
1110 + private function build_atts_from_term( int $term_id ) : array {
1111 + $atts = array();
1112 + $atts['password'] = get_term_meta( $term_id, 'passster_password', true );
1113 + return $atts;
1114 + }
1115 +
1116 + /**
1117 + * Build a passster shortcode string from term meta.
1118 + *
1119 + * @param int $term_id Term ID.
1120 + * @param string $content Content to wrap.
1121 + * @return string
1122 + */
1123 + private function build_shortcode_from_term( int $term_id, string $content ) : string {
1124 + $options = get_option( 'passster' );
1125 + $shortcode = '[passster ';
1126 + $password = get_term_meta( $term_id, 'passster_password', true );
1127 + $shortcode .= 'password="' . esc_attr( $password ) . '" ';
1128 + $shortcode .= 'protection="full" ';
1129 + $shortcode .= 'term_id="' . $term_id . '" ';
1130 + // Redirect.
1131 + $redirect = get_term_meta( $term_id, 'passster_redirect_url', true );
1132 + if ( !empty( $redirect ) ) {
1133 + $shortcode .= 'redirect="' . esc_url( $redirect ) . '" ';
1134 + }
1135 + // Overwrite defaults.
1136 + $headline = get_term_meta( $term_id, 'passster_headline', true );
1137 + if ( !empty( $headline ) ) {
1138 + $shortcode .= 'headline="' . esc_attr( $headline ) . '" ';
1139 + }
1140 + $instruction = get_term_meta( $term_id, 'passster_instruction', true );
1141 + if ( !empty( $instruction ) ) {
1142 + $shortcode .= 'instruction="' . base64_encode( $instruction ) . '" ';
1143 + }
1144 + $placeholder = get_term_meta( $term_id, 'passster_placeholder', true );
1145 + if ( !empty( $placeholder ) ) {
1146 + $shortcode .= 'placeholder="' . esc_attr( $placeholder ) . '" ';
1147 + }
1148 + $button = get_term_meta( $term_id, 'passster_button', true );
1149 + if ( !empty( $button ) ) {
1150 + $shortcode .= 'button="' . esc_attr( $button ) . '" ';
1151 + }
1152 + $shortcode .= ']' . $content . '[/passster]';
1153 + return $shortcode;
1154 + }
1155 +
1156 + /**
1157 + * Get published password lists.
1158 + *
1159 + * @return array
1160 + */
1161 + private function get_password_lists() : array {
1162 + return get_posts( array(
1163 + 'post_type' => 'password_lists',
1164 + 'post_status' => 'publish',
1165 + 'posts_per_page' => -1,
1166 + ) );
1167 + }
1168 +
1169 +}