true, 'hierarchical' => true, ), 'names' ); } /** * Register term meta fields. * * @return void */ public function register_term_meta() { $taxonomies = $this->get_taxonomies(); foreach ( $taxonomies as $taxonomy ) { register_term_meta( $taxonomy, 'passster_activate_protection', array( 'type' => 'boolean', 'single' => true, 'show_in_rest' => false, 'sanitize_callback' => 'rest_sanitize_boolean', ) ); register_term_meta( $taxonomy, 'passster_password', array( 'type' => 'string', 'single' => true, 'show_in_rest' => false, 'sanitize_callback' => 'sanitize_text_field', ) ); register_term_meta( $taxonomy, 'passster_redirect_url', array( 'type' => 'string', 'single' => true, 'show_in_rest' => false, 'sanitize_callback' => 'esc_url_raw', ) ); register_term_meta( $taxonomy, 'passster_headline', array( 'type' => 'string', 'single' => true, 'show_in_rest' => false, 'sanitize_callback' => 'sanitize_text_field', ) ); register_term_meta( $taxonomy, 'passster_instruction', array( 'type' => 'string', 'single' => true, 'show_in_rest' => false, 'sanitize_callback' => 'wp_kses_post', ) ); register_term_meta( $taxonomy, 'passster_placeholder', array( 'type' => 'string', 'single' => true, 'show_in_rest' => false, 'sanitize_callback' => 'sanitize_text_field', ) ); register_term_meta( $taxonomy, 'passster_button', array( 'type' => 'string', 'single' => true, 'show_in_rest' => false, 'sanitize_callback' => 'sanitize_text_field', ) ); register_term_meta( $taxonomy, 'passster_activate_overwrite_defaults', array( 'type' => 'boolean', 'single' => true, 'show_in_rest' => false, 'sanitize_callback' => 'rest_sanitize_boolean', ) ); register_term_meta( $taxonomy, 'passster_activate_misc_settings', array( 'type' => 'boolean', 'single' => true, 'show_in_rest' => false, 'sanitize_callback' => 'rest_sanitize_boolean', ) ); } } /** * Register hooks for each taxonomy (form fields, columns, row actions). * * @return void */ public function register_taxonomy_hooks() { $taxonomies = $this->get_taxonomies(); foreach ( $taxonomies as $taxonomy ) { // Add/Edit form fields. add_action( "{$taxonomy}_add_form_fields", array($this, 'add_form_fields') ); add_action( "{$taxonomy}_edit_form_fields", array($this, 'edit_form_fields'), 10, 2 ); // Save term meta (only on create; edit uses React + REST API). add_action( "created_{$taxonomy}", array($this, 'save_term_meta') ); // Admin columns. add_filter( "manage_edit-{$taxonomy}_columns", array($this, 'add_columns') ); add_filter( "manage_{$taxonomy}_custom_column", array($this, 'render_column'), 10, 3 ); // Row actions. add_filter( "{$taxonomy}_row_actions", array($this, 'add_row_actions'), 10, 2 ); } // Quick edit modal UI. add_action( 'admin_footer-edit-tags.php', array($this, 'quick_edit_ui') ); } // ------------------------------------------------------------------------- // Add Form Fields (Add New Term screen) // ------------------------------------------------------------------------- /** * Render React container on the "Add New" term form. * The actual UI is rendered by TermMetabox.jsx. * Hidden inputs are managed by React to submit data with the form. * * @param string $taxonomy Current taxonomy slug. * @return void */ public function add_form_fields( $taxonomy ) { ?>

base, array('term', 'edit-tags'), true ) ) { return; } $taxonomies = $this->get_taxonomies(); if ( !in_array( $screen->taxonomy, $taxonomies, true ) ) { return; } wp_enqueue_script( 'passster-term-meta-settings', PASSSTER_URL . '/inc/admin/build/index.js', array( 'wp-api', 'wp-components', 'wp-element', 'wp-api-fetch', 'wp-data', 'wp-i18n' ), PASSSTER_VERSION, true ); // On edit (term) page, get the term ID. On add new (edit-tags) page, term_id is 0. if ( 'term' === $screen->base ) { $term_id = ( isset( $_GET['tag_ID'] ) ? absint( $_GET['tag_ID'] ) : 0 ); } else { $term_id = 0; } $options = get_option( 'passster' ); $meta = array( 'passster_activate_protection' => get_term_meta( $term_id, 'passster_activate_protection', true ), 'passster_protection_type' => get_term_meta( $term_id, 'passster_protection_type', true ), 'passster_password' => get_term_meta( $term_id, 'passster_password', true ), 'passster_headline' => get_term_meta( $term_id, 'passster_headline', true ), 'passster_instruction' => get_term_meta( $term_id, 'passster_instruction', true ), 'passster_placeholder' => get_term_meta( $term_id, 'passster_placeholder', true ), 'passster_button' => get_term_meta( $term_id, 'passster_button', true ), 'passster_redirect_url' => get_term_meta( $term_id, 'passster_redirect_url', true ), 'passster_activate_overwrite_defaults' => get_term_meta( $term_id, 'passster_activate_overwrite_defaults', true ), 'passster_activate_misc_settings' => get_term_meta( $term_id, 'passster_activate_misc_settings', true ), ); $args = array( 'screen' => 'term-meta', 'is_pro' => \passster_fs()->is_plan_or_trial__premium_only( 'pro' ), 'meta' => $meta, 'term_id' => $term_id, ); if ( isset( $options['password_length'] ) ) { $args['password_length'] = esc_html( $options['password_length'] ); } else { $args['password_length'] = 12; } if ( isset( $options['include_uppercase'] ) ) { $args['include_uppercase'] = esc_html( $options['include_uppercase'] ); } else { $args['include_uppercase'] = true; } if ( isset( $options['include_numbers'] ) ) { $args['include_numbers'] = esc_html( $options['include_numbers'] ); } else { $args['include_numbers'] = true; } if ( isset( $options['include_symbols'] ) ) { $args['include_symbols'] = esc_html( $options['include_symbols'] ); } else { $args['include_symbols'] = true; } wp_localize_script( 'passster-term-meta-settings', 'options', $args ); if ( function_exists( 'wp_set_script_translations' ) ) { wp_set_script_translations( 'passster-term-meta-settings', 'content-protector', PASSSTER_PATH . '/languages' ); } wp_enqueue_style( 'passster-term-meta-style', PASSSTER_URL . '/inc/admin/build/index.css', array('wp-components') ); } // ------------------------------------------------------------------------- // REST API Endpoints for Term Meta // ------------------------------------------------------------------------- /** * Register REST API routes for term meta. * * @return void */ public function rest_api_init() { register_rest_route( 'passster/v1', '/term-meta', array( 'methods' => 'POST', 'callback' => array($this, 'save_term_meta_rest'), 'permission_callback' => function () { return current_user_can( apply_filters( 'passster_user_capability', 'manage_options' ) ); }, ) ); register_rest_route( 'passster/v1', '/term-meta', array( 'methods' => 'GET', 'callback' => array($this, 'get_term_meta_rest'), 'permission_callback' => function () { return current_user_can( apply_filters( 'passster_user_capability', 'manage_options' ) ); }, ) ); register_rest_route( 'passster/v1', '/term-password-lists', array( 'methods' => 'GET', 'callback' => array($this, 'get_term_password_lists_rest'), 'permission_callback' => function () { return current_user_can( apply_filters( 'passster_user_capability', 'manage_options' ) ); }, ) ); } /** * Save term meta via REST API. * * @param \WP_REST_Request $request Request object. * @return string */ public function save_term_meta_rest( \WP_REST_Request $request ) { $params = $request->get_params(); $term_id = ( isset( $params['term_id'] ) ? absint( $params['term_id'] ) : 0 ); $meta_key = ( isset( $params['meta_key'] ) ? sanitize_key( $params['meta_key'] ) : '' ); if ( !$term_id || !$meta_key ) { return wp_json_encode( array( 'status' => 400, 'message' => 'Missing term_id or meta_key', ) ); } if ( isset( $params['meta_value'] ) ) { if ( 'passster_instruction' === $meta_key ) { $meta_value = wp_kses_post( $params['meta_value'] ); } else { $meta_value = sanitize_text_field( $params['meta_value'] ); } update_term_meta( $term_id, $meta_key, $meta_value ); } elseif ( isset( $params['delete'] ) ) { delete_term_meta( $term_id, $meta_key ); } return wp_json_encode( array( 'status' => 200, 'message' => 'Ok', ) ); } /** * Get term meta via REST API. * * @param \WP_REST_Request $request Request object. * @return string */ public function get_term_meta_rest( \WP_REST_Request $request ) { $params = $request->get_params(); $term_id = ( isset( $params['term_id'] ) ? absint( $params['term_id'] ) : 0 ); $meta_key = ( isset( $params['meta_key'] ) ? sanitize_key( $params['meta_key'] ) : '' ); if ( !$term_id || !$meta_key ) { return wp_json_encode( array( 'status' => 400, 'message' => 'Missing term_id or meta_key', 'data' => '', ) ); } $meta = get_term_meta( $term_id, $meta_key, true ); if ( !empty( $meta ) ) { return wp_json_encode( array( 'status' => 200, 'message' => 'Ok', 'data' => $meta, ) ); } return wp_json_encode( array( 'status' => 400, 'message' => 'Empty value', 'data' => '', ) ); } /** * Get password lists for term meta dropdowns via REST API. * * @return array */ public function get_term_password_lists_rest() { $lists = $this->get_password_lists(); $fetched_lists = array(); foreach ( $lists as $list ) { $item = new \stdClass(); $item->title = $list->post_title; $item->id = $list->ID; $fetched_lists[] = $item; } return $fetched_lists; } // ------------------------------------------------------------------------- // Save Term Meta // ------------------------------------------------------------------------- /** * Save term meta on create/edit. * * @param int $term_id Term ID. * @return void */ public function save_term_meta( $term_id ) { if ( !current_user_can( 'manage_categories' ) ) { return; } $activate = ( !empty( $_POST['passster_activate_protection'] ) ? 1 : 0 ); update_term_meta( $term_id, 'passster_activate_protection', $activate ); if ( $activate ) { if ( isset( $_POST['passster_password'] ) ) { update_term_meta( $term_id, 'passster_password', sanitize_text_field( wp_unslash( $_POST['passster_password'] ) ) ); } if ( isset( $_POST['passster_redirect_url'] ) ) { update_term_meta( $term_id, 'passster_redirect_url', esc_url_raw( wp_unslash( $_POST['passster_redirect_url'] ) ) ); } // Overwrite defaults. if ( isset( $_POST['passster_headline'] ) ) { update_term_meta( $term_id, 'passster_headline', sanitize_text_field( wp_unslash( $_POST['passster_headline'] ) ) ); } if ( isset( $_POST['passster_instruction'] ) ) { update_term_meta( $term_id, 'passster_instruction', wp_kses_post( wp_unslash( $_POST['passster_instruction'] ) ) ); } if ( isset( $_POST['passster_placeholder'] ) ) { update_term_meta( $term_id, 'passster_placeholder', sanitize_text_field( wp_unslash( $_POST['passster_placeholder'] ) ) ); } if ( isset( $_POST['passster_button'] ) ) { update_term_meta( $term_id, 'passster_button', sanitize_text_field( wp_unslash( $_POST['passster_button'] ) ) ); } } } // ------------------------------------------------------------------------- // Admin Columns // ------------------------------------------------------------------------- /** * Add columns to taxonomy list table. * * @param array $columns Existing columns. * @return array */ public function add_columns( array $columns ) : array { $columns['passster_password'] = __( 'Password', 'content-protector' ); return $columns; } /** * Render column content for taxonomy list table. * * @param string $content Column content. * @param string $column_name Column name. * @param int $term_id Term ID. * @return string */ public function render_column( $content, $column_name, $term_id ) { $activate = get_term_meta( $term_id, 'passster_activate_protection', true ); if ( !$activate ) { if ( 'passster_password' === $column_name || 'passster_protection_type' === $column_name ) { return '-'; } return $content; } $protection_type = ( get_term_meta( $term_id, 'passster_protection_type', true ) ?: 'password' ); switch ( $column_name ) { case 'passster_password': return esc_html( get_term_meta( $term_id, 'passster_password', true ) ); break; case 'passster_protection_type': $types = array( 'password' => __( 'Password', 'content-protector' ), 'passwords' => __( 'Passwords', 'content-protector' ), 'password_list' => __( 'Password List', 'content-protector' ), 'password_lists' => __( 'Password Lists', 'content-protector' ), 'recaptcha' => __( 'reCaptcha', 'content-protector' ), 'turnstile' => __( 'Turnstile', 'content-protector' ), ); return esc_html( $types[$protection_type] ?? $protection_type ); } return $content; } // ------------------------------------------------------------------------- // Row Actions // ------------------------------------------------------------------------- /** * Add Quick Settings link to term row actions. * * @param array $actions Existing actions. * @param \WP_Term $term Term object. * @return array */ public function add_row_actions( $actions, $term ) { if ( current_user_can( 'manage_categories' ) ) { $actions['passster_quick_settings'] = sprintf( '%s', $term->term_id, wp_create_nonce( 'passster_quick_edit_cat_' . $term->term_id ), __( 'Quick Settings', 'content-protector' ) ); } return $actions; } // ------------------------------------------------------------------------- // Quick Edit Modal UI // ------------------------------------------------------------------------- /** * Output quick edit modal HTML, CSS and JS. * * @return void */ public function quick_edit_ui() { $screen = get_current_screen(); if ( !$screen ) { return; } $taxonomies = $this->get_taxonomies(); if ( !in_array( $screen->taxonomy, $taxonomies, true ) ) { return; } $is_pro = \passster_fs()->is_plan_or_trial__premium_only( 'pro' ); $lists = ( $is_pro ? $this->get_password_lists() : array() ); ?> __( 'Security check failed.', 'content-protector' ), ) ); } if ( !current_user_can( 'manage_categories' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'content-protector' ), ) ); } $term = get_term( $term_id ); if ( !$term || is_wp_error( $term ) ) { wp_send_json_error( array( 'message' => __( 'Invalid term.', 'content-protector' ), ) ); } if ( 'get' === $action ) { $data = array( 'term_id' => $term_id, 'activate' => (bool) get_term_meta( $term_id, 'passster_activate_protection', true ), 'password' => get_term_meta( $term_id, 'passster_password', true ), 'protection_type' => ( get_term_meta( $term_id, 'passster_protection_type', true ) ?: 'password' ), 'passwords' => get_term_meta( $term_id, 'passster_passwords', true ), 'password_list' => get_term_meta( $term_id, 'passster_password_list', true ), 'password_lists' => get_term_meta( $term_id, 'passster_password_lists', true ), ); wp_send_json_success( $data ); } if ( 'save' === $action ) { $activate = ( isset( $_POST['activate'] ) ? absint( $_POST['activate'] ) : 0 ); update_term_meta( $term_id, 'passster_activate_protection', $activate ); if ( $activate ) { $password = ( isset( $_POST['password'] ) ? sanitize_text_field( wp_unslash( $_POST['password'] ) ) : '' ); update_term_meta( $term_id, 'passster_password', $password ); } wp_send_json_success( array( 'message' => __( 'Settings saved.', 'content-protector' ), ) ); } wp_send_json_error( array( 'message' => __( 'Invalid action.', 'content-protector' ), ) ); } // ------------------------------------------------------------------------- // Frontend: Protect Posts in Protected Categories // ------------------------------------------------------------------------- /** * Filter the_content for posts belonging to a protected category. * Runs at priority 11, after PS_Public (priority 10). * * @param string $content Post content. * @return string */ public function filter_content_by_category( $content ) { if ( is_admin() ) { return $content; } if ( is_category() || is_tax() ) { return $content; } $post_id = get_the_id(); if ( !$post_id ) { return $content; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return $content; } $term_data = $this->get_active_category_lock( $post_id ); if ( !$term_data ) { return $content; } $shortcode = $this->build_shortcode_from_term( $term_data['term_id'], $content ); return do_shortcode( $shortcode ); } /** * @param int $post_id Post ID. * @return array|null */ public function get_active_category_lock( int $post_id ) { $post_protection = get_post_meta( $post_id, 'passster_activate_protection', true ); if ( $post_protection ) { return null; } $term_data = $this->get_protected_term_for_post( $post_id ); if ( !$term_data ) { return null; } $atts = $this->build_atts_from_term( $term_data['term_id'] ); if ( PS_Conditional::is_valid( $atts ) ) { return null; } return $term_data; } /** * Protect category archive pages. * * @return void */ public function protect_category_archive() { if ( is_admin() ) { return; } // WooCommerce single product: if the product belongs to a locked product_cat, // suppress all WooCommerce product hooks and show the password form inline. if ( class_exists( 'WooCommerce' ) && is_singular( 'product' ) ) { $post_id = get_the_ID(); $term_data = ( $post_id ? $this->get_protected_term_for_post( $post_id ) : null ); if ( $term_data && 'product_cat' === $term_data['taxonomy'] ) { $atts = $this->build_atts_from_term( $term_data['term_id'] ); if ( !PS_Conditional::is_valid( $atts ) ) { $shortcode = $this->build_shortcode_from_term( $term_data['term_id'], '' ); self::$woo_protected_form = do_shortcode( $shortcode ); // Replace the entire single-product template with the protected form. add_filter( 'template_include', array($this, 'use_protected_category_template'), 99 ); } } return; } if ( !is_category() && !is_tax() ) { return; } $term = get_queried_object(); if ( !$term || !is_a( $term, 'WP_Term' ) ) { return; } // Only handle hierarchical taxonomies. $taxonomy_obj = get_taxonomy( $term->taxonomy ); if ( !$taxonomy_obj || !$taxonomy_obj->hierarchical || !$taxonomy_obj->public ) { return; } // Check the term itself and then its ancestors for protection. $protected_term_id = $this->get_active_protected_term_id( $term ); if ( !$protected_term_id ) { return; } // Use the protected term (may be an ancestor) for all subsequent meta lookups. $term = get_term( $protected_term_id ); if ( !$term || is_wp_error( $term ) ) { return; } $atts = $this->build_atts_from_term( $term->term_id ); if ( PS_Conditional::is_valid( $atts ) ) { return; } // Check for redirect. $redirect = get_term_meta( $term->term_id, 'passster_redirect_url', true ); if ( !empty( $redirect ) ) { wp_redirect( esc_url_raw( $redirect ) ); exit; } $shortcode = $this->build_shortcode_from_term( $term->term_id, '' ); $form_html = do_shortcode( $shortcode ); // WooCommerce product category archives use their own template system and never // call the_content() on the main query posts, so the virtual-post trick doesn't work. // Instead, inject the form via WooCommerce's own template hooks and suppress the product loop. if ( class_exists( 'WooCommerce' ) && 'product_cat' === $term->taxonomy ) { self::$woo_protected_form = $form_html; // Replace WooCommerce's entire archive template with a minimal one that shows only the form. add_filter( 'template_include', array($this, 'use_protected_category_template'), 99 ); return; } self::$protected_form = $form_html; if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) { // FSE/block themes: let the block template render normally; replace only the // core/query block (the post loop) with the password form. This preserves the // theme's own header/footer block templates. add_filter( 'render_block', array($this, 'replace_query_block_with_form'), 10, 2 ); } else { // Classic themes: swap the archive template for a minimal protected-category // template that calls get_header()/get_footer() from the active theme. add_filter( 'template_include', array($this, 'use_protected_regular_category_template'), 99 ); } } // ------------------------------------------------------------------------- // Helper Methods // ------------------------------------------------------------------------- /** * Check a term and its ancestors for active protection. * * @param \WP_Term $term Term to check (and walk up from). * @return int|null The protected term ID (itself or an ancestor), or null if none. */ public function get_active_protected_term_id( \WP_Term $term ) { $ids_to_check = array_merge( array($term->term_id), get_ancestors( $term->term_id, $term->taxonomy, 'taxonomy' ) ); foreach ( $ids_to_check as $check_id ) { if ( get_term_meta( $check_id, 'passster_activate_protection', true ) ) { return $check_id; } } return null; } /** * Get the first protected term for a given post. * * @param int $post_id Post ID. * @return array|null Array with 'term_id' and 'taxonomy', or null. */ public function get_protected_term_for_post( int $post_id ) { $taxonomies = $this->get_taxonomies(); foreach ( $taxonomies as $taxonomy ) { $terms = get_the_terms( $post_id, $taxonomy ); if ( !$terms || is_wp_error( $terms ) ) { continue; } foreach ( $terms as $term ) { // Check the term itself, then walk up its ancestor chain. $protected_term_id = $this->get_active_protected_term_id( $term ); if ( $protected_term_id ) { return array( 'term_id' => $protected_term_id, 'taxonomy' => $taxonomy, ); } } } return null; } /** * Build $atts array from term meta (for PS_Conditional::is_valid). * * @param int $term_id Term ID. * @return array */ private function build_atts_from_term( int $term_id ) : array { $atts = array(); $atts['password'] = get_term_meta( $term_id, 'passster_password', true ); return $atts; } /** * Build a passster shortcode string from term meta. * * @param int $term_id Term ID. * @param string $content Content to wrap. * @return string */ private function build_shortcode_from_term( int $term_id, string $content ) : string { $options = get_option( 'passster' ); $shortcode = '[passster '; $password = get_term_meta( $term_id, 'passster_password', true ); $shortcode .= 'password="' . esc_attr( $password ) . '" '; $shortcode .= 'protection="full" '; $shortcode .= 'term_id="' . $term_id . '" '; // Redirect. $redirect = get_term_meta( $term_id, 'passster_redirect_url', true ); if ( !empty( $redirect ) ) { $shortcode .= 'redirect="' . esc_url( $redirect ) . '" '; } // Overwrite defaults. $headline = get_term_meta( $term_id, 'passster_headline', true ); if ( !empty( $headline ) ) { $shortcode .= 'headline="' . esc_attr( $headline ) . '" '; } $instruction = get_term_meta( $term_id, 'passster_instruction', true ); if ( !empty( $instruction ) ) { $shortcode .= 'instruction="' . base64_encode( $instruction ) . '" '; } $placeholder = get_term_meta( $term_id, 'passster_placeholder', true ); if ( !empty( $placeholder ) ) { $shortcode .= 'placeholder="' . esc_attr( $placeholder ) . '" '; } $button = get_term_meta( $term_id, 'passster_button', true ); if ( !empty( $button ) ) { $shortcode .= 'button="' . esc_attr( $button ) . '" '; } $shortcode .= ']' . $content . '[/passster]'; return $shortcode; } /** * Get published password lists. * * @return array */ private function get_password_lists() : array { return get_posts( array( 'post_type' => 'password_lists', 'post_status' => 'publish', 'posts_per_page' => -1, ) ); } }