cache_version_option, 1 ); if ( $ver < 1 ) { $ver = 1; } return $ver; } /** * Bump cache version to invalidate all user caches safely. * * @return void */ public function bump_cache_version() { $ver = $this->get_cache_version(); update_option( $this->cache_version_option, $ver + 1, false ); } /** * Bump cache only when our meta key changes. * * @param int $meta_id * @param int $post_id * @param string $meta_key * @param mixed $meta_value * @return void */ public function maybe_bump_on_meta_change( $meta_id, $post_id, $meta_key, $meta_value ) { if ( '_aisp_exclusive_users' !== $meta_key ) { return; } $this->bump_cache_version(); } /** * Build hidden product ID list for current user. * Cached (per-user, versioned) for performance. * * @return int[] */ private function get_hidden_ids() { $user_id = (int) get_current_user_id(); $ver = $this->get_cache_version(); $cache_key = 'aisp_hidden_u' . $user_id . '_v' . $ver; $cached = get_transient( $cache_key ); if ( false !== $cached && is_array( $cached ) ) { return $cached; } $access = AISP_Exclusive_Products_Access_Control::instance(); $hidden = array(); /** * IMPORTANT OPTIMIZATION: * Fetch only products that HAVE _aisp_exclusive_users meta. * No need to scan the whole catalog. * suppress_filters prevents our own the_posts filter from * triggering recursively on this internal query. */ // phpcs:disable WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- Required to prevent recursive execution of our own the_posts filter on this internal lookup query. // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Intentional: we target only products that have our meta key, avoiding a full catalog scan. $restricted_ids = get_posts( array( 'post_type' => 'product', 'post_status' => array( 'publish', 'private' ), 'posts_per_page' => -1, 'fields' => 'ids', 'no_found_rows' => true, 'cache_results' => false, 'suppress_filters' => true, 'meta_query' => array( array( 'key' => '_aisp_exclusive_users', 'compare' => 'EXISTS', ), ), ) ); // phpcs:enable WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_query foreach ( $restricted_ids as $pid ) { $allowed = aisp_exclusive_products_get_allowed_users( $pid ); // If empty => product is public. if ( empty( $allowed ) ) { continue; } if ( ! $access->current_user_can_view( $pid ) ) { $hidden[] = (int) $pid; } } $hidden = array_values( array_unique( $hidden ) ); /** * Cache TTL (seconds). * Default 300s = 5 minutes. * Filterable if needed. */ $ttl = (int) apply_filters( 'aisp_exclusive_products_hidden_ids_ttl', 300 ); if ( $ttl < 30 ) { $ttl = 30; } set_transient( $cache_key, $hidden, $ttl ); return $hidden; } /** * Filter main product queries (shop, categories, tags, search). * * @param WP_Query $query * @return void */ public function filter_product_queries( $query ) { if ( is_admin() || ! $query->is_main_query() ) { return; } $post_type = $query->get( 'post_type' ); $is_product_query = ( 'product' === $post_type ) || ( is_array( $post_type ) && in_array( 'product', $post_type, true ) ) || is_shop() || is_product_category() || is_product_tag() || $query->is_search(); if ( ! $is_product_query ) { return; } $hidden = $this->get_hidden_ids(); if ( empty( $hidden ) ) { return; } $existing = (array) $query->get( 'post__not_in' ); $query->set( 'post__not_in', array_values( array_unique( array_merge( $existing, $hidden ) ) ) ); } /** * Filter post list after query execution (final fail-safe). * * @param array $posts * @param WP_Query $query * @return array */ public function filter_posts_array( $posts, $query ) { if ( empty( $posts ) || ! is_array( $posts ) ) { return $posts; } // Skip our own internal suppress_filters query to avoid recursion. if ( $query->get( 'suppress_filters' ) ) { return $posts; } $post_type = $query->get( 'post_type' ); $is_product_query = ( 'product' === $post_type ) || ( is_array( $post_type ) && in_array( 'product', $post_type, true ) ); if ( ! $is_product_query ) { return $posts; } $hidden = $this->get_hidden_ids(); if ( empty( $hidden ) ) { return $posts; } $filtered = array(); foreach ( $posts as $p ) { if ( ! in_array( $p->ID, $hidden, true ) ) { $filtered[] = $p; } } return $filtered; } /** * Filter REST API product queries (Botiga live search, blocks, plugins). * * @param array $args * @param WP_REST_Request $request * @return array */ public function filter_rest_query( $args, $request ) { $post_type = isset( $args['post_type'] ) ? $args['post_type'] : $request->get_param( 'post_type' ); if ( ! $post_type || strpos( (string) $post_type, 'product' ) === false ) { return $args; } $hidden = $this->get_hidden_ids(); if ( empty( $hidden ) ) { return $args; } // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- Exclusion of restricted product IDs is the core purpose of this plugin; no alternative approach exists. $args['post__not_in'] = empty( $args['post__not_in'] ) ? $hidden : array_values( array_unique( array_merge( (array) $args['post__not_in'], $hidden ) ) ); return $args; } /** * Final REST layer (remove single forbidden product items). * Covers both rest_prepare_product and rest_prepare_product_variation. * * @param WP_REST_Response $response * @param WP_Post $post * @param WP_REST_Request $request * @return WP_REST_Response|WP_Error */ public function filter_rest_response( $response, $post, $request ) { $hidden = $this->get_hidden_ids(); if ( empty( $hidden ) ) { return $response; } if ( in_array( (int) $post->ID, $hidden, true ) ) { return new WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to view this product.', 'aisp-user-exclusive-products' ), array( 'status' => rest_authorization_required_code() ) ); } return $response; } } endif;