getHiddenProducts(), true );
}
public function isCategoryHidden( int $termId ): bool {
return in_array( $termId, $this->getHiddenCategories(), true );
}
/* --- the hidden sets (cached per visitor kind) --------------------------------------------- */
/**
* @return int[]
*/
public function getHiddenProducts(): array {
if ( null !== $this->hiddenProducts ) {
return $this->hiddenProducts;
}
if ( self::isExempt() ) {
return $this->hiddenProducts = array();
}
$this->hiddenProducts = $this->remember( 'products', function () {
return self::collectHiddenProducts( self::getRoles(), self::isGuest() );
} );
return $this->hiddenProducts;
}
/**
* @return int[]
*/
public function getHiddenCategories(): array {
if ( null !== $this->hiddenCategories ) {
return $this->hiddenCategories;
}
if ( self::isExempt() ) {
return $this->hiddenCategories = array();
}
$this->hiddenCategories = $this->remember( 'categories', function () {
return self::collectHiddenCategories( self::getRoles(), self::isGuest() );
} );
return $this->hiddenCategories;
}
protected function remember( string $what, callable $compute ): array {
$key = 'tpt_visibility_' . $what . '_' . md5( VisibilityMeta::getVersion() . '|' . implode( ',', self::getRoles() ) . '|' . ( self::isGuest() ? 'guest' : 'user' ) );
$ids = get_transient( $key );
if ( ! is_array( $ids ) ) {
$this->collecting = true;
$ids = array_values( array_unique( array_map( 'intval', (array) $compute() ) ) );
$this->collecting = false;
set_transient( $key, $ids, 12 * HOUR_IN_SECONDS );
}
return $ids;
}
/**
* @return int[] restricted categories a visitor with these roles may not see, with their child categories
*/
public static function collectHiddenCategories( array $roles, bool $isGuest ): array {
$hidden = array();
foreach ( VisibilityMeta::getRestrictedCategoryIds() as $termId ) {
if ( ! VisibilityRule::canSee( VisibilityMeta::getCategoryRule( $termId ), $roles, $isGuest ) ) {
$hidden[] = $termId;
foreach ( get_term_children( $termId, 'product_cat' ) as $child ) {
$hidden[] = (int) $child;
}
}
}
return $hidden;
}
/**
* @return int[] products a visitor with these roles may not see (not cached; the storefront uses getHiddenProducts())
*/
public static function collectHiddenProducts( array $roles, bool $isGuest ): array {
$hidden = array();
foreach ( self::findProductsWithOwnRule() as $productId ) {
if ( ! VisibilityRule::canSee( VisibilityMeta::getProductRule( $productId ), $roles, $isGuest ) ) {
$hidden[] = $productId;
}
}
// products in a hidden category that follow the category rules
foreach ( self::findProductsFollowingCategories( self::collectHiddenCategories( $roles, $isGuest ) ) as $productId ) {
$hidden[] = $productId;
}
return $hidden;
}
/**
* @return int[] every product that is hidden from somebody: an own rule, or a restricting category it follows
*/
public static function collectRestrictedProducts(): array {
return array_values( array_unique( array_merge(
self::findProductsWithOwnRule(),
self::findProductsFollowingCategories( VisibilityMeta::getRestrictedCategoryIds() )
) ) );
}
/**
* @return int[] products with a restricting rule of their own
*/
protected static function findProductsWithOwnRule(): array {
$ids = get_posts( array(
'post_type' => 'product',
'post_status' => 'any',
'posts_per_page' => -1,
'fields' => 'ids',
'no_found_rows' => true,
'tpt_visibility' => true,
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
array(
'key' => VisibilityMeta::PRODUCT_MODE,
'value' => array( VisibilityRule::MODE_INCLUDE, VisibilityRule::MODE_EXCLUDE ),
'compare' => 'IN',
),
),
) );
return array_map( 'intval', $ids );
}
/**
* @param int[] $categoryIds
*
* @return int[] products in these categories (or their children) that have no rule of their own
*/
protected static function findProductsFollowingCategories( array $categoryIds ): array {
if ( ! $categoryIds ) {
return array();
}
$ids = get_posts( array(
'post_type' => 'product',
'post_status' => 'any',
'posts_per_page' => -1,
'fields' => 'ids',
'no_found_rows' => true,
'tpt_visibility' => true,
'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
array(
'taxonomy' => 'product_cat',
'field' => 'term_id',
'terms' => array_map( 'intval', $categoryIds ),
'include_children' => true,
),
),
) );
$following = array();
foreach ( $ids as $productId ) {
if ( VisibilityRule::MODE_INHERIT === VisibilityMeta::getProductRule( (int) $productId )['mode'] ) {
$following[] = (int) $productId;
}
}
return $following;
}
/* --- lists ------------------------------------------------------------------------------- */
public function filterQuery( WP_Query $query ) {
if ( $this->collecting || $query->get( 'tpt_visibility' ) || is_admin() ) {
return;
}
$postType = $query->get( 'post_type' );
$types = array_filter( (array) $postType );
$isProduct = in_array( 'product', $types, true );
$isBroad = $query->is_main_query() && ( $query->is_search() || $query->is_post_type_archive( 'product' ) || $query->is_tax( get_object_taxonomies( 'product' ) ) );
if ( ! $isProduct && ! $isBroad ) {
return;
}
// a hidden product's own page is handled on template_redirect (login page or not-found page)
if ( $query->is_main_query() && $query->is_singular() ) {
return;
}
$hidden = $this->getHiddenProducts();
if ( ! $hidden ) {
return;
}
$query->set( 'post__not_in', array_values( array_unique( array_merge( (array) $query->get( 'post__not_in' ), $hidden ) ) ) );
}
public function filterRestQuery( array $args, $request ): array {
$hidden = $this->getHiddenProducts();
if ( $hidden ) {
$args['post__not_in'] = array_values( array_unique( array_merge( (array) ( $args['post__not_in'] ?? array() ), $hidden ) ) );
}
return $args;
}
public function filterIds( $ids ) {
if ( ! is_array( $ids ) ) {
return $ids;
}
$hidden = $this->getHiddenProducts();
return $hidden ? array_values( array_diff( array_map( 'intval', $ids ), $hidden ) ) : $ids;
}
public function filterIsVisible( $visible, $productId ) {
return $visible && ! $this->isProductHidden( (int) $productId );
}
public function filterIsPurchasable( $purchasable, $product ) {
if ( $product instanceof WC_Product && $this->isProductHidden( $this->rootId( $product ) ) ) {
return false;
}
return $purchasable;
}
public function validateAddToCart( $passed, $productId ) {
if ( $passed && $this->isProductHidden( $this->rootId( (int) $productId ) ) ) {
wc_add_notice( __( 'This product is not available to you.', 'tier-pricing-table' ), 'error' );
return false;
}
return $passed;
}
public function checkCartItems() {
if ( ! WC()->cart ) {
return;
}
foreach ( WC()->cart->get_cart() as $key => $item ) {
$productId = (int) ( $item['product_id'] ?? 0 );
if ( $productId && $this->isProductHidden( $productId ) ) {
WC()->cart->remove_cart_item( $key );
wc_add_notice( __( 'An item that is no longer available to you was removed from your cart.', 'tier-pricing-table' ), 'notice' );
}
}
}
/**
* Hidden categories leave category lists: widgets, blocks, dropdowns, menus and the Store API,
* whether they go through get_terms() or straight through WP_Term_Query.
*
* Only listing queries are touched: the per-object queries behind get_the_terms() and the
* "get everything" queries that build WordPress' own term caches stay complete, so no cache
* ever stores a visitor's view. The clause is part of the SQL, so the term query cache keeps
* a visitor's list apart from a customer's.
*/
public function filterTermsClauses( $clauses, $taxonomies, $args ) {
if ( $this->collecting || ! did_action( 'init' ) || ! in_array( 'product_cat', (array) $taxonomies, true ) ) {
return $clauses;
}
if ( ! empty( $args['tpt_visibility'] ) || ! empty( $args['object_ids'] ) || 'all' === ( $args['get'] ?? '' ) ) {
return $clauses;
}
if ( ! in_array( (string) ( $args['fields'] ?? 'all' ), array( 'all', 'all_with_object_id', 'ids', 'names', 'slugs', 'id=>name', 'id=>slug' ), true ) ) {
return $clauses;
}
$hidden = $this->getHiddenCategories();
if ( $hidden ) {
$clauses['where'] .= ' AND t.term_id NOT IN (' . implode( ',', array_map( 'intval', $hidden ) ) . ')';
}
return $clauses;
}
/**
* The Store API's single product and single category routes return nothing hidden.
*
* @param mixed $result
* @param \WP_REST_Server $server
* @param \WP_REST_Request $request
*/
public function guardStoreApi( $result, $server, $request ) {
if ( null !== $result ) {
return $result;
}
$route = (string) $request->get_route();
if ( preg_match( '#^/wc/store/v\d+/products/categories/(\d+)$#', $route, $m ) ) {
return $this->isCategoryHidden( (int) $m[1] ) ? $this->notFound() : $result;
}
if ( preg_match( '#^/wc/store/v\d+/products/([^/]+)$#', $route, $m ) ) {
$productId = is_numeric( $m[1] ) ? (int) $m[1] : 0;
if ( ! $productId ) {
$post = get_page_by_path( sanitize_title( urldecode( $m[1] ) ), OBJECT, 'product' );
$productId = $post ? (int) $post->ID : 0;
}
return $productId && $this->isProductHidden( $this->rootId( $productId ) ) ? $this->notFound() : $result;
}
return $result;
}
protected function notFound(): \WP_Error {
return new \WP_Error( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'tier-pricing-table' ), array( 'status' => 404 ) );
}
/**
* A visible product inside a hidden category does not name that category in its breadcrumb.
*/
public function filterBreadcrumb( $crumbs ) {
$links = $this->getHiddenCategoryLinks();
if ( ! $links || ! is_array( $crumbs ) ) {
return $crumbs;
}
return array_values( array_filter( $crumbs, function ( $crumb ) use ( $links ) {
return ! in_array( untrailingslashit( (string) ( $crumb[1] ?? '' ) ), $links, true );
} ) );
}
/**
* ... nor in its "Category:" line.
*/
public function filterTermLinks( $termLinks ) {
$links = $this->getHiddenCategoryLinks();
if ( ! $links || ! is_array( $termLinks ) ) {
return $termLinks;
}
return array_values( array_filter( $termLinks, function ( $html ) use ( $links ) {
foreach ( $links as $link ) {
if ( false !== strpos( (string) $html, 'href="' . esc_url( $link ) ) ) {
return false;
}
}
return true;
} ) );
}
/**
* When every category of the product is hidden, the empty "Category:" line goes too.
*/
public function bufferProductMeta() {
if ( $this->getHiddenCategories() ) {
ob_start();
}
}
public function flushProductMeta() {
if ( ! $this->getHiddenCategories() ) {
return;
}
$html = (string) ob_get_clean();
echo preg_replace( '#(?:(?!)(?!#s', '', $html ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* @return string[] permalinks (without the trailing slash) of the hidden categories
*/
protected function getHiddenCategoryLinks(): array {
$links = array();
foreach ( $this->getHiddenCategories() as $termId ) {
$link = get_term_link( $termId, 'product_cat' );
if ( ! is_wp_error( $link ) ) {
$links[] = untrailingslashit( $link );
}
}
return $links;
}
public function filterMenuItems( $items ) {
if ( ! is_array( $items ) || is_admin() || ! did_action( 'init' ) ) {
return $items;
}
return array_values( array_filter( $items, function ( $item ) {
if ( 'taxonomy' === $item->type && 'product_cat' === $item->object ) {
return ! $this->isCategoryHidden( (int) $item->object_id );
}
if ( 'post_type' === $item->type && 'product' === $item->object ) {
return ! $this->isProductHidden( (int) $item->object_id );
}
return true;
} ) );
}
public function filterSitemapQuery( array $args, string $postType ): array {
if ( 'product' !== $postType ) {
return $args;
}
// the sitemap is read by crawlers, that is, guests
$this->hiddenProducts = null;
$hidden = self::collectHiddenProducts( array(), true );
$this->hiddenProducts = null;
if ( $hidden ) {
$args['post__not_in'] = array_values( array_unique( array_merge( (array) ( $args['post__not_in'] ?? array() ), $hidden ) ) );
}
return $args;
}
/* --- single views -------------------------------------------------------------------------- */
public function guardSingleViews() {
if ( is_singular( 'product' ) && $this->isProductHidden( (int) get_queried_object_id() ) ) {
$this->deny();
}
if ( is_tax( 'product_cat' ) && $this->isCategoryHidden( (int) get_queried_object_id() ) ) {
$this->deny();
}
}
/**
* A guest is sent to the login page (with the way back), everybody else gets a not-found page.
*/
protected function deny() {
if ( self::isGuest() && VisibilitySettings::redirectGuestsFromHidden() ) {
wp_safe_redirect( ClosedStoreService::getLoginUrl( $this->currentUrl() ) );
exit;
}
// the template loader, which runs right after this action, renders the not-found page
global $wp_query;
$wp_query->set_404();
status_header( 404 );
nocache_headers();
}
protected function currentUrl(): string {
$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : (string) wp_parse_url( home_url(), PHP_URL_HOST );
$path = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
return esc_url_raw( ( is_ssl() ? 'https://' : 'http://' ) . $host . $path );
}
protected function rootId( $product ): int {
$product = $product instanceof WC_Product ? $product : wc_get_product( (int) $product );
if ( ! $product ) {
return 0;
}
return (int) ( $product->get_parent_id() ?: $product->get_id() );
}
public function onTermsSet( $objectId, $terms, $ttIds, $taxonomy ) {
if ( 'product_cat' === $taxonomy ) {
VisibilityMeta::bumpVersion();
}
}
}