PluginProbe
Tiered Pricing Table for WooCommerce / 8.0.2
Tiered Pricing Table for WooCommerce v8.0.2
8.0.2 7.1.7 7.1.5 7.1.4 7.1.1 6.5.0 6.4.0 6.1.0 trunk 1.0 1.1 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.2.0 2.2.1 2.2.2 2.2.3 2.3.0 2.3.1 2.3.2 2.3.3 All 91 releases
tier-pricing-table / src / Addons / NonLoggedInUsers / Visibility / ProductVisibilityService.php

ProductVisibilityService.php in Tiered Pricing Table for WooCommerce 8.0.2, at src/Addons/NonLoggedInUsers/Visibility/ProductVisibilityService.php

564 lines 17.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php namespace TierPricingTable\Addons\NonLoggedInUsers\Visibility;
2
3 use TierPricingTable\TierPricingTablePlugin;
4 use WC_Product;
5 use WP_Query;
6
7 /**
8 * Applies the visibility rules on the storefront: hidden products leave every product list, related
9 * and upsell lists, menus and the sitemap, cannot be bought, and their pages redirect or 404; hidden
10 * categories leave category lists, menus and their archives. Shop managers always see everything.
11 */
12 class ProductVisibilityService {
13
14 /** @var int[]|null */
15 protected $hiddenProducts = null;
16
17 /** @var int[]|null */
18 protected $hiddenCategories = null;
19
20 protected bool $collecting = false;
21
22 public function __construct() {
23 add_action( 'save_post_product', array( VisibilityMeta::class, 'bumpVersion' ) );
24 add_action( 'woocommerce_update_product', array( VisibilityMeta::class, 'bumpVersion' ) );
25 add_action( 'set_object_terms', array( $this, 'onTermsSet' ), 10, 4 );
26 add_action( 'delete_product_cat', array( VisibilityMeta::class, 'bumpVersion' ) );
27
28 if ( is_admin() && ! wp_doing_ajax() ) {
29 return;
30 }
31
32 add_action( 'pre_get_posts', array( $this, 'filterQuery' ) );
33 add_filter( 'woocommerce_rest_product_object_query', array( $this, 'filterRestQuery' ), 10, 2 );
34 add_filter( 'woocommerce_related_products', array( $this, 'filterIds' ) );
35 add_filter( 'woocommerce_product_get_upsell_ids', array( $this, 'filterIds' ) );
36 add_filter( 'woocommerce_product_get_cross_sell_ids', array( $this, 'filterIds' ) );
37 add_filter( 'woocommerce_product_is_visible', array( $this, 'filterIsVisible' ), 10, 2 );
38 add_filter( 'woocommerce_is_purchasable', array( $this, 'filterIsPurchasable' ), 10, 2 );
39 add_filter( 'woocommerce_add_to_cart_validation', array( $this, 'validateAddToCart' ), 10, 2 );
40 add_action( 'woocommerce_check_cart_items', array( $this, 'checkCartItems' ) );
41 add_filter( 'terms_clauses', array( $this, 'filterTermsClauses' ), 10, 3 );
42 add_filter( 'rest_pre_dispatch', array( $this, 'guardStoreApi' ), 10, 3 );
43 add_filter( 'woocommerce_get_breadcrumb', array( $this, 'filterBreadcrumb' ) );
44 add_filter( 'term_links-product_cat', array( $this, 'filterTermLinks' ) );
45 add_action( 'woocommerce_product_meta_start', array( $this, 'bufferProductMeta' ) );
46 add_action( 'woocommerce_product_meta_end', array( $this, 'flushProductMeta' ) );
47 add_filter( 'wp_get_nav_menu_items', array( $this, 'filterMenuItems' ) );
48 add_filter( 'wp_sitemaps_posts_query_args', array( $this, 'filterSitemapQuery' ), 10, 2 );
49 add_action( 'template_redirect', array( $this, 'guardSingleViews' ), 5 );
50 }
51
52 /* --- who is looking ------------------------------------------------------------------------ */
53
54 public static function isExempt(): bool {
55 return current_user_can( 'manage_woocommerce' );
56 }
57
58 public static function isGuest(): bool {
59 return ! is_user_logged_in();
60 }
61
62 /**
63 * @return string[]
64 */
65 public static function getRoles(): array {
66 return self::isGuest() ? array() : array_values( array_map( 'strval', TierPricingTablePlugin::getCurrentUserRoles() ) );
67 }
68
69 public static function isProductVisibleFor( int $productId, array $roles, bool $isGuest ): bool {
70 return VisibilityRule::isProductVisible(
71 VisibilityMeta::getProductRule( $productId ),
72 VisibilityMeta::getProductCategoryRules( $productId ),
73 $roles,
74 $isGuest
75 );
76 }
77
78 public function isProductHidden( int $productId ): bool {
79 return in_array( $productId, $this->getHiddenProducts(), true );
80 }
81
82 public function isCategoryHidden( int $termId ): bool {
83 return in_array( $termId, $this->getHiddenCategories(), true );
84 }
85
86 /* --- the hidden sets (cached per visitor kind) --------------------------------------------- */
87
88 /**
89 * @return int[]
90 */
91 public function getHiddenProducts(): array {
92 if ( null !== $this->hiddenProducts ) {
93 return $this->hiddenProducts;
94 }
95
96 if ( self::isExempt() ) {
97 return $this->hiddenProducts = array();
98 }
99
100 $this->hiddenProducts = $this->remember( 'products', function () {
101 return self::collectHiddenProducts( self::getRoles(), self::isGuest() );
102 } );
103
104 return $this->hiddenProducts;
105 }
106
107 /**
108 * @return int[]
109 */
110 public function getHiddenCategories(): array {
111 if ( null !== $this->hiddenCategories ) {
112 return $this->hiddenCategories;
113 }
114
115 if ( self::isExempt() ) {
116 return $this->hiddenCategories = array();
117 }
118
119 $this->hiddenCategories = $this->remember( 'categories', function () {
120 return self::collectHiddenCategories( self::getRoles(), self::isGuest() );
121 } );
122
123 return $this->hiddenCategories;
124 }
125
126 protected function remember( string $what, callable $compute ): array {
127 $key = 'tpt_visibility_' . $what . '_' . md5( VisibilityMeta::getVersion() . '|' . implode( ',', self::getRoles() ) . '|' . ( self::isGuest() ? 'guest' : 'user' ) );
128 $ids = get_transient( $key );
129
130 if ( ! is_array( $ids ) ) {
131 $this->collecting = true;
132 $ids = array_values( array_unique( array_map( 'intval', (array) $compute() ) ) );
133 $this->collecting = false;
134
135 set_transient( $key, $ids, 12 * HOUR_IN_SECONDS );
136 }
137
138 return $ids;
139 }
140
141 /**
142 * @return int[] restricted categories a visitor with these roles may not see, with their child categories
143 */
144 public static function collectHiddenCategories( array $roles, bool $isGuest ): array {
145 $hidden = array();
146
147 foreach ( VisibilityMeta::getRestrictedCategoryIds() as $termId ) {
148 if ( ! VisibilityRule::canSee( VisibilityMeta::getCategoryRule( $termId ), $roles, $isGuest ) ) {
149 $hidden[] = $termId;
150
151 foreach ( get_term_children( $termId, 'product_cat' ) as $child ) {
152 $hidden[] = (int) $child;
153 }
154 }
155 }
156
157 return $hidden;
158 }
159
160 /**
161 * @return int[] products a visitor with these roles may not see (not cached; the storefront uses getHiddenProducts())
162 */
163 public static function collectHiddenProducts( array $roles, bool $isGuest ): array {
164 $hidden = array();
165
166 foreach ( self::findProductsWithOwnRule() as $productId ) {
167 if ( ! VisibilityRule::canSee( VisibilityMeta::getProductRule( $productId ), $roles, $isGuest ) ) {
168 $hidden[] = $productId;
169 }
170 }
171
172 // products in a hidden category that follow the category rules
173 foreach ( self::findProductsFollowingCategories( self::collectHiddenCategories( $roles, $isGuest ) ) as $productId ) {
174 $hidden[] = $productId;
175 }
176
177 return $hidden;
178 }
179
180 /**
181 * @return int[] every product that is hidden from somebody: an own rule, or a restricting category it follows
182 */
183 public static function collectRestrictedProducts(): array {
184 return array_values( array_unique( array_merge(
185 self::findProductsWithOwnRule(),
186 self::findProductsFollowingCategories( VisibilityMeta::getRestrictedCategoryIds() )
187 ) ) );
188 }
189
190 /**
191 * @return int[] products with a restricting rule of their own
192 */
193 protected static function findProductsWithOwnRule(): array {
194 $ids = get_posts( array(
195 'post_type' => 'product',
196 'post_status' => 'any',
197 'posts_per_page' => -1,
198 'fields' => 'ids',
199 'no_found_rows' => true,
200 'tpt_visibility' => true,
201 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
202 array(
203 'key' => VisibilityMeta::PRODUCT_MODE,
204 'value' => array( VisibilityRule::MODE_INCLUDE, VisibilityRule::MODE_EXCLUDE ),
205 'compare' => 'IN',
206 ),
207 ),
208 ) );
209
210 return array_map( 'intval', $ids );
211 }
212
213 /**
214 * @param int[] $categoryIds
215 *
216 * @return int[] products in these categories (or their children) that have no rule of their own
217 */
218 protected static function findProductsFollowingCategories( array $categoryIds ): array {
219 if ( ! $categoryIds ) {
220 return array();
221 }
222
223 $ids = get_posts( array(
224 'post_type' => 'product',
225 'post_status' => 'any',
226 'posts_per_page' => -1,
227 'fields' => 'ids',
228 'no_found_rows' => true,
229 'tpt_visibility' => true,
230 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
231 array(
232 'taxonomy' => 'product_cat',
233 'field' => 'term_id',
234 'terms' => array_map( 'intval', $categoryIds ),
235 'include_children' => true,
236 ),
237 ),
238 ) );
239
240 $following = array();
241
242 foreach ( $ids as $productId ) {
243 if ( VisibilityRule::MODE_INHERIT === VisibilityMeta::getProductRule( (int) $productId )['mode'] ) {
244 $following[] = (int) $productId;
245 }
246 }
247
248 return $following;
249 }
250
251 /* --- lists ------------------------------------------------------------------------------- */
252
253 public function filterQuery( WP_Query $query ) {
254 if ( $this->collecting || $query->get( 'tpt_visibility' ) || is_admin() ) {
255 return;
256 }
257
258 $postType = $query->get( 'post_type' );
259 $types = array_filter( (array) $postType );
260 $isProduct = in_array( 'product', $types, true );
261 $isBroad = $query->is_main_query() && ( $query->is_search() || $query->is_post_type_archive( 'product' ) || $query->is_tax( get_object_taxonomies( 'product' ) ) );
262
263 if ( ! $isProduct && ! $isBroad ) {
264 return;
265 }
266
267 // a hidden product's own page is handled on template_redirect (login page or not-found page)
268 if ( $query->is_main_query() && $query->is_singular() ) {
269 return;
270 }
271
272 $hidden = $this->getHiddenProducts();
273
274 if ( ! $hidden ) {
275 return;
276 }
277
278 $query->set( 'post__not_in', array_values( array_unique( array_merge( (array) $query->get( 'post__not_in' ), $hidden ) ) ) );
279 }
280
281 public function filterRestQuery( array $args, $request ): array {
282 $hidden = $this->getHiddenProducts();
283
284 if ( $hidden ) {
285 $args['post__not_in'] = array_values( array_unique( array_merge( (array) ( $args['post__not_in'] ?? array() ), $hidden ) ) );
286 }
287
288 return $args;
289 }
290
291 public function filterIds( $ids ) {
292 if ( ! is_array( $ids ) ) {
293 return $ids;
294 }
295
296 $hidden = $this->getHiddenProducts();
297
298 return $hidden ? array_values( array_diff( array_map( 'intval', $ids ), $hidden ) ) : $ids;
299 }
300
301 public function filterIsVisible( $visible, $productId ) {
302 return $visible && ! $this->isProductHidden( (int) $productId );
303 }
304
305 public function filterIsPurchasable( $purchasable, $product ) {
306 if ( $product instanceof WC_Product && $this->isProductHidden( $this->rootId( $product ) ) ) {
307 return false;
308 }
309
310 return $purchasable;
311 }
312
313 public function validateAddToCart( $passed, $productId ) {
314 if ( $passed && $this->isProductHidden( $this->rootId( (int) $productId ) ) ) {
315 wc_add_notice( __( 'This product is not available to you.', 'tier-pricing-table' ), 'error' );
316
317 return false;
318 }
319
320 return $passed;
321 }
322
323 public function checkCartItems() {
324 if ( ! WC()->cart ) {
325 return;
326 }
327
328 foreach ( WC()->cart->get_cart() as $key => $item ) {
329 $productId = (int) ( $item['product_id'] ?? 0 );
330
331 if ( $productId && $this->isProductHidden( $productId ) ) {
332 WC()->cart->remove_cart_item( $key );
333 wc_add_notice( __( 'An item that is no longer available to you was removed from your cart.', 'tier-pricing-table' ), 'notice' );
334 }
335 }
336 }
337
338 /**
339 * Hidden categories leave category lists: widgets, blocks, dropdowns, menus and the Store API,
340 * whether they go through get_terms() or straight through WP_Term_Query.
341 *
342 * Only listing queries are touched: the per-object queries behind get_the_terms() and the
343 * "get everything" queries that build WordPress' own term caches stay complete, so no cache
344 * ever stores a visitor's view. The clause is part of the SQL, so the term query cache keeps
345 * a visitor's list apart from a customer's.
346 */
347 public function filterTermsClauses( $clauses, $taxonomies, $args ) {
348 if ( $this->collecting || ! did_action( 'init' ) || ! in_array( 'product_cat', (array) $taxonomies, true ) ) {
349 return $clauses;
350 }
351
352 if ( ! empty( $args['tpt_visibility'] ) || ! empty( $args['object_ids'] ) || 'all' === ( $args['get'] ?? '' ) ) {
353 return $clauses;
354 }
355
356 if ( ! in_array( (string) ( $args['fields'] ?? 'all' ), array( 'all', 'all_with_object_id', 'ids', 'names', 'slugs', 'id=>name', 'id=>slug' ), true ) ) {
357 return $clauses;
358 }
359
360 $hidden = $this->getHiddenCategories();
361
362 if ( $hidden ) {
363 $clauses['where'] .= ' AND t.term_id NOT IN (' . implode( ',', array_map( 'intval', $hidden ) ) . ')';
364 }
365
366 return $clauses;
367 }
368
369 /**
370 * The Store API's single product and single category routes return nothing hidden.
371 *
372 * @param mixed $result
373 * @param \WP_REST_Server $server
374 * @param \WP_REST_Request $request
375 */
376 public function guardStoreApi( $result, $server, $request ) {
377 if ( null !== $result ) {
378 return $result;
379 }
380
381 $route = (string) $request->get_route();
382
383 if ( preg_match( '#^/wc/store/v\d+/products/categories/(\d+)$#', $route, $m ) ) {
384 return $this->isCategoryHidden( (int) $m[1] ) ? $this->notFound() : $result;
385 }
386
387 if ( preg_match( '#^/wc/store/v\d+/products/([^/]+)$#', $route, $m ) ) {
388 $productId = is_numeric( $m[1] ) ? (int) $m[1] : 0;
389
390 if ( ! $productId ) {
391 $post = get_page_by_path( sanitize_title( urldecode( $m[1] ) ), OBJECT, 'product' );
392 $productId = $post ? (int) $post->ID : 0;
393 }
394
395 return $productId && $this->isProductHidden( $this->rootId( $productId ) ) ? $this->notFound() : $result;
396 }
397
398 return $result;
399 }
400
401 protected function notFound(): \WP_Error {
402 return new \WP_Error( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'tier-pricing-table' ), array( 'status' => 404 ) );
403 }
404
405 /**
406 * A visible product inside a hidden category does not name that category in its breadcrumb.
407 */
408 public function filterBreadcrumb( $crumbs ) {
409 $links = $this->getHiddenCategoryLinks();
410
411 if ( ! $links || ! is_array( $crumbs ) ) {
412 return $crumbs;
413 }
414
415 return array_values( array_filter( $crumbs, function ( $crumb ) use ( $links ) {
416 return ! in_array( untrailingslashit( (string) ( $crumb[1] ?? '' ) ), $links, true );
417 } ) );
418 }
419
420 /**
421 * ... nor in its "Category:" line.
422 */
423 public function filterTermLinks( $termLinks ) {
424 $links = $this->getHiddenCategoryLinks();
425
426 if ( ! $links || ! is_array( $termLinks ) ) {
427 return $termLinks;
428 }
429
430 return array_values( array_filter( $termLinks, function ( $html ) use ( $links ) {
431 foreach ( $links as $link ) {
432 if ( false !== strpos( (string) $html, 'href="' . esc_url( $link ) ) ) {
433 return false;
434 }
435 }
436
437 return true;
438 } ) );
439 }
440
441 /**
442 * When every category of the product is hidden, the empty "Category:" line goes too.
443 */
444 public function bufferProductMeta() {
445 if ( $this->getHiddenCategories() ) {
446 ob_start();
447 }
448 }
449
450 public function flushProductMeta() {
451 if ( ! $this->getHiddenCategories() ) {
452 return;
453 }
454
455 $html = (string) ob_get_clean();
456
457 echo preg_replace( '#<span class="posted_in">(?:(?!</span>)(?!<a ).)*</span>#s', '', $html ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
458 }
459
460 /**
461 * @return string[] permalinks (without the trailing slash) of the hidden categories
462 */
463 protected function getHiddenCategoryLinks(): array {
464 $links = array();
465
466 foreach ( $this->getHiddenCategories() as $termId ) {
467 $link = get_term_link( $termId, 'product_cat' );
468
469 if ( ! is_wp_error( $link ) ) {
470 $links[] = untrailingslashit( $link );
471 }
472 }
473
474 return $links;
475 }
476
477 public function filterMenuItems( $items ) {
478 if ( ! is_array( $items ) || is_admin() || ! did_action( 'init' ) ) {
479 return $items;
480 }
481
482 return array_values( array_filter( $items, function ( $item ) {
483 if ( 'taxonomy' === $item->type && 'product_cat' === $item->object ) {
484 return ! $this->isCategoryHidden( (int) $item->object_id );
485 }
486
487 if ( 'post_type' === $item->type && 'product' === $item->object ) {
488 return ! $this->isProductHidden( (int) $item->object_id );
489 }
490
491 return true;
492 } ) );
493 }
494
495 public function filterSitemapQuery( array $args, string $postType ): array {
496 if ( 'product' !== $postType ) {
497 return $args;
498 }
499
500 // the sitemap is read by crawlers, that is, guests
501 $this->hiddenProducts = null;
502 $hidden = self::collectHiddenProducts( array(), true );
503 $this->hiddenProducts = null;
504
505 if ( $hidden ) {
506 $args['post__not_in'] = array_values( array_unique( array_merge( (array) ( $args['post__not_in'] ?? array() ), $hidden ) ) );
507 }
508
509 return $args;
510 }
511
512 /* --- single views -------------------------------------------------------------------------- */
513
514 public function guardSingleViews() {
515 if ( is_singular( 'product' ) && $this->isProductHidden( (int) get_queried_object_id() ) ) {
516 $this->deny();
517 }
518
519 if ( is_tax( 'product_cat' ) && $this->isCategoryHidden( (int) get_queried_object_id() ) ) {
520 $this->deny();
521 }
522 }
523
524 /**
525 * A guest is sent to the login page (with the way back), everybody else gets a not-found page.
526 */
527 protected function deny() {
528 if ( self::isGuest() && VisibilitySettings::redirectGuestsFromHidden() ) {
529 wp_safe_redirect( ClosedStoreService::getLoginUrl( $this->currentUrl() ) );
530 exit;
531 }
532
533 // the template loader, which runs right after this action, renders the not-found page
534 global $wp_query;
535
536 $wp_query->set_404();
537 status_header( 404 );
538 nocache_headers();
539 }
540
541 protected function currentUrl(): string {
542 $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : (string) wp_parse_url( home_url(), PHP_URL_HOST );
543 $path = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
544
545 return esc_url_raw( ( is_ssl() ? 'https://' : 'http://' ) . $host . $path );
546 }
547
548 protected function rootId( $product ): int {
549 $product = $product instanceof WC_Product ? $product : wc_get_product( (int) $product );
550
551 if ( ! $product ) {
552 return 0;
553 }
554
555 return (int) ( $product->get_parent_id() ?: $product->get_id() );
556 }
557
558 public function onTermsSet( $objectId, $terms, $ttIds, $taxonomy ) {
559 if ( 'product_cat' === $taxonomy ) {
560 VisibilityMeta::bumpVersion();
561 }
562 }
563 }
564